Skip to content

Lower spread-arm ternaries into per-branch blocks instead of hoisting#160

Merged
ctate merged 43 commits into
mainfrom
fix/ts-ternary-spread-arms
Jul 20, 2026
Merged

Lower spread-arm ternaries into per-branch blocks instead of hoisting#160
ctate merged 43 commits into
mainfrom
fix/ts-ternary-spread-arms

Conversation

@ctate

@ctate ctate commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

A dogfooding agent's stock-quotes core hit broken Zig from the transpiler on the classic TEA reducer idiom — conditional expressions whose arms are object literals with spreads. Reproducing through the real transpile-and-compile harness exposed three distinct classes:

  • Narrowed ternaries hoisted their arms' statements into the parent scope: a spread arm's frameCreate + field stores landed above the conditional, so the optional capture was read before it bound (use of undeclared identifier) and both arms evaluated unconditionally. Broken in return, declaration, argument, and object-field positions, both null-check polarities, and nested arm-within-arm.
  • Switch-payload captures were mistyped as null-narrowed: any member substitution was treated as a narrowing, so a payload capture over an optional field skipped the narrow path and emitted ?f64 into an f64 store — the reporter's exact update shape.
  • The orelse fusion emitted spread miss-arms with no expected type, failing transpile with an internal error instead of a teaching.

The lowering now probes each arm: statement-free arms keep the existing tight if (x) |c| A else B emission unchanged (pinned by regression tests), statement-bearing arms re-emit as per-branch blocks — single evaluation, correct capture scoping, frame-arena discipline preserved, nested conditionals composing. Rename-only substitutions (switch payloads, kind-guard payloads) now register distinctly so optional typing stays honest through save/restore cycles.

Tests: ten conformance cases across every breaking position and polarity (compiling in Debug and ReleaseFast), a run-fidelity reducer case driving both branches of every shape with byte-identical node-vs-native transcripts, and pure-arm emission pins. All sabotage-verified against the pre-fix emitter with the exact reported error classes.

- The R7 narrowed-ternary paths emitted both arms into the surrounding ctx, so a spread literal's statements landed above the conditional: the capture was read before it bound (use of undeclared identifier) and both arms ran unconditionally; arms that lower statements now take the R17b temp lowering with the capture substitution active, while pure arms keep the tight if/orelse expression forms.
- Union payload captures of optional fields registered in memberSubst like null-narrowing captures, so zTypeOfExpr unwrapped ?f64 payload reads to f64 and routed reducers around the narrowing lowering; stillOptionalSubst records rename-only substitutions by exact replacement.
- The orelse fusion emitted its miss arm with no expected type, failing spread/plain literal arms with an internal NS9001; the arm now gets the unwrapped expected (or the target's inner type) and falls through to the narrowed lowering when it needs statements.
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
native-sdk Ready Ready Preview, Comment Jul 20, 2026 11:20pm

ctate added 2 commits July 19, 2026 11:03
- if (r === null) break; — and the continue, labeled, multi-statement, throw-exit, and if (x !== null) {...} else { exit } spellings — now narrow like the early-return guard: alwaysExits covers every statement tsc's flow analysis treats as never-falling-through, with breaks bound to the switch itself recognized as fall-through.
- Narrowing maps are flow-scoped at block boundaries now, so a guard's captures end with the loop body or branch they narrow instead of leaking into code where the Zig capture is out of scope and the narrowing wrong in TS.
- An unlabeled break that exits a switch from inside a clause body teaches at transpile time instead of emitting a Zig break that would jump past the enclosing loop; conformance pins every fixed shape in both optimize modes, and a run-fidelity case holds the loops byte-identical to node.
- withKindNarrow snapshotted memberSubst and narrowedUnion but not stillOptionalSubst, while narrowUnion mutates all three: a redundant kind guard inside a switch payload capture overwrote the optional marker for good, so the capture read as non-null and null tests routed around the narrowing lowerings (emitting ?f64 into float arithmetic).
- The kind-switch arm restore now drops its stillOptionalSubst additions alongside the memberSubst ones, keeping the marker map paired with the substitutions it annotates.
- Conformance pins the reviewer's shape (a redundant kind guard, expression and statement spellings, before a null test on the payload) in both optimize modes.
ctate added 2 commits July 19, 2026 11:50
- zTypeOfExprRaw typed `q === null ? { ...fallback, price: 0 } : q` by the raw optional arm, so an UNANNOTATED local declared a `?Quote` temporary whose first non-optional use failed Zig compilation ("expected type 'Quote', found '?Quote'"); the annotated spelling masked it by supplying the expected type, which every fusion and lowering site then forwarded unwrapped.
- The conditional's computed type now applies the condition's own null test to the arm that reuses the tested value (both polarities), keeping the optional when the other arm is itself optional — its null still flows through.
- Conformance pins the inferred-local shape in both polarities and both optimize modes; runfidelity drives both branches of both polarities against node.
- The narrowing maps key on the tested expression's text, so a shadowing declaration of the same union-typed name collides the switch subject's key: a kind guard over the shadow overwrites the subject's optional-payload marker, and only the full-map restore in withKindNarrow puts it back on exit.
- The subset accepts variable shadowing, so the shapes pin directly: a callback parameter shadowing the subject with a ternary kind guard inside `.some` before a null test on the subject's payload (reverting the full-map restore emits the optional into integer arithmetic — "invalid operands to binary expression: 'optional' and 'int'"), plus the block-scoped statement spelling of the same shadow.
- Test-only: both cases compile in both optimize modes with the restore in place.
ctate added 3 commits July 19, 2026 13:06
- The adjacent-declaration orelse fusion consumed reassigned `let` bindings and emitted `const`, so the later assignment failed Zig with "cannot assign to constant"; fusion now consults the reassignment tracking and skips them.
- The plain-path exit guard bound a capture that went stale at the assignment; reassigned targets now keep the plain null test and narrow through the live-variable `.?` spelling, which non-null writes keep alive.
- Pins: emitter shapes (no-fusion + const-fusion regression), conformance compile, and a runfidelity case asserting the spread reassignment lands.
- The arm cleanup deleted keys absent from its snapshot but never restored OVERWRITTEN entries, so a redundant nested switch on the same subject left the inner capture name active and the continuation emitted it after its Zig block closed ("use of undeclared identifier").
- Both snapshotted maps (memberSubst, stillOptionalSubst) now clear-and-repopulate, matching every other restore in the family; audit found no remaining delete-only restore.
- Pins: emitter shape (continuation reads the outer capture) and a conformance compile case.
- The else-if chain path returned from emitIf before the post-if narrowing ran, so an exiting null guard heading a chain left fall-through reads on the still-optional value ("optional type does not support field access").
- The narrowing tail is now a helper applied on both exit paths; conformance covers else-if, else-if-else, chained else-if-else-if, and the present-test polarity, plus an emitter shape pin.
- Scope exits restore their narrowing-map snapshots for containment, which also resurrected a narrow an assignment inside the branch had killed; post-merge null checks then emitted `p.? == null` (Zig: comparison of 'f64' with null).
- Invalidation frames now ride the ctx: the assignment path records each permanently deleted memberSubst key into the innermost frame, and every snapshot/restore scope (blocks, switch arms, kind guards) re-applies its frame's kills after the restore and merges them outward through nested exits.
- Conformance pins the reassign-to-null, optional-call, else-branch, nested-block, read-then-kill, switch-arm, and kind-guard shapes plus a containment regression pin; runfidelity drives the null path whose output would differ under a resurrected narrow.
- withSubsts and emitNullGuardChainUnwrap restored their full memberSubst snapshots after the branch had re-applied assignment kills, resurrecting a narrow killed under a compound null guard (post-merge re-check emitted `p.? == null`).
- Both scopes now push/pop kill frames like branch and switch-arm exits, so every full-map narrowing restore in the emitter re-deletes killed entries and merges them outward.
- Conformance pins the reviewer repro plus else-branch and kind-guard-nested variants; runfidelity drives the compound-guard kill path against node.
ctate added 2 commits July 19, 2026 20:00
- A branch that killed a narrow and then always left the function (return, or a throw no enclosing try catches) merged that kill outward anyway, deleting the substitution a surviving read depends on: `if (p.v < 0) { p = null; return -1; } return p.v;` emitted field access straight onto the `?P` — tsc keeps p narrowed there because the killing path cannot reach it.
- emitBlockStatements now asks the existing alwaysExits predicate the stronger function-leaving question (returns count only where they emit as real returns, not the `break :label` lowering inside lifted callbacks; throws only where ctx.tryLabel is null; break/continue never — a break-guarded kill resumes after the loop, where it must hold) and pops its kill frame in drop mode when the list cannot fall through.
- The other kill-frame pops keep the unconditional merge: withKindNarrow, the chain-condition emitter, and withSubsts bracket branch or expression scopes whose statement bodies already made the drop/merge decision at their own block frame, and the switch-arm frame only ever receives kills from paths that resume at the arm's merge (a clause-terminating break binds the switch).
- The straight-line callback lifting emitted the statement prefix under emitBlockStatements — whose exit restores the narrowing maps — and then emitted the trailing return's expression outside that scope, so a prefix guard's narrowing unwound before the expression it covers: `xs.map((p) => { if (p === null) throw bad; return p.v; })` read `p.v` against the raw `?P`.
- The flow-scope bracket now lives in withNarrowScope (snapshot, kill frame, restore, merge-or-drop), emitBlockStatements delegates to it, and the callback path brackets prefix plus trailing expression in ONE scope — the restore still runs before the callback's siblings in the emitted loop body, so its narrowing cannot leak.
- Conformance pins the throw-guard and kind-guard prefix spellings plus the labeled-block early-return lifting; runfidelity drives a throw-guarded map trailing read against node.
- A try body ending in return still exits from any throwing call, so its kill frame drops only when no local catch can resume past the merge; a catch that itself always leaves the function re-enables the drop.
- try/finally adds no fallthrough of its own: the body inherits the enclosing handler's answer, stated at the emit site.
- Conformance pins the fall-through-catch, catch-always-returns, and finally shapes; runfidelity drives the thrown path through a swallowing catch to the post-try re-check.
- Replace the final-statement leavesFn question and alwaysExits's fnExit flavor with allRoutesLeaveFunction: a list's kills drop only when every route out of it leaves the function — fallthrough, lowered returns, break/continue bound at or outside the list, and throws (or throwing calls) with a resumable handler all merge them outward instead.
- Compute a catch arm's resumability with the same analysis against the outer handler, so a throwing call inside an exiting catch that lands in an outer fall-through catch keeps the try body's kills alive at the outer merge.
- Pin the escaped break/continue, labeled break, do-while, and nested-try shapes in conformance plus a run-fidelity case for the break path, with inner-loop, switch-bound-break, and exiting-outer-catch drops as controls.
- alwaysExits now recognizes a constant-true loop no break binds (literal-true while/do-while, literal-true or omitted classic-for condition), so a kill sealed behind an infinite loop drops at the merge the way tsc's reachability keeps the surviving narrow.
- bindsBreak generalizes from switches to loops: a break bound to the loop makes its end reachable, so those kills still merge.
- Gate the callback whose every path loops forever or throws with a teaching; its labeled value block would carry a label no break uses, which Zig rejects.
ctate added 2 commits July 19, 2026 22:26
- A fall-through catch resumes at the statements following the try, so the throw route's destination is the handler plus that continuation: allRoutesLeaveFunction now scans statement lists positionally and asks whether catch-then-rest leaves before calling the route resuming
- The catch's own routes still classify against the handler visible outside its try, and loop bodies, switch clauses, and inline callbacks keep a conservative resuming continuation
- Pins the drop, the fall-through-continuation merge, and the returning-catch composition in the kill-fallthrough corpus, plus a byte-fidelity run driving both the throw and no-throw routes
…hinery

- normalize() folds top-level parens so keys and comparisons collide for (q) and q: the ternary value narrowing, the orelse arm match, and every memberSubst key/lookup site now treat parenthesized spellings as the tested target
- emptyTestOf and kindGuardOf skip parens on the condition, both operands, and the returned target, so guard recognition and downstream shape checks see the bare expression
- Pins the parenthesized spread arm, the fused statement-free arm, the doubly-parenthesized arm, and the parenthesized tested side in the ternary conformance corpus
- A parenthesized or assertion-wrapped null arm ((null), ((null)), null as T) typed the ternary non-optional, so the later guard's unwrap was skipped and field reads hit the raw optional
- New isNullLiteral helper strips value-preserving wrappers before every null-literal decision; applied to the ternary arm checks and the nullish-coalescing fold
- Conformance pins the wrapped spellings on both arms and the doubly-wrapped one
- narrowKey now strips non-null assertions, as, and satisfies alongside parentheses, so every key consumer at once matches q! and (q as Quote) to the tested q — ternary arm identity, orelse fusion, and substitution lookups included.
- emptyTestOf and kindGuardOf strip the same wrappers on the condition and its operands, so a wrapped tested target (q! !== null) narrows through the capture form exactly like the bare spelling.
- The statement-level orelse fusion matches its guard by declaration identity instead of name text, and the ternary read gates use declaration identity too (a wrapped read is still a read of the declaration).
ctate added 3 commits July 20, 2026 09:42
…isions

- An undefined arm's internal type is void, so the ZType-based arm check read it as a non-empty value: the ternary typed non-optional, the later === undefined guard's unwrap was skipped, and the field read hit the raw optional (invalid Zig on a shape main compiles)
- New isEmptyLiteral helper (wrapper-stripped null keyword OR the global undefined identifier) replaces isNullLiteral in the ternary arm-emptiness checks and the nullish-coalescing fold, making the undefined path symmetric with the null path in both arm positions
- Conformance pins both arm positions plus the as-wrapped and doubly-parenthesized spellings; runfidelity drives both polarities and both branches against node
- Throwaway probe ctxs shared the narrowing maps and kill-frame stack by reference, so a probe processing a callback assignment to an outer narrowed local deleted the narrow before the sibling arm emitted — the sibling read the raw optional, invalid Zig
- Every probe now brackets in withNarrowScope, and ternary arms follow the statement-level sibling law: each arm probes and emits from the entry state, real kills joining once after the last arm via applyJoinedNarrowKills; the isolation law is documented at childCtx
- Conformance pins the generic and capture-lowered flavors; runfidelity drives both arms and the empty-input corner against node
…e text

- The capture gates matched property chains by getText().includes, so a shadowed spelling inside a nested callback counted as a read of the outer target: the capture bound, the declaration-keyed substitution correctly declined to rewrite the shadow, and Zig rejected the unused capture
- New anyReadsKey walks the nodes' property accesses and compares canonicalized narrowKeys — the same identity substitutions are looked up by — and replaces the text gates in anyReadsTarget, branchReadsTarget, followingReadsTarget, and the switch payload-use check; diagnostics keep text
- Conformance pins shadow-only (no capture), outer-plus-shadow (used capture), guarded-branch, exit-guard, and payload-field shapes; runfidelity drives hit/miss and empty inputs against node
ctate added 2 commits July 20, 2026 10:58
- A finally emits as a Zig defer whose text precedes the try body's but whose flow follows it: its assignment kills previously applied at emission time, stripping the body's reads of narrows tsc keeps there and emitting field accesses against raw optionals.
- The finally now emits in a bracketed scope whose kills stage until the construct's exit, and its entry state drops every key the try or catch may assign null to — tsc types a finally path-insensitively (an exception can enter it between any two statements), so such reads re-check the live variable, never a stale capture.
- Pins the emission-order case, the finally-reads-killed-narrow case, and three-region kills in conformance plus a byte-fidelity run against node.
- Kills riding throw edges into a catch previously applied to the post-try state unconditionally, poisoning the normal fallthrough when the catch leaves by break, continue, or return — paths tsc types with the narrow intact, since only clean flow reaches the continuation.
- The pending set now enters as the catch list's input state: its reads still see the kills, and they ride the list's kill-frame routing out — post-try on fallthrough, the edge stage on break/continue, dropped when every catch route leaves the function.
- Pins catch-break in a loop, a labeled break out of nested loops, a re-killing fallthrough catch, and an always-returning catch in conformance plus a byte-fidelity run against node.
ctate added 2 commits July 20, 2026 11:50
- narrowKey qualifies element accesses recursively: literal indexes canonicalize by value, identifier indexes key their own declaration, and computed indexes decline narrowing entirely instead of falling back to raw source text, which let a shadowed declaration's narrow rewrite the outer name's reads across flattened blocks.
- Every consumer honors the keyless decline (no substitution, capture gates off, live optional reads); the optional-call receiver rewrite keeps working through an explicit node-scoped key.
- Conformance pins both chain directions, shadowed identifier indexes, and the computed-index decline; the run-fidelity corpus pins the outer read's value against node.
- A capture substitution now transitions to the live-slot `.?` spelling when the assignment's value is provably non-null, matching tsc, which keeps the target narrowed; possibly-null assignments still kill.
- Guarded branches choose their form up front (captureServesBranch): a capture binds only when every reassignment is straight-line and a read precedes it; otherwise the branch takes the plain test with `.?` reads, which stay valid through nested reassigns, sibling joins, and loop back edges.
- Conformance pins all-reads-after, reads-around, conditional, else-arm, loop, and possibly-null shapes; the run-fidelity corpus pins the reassigned values against node.
- tsc evaluates the do-while condition after the body and carries the body's flow state into it, but the while(true)+trailing-exit-test lowering closed the body's narrowing scope before emitting the lowered `if (!(cond)) break;` — a terminal guard's narrow unwound before the test it covers, and `do { if (p === null) return -1; n += p.v; } while (p.v > 0);` read `p.v` on the raw `?P` (transpile reported success; Zig rejected the field access). Body and test now share one scope via emitBlockStatements' trailing hook, restored only at the loop boundary.
- The early-exit guard's follower walk now sees that trailing test (trailingDoWhileTestOf), so a guard read only by the condition still binds its capture — and binds none for the first-pass-flag form (a body binding `continue` hoists the test ahead of the body, where the capture would be an unused Zig const) or an always-exiting body (no test emits). Continue-carried kills still widen the hoisted test onto the live optional (tsc rejects the bare read — pinned), and break-carried kills still land only post-loop.
- Conformance pins the guard/capture-only/back-edge-re-narrow/continue/break/nested spellings, emitter tests pin the tsc-level widenings (continue edge into the test; no body narrow across the back edge into the body top), and runfidelity drives the trailing-test read through zero, one, and multiple iterations against node.
- alwaysExits read each switch clause alone, so an empty stacked label
  (case "a": case "b": return x) judged non-exiting and the branch's
  narrowing kills merged into flow that cannot reach the merge — valid TS
  emitted raw optional arithmetic. Clauses now group to their
  statement-bearing tail, matching the switch emitters' label coalescing;
  a trailing run of empty labels keeps the switch nonterminal.
- A LabeledStatement always read as non-exiting, defeating the
  constant-true-loop and terminal-block judgments under it. A labeled
  statement is now terminal iff its wrapped statement is terminal and no
  break names the label (a break to the label resumes right after it).
- Conformance and run-fidelity cases pin both shapes and the
  merge-preserving variants: a stacked group that falls out of the
  switch, trailing empty labels, and break-to-label loops and blocks.
…tinee's type

- alwaysExits read every defaultless value switch as nonterminal, so a
  branch ending in a switch whose case labels cover the scrutinee's
  literal-union type merged kills from flow that cannot reach the merge -
  valid TS emitted raw optional arithmetic. The predicate now consults
  the checker's flow type at the scrutinee (string/number/boolean literal
  unions with literal case labels only; anything wider keeps the
  conservative merge) before applying the stacked-group exit rule.
- The lowering closes the same path the claim closes: a covered-by-type
  defaultless switch whose arms all exit emits `else => unreachable` for
  the never-reached fallthrough (string enums already emitted a
  Zig-exhaustive switch with no else), so an exhaustive switch can end a
  value-returning function with no reachable missing-return path.
- Conformance and run-fidelity cases pin full coverage (separate cases,
  a stacked group, numeric literal unions, function-final position) and
  the merge-preserving variants: one member uncovered, a plain-string
  scrutinee, and the boolean-scrutinee emission gate.
ctate added 2 commits July 20, 2026 15:47
…er the raw flow type

- tsc never widens a local's narrowing for assignments inside callbacks, so trusting its flow type at the scrutinee let a defaultless switch claim coverage real execution escapes — the lowering then armed the fallthrough with an `else => unreachable` that non-empty input REACHES (safety panic in Debug, undefined behavior in ReleaseFast) while node falls out and returns.
- Coverage now judges by the scrutinee's declared union (sound unconditionally: a local can hold any declared member at runtime), keeping the flow type only for an identifier local/param no nested function assigns, where the straight-line CFA is exact; property reads judge by the declared property type and unjudgeable shapes decline.
- Both consumers demote together through the one memoized judgment: when it declines, emitValueSwitch closes the fallthrough with a completing `else => {}` and alwaysExits answers nonterminal, so the kill merges and post-switch re-checks stay live.
…lly's entry narrows

- finallyEntryKills scanned the raw syntax of the try and catch, so assignments tsc's CFA never counts — code under a keyword-literal false condition, callback bodies — killed narrows tsc typed the finally with, stripping the substitution and emitting member access on a raw optional Zig rejects.
- Probed the checker provider and matched it exactly: `if (false)`, the else of `if (true)`, a `while (false)` body, and the short-circuited side of `false &&` / `true ||` keep the finally narrowed, while a `const NEVER = false` alias condition widens (so only bare keywords skip); a callback defined in the try that assigns the target never widens, called there or not.
- Genuinely conditional assignments (`if (flag) p = null`) still kill path-insensitively, and everything unpinned (do-while, non-literal conditions) stays on the conservative walk.
ctate added 4 commits July 20, 2026 17:21
… chains

- anyReadsTarget counted a plain assignment's bare identifier target as a consuming use, so a write-only ternary arm bound a payload capture nothing reads — Zig rejects the unused capture; the arm now takes the plain comparison while reading arms keep the capture.
- The identifier walk now uses identifierRead, the same judgment branchReadsTarget and captureServesBranch already apply: compound assignments and ++/-- still read the slot, and property-chain targets are immutable model data, so their key walk is unchanged.
…dgment

- Kills under a bare keyword-literal condition — `if (false)`, the else of `if (true)`, a `while (false)` body — merged at branch joins and stripped narrows tsc keeps, emitting member access on a raw optional; the arms still emit, only the flow bookkeeping skips them.
- Factored the judgment into tscExcludedArm, shared by the finally-entry scan and the join layer (if/else arms, else-if chains, the while body merge, and the loop's staged break edges).
- Pinned the boundaries: a do-while(false) body runs once (the test follows the body), so its kills still count, and a `const NEVER = false` alias condition never excludes.
- scrutineeFlowTrustable declined flow trust for ANY nested-function assignment in the enclosing function, including callbacks textually after the switch that cannot have executed before it — demoting flow-exhaustive switches to their declared unions and leaving guarded fall-through reads on raw optionals.
- Arrows and function expressions do not exist as values before their definition evaluates: they now count only when the definition precedes the switch, or a loop encloses both so the back edge re-enters the switch after the definition ran.
- Function declarations hoist and class-member bodies exist from their container's creation, so both still count regardless of position; the judgment sits on the OUTERMOST nested function.
- Labels are function-scoped in JS: a nested helper's `break outer` binds the helper's own `outer` label, never the enclosing function's loop — but bindsBreak walked into nested function bodies, reading an enclosing constant-true loop as fallible and merging branch kills tsc keeps, and labelReferenced kept emitting a Zig loop label nothing in the function consumes (an unused-label error).
- bindsBreak, bindsContinue, labelReferenced, and the early-break gate now stop at function boundaries — the same rule breaksToLabel, escapingEdgesOf, and the route analysis already applied, so every binding walk is uniform.
ctate added 2 commits July 20, 2026 18:05
…se;)

- The route walks (allRoutesLeaveFunction, escapingEdgesOf) and the terminality binders (bindsBreak, breaksToLabel) now consult tscExcludedArm: a break/continue under an arm tsc's CFA cannot reach is not an edge, so it no longer stages kills at loop exits the surviving flow never carries. labelReferenced and bindsContinue deliberately keep counting — the arm's text still emits, so the label must stay referenced and the do-while flag form must host the emitted continue; the split is documented at the helper.
- tscExcludedArm gains the literal-false-condition ForStatement case (body and incrementor), mirroring while(false); wired through every consumer: the finally entry scan, the loop-dispatch join sites, and emitClassicFor's body scope. An omitted for condition stays the opposite judgment (infinite loop) — literal false never collides with for(;;).
- Conformance, emitter-pin, and runfidelity coverage for both directions: dead break/continue keep the post-loop narrow, real break/for kills still stage and re-guard.
- An inferred literal union (const k = x > 0 ? 1 : 2) passes the sound coverage judgment, so alwaysExits claims the switch terminal, but the emitter-level type is plain number and the lowering is emitPlainSwitch's if/else chain — left open, a callback value block or returning function falls off the chain's end into invalid Zig.
- emitPlainSwitch now reads the same memoized judgment through alwaysExits and closes the claimed-terminal defaultless chain with an unreachable else the claim has already proven no execution reaches; a non-exhaustive inferred union still declines and completes normally.
- The agreement invariant gains its third leg: no switch lowering may leave a completable fallthrough under a terminality claim — emitValueSwitch and emitPlainSwitch assert it, the kind switch closes by construction, and a test pins all three lowerings.
@ctate
ctate merged commit f4b9d26 into main Jul 20, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant