Commit Graph

49 Commits

Author SHA1 Message Date
agra
08f263c6e4 fix(ir): open a fresh defer window when lowering a lambda body (issue 0073)
A closure literal declared inside a `defer` body segfaulted the compiler.
Root cause: lowerLambda never opened its own `func_defer_base` window. Every
other function-lowering entry (lowerFunction / monomorphizeFunction /
monomorphizePackFn) saves func_defer_base, sets it to defer_stack.items.len, and
restores it — lowerLambda didn't. So a lambda's `return` drained the ENCLOSING
function's defers; when the defer body itself declared the lambda, draining
re-lowered the lambda, which returned, which drained again → infinite recursion
→ stack-overflow SIGSEGV (the failable variant surfaced one frame out, in
expandCallDefaults→lookupFn reading a clobbered scope).

Fix: lowerLambda now saves func_defer_base + the defer_stack length, sets the
base to the current length (a fresh window), and restores both on exit — so a
lambda's `return` drains only its own defers.

Regression: examples/0310-closures-closure-literal-in-defer.sx — a closure
declared and called inside a `defer`; verifies `body` then `defer closure: 42`
at scope exit (exit 0). Issue 0073 marked RESOLVED; repro promoted from
issues/0073-*.sx.

zig build, zig build test, tests/run_examples.sh (358/0) all green.
2026-06-02 23:29:49 +03:00
agra
eb20b2ddb5 docs(issues): file 0073 — closure literal inside a defer body segfaults lowering
Minimal repro (issues/0073-...sx): a non-failable, uncalled closure literal
declared inside a `defer` body crashes the compiler with a SIGSEGV in
lowerLambda (src/ir/lower.zig). Isolation shows the trigger is "a closure
literal lowered inside a defer body" — not failability, not whether it's called
(closures and failable closures lower fine outside a defer). Pre-existing
lowering bug, unrelated to the A5 error-analysis extraction; surfaced while
writing an A5.2 cleanup-absorption test example.

Filed per the IMPASSIBLE RULE: work paused pending a fix in another session.
2026-06-02 23:20:19 +03:00
agra
b72d49073e fix(ir): diagnose non-constant global initializers loudly (issue 0072)
globalInitValue's issue-0071 .identifier arm closed the bare-identifier hole,
but .field_access (and every other non-literal expression shape) still fell
through to `else => null`, so a global like `g : s32 = K.x;` was emitted with
no payload and silently zero-initialized (g=0).

Make the `else` emit a diagnostic — "global '<name>' must be initialized by a
compile-time constant" — instead of a null payload, so no unsupported shape can
silently zero. Two arms added alongside:

- `.null_literal => .null_val`: a `*void = null` global was previously a
  no-payload zero-init; this preserves the exact LLVMConstNull emission (fixes
  3 ffi examples that regressed on the first cut).
- explicit `.enum_literal => null` carve-out: the stdlib's
  `OS : OperatingSystem = .unknown;` zero-init is load-bearing for compile-time
  `inline if OS == .X`; documented, not folded into a silent fallthrough.

Field-access constant *evaluation* (materializing K.x -> 9) is intentionally
not implemented: a typed struct const like K is not registered in
module_const_map, so it would require new plumbing whose writes are read at
runtime — out of scope. The diagnostic is the issue-sanctioned outcome.

Regression: examples/1118-diagnostics-global-non-const-initializer-rejected.sx
(exit 1). Gate: zig build, zig build test, run_examples.sh -> 356/0.
2026-06-02 17:57:17 +03:00
agra
ad7200c196 fix(ir): materialize global initialized from module const (issue 0071)
registerTopLevelGlobal's init_val switch serialized only literal / array-
literal / struct-literal initializers. An identifier initializer
(`K : A : 42; g : A = K;`) fell through to `else => null`, so the global was
emitted with no payload and silently zero-initialized (printed g=0).

Extract the initializer serialization into globalInitValue and add an
.identifier arm that materializes the global's static value from
ProgramIndex.module_const_map (typed module consts are registered in the same
scanDecls pass-2 just before, via registerTypedModuleConst). An identifier
that names no usable constant now emits a diagnostic instead of silently
zeroing — a global has no run site for a dynamic initializer.

Other initializer shapes (enum-literal shorthand, etc.) keep their established
static-lowering behavior; enum-literal globals' zero-init is load-bearing for
`inline if OS == ...` in the stdlib, so it stays out of scope here. This pass
only closes the identifier/module-const hole.

Regression: examples/0134-types-global-init-from-module-const.sx (g=42, exit
42). Gate: zig build, zig build test, run_examples.sh -> 355/0.
2026-06-02 17:45:37 +03:00
agra
932cdfa2ec fix(ir): resolve forward alias in top-level global annotations (issue 0070)
Issue 0069's resolveForwardIdentifierAliases fixpoint runs at the END of
scanDecls, but top-level var_decl globals and typed module constants had
their annotations resolved via resolveType(ta) inside the SAME scan loop,
before the fixpoint. So a forward identifier alias (`A :: B; B :: s32;`)
used as a global's type (`g : A = 7;`) was still absent from
type_alias_map: resolveType fabricated an empty-struct stub, and the global
got a type mismatching its initializer at LLVM verification (the typed-const
path `K : A : 42;` silently mistyped the constant instead).

Split scanDecls into two passes: pass 1 registers function/type/alias facts,
then resolveForwardIdentifierAliases converges the aliases, then pass 2
registers var_decl globals (registerTopLevelGlobal) and typed module
constants (registerTypedModuleConst) against the converged alias map.
Globals/typed-consts can't be named in a type position, so deferring them
past type/alias registration is order-safe; the untyped module-const branch
(no annotation to resolve) stays in pass 1.

One incidental IR snapshot reorder (examples/1309: user globals now emit
after foreign-class globals — semantically identical, program still exits 0).

Regression: examples/0133-types-forward-alias-global.sx (forward-alias global
+ typed const). Gate: zig build, zig build test, run_examples.sh -> 354/0.
2026-06-02 17:20:31 +03:00
agra
49a383df6d fix(ir): resolve forward identifier type aliases in scanDecls (issue 0069)
scanDecls' `.identifier` alias branch registered `A :: B` into
ProgramIndex.type_alias_map only when `B` was already known (in
type_alias_map or the TypeTable). A forward target declared later
(`MyChain :: MyInt; MyInt :: s32;`) was never present during the single
forward scan, so the alias name went unregistered and the A2.4
unknown-type pass — which treats type_alias_map keys as declared types —
flagged its uses as `unknown type 'MyChain'`.

Add a fixpoint post-pass `resolveForwardIdentifierAliases` at the end of
scanDecls that re-resolves identifier-RHS aliases until no progress, after
every top-level name has been seen. A value const is never an `.identifier`
node, and an alias whose target is a value const still misses both lookups,
so issue 0068's value-const rejection is preserved.

Regression: examples/0132-types-forward-type-alias.sx (forward alias +
forward chain). Gate: zig build, zig build test, run_examples.sh -> 353/0.
2026-06-02 16:59:20 +03:00
agra
877014578e fix(ir): value const used as a type must not satisfy unknown-type check (issue 0068)
The A2.4 unknown-type pass (semantic_diagnostics) added EVERY const_decl name to
its declared-type-name set. A value const (`NotAType :: 123`) thus satisfied
reportIfUnknownType, so `v: NotAType` was not flagged; lowering then hit
TypeResolver.resolveNamed's empty-struct-stub fallback and fabricated
`NotAType{}` (the program ran, printing it).

Fix: collectDeclaredTypeNames and harvestScopeDecls now gate the const-name-add
on a new constValueIntroducesType — true only when the value introduces a type
(declarations: struct/enum/union/error; type-expression aliases: type_expr,
pointer/many-pointer/slice/optional/array/function/closure/tuple, parameterized).
`.identifier` / `.call` aliases are intentionally excluded: the scan registers
the type-valued ones into ProgramIndex.type_alias_map / the TypeTable (both
queried separately by the pass), so a value-RHS alias is correctly left out and
flagged, while a type-RHS alias stays covered by the canonical facts.

Regression: examples/1117-diagnostics-value-const-as-type-rejected.sx (exit 1).
Issue-0064 regressions 1111-1116 and the 0115 aliases stay green. Gate: zig
build, zig build test, run_examples 352/0.
2026-06-02 16:33:38 +03:00
agra
744decc6a1 fix(ir): reject non-type elements in tuple-literal-as-type (issue 0067)
`size_of((s32, 1))` treated the tuple literal as a tuple TYPE: for the non-type
element `1` it emitted a `std.debug.print` and substituted `.s64` for that field,
then compiled and printed a bogus size — a silent fabricated type (the forbidden
silent-fallback pattern).

Fix:
- type_bridge.resolveTupleLiteralAsType: a non-type element now yields
  `.unresolved` (no `.s64`, no debug print) — it refuses to fabricate a tuple.
  type_bridge is stateless, so this is the binding-free backstop.
- New stateful Lowering.resolveTupleLiteralTypeArg validates each element via
  isTypeShapedAstNode, emits a user-facing diagnostic at the offending element's
  span, and returns `.unresolved`. Wired into resolveTypeArg (size_of/align_of/…)
  and the resolveTypeWithBindings name-fallback; type_bridge builds the tuple
  only after validation passes.

Regression: examples/1116-diagnostics-tuple-type-nontype-element-rejected.sx
(exit 1 + diagnostic). Valid `(s32, s32)` still works (0115). Gate: zig build,
zig build test, run_examples 351/0.
2026-06-02 15:51:04 +03:00
agra
bd01d2224d fix(types): check nested closure/function bodies and cast targets (issue 0064)
Closes the two residual silent holes in the unknown-type diagnostic:

- Nested closure / function bodies. The body walk stopped at closure and
  nested-fn boundaries, so a typo'd type in a closure's local annotation
  silently became a 0-field struct. `walkBodyTypes` now descends control
  flow and expressions to re-enter each closure / nested fn via `checkScope`,
  which accumulates that scope's generic + value-`Type` params onto the
  parent's — so an inner closure still sees the outer function's `$T` (no
  false positive) while a genuine unknown is flagged at any nesting depth.
  `harvestScopeDecls` collects type-decl names across the whole body
  (including nested scopes) up front so locals are never false-flagged.

- Cast targets. `cast(T)` where `T` is a value-`Type` param (no `$`) cast to
  a fabricated empty struct silently; it now gets the tailored `$T` hint. An
  unknown *literal* cast target already errors via value resolution, so it's
  left to that path — no double diagnostic.

Suite: 350 passed, 0 failed. Regressions: examples/1114 (nested-closure
annotation), 1115 (cast value param).
2026-06-02 10:57:17 +03:00
agra
63b512a182 fix(types): extend unknown-type check into function bodies (issue 0064)
The signature/field check missed body-level type positions: a local
annotation naming a non-existent type flowed through the empty-struct stub
untouched, so `v: Coordnate = 5` silently compiled and ran (the value
dropped) — an invalid program accepted with no diagnostic.

`checkUnknownTypeNames` now also walks each main-file function body
(`checkBodyTypes`): local var/const type annotations — including inside
if / loop / match / push / defer / onfail blocks and decl-value blocks — are
validated with the enclosing function's generic params in scope, and
body-local `T :: struct/enum/union` declarations are collected first
(`collectBodyDeclNames`) so legitimate locals aren't false-flagged. Nested
function/closure bodies are their own scope and are not descended (safe
under-coverage); explicit `cast(T)` already surfaces its own `unresolved`
diagnostic and is left to it.

Regression: examples/1113 (local annotation of a non-existent type, exit 1).
2026-06-02 10:41:29 +03:00
agra
c490ffcfe9 fix(types): reject unknown type names instead of silent empty struct (issue 0064)
An identifier used in a type position that resolved to nothing fell through
to `type_bridge.resolveTypeName`'s empty-struct-stub fallback, silently
interning a 0-field struct named after the identifier. A value parameter
mistakenly used as a type (`(T: Type, ...) -> T`, missing the `$`) or a
typo'd type name therefore compiled and ran, rendering as `T{}`.

New post-scan diagnostic pass `checkUnknownTypeNames` (lower.zig Pass 1f)
walks every main-file function signature and non-generic struct field type
and rejects any leaf name that is not a primitive, an in-scope generic param
(`$T` / `type_params`), a declared type, or a real (non-stub) registered
type. The load-bearing empty-struct stub is left intact — forward references
and foreign-class opaque types still depend on it during the scan — and the
pass runs before body lowering, so `hasErrors()` halts the build before any
stub reaches codegen.

A value param used as a type gets a tailored hint to write `$T: Type`; a
genuine unknown gets "unknown type 'X'". Imported concrete types are
recognized via the type table, and inline compound spellings (`[:0]u8`),
arbitrary-width ints (`u1`/`u2`), and `$`-introduced generics (`-> $R`) are
exempted to avoid false positives.

Regressions: examples/1111 (tailored hint) + 1112 (typo'd field type).
2026-06-02 10:24:30 +03:00
agra
80abaf1e7d issues/0066: RESOLVED — match-value arms lowered against result type
A value-position match's arms are now lowered with `target_type` set to
the merge's `result_type`, so positive and negated integer literals pick
the same width. Fixes the `PHI node operands are not the same type as the
result` failure for `if n == { case 0: 100; else: -1; }`-style returns.

Regression: examples/0043-basic-match-value-mixed-width.sx.
Gates: zig build, zig build test, run_examples.sh -> 345 passed.
2026-06-02 09:35:41 +03:00
agra
92dba9078c issues/0065: RESOLVED — value-block destructure parses
The block-value rework routes value-position `{ … }` through the same
statement parser as every other block, so a destructure decl (and any
statement form) inside a value-bound block now parses, with the trailing
expression as the block's value. The `defer { … }` half was fixed
earlier (634cf9b). Regression: examples/0042-basic-block-value-destructure.sx.

Gates: zig build test, run_examples.sh -> 344 passed.
2026-06-02 09:31:18 +03:00
agra
bdd0e96d78 feat(lang): block value requires no trailing ; (Rust-style)
A block's value is now its last statement ONLY when that statement is a
trailing expression with no `;`. A trailing `;` discards the value,
leaving the block void. This makes value-vs-statement explicit and lets
the compiler reject "this block was supposed to produce a value".

Compiler:
- Parser records `Block.produces_value` (last stmt is a no-`;` trailing
  expression) + `Block.discarded_semi` (the `;` that discarded a value),
  via `expectSemicolonAfter`. A trailing expression before `}` may now
  omit its `;` (previously a parse error). Match-arm and else-arm bodies
  are built value-producing regardless of the arm `;` (arms are exempt —
  the `;` is an arm terminator).
- Lowering: `lowerBlockValue` / the block-expr path / `inferExprType`
  respect `produces_value`. A value-position block that discards its value
  is a hard error (`lowerValueBody` for function bodies; the value-context
  `.block` path for if/else branches, `catch` bodies, value bindings,
  match arms). Pure-failable `-> !` bodies (value rides the error channel)
  and a value-if whose branches are void are handled without false errors.
- `defer`/`onfail` cleanup bodies lower as statements (void), so a
  trailing `;` there is fine.

Migration (behavior-preserving — output unchanged):
- stdlib + ~210 examples: dropped the trailing `;` on value-position last
  expressions. `format` now ends with an explicit `#insert "return
  result;"` (it relied on `#insert`-as-block-value, which `;` discards).
- Two `main :: () -> s32` examples that relied on the old silent
  default-return got an explicit trailing `0`.
- Rejection snapshots 0412 / 1013 regenerated (their quoted source lines
  lost a `;`); the diagnostics themselves are unchanged.

Docs/tests: specs.md "Block values" section; examples 0040 (rules) + 0041
(rejection); 3 parser unit tests. Filed issue 0066 (pre-existing
match-arm negated-literal phi-width quirk, surfaced not caused here).

Gates: zig build, zig build test, run_examples.sh -> 343 passed,
cross_compile.sh -> 7 passed (also refreshed its stale example names).
2026-06-02 09:23:50 +03:00
agra
634cf9bc7f fix(parser): parse braced defer { … } body as a statement block (issue 0065)
A braced `defer` body routed through `parseExpr` + a mandatory trailing
`;`, so it parsed the `{ … }` as a block-EXPRESSION whose statement loop
doesn't handle a destructure decl or a `catch`-statement — `defer { v, e
:= f(); … }` and `defer { x() catch e … }` failed with "expected ';'",
and even `defer { stmt; }` needed a spurious trailing semicolon.

Now the `kw_defer` arm parses a braced body with `parseBlock` (the same
path `onfail` uses), so every statement form works; the bare-expression
form (`defer expr;`) is unchanged. `in_defer_body` is still set before
parsing, so the cleanup-body control-flow bans (return/break/continue/
try/raise) and the E1.7 failable-absorption check still fire.

Resolves the `defer` manifestation of issue 0065 (the general
value-block-in-binding-position destructure remains open). Regression:
examples/1050-errors-defer-block-body.sx.

Gates: zig build, zig build test, run_examples.sh -> 341 passed, 0 failed.
2026-06-01 23:29:07 +03:00
agra
c3bc6acd42 ERR/E1.7: reject bare failable calls in defer/onfail cleanup bodies
A `defer`/`onfail` body runs while the block is already exiting, so a
failable call there has nowhere to propagate its error. The parser
already bans `try`/`raise`/`return`/`break`/`continue` in cleanup bodies
(f9dd965); this adds the remaining sema rule — a bare (un-absorbed)
failable call must be absorbed locally with `catch` or `or <value>`.

Implemented in the shared error-flow pass (`checkCleanupBody` /
`checkCleanupNode` / `cleanupReject` in ir/lower.zig): when the walk hits
a `defer`/`onfail`, it scans the body transitively (through blocks, `if`,
loops, match arms, `catch` handlers; stopping at nested closures) and
flags any still-failable expression. `catch` / `or value` strip the
error channel, so `exprIsFailable` is false for them — only an unhandled
failable trips the check. This completes ERR PLAN E0–E5 plus the two
deferred E1 follow-ups (E1.7 + E1.8).

New regressions: 1048 (catch/or-value absorbed forms compile + run) and
1049 (bare failable in defer and onfail rejected, exit 1).

Filed issue 0065: a braced `defer { … }` / value-block body routes
through `parseExpr` (not `parseBlock` like `onfail`), so it can't parse a
destructure or `catch`-statement inside. Orthogonal to E1.7 — the spec'd
cleanup absorbers (`catch` / `or value`) parse fine in a `defer` body.

Gates: zig build, zig build test, run_examples.sh -> 340 passed, 0 failed.
2026-06-01 23:24:15 +03:00
agra
1c14383495 ERR/E5.1: verify generic failable composition (sub-feature 8); resolve 0062, file 0064
Generic value-carrying failable composition works with the documented
$T: Type generic form (catch / destructure / failure-propagation / a
second monomorphization at a different T). Issue 0062 was an invalid-repro
report — it used the non-generic T: type form, which is a plain Type-valued
param, not a generic type parameter. Marked 0062 resolved (not a bug).

The only real residual: a non-$ T: Type function param used as a type
silently resolves to an empty {} (renders T{}) instead of erroring. Filed
as 0064 (deferred, orthogonal to ERR — the $T idiom works).

Regression: 1044-errors-generic-failable-composition.sx.
2026-06-01 22:35:02 +03:00
agra
547148b8b6 fix(lower): free-fn UFCS auto-address-of + lazy lowering (issue 0063)
A free function called via UFCS (recv.fn(args)) whose first param is *T
was passed the receiver by value (LLVM "Call parameter type does not
match function signature"), and a function reached only via UFCS was
declared but never emitted (undefined symbol at link).

The bare-name UFCS fallback now mirrors the qualified-method path: it
lazily lowers the target body and calls fixupMethodReceiver +
coerceCallArgs, so the value receiver gets the same implicit address-of
as a struct-defined method and mutations through *T are visible.

Regression: 0039-basic-free-fn-ufcs-pointer-receiver.sx.
2026-06-01 22:28:15 +03:00
agra
34bdf8b87c issues: file 0062 (generic failable return not monomorphized) + 0063 (free-fn UFCS pointer param by-value)
Both discovered while verifying ERR E5.1 "verify-only" sub-features against
the built compiler. 0062 is sub-feature 8 (generic + ! returns); 0063 is a
general UFCS/address-of miscompile orthogonal to ERR.
2026-06-01 22:13:12 +03:00
agra
0e1afa3eba fix(lower): drop dead statements after a return/raise terminator (issue 0061)
A bare `return X;` / `raise` in the middle of a block closed the current
LLVM basic block, but lowerBlock / lowerBlockValue only stopped the
statement loop on the `block_terminated` flag — which lowerReturn
deliberately never sets (it would leak past an `if cond { return }` merge
block). So trailing dead statements were emitted into the already-closed
block, tripping the LLVM verifier with "Terminator found in the middle of
a basic block".

Fix: also stop the statement loop when currentBlockHasTerminator() is
true. That is CFG-level termination of the *current* block, which is
naturally false at an if / inline-if merge block, so conditional returns
still fall through to their trailing statements.

This unblocks ERR E5.1: the canonical failable-closure form
`closure((x) -> (s32,!) { raise error.X; return x; })` has a dead
`return x;` after the unconditional raise and tripped the verifier.

Regression: examples/0038-basic-dead-code-after-terminator.sx.
2026-06-01 21:42:20 +03:00
agra
06e2685350 fix(lower): closure literals compose with bare function-type slots (issue 0060)
A closure's underlying function carries a hidden env arg that a bare (T)->U slot
doesn't pass, so a closure flowing into a bare function-type slot dropped the
env — the first user arg landed in the env slot and the rest read garbage
(apply(closure((x)->s64 { x*2 })) returned 192 instead of 10; non-failable too).

- createClosureToBareFnAdapter: a capture-free closure into a bare (T)->U slot is
  bridged by a generated adapter carrying the bare ABI (forwards a null env);
  lowerLambda returns its func_ref. Rejected (no silent miscompile): a capturing
  closure into a bare slot (env has nowhere to live) and a failable closure into
  a non-failable slot (the ERR E5.1 FFI-boundary rule).
- Arrow-body failable closures (-> (T,!) => expr) now wrap the bare success value
  into {value, 0} via lowerFailableSuccessReturn (the implicit return previously
  returned a malformed tuple → caught value read as 0).

The isLambda .bang parser fix (failable closure literals parse) already landed in
485b4fa. Regressions: examples/0309-closures-literal-as-bare-fn-param (non-
failable, block + arrow, called in callee) + 1039-errors-failable-closure-literal
(failable, block + arrow, direct + Closure(...) param). Resolves issue 0060
(remaining E5.1 follow-ups noted in the .md). Suite: 328 passed.
2026-06-01 20:35:25 +03:00
agra
485b4fa618 issues: file 0060 — closure-literal composition miscompiles (blocks ERR/E5.1)
Probing ERR/E5.1 (composition with closures) surfaced pre-existing closure-
literal lowering bugs: a closure literal passed as a function-type argument and
called inside the callee returns wrong values (block-body 192, arrow-body 20,
want 10 — non-failable too; the working contrast passes the value as a separate
arg, examples/0302). On top of that, failable closure returns don't parse
(isLambda omits .bang — one-line fix in the issue) and arrow-body failable
closures miscompile (return 0); block-body failable closures called directly
work. Runnable repro + parser patch + investigation prompt in the issue.

E5.1 paused per the impassable rule rather than built on miscompiling closures;
the parser fix + a regression example were reverted to avoid landing silently-
miscompiling failable closures on master.
2026-06-01 20:18:25 +03:00
agra
34819f05de issues: relocate legacy examples/issue-* repros into issues/
Clear the examples/issue-* namespace (new layout keeps open-issue repros under
issues/, co-located with their .md). Two legacy files:

- issue-0030 was a feature-request placeholder (trivial main, no real test).
  `extern G : T;` cross-file sx globals are still unimplemented (parse error),
  so it's an open feature request: issues/0030-extern-global-declarations.{md,sx}.
- issue-0019 was a broken/superseded multi-file fixture (relative imports, not
  runnable from root; the non-transitive-#import scenario is covered by the
  passing 0706-modules-import-non-transitive). Moved to
  issues/0019-import-non-transitive-c-scope/ with a status note; safe to delete.

Suite unchanged: 324 passed.
2026-06-01 19:38:09 +03:00
agra
ba3c094283 fix(lower): infer no-annotation return type with params in scope (issue 0059)
A function with no explicit return type (arrow `=> expr`, or a block whose
`return <v>` drives the type) has its return type inferred from the body — but
the body references the function's own params. resolveReturnType ran that
inference before the params were pushed into self.scope (they're bound later, at
body lowering), so inferExprType couldn't resolve them and yielded .unresolved,
which reached LLVM emission and panicked. It only worked when a same-named
binding lingered in scope from earlier lowering (e.g. inside the big smoke file).

Bind the function's plain annotated value params into a temporary scope during
return-type inference. Resolve their types via resolveTypeWithBindings rather
than resolveParamType — the latter does variadic/pack bookkeeping that must run
exactly once, at body lowering; calling it here too corrupted the format/index
path. Variadic/pack/comptime/unannotated params are skipped (no by-name return
dependency; their types come from substitution).

Regression: examples/0308-closures-arrow-inferred-return.sx (arrow + block
inferred-return, top-level + local). Resolves issue 0059. Suite: 293 passed.
2026-06-01 19:28:35 +03:00
agra
70b4341682 issues: file 0059 — expr-bodied lambda inferred return type panics LLVM emission
An expression-bodied lambda `f :: (x: s32) => x * 2;` without an explicit
return type reaches emit_llvm declareFunction with func.ret = .unresolved and
trips the emission guard panic. Explicit `-> s32` works; the same lambda inside
a large module (the old 50-smoke.sx) resolves fine — so inferred-return
resolution for => lambdas runs only conditionally. Repro co-located.

Surfaced while splitting 50-smoke.sx (test-layout migration); the functions
section uses this exact construct, so the split is paused per the impassable
rule (no workaround).
2026-06-01 19:17:21 +03:00
agra
0d7f786db2 fix(dwarf): non-empty comp_dir so ld keeps the debug map (issue 0058)
A source path with no directory component (`sx build main.sx` from the
project dir — what the chess app does) made `diFileFor` emit a `DIFile`
with an empty `directory:`, so the compile unit's `DW_AT_comp_dir` was
"". Apple's ld then silently drops the *entire* object's debug map (0
N_OSO) and the binary is undebuggable — lldb resolves no sx source.
Builds whose path had any directory (`.sx-tmp/x.sx`, `examples/x.sx`)
were unaffected, which is why small repros + the stepping smoke passed
and only the bundled chess app hit it.

Fix: diFileFor falls back to "." (and "/" for a root-level file) when
the path has no directory component, so comp_dir is never empty.

Verified: chess (`sx build --target macos --emit-obj main.sx`) now
links with OSO=1 and lldb resolves `frame at main.sx:82:8`. Regression
guard added to the DWARF unit test (asserts `DIFile(... directory: ".")`
for a bare filename). Gates: zig build, zig build test, run_examples.sh
-> 291 passed, debug-stepping smoke ok.
2026-06-01 16:47:51 +03:00
agra
a694d91bca fix(0057): clear target_type when lowering variadic pack args
An `xx <int>` argument to a variadic `format`/`print` (a comptime `..$args`
pack) segfaulted when the call was inside an imported-module function. Root
cause: lowerPackCall lowered each pack arg with whatever self.target_type was
set to from the surrounding context. A bare arg is unaffected (inferExprType
ignores target_type), but `xx <expr>`'s result type IS target_type — so
`format("…", xx i)` inside a `-> string` fn cast the int to `string`,
monomorphized __pack_string, and ABI-coerced the 4-byte int as a 16-byte string
fat pointer → corruption. Inline it worked only because target_type was null
there; the imported-module path left it set.

Fix: save/clear/restore self.target_type around the pack-arg lowering loop. A
pack arg is independently typed — comptime `..$args` auto-boxes to Any; a value
pack takes its declared element/protocol type — never a leftover outer target.

examples/242-xx-any-pack-cross-module.sx (+ companion fmt.sx) is the regression.
issues/0057 marked resolved. Unblocks ERR E3.3 (the trace.sx formatter formats
frames with `xx frame`).

Gates: zig build, zig build test, bash tests/run_examples.sh (279 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
2026-06-01 08:51:44 +03:00
agra
29a4891374 imports: dedup flat decl list by node identity (issue 0056 FIXED)
Impl blocks are anonymous (no declName), so a parameterised-protocol impl in a module reached via a diamond import was appended once per path and registered twice — 'duplicate impl Into for source s64'. mergeFlat and the directory-import merge loop now also dedup by node pointer; a physical AST node is lowered once regardless of how many import paths reach it.

Regression: examples/issue-0056-diamond-param-impl.sx.
2026-05-30 17:36:35 +03:00
agra
ac7f1d10e5 lang: extend operand-type check to ordering + bitwise/shift (issue 0055 follow-up)
The arithmetic-only check from the previous commit shared a hole with the
comparison and bitwise/shift ops: lowerBinaryOp derives the result type
from the LHS, so `s64 < string` fed mismatched types to `icmp` (LLVM
verifier failure) and `s64 & string` reinterpreted the string's bytes.

Add isOrderingOperand (numeric / enum / pointer / bool / vector) and
isBitwiseOperand (integer / enum / bool / vector), and route `< <= > >=`
and `& | ^ << >>` through them alongside the existing arithmetic check, all
sharing one diagnostic + placeholder-sentinel path. Flags-enum bitwise
(`.read | .write`, `perm & .read`), enum/pointer comparison, and int
literals stay legal (50-smoke unaffected).

Equality `== / !=` is deliberately left unchecked — its path is heavily
special-cased (str_eq, Any unbox, optional == null); folding a check in
without regressing those is a separate change, noted in the issue.

Regression test renamed arith→binop and broadened to cover `+ * < & <<`
against a string operand: examples/214-binop-operand-type-check.sx.
2026-05-30 10:30:57 +03:00
agra
6016b08712 lang: reject mismatched operand types in scalar arithmetic (issue 0055)
lowerBinaryOp derived the result type from the LHS alone and emitted
add/sub/mul/div/mod without checking the RHS, so `s64 + string` lowered
as `add : s64` and reinterpreted the string's bytes — printing garbage
instead of erroring.

Add isArithOperand (int / float / vector / pointer, plus custom int
widths) and, for `+ - * / %`, diagnose `cannot apply '<op>' to operands
of type '<lhs>' and '<rhs>'` and return a placeholder sentinel instead of
the corrupting op. `.unresolved` operands pass through so a type we
couldn't infer is never falsely rejected; the existing optional-unwrap
and int×float promotion are accounted for before the check.

Ordering (`< <= > >=`) and bitwise/shift (`& | ^ << >>`) ops share the
same LHS-derived-type hole and are left as a noted follow-up in the issue.

Regression: examples/214-arith-operand-type-check.sx (s64 + string, and
non-numeric LHS string * s64).
2026-05-30 09:56:32 +03:00
agra
f2e1f401ce issues/0054: mark FIXED (generic-struct -> param-protocol erasure) 2026-05-30 03:26:24 +03:00
agra
e96e76f6b0 issues/0054: generic-struct -> parameterized-protocol erasure traps (canonical xx c) 2026-05-30 03:16:55 +03:00
agra
82b46bc412 lang: xx <pack> materializes a comptime pack into a runtime slice (issue 0053)
xx args with a slice target now bridges a comptime pack to a runtime slice:
[]Any boxes each element to Any; []P xx-erases each to the protocol (reusing
the slice-of-protocol erasure from 0052). New lowerPackToSlice; the unary-op
arm intercepts xx <pack> before the pack-as-value diagnostic. This is the
working forward to a runtime []Any/[]P helper -- log_count(xx args) -> 3 --
so the 2.7 pack-as-value diagnostics now suggest xx <name> for the call case.

examples/204-pack-xx-to-slice.sx (both []Any and []P paths); 203 help text
updated. issue 0053 FIXED. 239 examples + unit green.
2026-05-30 02:17:55 +03:00
agra
8a875d354c lang F1 2.7: pack-as-value diagnostics (Phase 2 complete)
Using a bare pack name where a runtime value is required was silent garbage
(f(xs)/return xs produced a stray pointer). Now a clear, context-tailored
compile error: isPackName + diagPackAsValue, caught at lowerVarDecl (storage),
lowerReturn (return), lowerFor (iterate), and an identifier-arm catch-all for
call/other. Storage binds a placeholder so there is no cascade error.

Suggestions point at WORKING fixes -- materialize (..xs), or declare the slice
form ..xs: []P for runtime use. The plan category-B "spread ..xs" is broken
(spreading a comptime pack into a []Any param crashes the LLVM verifier; filed
issue 0053), so the diagnostics steer to the slice-of-protocol variadic instead.

Repurposed examples/162-pack-bare-args.sx (was an aspirational bare-$args->[]Any
auto-materialise, contradicting Decision 1) into the slice-form forward
(..args: []Any). examples/203 is the four-category negative test. specs.md "Pack
as value" updated. 238 examples + unit green.
2026-05-30 02:09:41 +03:00
agra
ab572359ae lang: slice-of-protocol variadic ..xs: []P erases each arg to the protocol
packVariadicCallArgs stored the raw concrete arg into a [N x P] array when the
element type was a protocol, so an 8-byte struct landed in a 16-byte {ctx,
vtable} slot -> garbage vtable -> Bus error on dispatch. Now, when the slice
element type is a protocol, each arg is xx-erased to the protocol value via
buildProtocolErasure (same impl-driven machinery as the xx cast). This makes
..xs: []P the runtime, protocol-erased counterpart to the comptime
heterogeneous pack ..xs: P (which stays comptime-only): xs[runtime_i].method()
now works in an ordinary loop.

specs.md: full variadic/pack form-comparison table (concrete-vs-erased,
comptime-vs-runtime). Regression: examples/202. Issue 0052 (FIXED). 237 green.
2026-05-30 01:50:29 +03:00
agra
76e0e97bfa issues/0051: mark FIXED (sdl3 chdir + ad-hoc signing) 2026-05-30 01:16:09 +03:00
agra
b31fbae757 platform/sdl3: chdir to .app bundle on macOS so CWD-relative assets resolve
A macOS .app launched with CWD=/ (Finder/open) could not find CWD-relative
assets (read_file_bytes("assets/...")) and crashed in stbtt with a null font.
SdlPlatform.init now chdirs to SDL_GetBasePath() when running from inside a
.app bundle (detected by ".app" in the base path), mirroring uikit.sx s iOS
chdir_to_bundle. Gated so the sx run dev flow (binary not bundled) keeps the
project CWD. Verified: direct-exec with CWD=/ now stays alive (was: instant
stbtt segfault). Filed issue 0051 with the analysis.

Note: launching via Finder/open additionally triggers Gatekeeper App
Translocation for the dev-signed bundle (separate code-signing concern, not
the asset path).
2026-05-30 01:10:13 +03:00
agra
6fdfe8d073 issues: mark 0041, 0042, 0043, 0047 FIXED
Triage pass: every issue file in `issues/` was re-verified against
HEAD. Three (0041, 0042, 0043) reproduce no longer — they were
silently fixed by adjacent work since the issue was filed. 0047
landed in the previous commit. All four header sections now lead
with **FIXED** + a one-line locator so the next reader doesn't
re-investigate.

After this, `issues/` is the actual open-issue list:

| Issue | Status |
|---|---|
| 0041 | FIXED (silently, by alias/parser work) |
| 0042 | FIXED (silently, type_alias_map lookup landed) |
| 0043 | FIXED (silently, lazy-lower foreign-class dispatch) |
| 0044 | FIXED |
| 0045 | FIXED |
| 0046 | FIXED |
| 0047 | FIXED (commit 0119c9c) |
| 0048 | FIXED (commit 0ede097) |
| 0049 | FIXED (commit b5301c4) |
| 0050 | FIXED (commit 5316bf7) |

No open issues remain. The files stay in tree as a record; new
issues take the next free number (0051).
2026-05-28 08:16:06 +03:00
agra
ec2a99a1a3 ffi issue-0050: monomorphizeFunction leaks pack-fn state — xfail lock-in
A generic fn (with `$T: Type` type params) called from inside a
pack-fn mono inherits the outer pack maps during its OWN body
lowering. Same root cause as issue-0048 — the lowering helper
doesn't save/null `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` — but on the generic-mono path
(`monomorphizeFunction`, ~line 8718) rather than
`lazyLowerFunction`.

`examples/175-generic-fn-pack-state-leak.sx` calls
`build(args: []Type, $ret: Type)` from a four-shape pack-fn. The
expected output is `len=0 / 1 / 2 / 4`; today's run reports
`len=0` for every shape because `build__void` was first
monomorphised under `probe()`'s mono (N=0) and `args.len` got
constant-folded to 0 inside the cached body. The next commit
adds the same isolation pattern to `monomorphizeFunction`.

Step 5 of the FFI plan (generic `Into(Block)` impl) needs the
`build_block_convert(args: []Type, $ret: Type) -> string` builder,
which trips this leak directly.
2026-05-27 21:44:39 +03:00
agra
64dcbca06a ffi issue-0049: new-form variadic cross-module LLVM crash — xfail lock-in
Migrating stdlib's `path_join` to the new variadic syntax
(`(..parts: []string) -> string`) surfaces a latent compiler bug:
`resolveParamType` and `packVariadicCallArgs` treat the new-form
declaration the same as the legacy `parts: ..string` and wrap the
element type in `sliceOf` regardless of whether it already is one.
The new form's `[]string` becomes `[][]string`; the call-site
marshal pack emits `[N x string]` (correct) but the callee stores
its slice param into a `[]([]string)`-typed slot. The shape
mismatch propagates as null/undef Refs that crash
`LLVMBuildExtractValue` inside `emitStrCmp` during emission.

`examples/121-ios-sim-bundle.sx` (existing) and the new focused
`examples/174-new-form-variadic-cross-module.sx` both fail today
with the segfault. The next commit fixes `resolveParamType` +
`packVariadicCallArgs` so both flip green. Stdlib's `format` /
`print` / `open` and the example fixtures stay on the legacy form
in this commit — they migrate in the follow-up cleanup commit.
2026-05-27 21:29:08 +03:00
agra
8fcf352de8 ffi issue-0048: bare $args slice loses .len across call — xfail lock-in
Bare `$args` evaluated inside a pack-fn body has the right `.len` /
per-element types inline, but the moment the same slice is passed
as an argument to another function, the callee silently reads
length 0 and every element comes back as undef.

Cause (per issue file): `lazyLowerFunction` saves/restores builder
state but not `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` / `inline_return_target`. When a regular fn like
`describe(args: []Any)` is lazily lowered from inside a pack-fn
mono, the outer pack maps are still active; `lowerFieldAccess`'s
`<pack_name>.len` intercept fires on `describe`'s same-named param
and bakes the outer mono's arity as a constant into describe's IR.
Every subsequent shape's call to describe returns that constant.

`examples/173-pack-bare-args-cross-call.sx` exercises four shapes
(0, 1, 3, 5 elements) through the same `describe(args: []Any)`
walker. The expected output holds the per-position type names
(`[s64]`, `[s64, string, bool]`, etc); today's diff fails — the
walker reads `args.len = 0` for every shape and returns `[]`. The
next commit fixes `lazyLowerFunction`.
2026-05-27 21:09:25 +03:00
agra
95755be888 ffi issue-0047: file #run-on-stderr-vs-runtime-on-stdout split
Surfaced while adding the `--- build done ---` delimiter
(commit 2993072). `#run print()` output is buffered by the
interp and flushed via std.debug.print → stderr at
core.zig:187/190; JIT runtime `print()` writes via libc
write(1, ...) → stdout. Same `print` call from the user's
viewpoint, different streams in practice.

Not blocking step 3 — tests capture both streams via 2>&1 so
snapshots are unaffected. Issue file documents the fix path
(move the two `std.debug.print` flushes in core.zig to
stdout-writes) for a future session.
2026-05-27 17:11:31 +03:00
agra
248d6e669c ffi issue-0046 fix: save/restore outer state in createComptimeFunction
`createComptimeFunction` wraps a comptime expression into a
fresh fn that the interp executes in isolation. The wrapper
must not inherit the enclosing call's lowering state — any
leaked slot, binding, or scope flag corrupts the wrapper's
own lowering.

Pre-fix, only `func` / `current_block` / `inst_counter` /
`scope` / `current_ctx_ref` were saved. Specifically NOT
saved:

- `inline_return_target` — set by `lowerComptimeCall` for an
  outer comptime body with `return X;`. The wrapper's body
  was lowering through this slot, routing the wrapper's
  `ret` into a basic block from a different function.
- `pack_arg_nodes`, `pack_param_count`, `pack_arg_types` —
  active during a pack-fn mono's body lowering. (Pack-fn
  face of 0046 was already fixed by step 2b moving pack-fn
  calls off the inline path; these saves close a latent
  cross-contamination if any future pack-mono body invokes
  the comptime interp.)
- `comptime_param_nodes` — active during an outer
  `lowerComptimeCall` to bind `$fmt`-style substitutions.
- `block_terminated`, `target_type`, `func_defer_base` — fn-
  local flags that the wrapper's lowering needs fresh.

All eight now save/restore in `createComptimeFunction`. The
wrapper runs in a clean state.

`examples/issue-0046.sx` flips from the
non-deterministic interp panic to "inside\n" + "n=42\n".

204/204 example tests + `zig build test` green. Issue file
marked FIXED with a pointer to the regression test.
2026-05-27 16:57:19 +03:00
agra
83c2c9d176 ffi issue-0046: file nested-comptime-call + return latent bug
Comptime fn body containing BOTH a nested comptime call
(`print(...)`) AND a `return X;` fails in one of two shapes
depending on the comptime-param flavour: a `storeAtRawPtr`
panic in the interp (plain `$x: s32` comptime) or "unresolved
'result'" at compile time (pack-fn `..$args`).

Same root: my issue-0045 fix's `inline_return_target` slot
setup interacts badly with the recursive comptime-call path
that invokes `#insert build_format(fmt)` → interpreter →
parse-and-lower of `result := ...` statements.

Pre-issue-0045-fix the pattern crashed at the LLVM verifier
("Terminator found in the middle of a basic block") so the
recursive path never ran. The fix exposed the deeper bug; it
didn't create it.

Not blocking the next pack-feature slices:
- Step 2a tests use arrow-form bodies with no nested print.
- Steps 2b/3 don't inherently require nested comptime calls —
  builders run inside `#insert` contexts, not inside public
  pack-fn bodies.
- Will bite when step 5 refactors stdlib's `print`/`format` to
  `..$args` or when user code writes a pack-fn with both
  `print` debug output and an early `return`.

Investigation prompt in the issue file points at
`createComptimeFunction`'s saved/restored state list (missing
`inline_return_target`, `pack_arg_nodes`,
`comptime_param_nodes`) as the most likely angle.
2026-05-27 14:09:20 +03:00
agra
9e78790ebf ffi issue-0045 fix: inline-return slot for comptime-call bodies
`lowerComptimeCall` now scans the body for `return` statements
via `fnBodyHasReturn`. When found, it allocates a stack slot
typed to the fn's return type and installs it as
`self.inline_return_target` before lowering the body.

`lowerReturn` checks `inline_return_target` first:
- If set, it stores the coerced return value into the slot,
  drains pending defers, sets `block_terminated = true`, and
  returns without emitting a `ret` into the caller's basic
  block.
- Otherwise it emits the standard `ret` as before.

After the body lowers, the inliner either returns the
tail-expression value (existing fast path — bodies with no
`return` skip the slot entirely) or loads the slot when
`block_terminated` is set.

Why the bug was invisible until now: `format`/`print` and
every other stdlib comptime fn use arrow form (`=> expr`) or
`#insert`-only bodies — no `return` statement, no path through
`lowerReturn`. Step 1.b of the pack feature made `..$args`
parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first
comptime-fn body to take the `return`-with-trailing-statements
path, surfacing the LLVM verifier crash.

`examples/issue-0045.sx` flips from the lock-in failure to
`42`. 194/194 example tests + `zig build test` green.
2026-05-27 13:21:23 +03:00
agra
3d32ab0fc6 ffi issue-0045: pack-fn block-body call — lock in LLVM verifier crash
Filed `issues/0045-pack-fn-call-llvm-verifier-failure.md`.
Surfaced by probing step 2 territory of the variadic
heterogeneous type packs feature: any `..$args` fn whose body
is a block containing `return X;` (or any comptime fn with a
non-void return, comptime params, and explicit `return` in a
block body) trips LLVM's "Terminator found in the middle of a
basic block" verifier.

`lowerComptimeCall` inlines the body's statements directly into
the caller's LLVM function. `lowerReturn` then emits a `ret`
into the caller's basic block — but the caller still has
trailing instructions, hence the verifier failure.

`examples/issue-0045.sx` reproduces the crash with the minimum
pack-fn shape (`foo :: (..$args) -> s64 { return 42; }`). Same
shape with a plain comptime param (`($x: s32) -> s64 { return
42; }`) reproduces identically, so the bug is broader than
packs. Arrow-form bodies (`=> 42`) work today because they have
no `return` statement.

Next commit teaches `lowerComptimeCall` to allocate a result
slot when the body contains a `return`, and reroutes
`lowerReturn` to store into that slot + flag the block as
terminated so the inliner picks up the value.
2026-05-27 13:19:49 +03:00
agra
a923b6f6f0 ffi fix: route foreign-class UFCS arg target_types through extends chain
For UFCS dispatch on foreign-class receivers (`#foreign #objc_class`
aliases), `resolveCallParamTypes` was returning an empty slice — both
`resolveFuncByName(qualified)` and `fn_ast_map.get(qualified)` miss
for `#foreign` methods (they live in `foreign_class_map`, not the
regular fn maps). With `param_types` empty, the per-arg `target_type`
assignment in `lowerCall` was skipped, leaving `self.target_type` as
whatever it held on entry — usually the enclosing function's return
type. Inside a `-> BOOL` method, `xx ptr` then lowered with target
type `i8`: `ptrtoint ptr to i64` → `trunc i64 to i8`, sending the low
byte of the pointer through.

Symptom: chess on iOS-sim crashed in
`-[NSNotificationCenter addObserver:selector:name:object:]` with
`observer = 0xC0` (low byte of the SxAppDelegate receiver) when the
AppDelegate method's first param was renamed to anything other than
`self`. The original session diagnosed it as a `self`-vs-`this`
hardcoding in `lower.zig`, but those hardcoded `"self"` strings are
all on compiler-synthesized parameters (init scopes, JNI stubs,
property IMPs, dealloc IMPs) — not the user-facing #objc_class body
params. The bug was in arg-type resolution.

Fix walks `foreign_class_map` + `findForeignMethodInChain` to recover
the declared param types (skipping the implicit `*Self` for instance
methods). Regression test `examples/issue-0044.sx` exercises the
BOOL-return + foreign-class arg shape; pre-fix the receiver round-trip
prints WRONG, post-fix it prints ok.
2026-05-26 16:42:21 +03:00
agra
15f10c5031 ffi 3.2 C4/C5 BLOCKED: file issue-0043 — lazy lowering loses foreign-class dispatch
Per CLAUDE.md IMPASSIBLE RULES. Attempted Phase 3.2 C4 migration of
the UIKit chrome cluster in `library/modules/platform/uikit.sx`
(UIScreen / UIWindow / UIViewController / UITextField / UIView)
surfaced a real compiler bug: when a function body contains
`recv.method(...)` calls against an `#objc_class` receiver AND that
body is reached via `lazyLowerFunction` invoked from another
`inline if OS == ...` branch, the method dispatch fails with
"unresolved 'methodName'".

Specifically: `uikit_scene_will_connect_ios` (the iOS-sim crashing
case) contains `UIWindow.alloc().initWithWindowScene(scene)` etc.
The same calls compile cleanly in isolated probes — only the lazy-
lower-via-inline-if entry chain reproduces the bug. macOS target
builds fine throughout; ios-sim trips it.

C1/C2/C3 (commits 1ea9cda / 17775b2 / 2a7c8e0) happen to land cleanly
because the methods they migrate are reached eagerly (or are niladic
so the dispatch path doesn't hit the failing branch). C4 + C5 stay
blocked pending issue-0043's fix in a separate session.

Issue filed at `issues/0043-lazy-lower-loses-foreign-class-method-dispatch.md`
with the reproduction, stack trace, and investigation prompt
pointing at `lower.zig:1057` (`lazyLowerFunction`) and
`lower.zig:5290` (the field-access foreign-class dispatch chain).

FFI checkpoint updated to mark C4+C5 as BLOCKED on 0043.

The in-progress C4 working-tree changes were reverted; tree is at
the C3 commit `2a7c8e0` and chess on macOS/iOS-sim/Android builds
cleanly.
2026-05-25 17:27:29 +03:00
agra
29784c22a8 mem: implicit-context foundation + many compiler fixes
The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):

  Step 1 — `CAllocator :: struct {}` stateless allocator in
    library/modules/allocators.sx, delegating directly to
    libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
    `func_ref: FuncId` leaf so nested aggregates can carry function
    pointers (the inline Allocator value's fn-ptr fields). Switch
    sites updated in emit_llvm.zig, print.zig, interp.zig.

  Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
    a static `__sx_default_context` global with a nested-aggregate
    init_val pointing at the CAllocator → Allocator thunks. The
    second-pass `initVtableGlobals` in emit_llvm.zig is generalised
    to handle `.aggregate` init_vals (re-emits after func_map is
    populated so func_ref leaves resolve to real symbols).

Also folded in from earlier work this session:

  - Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
    through `context.allocator` via the new `allocViaContext` helper.
  - interp.zig: `marshalForeignArg` double-offset bug fixed —
    `heapSlice` already adds `hp.offset` to the slice ptr, so the
    extra `+ hp.offset` was scribbling memcpy/memset into adjacent
    heap state, corrupting `heap.items[0]`. Symptom: `build_format`
    at comptime produced zero bytes, all `print` calls failed.
  - Lazy lowering: `lazyLowerFunction` now declares foreign-body
    functions as extern stubs in the local (comptime) module so
    cross-module foreign calls resolve.
  - Allocator API: all stdlib allocators on one-line `init() -> *T`
    (CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
    backed; BufAlloc: embeds state at head of user buffer).
  - issues 0038 (transitive #import), 0039 (chess + stdlib migration
    fallout), 0040 (generic struct method dot-dispatch), 0041
    (pointer types as type-arg), 0042 (alias name resolution) — all
    fixed; regression tests in examples/.
  - Diagnostic: `emitError` now embeds the lowering's
    `current_source_file` and enclosing function in the literal
    message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
    emit site so misattributed spans can't hide where the failure
    is.
  - tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
    (interp/codegen parity tester) added.

Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.
2026-05-24 22:59:20 +03:00