Commit Graph

614 Commits

Author SHA1 Message Date
agra
91b300580b Merge Phase A6 (FFI domain extraction) into master
Extracts the pure FFI decision helpers out of lower.zig into domain modules,
behavior-preserving, test-first (scaffolding commit locks behavior, extraction
commit moves code), gated on zig build / zig build test / tests/run_examples.sh.

- A6.1 — Obj-C decision helpers → src/ir/ffi_objc.zig (ObjcLowering, a *Lowering
  facade): deriveObjcSelector, objcTypeEncodingFromSignature (+appendObjcEncoding/
  bailObjcEncoding/ObjcEncodingStack), objcPropertyKind (+ObjcPropertyKind enum),
  isObjcClassPointer, objcDefinedStateStructType (+objcStateAllocatorType).
- A6.2 — pure JNI helpers → src/ir/jni_descriptor.zig (plain pub free fns, no
  facade): jniMangleNativeName, isJniReturnTypeSupported. (Descriptor derivation
  was already there.)

Emission-heavy code (emitObjc*/lowerObjc*Call, synthesizeJniMainStub*/lowerJni*)
stays in lower.zig per PLAN A6 step 6; Java rendering stays in jni_java_emit.zig.

Regression anchors: 48 13xx Obj-C + 26 14xx JNI examples, 4 Obj-C + 9 JNI .ir
snapshots, ObjC/JNI helper unit tests. Gate at merge tip: zig build,
zig build test, tests/run_examples.sh -> 361/0.
2026-06-03 08:35:38 +03:00
agra
20c767e336 refactor(ir): move pure JNI helpers into jni_descriptor.zig (A6.2 step 2)
Relocate the two pure JNI decision helpers out of lower.zig into
jni_descriptor.zig (already the JNI helper module), alongside the descriptor
derivation. Behavior-preserving move — no facade, since neither takes *Lowering.

- jniMangleNativeName(allocator, foreign_path, method_name) and
  isJniReturnTypeSupported(table, ret_ty) moved verbatim as pub free fns; added a
  types import + TypeId alias to jni_descriptor.zig.
- Rerouted lower.zig's 2 call sites (synthesizeJniMainStub; the JNI return-type
  guard at lower.zig:6000) through jni_descriptor.* — lower.zig already imported
  the module.
- Moved the 2 unit tests lower.test.zig -> jni_descriptor.test.zig (re-pointed to
  desc.*; a standalone TypeTable.init replaces the Module setup). Dropped the
  now-unused lower_mod alias.
- Stayed in lower.zig per PLAN A6.2 step 5/6: jniMapParamType (trivial resolveType
  wrapper), synthesizeJniMainStub(s), lowerJniCall, lowerJniConstructor,
  lowerSuperCall, getJniEnvTlFids. Java rendering stays in jni_java_emit.zig.
  Phase A6 complete.

Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(9 JNI .ir snapshots + 26 14xx examples green, no churn).
2026-06-03 08:28:41 +03:00
agra
0a4a240e31 test(ir): lock pure JNI decision helpers before A6.2 extraction (A6.2 scaffolding step 1)
Test-first scaffolding for the JNI FFI domain (Phase A6.2) before the pure
helpers move out of lower.zig. Visibility-only change — no behavior change.

- 2 new lower.test.zig tests for the pure JNI helpers lacking unit coverage:
  - jniMangleNativeName: `/`->`_` separator, `_`->`_1` escape (path AND method),
    `Java_` prefix, `_sx_1` infix (2 cases lock all rules).
  - isJniReturnTypeSupported: void/bool/s32/s64/f32/f64 + pointer/many-pointer
    -> true; other widths (s8/s16/u8/u32/u64) + by-value struct -> false.
- JNI descriptor derivation (writeType/deriveMethod) is already extracted into
  jni_descriptor.zig (15 tests) — not part of A6.2.
- Widened jniMangleNativeName -> pub (file-scope free fn; isJniReturnTypeSupported
  already pub). Reached from the test via ir_mod.lower.*. No logic touched.
- Recorded the A6.2 coverage inventory + residual emission-bound gaps
  (synthesizeJniMainStub*/lowerJniCall/lowerJniConstructor/lowerSuperCall/
  getJniEnvTlFids stay in lower.zig; jniMapParamType is a trivial resolveType
  wrapper) in ARCH-SAFETY.md.

Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(no .ir churn; 9 JNI .ir snapshots green).
2026-06-03 08:14:46 +03:00
agra
9bde1dd590 refactor(ir): extract ObjcLowering (ffi_objc.zig) for pure Obj-C decision helpers (A6.1 step 2)
Move the pure Obj-C decision helpers out of lower.zig into src/ir/ffi_objc.zig
behind an ObjcLowering *Lowering facade (Principle 5, like the A4/A5 resolvers).
Behavior-preserving relocation — the only non-self.l rewrites are facade
plumbing.

Moved verbatim (self. -> self.l. for Lowering members):
- deriveObjcSelector (selector derivation)
- objcTypeEncodingFromSignature + appendObjcEncoding + bailObjcEncoding +
  the ObjcEncodingStack type
- objcPropertyKind + the ObjcPropertyKind enum
- isObjcClassPointer
- objcDefinedStateStructType + objcStateAllocatorType

Emission-heavy code stays in lower.zig per PLAN A6.1 step 6: emitObjc* IMP
builders, lowerObjc*Call, registerObjc*, declareObjc*, the lookupObjc* property/
state lookups, and the Self-substitution resolvers.

- Call sites rerouted through a new objc() accessor: 15 in lower.zig, 1 in
  expr_typer.zig, 39 in lower.test.zig (the A6.1 scaffolding tests now drive the
  facade). No Lowering wrappers kept. Barrel-wired ffi_objc + ObjcLowering.
- No new visibility widening beyond sub-step 1's two pubs — the facade reads
  self.l.{alloc,module,program_index,diagnostics} (fields) + the already-pub
  resolveType. lower.zig -478 (->16615); ffi_objc.zig 428.
- Doc-only re-home: the property-IMP getter/setter comment was attached (a
  pre-existing artifact) to the moving ObjcPropertyKind enum, two decls away from
  its real subject emitObjcDefinedClassPropertyImps (which had no doc). Re-homed
  it there so the move neither orphans a `///` block (Zig errors on a dangling doc
  comment) nor misattributes it to ensureArcRuntimeDecls.

Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(48 13xx Obj-C examples + 4 Obj-C .ir snapshots green, no churn).
2026-06-03 08:00:42 +03:00
agra
b5119e8587 test(ir): cover Obj-C protocol pointers in isObjcClassPointer/objcPropertyKind (A6.1 scaffolding review fix)
Codex review of 0012228 noted isObjcClassPointer's contract is
`fcd.runtime == .objc_class or fcd.runtime == .objc_protocol`, but the new tests
only exercised the class case. Test-only fix (no visibility/behavior change —
still exactly the two pub widenings from the parent commit):

- isObjcClassPointer: add a *NSCopying case where NSCopying is a registered
  .objc_protocol foreign class -> true (alongside the .objc_class *NSString case).
- objcPropertyKind: add a *NSCoding protocol-pointer field -> strong default
  assertion, since it uses the same class/protocol object-pointer predicate.

Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0.
2026-06-03 07:45:10 +03:00
agra
0012228796 test(ir): lock pure Obj-C decision helpers before A6.1 extraction (A6.1 scaffolding step 1)
Test-first scaffolding for the Obj-C FFI domain (Phase A6.1) before the pure
helpers move into src/ir/ffi_objc.zig. Visibility-only change to the targets —
no behavior change.

- 3 new lower.test.zig tests for the pure helpers the ARCH-SAFETY A6.1 row names
  that lacked direct unit coverage:
  - deriveObjcSelector: niladic (bare name) / single-keyword (name:) /
    multi-keyword (_ -> : + trailing) / #selector(...) override (verbatim,
    keyword_count = #colons).
  - objcPropertyKind: assign default (primitive), strong default (object ptr),
    explicit weak/copy/assign win over the default.
  - isObjcClassPointer: pointer-to-foreign-Obj-C-class true; plain-struct ptr /
    *void / builtin false.
- objcTypeEncodingFromSignature (x6) + objcDefinedStateStructType (x3) already
  covered — no new tests.
- Widened deriveObjcSelector + objcPropertyKind to pub (they become facade
  methods in step 2; the ObjcPropertyKind enum stays private — tests compare via
  enum-literal == .strong). No logic touched.
- Recorded the A6.1 coverage inventory + residual gaps (resolveObjcParentName,
  class-method metadata, property/state lookups — example-guarded) in
  ARCH-SAFETY.md.

Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(no .ir churn; Obj-C snapshots 1309/1329/1332/1347 green).
2026-06-03 07:15:56 +03:00
agra
d346bbb677 Merge arch-refactor through Phase A5 into master
Brings the compiler-architecture refactor from the Phase A2 merge (7cc8057)
up through Phase A5. Each phase converged a compiler question onto a single
canonical owner, behavior-preserving, test-first (scaffolding commit locks
behavior, extraction commit moves code), gated on
zig build / zig build test / tests/run_examples.sh at every step.

- A2.4 — unknown-type diagnostic pass → semantic_diagnostics.zig.
- Issues 0068–0073 — forward type aliases, global/module-const initializers,
  non-constant global diagnostics, closure-in-defer lowering segfault.
- A3.1 — non-call expression typing → ExprTyper (expr_typer.zig).
- A3.2 — call result typing + classification → CallResolver/CallPlan (calls.zig);
  lowering shares the namespace/value boundary.
- A4.1 — generic substitution + mono keys → GenericResolver (generics.zig).
- A4.2 — protocol/impl lookup + registration + planning → ProtocolResolver
  (protocols.zig).
- A4.3 — coercion/xx classification → CoercionResolver (conversions.zig).
- A5.1 — error-set convergence → ErrorAnalysis (error_analysis.zig).
- A5.2 — path-sensitive error-flow diagnostics → ErrorFlow (error_flow.zig).

Regression anchors incl. examples 1046–1053 for the error streams.
Gate at merge tip: zig build, zig build test, tests/run_examples.sh -> 361/0.
2026-06-03 07:01:19 +03:00
agra
1f354f6da0 refactor(ir): extract ErrorFlow (error_flow.zig) for path-sensitive error-flow diagnostics (A5.2 step 2)
Move the diagnostic-only Pass 1e (ERR E1.7 cleanup-absorption + E1.8 value-slot
liveness) out of lower.zig into src/ir/error_flow.zig behind an ErrorFlow
*Lowering facade (Principle 5, like ErrorAnalysis/CoercionResolver). Behavior
preserved exactly — pure relocation.

Moved verbatim (self. -> self.l. for Lowering members; sibling calls stay on the
facade; provenHas is a file-local free fn): checkErrorFlow, analyzeFnBody,
flowWalk, flowStmt, flowIf, flowMatch, flowExpr, applyRefinement,
provenAdd/provenClone/provenIntersect, registerFailableDestructure,
checkCleanupBody/checkCleanupNode/cleanupReject, plus the FlowCtx/ProvenSet types.

- lowerRoot routes the single call site through
  self.errorFlow().checkErrorFlow(decls); no Lowering wrapper kept (only the
  pipeline calls it, no unit-test caller). New errorFlow() accessor.
- The pass takes AST decls + ProgramIndex + diagnostics only — independent of IR
  Builder state (PLAN-ARCH A5.2 success criterion).
- New pub: exprIsFailable (only widening; inferExprType/errorChannelOf already
  pub). lower.zig -389 (->17030); error_flow.zig 407. Barrel-wired in ir.zig.
- No .test.zig: diagnostic-pass altitude (functions return only bool + emit
  diagnostics) — guarded by example anchors 1046-1053 (incl. scaffolding
  1051/1052/1053). Phase A5 complete.

Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0
(anchors 1046-1053 all ok, no .ir churn).
2026-06-03 06:54:13 +03:00
agra
2d2bfafa29 test(ir): pin both lambda arms of the error-flow pass (A5.2 scaffolding review fix)
Codex review of 95895a3 found 1051 reached neither lambda arm it claimed to
pin: the lambda arrived only as a var_decl initializer, which routes through
checkCleanupNode's `.var_decl` arm -> cleanupReject(lambda) -> early-return
(a lambda literal is not failable), so the `.lambda` stop never ran; and its
accepted-direction `if !err` guard would still pass with flowExpr's lambda
recursion removed.

Scaffolding-only fix (no compiler change):
- 1051: add a bare lambda STATEMENT `() -> !E { failing(); };` in the cleanup
  body so checkCleanupNode sees a `.lambda` node directly and stops (the bare
  failable inside is accepted; were the arm to recurse it would reject like
  1052). Output byte-identical — only the .sx gained the statement.
- 1053-errors-nested-lambda-liveness-reject (exit 1): an E1.8 value-slot read
  inside a never-called nested lambda, rejected only because flowExpr recurses
  via `.lambda => analyzeFnBody`. Remove that arm and the diagnostic vanishes
  -> suite fails. This is the discriminating negative 1051 lacked.

Gate: zig build test, bash tests/run_examples.sh -> 361/0.
2026-06-03 06:42:51 +03:00
agra
95895a3bb2 test(ir): lock error-flow diagnostics before A5.2 extraction (A5.2 scaffolding step 1)
Test-first scaffolding for the path-sensitive error-flow pass
(checkErrorFlow/analyzeFnBody/flowWalk/flowIf/checkCleanupBody) before it
moves into src/ir/error_flow.zig. No compiler change — both examples lock
current behavior.

- 1051-errors-cleanup-closure-boundary (accepted): a closure literal inside a
  `defer` body is its own function boundary — the E1.7 cleanup rule and the
  parser's try/raise ban both stop at the lambda, and E1.8 value-slot liveness
  runs per-boundary. Pins checkCleanupNode's `.lambda` stop + flowExpr's
  `.lambda` recursion. Constructible since issue 0073 (0310).
- 1052-errors-cleanup-transitive-reject (exit 1): the E1.7 cleanup check is
  transitive — bare failables nested in an `if` (both branches), a nested
  block, and a `while` body all reject. Pins checkCleanupNode's recursive arms,
  distinct from 1049's direct-body case.

No .test.zig/.ir: diagnostic-pass altitude (checkErrorFlow/A2.4 precedent) —
the pass returns no fact object and emits no IR.

Gate: zig build, zig build test, run_examples.sh -> 360/0.
2026-06-03 06:31:18 +03:00
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