Commit Graph

604 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
667192c718 refactor(ir): extract ErrorAnalysis (error_analysis.zig) for error-set convergence (A5.1 step 2)
Error-set convergence now lives in src/ir/error_analysis.zig behind a *Lowering
facade (ErrorAnalysis), mirroring the other domain extractions. Moved verbatim:
- convergeInferredErrorSets (whole-program inferred-`!` SCC fix-point),
- convergeClosureShapeSets,
- collectErrorSites / collectClosureShapes (the AST collectors).

Added ErrorFacts (the PLAN-ARCH shape: inferred_error_sets + shape_inferred_sets)
+ a facts() view over the maps, which stay on Lowering for now (consumers read
them via self.*). recordClosureShape and its deep type/shape helper web stay in
Lowering; it reaches the moved collectErrorSites via self.errorAnalysis().

Lowering keeps convergeInferredErrorSets / convergeClosureShapeSets as thin pub
wrappers (the lowering pipeline + the E1.4b unit test call them); collectErrorSites
/ collectClosureShapes are deleted (no fallback). New pub: isErrorTagLiteralNode /
callTargetName / astIsPureBareInferred / astPureNamedSet / containsTag /
namedSetTags / recordClosureShape (the moved collectors / facade reach them).
lower.zig net -216 lines.

The 2 convergence unit tests (transitive SCC across a try edge; closure-shape
union) moved from lower.test.zig to error_analysis.test.zig and now drive the
facade directly; the E1.4b test stays in lower.test.zig via the wrapper. Module
named error_analysis.zig, NOT errors.zig (src/errors.zig is the DiagnosticList).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir churn.
2026-06-02 23:11:18 +03:00
agra
9153f958ea test(ir): lock error-set convergence before A5.1 extraction (A5.1 scaffolding step 1)
Test-first scaffolding ahead of extracting src/ir/error_analysis.zig — no code
change to the convergence targets (convergeInferredErrorSets /
convergeClosureShapeSets / collectErrorSites / collectClosureShapes).

Adds 2 unit tests via the already-pub convergence functions (no new exposure):
- convergeInferredErrorSets transitive/SCC: a `caller :: () -> ! { try raiser(); }`
  with no direct raise converges to raiser's {Foo} across the try edge — the
  whole-program fixpoint A5.1 must preserve. (Today's E1.4b test only covered a
  direct raiser + the empty-set warning.)
- convergeClosureShapeSets: a bare-`!` closure literal `() -> ! { raise error.Bar }`
  inside a host fn unions {Bar} into one shape_inferred_sets entry.

Adds 2 .ir snapshots (first .ir for these error forms), vetted clean
(idempotent, path-free, no #run): 1006-errors-inferred-error-sets (inferred-set
error-channel shapes) and 1009-errors-catch (catch lowering). 1004-errors-try
was already pinned.

PLAN-ERR is complete/idle, so the A5 overlap risk is low (the target functions
are stable, not in-flight). The sub-step-2 module will be named
src/ir/error_analysis.zig, NOT errors.zig (src/errors.zig is the DiagnosticList).

zig build, zig build test, tests/run_examples.sh (357/0) all green.
2026-06-02 22:57:39 +03:00
agra
f3bda369f6 refactor(ir): extract CoercionResolver (conversions.zig) for coercion planning (A4.3 step 2)
Coercion classification now lives in src/ir/conversions.zig behind a *Lowering
facade (CoercionResolver), mirroring CallResolver / GenericResolver /
ProtocolResolver. Two pure classifiers:
- classify(src, dst) -> CoercionPlan (15 kinds: no_op / unbox_any / box_any /
  closure_to_fn_reject / tuple_elementwise / optional_unwrap / void_to_optional /
  optional_wrap / erase_protocol / int_to_float / float_to_int / ptr_int_bitcast /
  widen / narrow / none) — the built-in coercion ladder.
- classifyXX(src, dst) -> XXPlan (unbox_any / no_op / erase_protocol /
  protocol_to_pointer / coerce) — the xx-operator head.

coerceToType and lowerXX now `switch (classify…)` then emit; branch order
mirrors the originals exactly and every arm reproduces the prior lowering — the
f32/f64 Any match dispatch, buildProtocolErasure (lowerXX) vs buildProtocolValue
(coerceToType), tuple/optional recursion, and the user-Into fallback + pointer
materialization + recursion-guard/diagnostics (which stay in lowerXX /
tryUserConversion). IR emission stays entirely in Lowering; the classifiers are
pure. lowerXX keeps the operand's lowered Ref type as src_ty. `.none` means no
built-in applies (pass through; the Into fallback runs) — no silent default.

New pub: isFloat / isIntEx / typeBitsEx / resolveConcreteTypeName (the classifier
reads them); coercionResolver() accessor. lower.zig net -54 lines.

conversions.test.zig drives CoercionResolver directly: the full classify ladder
(no-op, Any box/unbox, widen/narrow, int<->float, ptr<->int, optional
wrap/unwrap, void->optional, tuple, closure-reject, .none for two unrelated
structs), erase_protocol for a concrete source, and classifyXX (all 5 kinds incl.
protocol-to-pointer vs coerce and pointer-materialization -> coerce).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir churn.
2026-06-02 22:45:56 +03:00
agra
50dd2cc3d8 test(ir): lock coercion forms before A4.3 extraction (A4.3 scaffolding step 1)
Test-first scaffolding ahead of extracting src/ir/conversions.zig — no code
change to the coercion targets (lowerXX / coerceToType / coerceOrErase /
buildProtocolErasure / tryUserConversion / failable-adapter selection).

Adds 4 .ir snapshots (first .ir for 01xx/09xx/10xx), each captured surgically
via `sx ir | normalize_ir`, path-free, idempotent, and print-free at IR-gen time
(0114-types-build-block-convert was rejected — it prints `--- void / 0 args ---`
+ sx source at IR-gen):
- 0107-types-int-cmp-in-float-ternary   numeric int<->float coercion
- 0903-optionals-optional-roundtrip     optional wrap/unwrap
- 0904-optionals-any-to-string-optional xx unbox_any + optional
- 1004-errors-try                       error-channel adapter/coercion

Protocol erasure + user Into are already pinned by the 04xx snapshots
(0400/0413/0414/0416); duplicate-conversion rejection by the 0410/0411/0412
anchors.

Adds 1 unit test via the public surface (no new exposure, mirroring A4.1/A4.2
sub-step 1): optionalOfFlattened — the optional wrap/flatten coercion rule
(T -> ?T; ?T -> ?T, never ??T; contrasted with the non-flattening optionalOf).
The lowerXX/coerceToType/coerceOrErase/buildProtocolErasure decisions are private
+ emission-bound, so their CoercionPlan unit tests land with the extracted module
in sub-step 2.

zig build, zig build test, tests/run_examples.sh (357/0) all green.
2026-06-02 22:32:01 +03:00
agra
137285f33d refactor(ir): factor protocol/impl planning into ProtocolResolver (A4.2 planning increment)
Factor the lookup/planning half of the protocol emission functions into
protocols.zig, keeping IR emission in Lowering (PLAN-ARCH A4.2 final increment):
- protocolMethodInfos(proto) — the dispatch method table = which methods
  getOrCreateThunks must thunk. getOrCreateThunks now does PLANNING via this +
  EMISSION (createProtocolThunk loop) in Lowering.
- findVisibleImpls(entries, out) — moved verbatim (pure BFS over the import
  graph; the cross-module visibility selection behind the 0410 path).
  tryUserConversion calls it via the resolver.
- matchPackImpl(src_ty, pack_key) -> ?PackImplMatch — the pure pack-impl
  matching loop (prefix + return match) + convert-method find, returning the
  matched entry + convert fd + src params/ret. tryPackImplMatch consumes it; the
  binding + monomorphise + call emission stays in Lowering.

Emission untouched: createProtocolThunk, buildProtocolValue, and the
monomorphise+call tails of tryUserConversion / tryPackImplMatch remain in
Lowering. The reentrancy guard, key-build, and the Into no-visible / duplicate /
recursive diagnostics stay in tryUserConversion (byte-for-byte). lower.zig net
-94 lines. No new pub exposure (uses the existing ParamImplEntry /
PackParamImplEntry / formatTypeName surface).

protocols.test.zig +3: protocolMethodInfos (method table + null-for-unknown, no
silent empty default); findVisibleImpls (falls open with no graph; filters to
here + transitive imports); matchPackImpl (selects on prefix+return; null for
non-closure source / unknown key).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
churn; the 0410/0411/0412 diagnostics are byte-for-byte preserved.
2026-06-02 22:23:01 +03:00
agra
e6cbb60d8f refactor(ir): move protocol/impl registration into ProtocolResolver (A4.2 registration increment)
Move the registration functions behind the protocols.zig facade, per PLAN-ARCH
A4.2 ("then registration", keeping IR emission in Lowering):
- registerProtocolDecl (protocol struct + dispatch method table + vtable type),
- registerImplBlock (concrete impl -> <Target>.<method> in fn_ast_map + default-
  method synthesis),
- registerParamImpl (parameterised impl -> param_impl_map / param_impl_pack_map
  + the same-file duplicate diagnostic),
- synthesizeDefaultMethod (facade-private; its only caller moved too).

Moved verbatim with self. -> self.l. facade rewrites. Emission stays in
Lowering: the registry calls self.l.declareFunction (the extern-stub primitive)
but the thunk/value builders (createProtocolThunk / buildProtocolValue /
tryUserConversion / getOrCreateThunks) are NOT moved.

Lowering keeps registerProtocolDecl as a thin pub wrapper (scan pass + 7
unit-test callers); registerImplBlock / registerParamImpl /
synthesizeDefaultMethod deleted (no fallback), the 2 scan call sites routed
through protocolResolver(). New pub: declareFunction (8 callers, emission infra),
ParamImplEntry / PackParamImplEntry (the registry constructs them; stay as
Lowering nested types). State maps remain on Lowering; the facade reads/writes
self.l.* (migrate once planning lands).

protocols.test.zig +2: registerImplBlock records Circle.draw in fn_ast_map (and
packArgConformsTo then sees it); registerParamImpl flags a same-file duplicate
impl Into(s64) for IntCell (the 0412-class, unit level).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
churn; the 0410/0411/0412 rejection diagnostics are byte-for-byte preserved.
2026-06-02 22:10:40 +03:00
agra
81d332dfb0 refactor(ir): extract protocol/impl lookup into protocols.zig (A4.2 step 2)
Move the pure protocol/impl conformance lookups into one module,
src/ir/protocols.zig, behind a *Lowering facade (ProtocolResolver), mirroring
GenericResolver / CallResolver. Per PLAN-ARCH A4.2 ("move pure lookup first;
keep emission in Lowering"), this increment moves only the read-only queries:
- getProtocolInfo (is a type a registered protocol + its method table),
- hasImplPlain (have the (protocol, type) thunks been materialized),
- packArgConformsTo (impl-declaration-level conformance for ..xs: P).

Registration (registerProtocolDecl / registerImplBlock / registerParamImpl) and
all IR emission (createProtocolThunk / buildProtocolValue / tryUserConversion /
getOrCreateThunks) stay in Lowering for the later increments. The state maps
(protocol_thunk_map / param_impl_map on Lowering, protocol_decl_map /
protocol_ast_map in ProgramIndex) stay put; the facade reads them via self.l.* —
no map migration.

Lowering keeps getProtocolInfo as a thin pub wrapper (~9 callers incl.
calls.zig); hasImplPlain + packArgConformsTo are deleted (no fallback), their 3
call sites (computeHasImpl x2, the pack-conformance check x1) routed through
self.protocolResolver(). formatTypeName widened to pub (the lookups use it);
protocolResolver() accessor added.

protocols.test.zig (wired into the barrel) drives ProtocolResolver directly:
getProtocolInfo (registered vs builtin/plain-struct + wrapper delegation),
hasImplPlain (thunk-map materialization), packArgConformsTo (non-parameterised
requires <ty>.<m> in fn_ast_map; trivially-true for an erased protocol value;
false for unknown protocol).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
snapshot churn; the 0410/0411/0412 rejection anchors still pass.
2026-06-02 21:56:03 +03:00
agra
df386a422e test(ir): lock protocol/impl lookup before A4.2 extraction (A4.2 scaffolding step 1)
Test-first scaffolding ahead of extracting src/ir/protocols.zig — no code change
to the refactor targets (registerProtocolDecl / registerImplBlock /
registerParamImpl / hasImplPlain / tryUserConversion / tryPackImplMatch /
createProtocolThunk / buildProtocolValue).

Adds 4 .ir snapshots (only 0400 existed for 04xx), each captured surgically via
`sx ir | normalize_ir`, path-free, idempotent, and print-free at IR-gen time
(the 0524 contamination lesson):
- 0413-protocols-parameterized-protocol-value  parameterized protocol
                                               (registerParamImpl + tryUserConversion)
- 0414-protocols-generic-struct-protocol-erase generic-struct erasure
                                               (createProtocolThunk + buildProtocolValue)
- 0416-protocols-auto-type-erasure             auto erasure (buildProtocolValue + thunk)
- 0528-packs-protocol-pack-methods             pack-variadic impl (tryPackImplMatch)

With existing 0400 (impl-for-builtin) they pin erasure (auto/generic/builtin) +
parameterized + pack-variadic + dispatch; the 0410/0411/0412 runtime anchors
already pin cross-module visibility + duplicate-impl rejections.

Adds 1 unit test via the public surface (no new exposure, mirroring A4.1
sub-step 1): registerProtocolDecl -> getProtocolInfo builds the dispatch method
table (method names, param_types with self excluded, concrete vs Self return
with ret_is_self + *void encoding). The impl-lookup / conversion plan-object
tests (hasImplPlain, tryUserConversion, tryPackImplMatch — private today) land
with the registry in sub-step 2.

zig build, zig build test, tests/run_examples.sh (357/0) all green.
2026-06-02 21:44:01 +03:00
agra
7ab5d7bee9 test(ir): cover buildTypeBindings strategy-2 inference (A4.1 coverage closeout)
Adds the one deferred A4.1 coverage item: a focused unit test for
GenericResolver.buildTypeBindings inferring a type param from value args
(strategy 2) with widest-match — add(1,2) => T=s64, and add(1.0,2) / add(1,2.0)
=> T=f64 regardless of argument order.

Previously this inference path was guarded only by the 0200 .ir snapshot; the
unit test pins it directly against the new generics.zig API. Test-only.

zig build test and tests/run_examples.sh (357/0) green.
2026-06-02 21:34:58 +03:00
agra
3ca68189c0 refactor(ir): extract GenericResolver (generics.zig) for substitution + mono keys (A4.1 step 2)
Generic substitution and monomorphization-key construction now live in one
module, src/ir/generics.zig, behind a *Lowering facade (GenericResolver),
mirroring CallResolver / ExprTyper. Moved verbatim:
- mangleTypeName + mangleParamList (the mono-key fragment builder),
- mangleGenericName (generic mono key), appendComptimeValueMangle (comptime-value
  fragment),
- buildTypeBindings (call-site type-param inference), inferGenericReturnType
  (generic return resolution).

inferGenericReturnType now uses a scoped TypeBindingScope (enter/exit with defer)
instead of a manual type_bindings save/restore — the PLAN-ARCH A4.1 "scoped
substitution env" shape; a generics.test.zig assertion confirms the prior
bindings are restored (the issue-0048/0050 leak class, for this field).

Lowering keeps a thin pub mangleTypeName wrapper delegating to
genericResolver().mangleTypeName, because ~30 cross-cutting callers (impl-map
keys, conversion keys, shape keys) reach it well beyond generics. mangleParamList
(sole caller was mangleTypeName) moved fully. The other 4 originals are deleted
(no fallback); their 6 call sites now go through self.genericResolver()
(calls.zig via self.l.genericResolver()).

matchTypeParam / extractTypeParam / isTypeParamDecl widened to pub (the moved
substitution logic calls them); genericResolver() accessor added. The 2
mangleTypeName / inferGenericReturnType unit tests moved from lower.test.zig to
generics.test.zig (driving GenericResolver directly) and wired into the barrel.

monomorphizeFunction / monomorphizePackFn intentionally stay in lower.zig (they
save/restore three fields across nested mono and call emission helpers) — a
heavier scoped-env adoption deferred to an optional sub-step 3.

zig build, zig build test, and tests/run_examples.sh (357/0) all green — no .ir
snapshot churn, confirming the move preserved mono-key/substitution output.
2026-06-02 21:28:31 +03:00
agra
e1f167a1c3 test(ir): replace contaminated 0524 IR snapshot with clean 0513 (A4.1 scaffolding fix)
The 0524-packs-generic-fn-pack-state-leak example has a #run that prints at
IR-gen time, and tests/run_examples.sh captures `sx ir ... 2>&1`, so its .ir
snapshot was contaminated with #run stdout (`0: len=0` ...) instead of pure IR.

Remove 0524.ir — pack-state isolation (the issue-0048/0050 class) stays guarded
by 0524's existing runtime .stdout/.exit, where a leaked outer pack_arg_types
would corrupt the printed len= sequence.

Replace it with 0513-packs-pack-mixed-comptime.ir, which is print-free at
IR-gen time (clean, idempotent, path-free) and additionally locks the
comptime-value mono-key path (appendComptimeValueMangle): the IR shows
tagged(7,..) vs tagged(9) producing distinct monos
@tagged__ct_7__pack_s64_s64_s64 / @tagged__ct_9__pack.

zig build, zig build test, tests/run_examples.sh (357/0) all green.
2026-06-02 21:13:50 +03:00
agra
91e99f80c7 test(ir): lock generic substitution + mono keys before A4.1 extraction (A4.1 scaffolding step 1)
Test-first scaffolding ahead of extracting src/ir/generics.zig — no code change
to the refactor targets (buildTypeBindings / mangleGenericName / monomorphize* /
inferGenericReturnType / mangleTypeName).

Adds the first non-FFI generic/pack .ir snapshots (closing the ARCH-SAFETY §3
gap for this phase), each captured surgically via `sx ir | normalize_ir`,
path-free and idempotent:
- 0200-generics-generic            generic fn, type-param inference + mono
- 0201-generics-generic-struct     generic struct instantiation
- 0507-packs-pack-mono-dedup       mono-key dedup (same shape => one mono)
- 0518-packs-pack-value-dispatch   pack value dispatch (monomorphizePackFn)
- 0524-packs-generic-fn-pack-state-leak  pack-state isolation (issue-0048/0050
                                         class; guards the future scoped-env change)

Adds 2 unit tests via the existing public surface (no new pub exposure,
mirroring the A3.2 sub-step-1 cadence):
- mangleTypeName: pins the mono-key fragment encoding per type shape
  (s64 / ptr_X / opt_X / SL_X / mptr_X / AR_n_X / vec_n_X / struct-name / tu_X_Y).
- inferGenericReturnType: explicit type-arg path binds $T and resolves the
  -> T return (pair(s64,..) => s64, pair(f64,..) => f64).

The internal substitution/mono-key unit tests (comptime-value mangle,
buildTypeBindings strategies, scoped-env isolation) land with the generics.zig
extraction in sub-step 2, as A3.2's plan-object tests landed with CallPlan.

zig build, zig build test, tests/run_examples.sh (357/0) all green.
2026-06-02 21:05:33 +03:00
agra
1007e23561 refactor(ir): source lowerCall's namespace/value boundary from CallResolver (A3.2 convergence step 3)
lowerCall re-derived the namespace-vs-value (receiver-prepend) decision with a
19-line block duplicating the exact identifier/type_expr + scope/global walk
that CallResolver already owns (objectIsValue, the negation of is_namespace).
This boundary determines whether the receiver is prepended, so it must agree
with the plan's free_fn_ufcs (prepends) vs namespace_fn (does not)
classification from fa59a9d.

Make CallResolver.objectIsValue pub and set
  is_namespace = !self.callResolver().objectIsValue(fa.object)
so plan and lowering share one boundary definition and can never drift.
`!objectIsValue` matches the old block case-for-case (non-identifier => value;
identifier/type_expr in scope/global => value; else => namespace), so this is a
behavior-identical substitution.

Deeper switch(plan.kind) routing of lowerCall is intentionally NOT done here: it
is not behavior-preserving as-is. `plan` is typing-only and coarser than
`lowerCall` — its method/namespace arms carry comptime / generic /
generic-template / #compiler / type-constructor dispatch `plan` does not model,
and its value-receiver kinds (struct_method/protocol_dispatch/foreign_instance)
do not gate on objectIsValue, so a type-name receiver (Point.make()) could be
mis-classified vs the namespace/static call lowerCall actually performs. Driving
prepend decisions off plan.kind would mis-prepend; objectIsValue is the correct
single source, hence routing the boundary specifically. PLAN-ARCH A3.2 success
criteria met (shared classifier; no duplicated return-type logic; plan tests;
stable .ir snapshots).

zig build, zig build test, tests/run_examples.sh (357/0) all green.
2026-06-02 20:53:13 +03:00
agra
fa59a9dc25 refactor(ir): distinguish free-function UFCS from namespace calls in CallPlan (A3.2 review fix)
CallPlan collapsed two different field-access dispatches onto namespace_fn:
a true namespace call (`pkg.fn()`, no receiver) and free-function UFCS
(`c.bump()`, receiver prepended + `*T` fixup). Return typing was preserved
either way, but sub-step 3 could not consume the plan — it would have had to
re-classify the AST to decide whether to prepend the receiver.

Add a distinct `free_fn_ufcs` kind and a plan(c) branch, inserted after the
struct-method block and gated on `objectIsValue` (the negation of lowerCall's
`is_namespace`: a non-identifier receiver is always a value; an
identifier/type_expr is a value iff it names a local or a global). The branch
sets prepends_receiver = true and reads prepends_ctx from the resolved FuncId
(best-effort, like direct_fn). namespace_fn now means strictly "receiver is a
namespace/type prefix".

New test `plan: free-function UFCS prepends receiver, distinct from
namespace_fn` covers a scope-bound `c.bump()` against a lowered free fn:
asserts free_fn_ufcs kind, func target, prepends_receiver, prepends_ctx, and
preserved s32 return type.

zig build, zig build test, tests/run_examples.sh (357/0) all green; return
typing unchanged.
2026-06-02 20:33:26 +03:00
agra
61f1f2368a refactor(ir): add CallPlan + CallResolver.plan(c); resultType delegates (A3.2 convergence step 2)
Introduce CallPlan — the single classification record for a call: kind (14
variants), return_type, a Target union (builtin/func/named/protocol_method/
foreign_method/constructed/none), variant tag, and the prepends_receiver /
prepends_ctx / expands_defaults properties the selected dispatch implies.

Move call recognition into CallResolver.plan(c) (branch order preserved
exactly) and reimplement resultType(c) as plan(c).return_type — the typing
consumer converges onto the plan first. lowerCall is untouched; routing it
through plan(c) is sub-step 3.

10 plan-object tests assert kind/target/variant + receiver/ctx/default
properties for every pinned call form: builtin/reflection, lazy + resolved
direct fn (incl. default-arg expansion + __sx_ctx prepend), closure /
default-conv vs C-conv fn-pointer, protocol dispatch, struct/UFCS #compiler
method, foreign instance vs static, qualified + dot-shorthand enum
construction, namespace fn, and the unresolved fallthrough.

Widen for the new collaborator only: resolveVariantIndex -> pub (plan resolves
the variant tag); Scope/Binding + init/deinit/put -> pub (so unit tests can
stand up a lexical scope for closure/fn-ptr callees without a full lowering).

zig build, zig build test, and tests/run_examples.sh (357/0) all green; no
behavior change.
2026-06-02 20:15:53 +03:00
agra
297f127821 test(ir): lock call lowering with .ir snapshots + classification tests (A3.2 convergence step 1)
Test-first scaffolding before the CallPlan convergence — no call-code
change. Locks current call behavior so the later lowerCall rewrite is
guarded.

- .ir snapshots for representative call forms: 0031 (direct local-fn +
  dot-shorthand enum ctor), 0032 (UFCS/struct method), 0301 (closure/
  fn-pointer slot), 0400 (protocol dispatch + static-through-impl).
- New focused example 0044-basic-default-arg-expansion + .ir snapshot,
  pinning call-site default expansion (scale(5)->scale(ctx,5,2),
  label(1)->label(ctx,1,"v","!")). Foreign-class instance+static is
  already pinned by the existing FFI .ir set.
- Broaden calls.test.zig (scope-free classification): remaining reflection
  builtins, sqrt->f64, cast->resolved type arg, enum_literal->target_type.

1033 (#caller_location) was rejected as a snapshot: it embeds the absolute
source path as a length-typed string that normalize_ir can't reconcile;
default-arg coverage uses the path-free 0044 instead.

Gate green: zig build, zig build test, tests/run_examples.sh -> 357/0.
2026-06-02 19:20:14 +03:00
agra
64cd3da5f5 docs(ir): correct stale ExprTyper comments re call result typing owner
The module doc and the `.call` arm comment still said call result typing
"stays in Lowering" and "converges in A3.2". As of 7f3a7b3 calls are routed to
CallResolver (calls.zig); update both comments to name the current owner. The
`.call` arm still delegates through Lowering.inferExprType — that's the routing
path to the owner, not a claim that Lowering owns the typing.

Comment-only. Gate: zig build, zig build test, run_examples.sh -> 356/0.
2026-06-02 19:02:44 +03:00
agra
7f3a7b35ef refactor(ir): extract CallResolver for call result typing (A3.2 relocation)
Move call-result-type discovery out of Lowering into a new src/ir/calls.zig
(CallResolver): the A3.1 Lowering.inferCallType body moves verbatim into
CallResolver.resultType. inferExprType's `.call` arm now delegates via
callResolver(); Lowering.inferCallType is gone.

CallResolver is a *Lowering facade (Principle 5, like ExprTyper/PackResolver):
call typing reads live lexical-scope / target-type state and the function /
foreign-class / protocol resolver helpers, so it borrows *Lowering. Transform
was `self.` -> `self.l.` plus the file-local static `resolveBuiltin(` ->
`Lowering.resolveBuiltin(`.

Widened to pub only what the facade actually consumes: resolveTypeArg,
inferGenericReturnType, resolveFuncByName, getProtocolInfo,
resolveForeignMethodReturnType, the static resolveBuiltin, and Scope.lookupFn.
resolveTypeArg widening is genuinely required here — the `cast` builtin's
result type calls it.

calls.test.zig adds focused tests (builtin/reflection classification, unknown
callee -> unresolved) for the scope-free paths. Barrel-wired in ir.zig.

This is the relocation half of PLAN-ARCH A3.2; call LOWERING (lowerCall) still
owns its own dispatch, and the CallPlan convergence (one plan shared by typing
and lowering, deleting the duplicated qualified/bare/lazy logic) remains.

Behavior-preserving. Gate: zig build, zig build test (incl. new CallResolver
tests), bash tests/run_examples.sh -> 356/0. lower.zig 18598 -> 18413.
2026-06-02 18:44:08 +03:00
agra
7d069107c8 refactor(ir): extract ExprTyper for non-call expression typing (A3.1)
Move the structural / non-call arms of Lowering.inferExprType into a new
src/ir/expr_typer.zig (ExprTyper): literals, unary/binary ops, try/catch, if,
block, field access, identifier/type-name, struct/tuple literals,
index/slice/deref, null-coalesce, caller_location, and the no-value statement
shapes. ExprTyper is a *Lowering facade (Principle 5, same as PackResolver) —
expression typing reads live lexical-scope / pack / target-type state and ~14
resolver helpers, so it borrows *Lowering rather than re-threading every field;
the plan's TypeResolver/ProgramIndex/ResolveEnv ideal is the later-phase target
as that state lifts into an explicit context (documented in the module doc).

Lowering.inferExprType is now a 2-arm dispatcher: `.call => inferCallType(c)`
(call result typing stays in Lowering until A3.2), else delegates to
ExprTyper.inferType. The call arm body moved verbatim into the new
Lowering.inferCallType (the by-value `|c|` capture became a `*const ast.Call`
param; the lone `&c` -> `c`).

14 Lowering helper methods consumed by the facade were widened to pub
(orIsFailableChain, orChainSuccessType, errorChannelOf, failableSuccessType,
isObjcClassPointer, lookupObjcPropertyOnPointer,
lookupObjcDefinedStateFieldOnPointer, getElementType, optionalOfFlattened,
getStructFields, isKnownTypeName, comptimeIndexOf, packArgNodeAt, resolveType)
plus Scope.lookup — the same pub-for-facade step PackResolver took. Fields need
no change (Zig fields are always cross-file accessible).

expr_typer.test.zig adds focused unit tests (literal shapes, comparison vs
arithmetic, unary not/negate, deref of non-pointer) for the scope-free
structural arms. Barrel-wired in ir.zig.

Behavior-preserving. Gate: zig build, zig build test (incl. new ExprTyper
tests), bash tests/run_examples.sh -> 356/0. lower.zig ~18774 -> 18598.
2026-06-02 18:14:34 +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
8ff24472c9 refactor(ir): extract unknown-type diagnostic pass into semantic_diagnostics (A2.4)
Moves the issue-0064 unknown-type pass (checkUnknownTypeNames + 11 helpers:
collectDeclaredTypeNames, harvestScopeDecls, checkStructFieldTypes,
checkFnSignatureTypes, checkScope, walkBodyTypes, checkCastTarget,
checkTypeNodeForUnknown, reportIfUnknownType, isBuiltinTypeName, isIdentLike)
out of Lowering into a new src/ir/semantic_diagnostics.zig (UnknownTypeChecker).

The checker holds borrowed references (alloc, *DiagnosticList, *TypeTable,
*ProgramIndex, main_file) — not *Lowering — and queries the canonical facts:
declared top-level names from ProgramIndex, primitives from
TypeResolver.resolvePrimitive, registered concrete types from the TypeTable.
The AST decl/scope walk stays (it collects LOCAL type decls, which ProgramIndex
doesn't track — a per-pass scope need, not a parallel authoritative list).

Lowering.lowerRoot builds the checker only when diagnostics are active and runs
it; the 12 functions are deleted from lower.zig. Barrel-wired in ir.zig.
Example snapshots (issue-0064 regressions 1111-1115) are the guard, matching the
checkErrorFlow precedent (no .test.zig).

Phase A2 complete. Gate: zig build, zig build test, run_examples 351/0.
2026-06-02 16:12:28 +03:00
agra
7cc8057374 Merge Phase A2 (canonical type resolution) into master
Brings the architecture stream's Phase A2 onto master (A2.1–A2.3b + issue 0067):
- A2.1 (9eb85cf): ResolveEnv + TypeResolver shell (primitives + compounds).
- A2.2 (dd16bab): generic-binding + alias-aware name resolution into TypeResolver.
- A2.3 (3ed1b3a): pack projections → PackResolver; retire the TypeTable.aliases
  borrow (alias map threaded explicitly).
- A2.3b (9b50aac): converge structural type-shape resolution onto the single
  TypeResolver.resolveCompound; type_bridge reduced to a thin adapter.
- 0067 (744decc): reject non-type tuple-literal-as-type elements with a
  diagnostic instead of fabricating .s64.

Gate: zig build, zig build test, run_examples 351/0. Codex-reviewed (round 2).
2026-06-02 16:02:59 +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
9b50aacbe4 refactor(ir): converge structural type-shape resolution onto resolveCompound (A2.3b)
Codex corrective step before the A2 merge gate: A2.3 left type_bridge with a
parallel structural type-resolution algorithm and an inline tuple-literal-spread
shape in lower.zig with a `.void` fallback.

Finding 1 — single owner for structural shapes:
- TypeResolver.resolveCompound is now the sole structural type-shape
  constructor. Namespaced on `table` (so the stateless type_bridge can call it)
  and extended to own function types, plain `Closure(P...) -> R`, and plain
  positional/named tuples (it already owned *T/[*]T/[]T/?T/[N]T). It returns
  null only for the pack-shaped forms that need caller state (`Closure(..p)`,
  spread tuples); OOM yields `.unresolved`.
- type_bridge: deleted its 8 independent structural resolvers
  (resolveArray/Slice/Pointer/ManyPointer/Optional/Function/Closure/TupleType).
  resolveAstType delegates those node kinds to resolveCompound via a binding-free
  StatelessInner adapter. The only residual stateless shape code is two tiny
  fallbacks for the pack-shaped forms resolveCompound defers
  (resolveClosurePackShape — used by Into(Block) at registration time —
  and resolveTupleSpreadShape) plus resolveParameterizedType (kept:
  generic-instantiation convergence is A4.1 per PLAN-ARCH).
- lower.zig: stateful resolveTypeWithBindings uses resolveCompound; the
  `.function_type_expr` switch arm is gone. PackResolver.resolveFunctionTypeWithBindings
  deleted (subsumed). Plain closures/tuples now resolve via resolveCompound in
  both paths; only pack closures / spread tuples reach PackResolver.

Finding 2 — no `.void` failure fallback in lower.zig pack handling:
- the inline tuple_literal-with-spread type assembly moved into
  PackResolver.resolveTupleLiteralType (returns ?TypeId; OOM `catch return .void`
  became `catch return .unresolved`).

Alias result preserved: TypeTable.aliases stays gone; no table.aliases reads;
ProgramIndex.type_alias_map threaded explicitly.

type_resolver.test.zig: resolveCompound test rewritten (namespaced + new
function/closure/tuple/pack-shape arms, arena-backed). Gate green: zig build,
zig build test, run_examples 350/0.
2026-06-02 15:20:31 +03:00
agra
3ed1b3a7a0 refactor(ir): pack projections → PackResolver + retire the alias borrow (A2.3)
A2-merge gate: both parts in one commit, behavior-preserving (350/0).

Part 1 — retire the TypeTable.aliases borrow (build-enforced):
- type_bridge.zig: add `AliasMap` and thread it as an explicit param through
  every name-resolving fn (resolveAstType, bridgeType, resolveTypeName, the
  compound resolvers, resolveTupleLiteralAsType, resolveParameterizedType, the
  inline enum/struct/union + error resolvers). resolveTypeName now forwards the
  threaded map to TypeResolver.resolveNamed instead of reading table.aliases.
- lower.zig: all 31 resolveAstType callers pass
  &self.program_index.type_alias_map; drop the lowerRoot loan.
- types.zig: remove the now-unused TypeTable.aliases field.
- type_bridge.test.zig: alias test passes alias_map explicitly; other calls
  pass null.

Part 2 — pack projections get one owner + no .void failure sentinel:
- New packs.zig (PackResolver, a *Lowering facade): moves
  resolveClosure/Tuple/FunctionTypeWithBindings, packTypeElems, packTypeArgs,
  elementProtocolTypeArg out of Lowering. Call sites route through
  Lowering.packResolver(); barrel-wired in ir.zig.
- The missing-projection `orelse .void` in packTypeArgs now emits a diagnostic
  and fills the slot with .unresolved (the tripwire sentinel), never a real
  .void; OOM `catch return .void` in the moved fns became .unresolved too.
  Legitimate no-return-type `else .void` defaults are preserved.
- packs.test.zig: packTypeArgs bound/unbound/no-constraint/no-state cases +
  the missing-projection backstop (diagnostic + .unresolved slot).
2026-06-02 14:43:47 +03:00
agra
dd16bab2c2 refactor(ir): move generic-binding + alias-aware name resolution into TypeResolver (A2.2)
Architecture phase A2.2 -- behavior-preserving. TypeResolver gains the
generic-binding and bare-name resolution it now owns:

- resolveBinding(node, env): $T / bare return-type T lookup via an explicit
  ResolveEnv (no hidden Lowering state).
- resolveNamed(name, table, alias_map): the full bare-name algorithm (primitive
  -> arbitrary-width int -> string-prefix [*]/*/?/[:0]u8 -> already-registered
  -> alias(alias_map) -> empty-struct stub), MOVED from
  type_bridge.resolveTypeName so it is single-sourced.
- resolveName(self, name): resolves through the canonical alias source
  ProgramIndex.type_alias_map -- the compiler path no longer reads the
  TypeTable.aliases borrow.

Lowering.resolveTypeWithBindings: the `if (self.type_bindings)` block (the $T
lookup plus parameterized/call/closure/function arms that were redundant with
the unconditional handling below) collapses to one resolveBinding delegation via
a new resolveEnv() snapshot; the bare-name fallback routes type_expr/identifier
to resolveName (index-based alias), other node kinds still to resolveAstType.

type_bridge.resolveTypeName becomes a 1-line delegate to resolveNamed, passing
its TypeTable.aliases borrow as the alias source. Single algorithm; the alias
map stays single-sourced in ProgramIndex.

Deferred to A2.3: removing the TypeTable.aliases borrow (its ~30 resolveAstType
callers must converge onto TypeResolver first) and type_bridge's stateless
compound resolvers. A2.2 #3 (templates/protocols/type-fns via ProgramIndex) was
already satisfied by A1.1b.

Tests: resolveBinding ($T bound/unbound/no-env), resolveName (alias->primitive,
alias->pointer via ProgramIndex), resolveNamed (width-int, string-prefix,
unknown->stub).

No new fallback path; no duplicate truth. Gate green: zig build, zig build test,
bash tests/run_examples.sh (350 passed, 0 failed).
lower.zig 19372->19367; type_bridge.zig 647->592; type_resolver.zig 90->159.
2026-06-02 13:56:32 +03:00
agra
9eb85cf9e3 refactor(ir): add ResolveEnv + TypeResolver shell; own primitives + compounds (A2.1)
Architecture phase A2.1 -- behavior-preserving. Introduce src/ir/type_resolver.zig
as the canonical AST-type-node -> TypeId resolver (Principle 1), starting with:

- ResolveEnv: the explicit resolution-context shape (Principle 2) -- type/pack/
  comptime bindings + target_type. Defined now; consumed as A2.2/A2.3 move the
  cases that need it.
- TypeResolver.resolvePrimitive(name): the builtin keyword table, MOVED here from
  type_bridge.resolveTypePrimitive (now a re-export -> single source; its 7
  callers are unaffected; no import cycle).
- TypeResolver.resolveCompound(node, inner): the structural compound types
  *T / [*]T / []T / ?T / [N]T. Element types recurse via inner.resolveInner (an
  anytype callback) so generic structs / bindings in element position keep their
  full stateful resolution.

Lowering.resolveTypeWithBindings duplicated the 5 simple compounds across its
bindings and no-bindings blocks (10 arms). Both are replaced with a single
self.typeResolver().resolveCompound(node, self) delegation; adds
Lowering.resolveInner (recursion hook) + typeResolver() (by-value view).

Deliberately deferred: tuples, closures, and function types stay on the existing
pack-aware helpers (resolveClosure/Tuple/FunctionTypeWithBindings); A2.3 owns
their pack-projection logic.

Tests: src/ir/type_resolver.test.zig (resolvePrimitive keyword/null cases;
resolveCompound for all 5 + null for non-compound; ResolveEnv defaults), wired
into the ir.zig barrel.

No new fallback path; no duplicate truth. Gate green: zig build, zig build test,
bash tests/run_examples.sh (350 passed, 0 failed). lower.zig 19393 -> 19372.
2026-06-02 13:25:27 +03:00
agra
8fbaf9ca6a docs(ir): mark sema/types as editor-only, not compiler truth (A1.2)
Architecture phase A1.2 — documentation/comment only, no behavior change.

Resolve the ambiguity over which type model compiler decisions trust:

- src/sema.zig: file-level module doc stating it is the editor symbol/type
  index for the language server (navigation/completion), NOT a compiler
  semantic pass. Its Type values are editor metadata; the compiler uses the
  canonical TypeId/TypeTable model in src/ir/. sx requires no as-you-type type
  checking -- authoritative diagnostics are produced on save by the canonical
  pipeline. Added notes on SemaResult, Analyzer, resolveTypeNode, inferExprType.
  No public API renamed (would churn LSP call sites).
- src/types.zig: note that Type is editor metadata only, not compiler truth;
  do not expand for new compiler semantics (A8 deletes/reduces it).
- src/ir/types.zig: fix stale TypeTable.aliases comment -- it borrows
  Lowering.program_index.type_alias_map (post-A1.1b).

Deleting the LSP's parallel sema diagnostic stream is A8.1, not this step.

Gate green: zig build, zig build test, bash tests/run_examples.sh (350 passed).
2026-06-02 12:54:30 +03:00
agra
fb262e9e59 refactor(ir): move declaration maps into ProgramIndex (A1.1b)
Architecture phase A1.1b — mechanical storage relocation. Move the 9
declaration-fact maps out of the Lowering state bag into ProgramIndex:

  high-fanout:   fn_ast_map, foreign_class_map, global_names, type_alias_map
  medium-fanout: struct_template_map, protocol_decl_map, protocol_ast_map,
                 module_const_map, ufcs_alias_map

168 self.<map> sites in lower.zig repointed to self.program_index.<map>;
external readers repointed too (core.zig foreign_class_map iteration;
lower.test.zig fn_ast_map / foreign_class_map). No duplicate storage, no
fallback path; zig build enforces no missed reference.

The four maps whose value types were Lowering-private pull those types into
program_index.zig as pub (GlobalInfo, StructTemplate + TemplateParam,
ProtocolDeclInfo + ProtocolMethodInfo, ModuleConstInfo); lower.zig aliases
them at file scope so call sites are unchanged.

Behavior is preserved exactly:
- per-map allocator unchanged — import_flags/fn_ast_map/global_names use the
  lowering allocator (ProgramIndex.init), the other 7 keep their page_allocator
  inline defaults;
- ProgramIndex.deinit frees only the 10 owned maps, never the borrowed
  module_scopes / import_graph;
- TypeTable.aliases still borrows &self.program_index.type_alias_map, loaned at
  lowerRoot with the same late-binding lifetime.

Extends program_index.test.zig with declaration-map round-trips (fn AST, type
alias, global, module const, foreign class, protocol decl/AST, struct template,
ufcs alias).

Registration logic (registerStructDecl / registerProtocolDecl /
registerForeignClassDecl, ...) stays in Lowering, writing through the index.

Gate green: zig build, zig build test, bash tests/run_examples.sh
(350 passed, 0 failed). lower.zig 19433 -> 19393 lines.
2026-06-02 12:30:11 +03:00
agra
90520eefeb refactor(ir): extract ProgramIndex, move low-fanout decl facts (A1.1a)
Architecture phase A1.1a. Introduce src/ir/program_index.zig as the single
storage owner for declaration-name / import / visibility facts, and move the
three low-fanout maps out of the Lowering state bag:

- import_flags     (owned by ProgramIndex)
- module_scopes    (borrowed pointer into a core.zig-owned map)
- import_graph     (borrowed pointer into a core.zig-owned map)

Lowering embeds one ProgramIndex by value and reaches every moved fact through
self.program_index.<field>; later phases hand collaborator modules a
*ProgramIndex instead of *Lowering. 8 call sites in lower.zig + 2 setters in
core.zig repointed. No duplicate storage, no fallback path; zig build enforces
no missed reference.

Mutation-heavy registration (registerStructDecl etc.) stays in Lowering and
now writes import_flags through the index. High-fanout maps are deferred to
A1.1b.

Adds src/ir/program_index.test.zig (init-empty, import_flags round-trip,
borrowed-view ownership) wired into the ir.zig barrel.

Behavior-preserving: zig build, zig build test, and bash tests/run_examples.sh
(350 passed, 0 failed) all green.
2026-06-02 12:04:31 +03:00
agra
795ce3dc7d test(runner): make example suite checkout-location independent
Diagnostics embed the absolute source path, but normalize() only scrubbed
hex addresses, so expected snapshots baked in the canonical checkout path
(/Users/agra/projects/sx/...). The suite only passed when run from that exact
directory; from a git worktree all 44 path-printing diagnostics mismatched.

Collapse any absolute `.../examples/` or `.../issues/` prefix to the repo-
relative form. The rule runs through normalize(), which is applied identically
to both expected and actual output, so it can only reconcile path noise — it
cannot desync an otherwise-matching pair. No snapshots regenerated.

Suite now reports 350 passed / 0 failed from a worktree as well as the
canonical tree.
2026-06-02 11:18:12 +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
9214eefba1 chore(bench): build servers into .sx-tmp; fix stale example path
Some checks failed
Build / build-linux (push) Has been cancelled
Build / build-windows (push) Has been cancelled
The bench built sx-server/zig-server into bench/ (committed as stray
artifacts) and pointed at the pre-migration examples/32-http-server.sx.
Now builds into the gitignored .sx-tmp/ and uses the current
examples/1602-platform-http-server.sx, so a run leaves the tree clean.
2026-06-02 09:48:06 +03:00
agra
2c518f2dc4 chore: remove zig-server bench binary; don't gitignore artifacts
Drop the accidentally-tracked-in-working-tree zig-server bench binary
(built by bench/run.sh). Reverted the wasm_check* gitignore entry from
bce53b7 — these are removed, not ignored.
2026-06-02 09:43:05 +03:00
agra
bce53b720b chore: drop accidentally-committed wasm_check build artifacts
The wasm_check{,2}.{html,js,wasm} files are emscripten smoke-test output
committed by accident in f9ecf9d; nothing in the build/tests/docs
references them. Removed and gitignored the pattern so future WASM
output stays out of the tree.
2026-06-02 09:42:08 +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
296c809d85 ERR/E1.8: path-sensitive value-slot liveness check
A `v, err := failable()` destructure now binds the value slot(s) "live
only where `err` is proven absent". Reading `v` where the compiler cannot
prove `err == null` is a compile error.

New diagnostic-only Pass 1e (`checkErrorFlow` in ir/lower.zig): a
structured, path-sensitive walk over each main-file function body. A
proven-null set is threaded across branches and joined by intersection
at each `if`'s merge. Proof shapes recognized:

  - `if !err { … v … }`           (proven inside the guard)
  - `if err { return/raise } … v` (proven on the fall-through)
  - `if err { … } else { … v … }` (proven in the else branch)
  - `!err and <reads v>`          (short-circuit refinement)

Error-set tag compares (`if err == error.X`) prove nothing about
absence — they narrow the tag only. Nested lambdas are analyzed as their
own boundaries. Library modules are trusted (skipped).

Migrated the canon value-failable examples (1011/1012/1018/1044) to read
their value slots under `if !err` guards — output unchanged. New
regressions: 1046 (every proof shape compiles + runs, exit 210) and 1047
(unproven reads rejected, exit 1).

Gates: zig build, zig build test, run_examples.sh -> 338 passed, 0 failed.
2026-06-01 23:14:24 +03:00
agra
5491acbcbb ERR/E5.4: add composition section to the errors smoke example
Extends 1036-errors-failable-smoke with an end-to-end Composition section
covering the E5.1 forms: a failable closure literal through a Closure(...)
param (try-propagated, caught), a non-failable closure literal widened
into a failable bare slot (∅-widening adapter), and generic ($T)
value-carrying failable composition. Completes E5.4 — the per-feature
examples (1039-1045) remain the focused units; this is the integrated
smoke.
2026-06-01 22:47:58 +03:00