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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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).
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.
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.
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).
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.
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.
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.
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).
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).
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).
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.
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.
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.
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.
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.
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).
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.
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.