diff --git a/changelog.d/ts-ternary-spread-arms.md b/changelog.d/ts-ternary-spread-arms.md new file mode 100644 index 00000000..331dd32e --- /dev/null +++ b/changelog.d/ts-ternary-spread-arms.md @@ -0,0 +1,15 @@ +fix: **Ternaries with spread-literal arms compile from TypeScript cores**: `parsed === null ? q : { ...q, state: "ok", price: parsed }` — and the nested, `!==`, both-arms, argument, object-field, and `x === null ? { ...fallback } : x` spellings — no longer emit Zig that reads the null-narrowing capture before it binds (`use of undeclared identifier`) or evaluates both arms unconditionally; arms that build values statement-by-statement now lower into per-branch blocks feeding a typed temp, so exactly the taken arm runs, and pure-arm ternaries keep their tight `if`/`orelse` expression forms. +- **Optional switch payloads keep their optional through the capture**: reading a `number | null` payload (directly or via a `const` local) inside its `case` no longer mistypes the value as non-null, which routed `msg.parsed === null ? ... : ...` around the narrowing lowering and emitted `?f64` into `f64` slots. +- **Early-exit guards narrow like early returns**: `if (r === null) break;` in a parse loop (and the `continue`, labeled, multi-statement, `throw`-exit, and `if (x !== null) { ... } else { break/return }` spellings) now narrows the optional for the rest of the loop body the way tsc's flow analysis does, instead of emitting Zig field access on the still-optional value; `if (msg.kind !== "num") break;` narrows the union payload the same way. +- **Guard narrowing ends with its block**: a guard's captures no longer leak past the loop body or branch they narrow — reads after the construct see the unnarrowed value again (matching tsc, whose exit path may bypass the guard) instead of referencing an out-of-scope Zig capture. +- **Early switch-clause breaks stop the build**: an unlabeled `break` that exits a `switch` from inside a clause body now teaches at transpile time — Zig's `break` binds loops, so the old emission jumped past the enclosing loop instead of resuming after the switch. +- **A redundant kind guard no longer un-optionals a switch payload**: scoped kind-narrowing now restores the still-optional markers alongside the substitutions it snapshots, so `const marker = msg.kind === "got" ? 1 : 2;` inside `case "got":` no longer leaves a `number | null` payload typed non-null for the rest of the clause (which emitted `if (parsed != null) parsed + marker else 0` — invalid Zig operands on the `?f64`). +- **Inferred locals from narrowed ternaries value non-optional**: `const picked = q === null ? { ...fallback, price: 0 } : q;` (either polarity, no `: Quote` annotation) now types the local by the arm the condition narrows — `Quote`, exactly as tsc infers — instead of the raw optional, which declared a `?Quote` temporary that failed Zig compilation at its first non-optional use (`expected type 'Quote', found '?Quote'`). +- **A redundant nested `switch` on the same subject hands back the outer capture**: the arm cleanup now repopulates its narrowing maps from the snapshot instead of only deleting the arm's additions, so an inner arm's capture that OVERWROTE the outer arm's entry no longer leaks into the continuation after the inner switch (which emitted the inner capture name after its Zig block had closed — `use of undeclared identifier`). +- **Else-if chains keep post-if narrowing**: `if (x === null) return -1; else if (flag) { n = 2; } return x.v + n;` — and the else-if-else, chained else-if-else-if, and `!==`-polarity spellings — now narrow `x` after the statement like the plain-else form does; the else-if emission path returned before applying the post-if narrowing, so the fall-through read landed on the still-optional value (`optional type does not support field access`). +- **Reassigned `let` bindings never fuse into a `const`**: `let p = next(i); if (p === null) continue; p = { ...p, v: 10 };` no longer fuses the declaration and guard into `const p = next(i) orelse continue;` (Zig: `cannot assign to constant`); the binding stays a `var`, the guard keeps its plain null test, and later reads unwrap the live variable — which the assignment path keeps narrowed across provably non-null writes. +- **A branch that widens a narrowed optional stays widened past the merge**: `if (p === null) return -1; if (flag) { p = null; } if (p === null) return 0;` — the branch-exit restore that keeps narrowing CONTAINED (additions inside a branch die at its exit) no longer also resurrects a narrow the branch killed by assigning null (or a fresh optional-returning call); kills now re-apply after every branch, switch-arm, and kind-guard exit and propagate through nested blocks, so the post-merge re-check tests the live value instead of emitting `p.? == null` (Zig: `comparison of 'f64' with null`). The merge is conservative — a kill on any path that can reach the merge drops the narrow, and the re-check tsc demands anyway always compiles. +- **Compound-guard branches keep those kills dead too**: `if (r !== null && r > 0) { p = null; }` — where the branch emits under the chain's `.?` substitutions — no longer resurrects p's killed narrow when that substitution scope restores its snapshot (its restore ran after the branch re-applied the kill); the scope now rides the same kill-frame protocol as branch and switch-arm exits, and so does the chain-condition emitter, so every full-map narrowing restore in the emitter re-deletes killed entries on exit. +- **A kill on an always-exiting branch stays off the surviving flow**: `if (p.v < 0) { p = null; return -1; } return p.v;` inside a null guard — tsc keeps `p` narrowed at the second return because the killing branch left the function, and the emitter now agrees: a branch that always returns (or throws uncaught) drops its kills at the merge instead of deleting the narrow the surviving read depends on (which emitted field access straight onto the `?P`). Kills on paths that resume inside the function — fall-through arms, `break`/`continue` guards in loops, throws caught by an enclosing `try` — still merge outward and drive the post-merge re-check. +- **A guard in a lifted callback covers the trailing return it precedes**: `xs.map((p) => { if (p === null) throw bad; return p.v; })` lifts the callback as its statement prefix plus the trailing return's expression, and the prefix's narrowing scope closed before that expression emitted — the read landed on the raw `?P` (`optional type does not support field access`); prefix and trailing expression now share one flow scope, and the scope still closes before the callback's siblings in the emitted loop body. +- **A do-while body guard covers the trailing test it flows into**: `do { if (p === null) return -1; n += p.v; } while (p.v > 0);` — tsc evaluates the condition after the body, under the body's flow state, but the body's narrowing scope closed before the lowered `if (!(cond)) break;` emitted, so the test read the raw `?P` (`optional type does not support field access`); the body and the trailing test now share one narrowing scope, restored at the loop boundary. A guard read only by the test binds its capture too, `continue`-carried kills still widen the hoisted first-pass test onto the live optional, and `break`-carried kills still land only on the post-loop state. diff --git a/packages/core/src/emitter.ts b/packages/core/src/emitter.ts index 6c23e25f..b0906ca4 100644 --- a/packages/core/src/emitter.ts +++ b/packages/core/src/emitter.ts @@ -55,6 +55,26 @@ class EmitError extends Error { } } +/// One enclosing edge-target construct (see Ctx.edgeKills). `label` is the +/// SOURCE label naming the construct (loopLabel mangling happens at +/// emission), null for unlabeled ones. An unlabeled `break` binds the +/// innermost loop-or-switch stage, an unlabeled `continue` the innermost +/// loop, labeled exits the innermost stage carrying their label, and a +/// lifted callback's lowered `return` the innermost callback stage. +interface EdgeKillStage { + readonly kind: "loop" | "switch" | "block" | "callback"; + readonly label: string | null; + readonly kills: Set; +} + +/// Where a closing scope's recorded kills go — kills travel the same edges +/// control does. `merge` applies them to the surviving fall-through flow; +/// `drop` discards them (every route leaves the emitted function); an array +/// of destination sets carries them along the scope's non-local edges (the +/// enclosing try's pending catch set, break/continue target stages, the +/// lifted callback's stage), each applied where its edge lands. +type KillRouting = "merge" | "drop" | readonly Set[]; + interface Ctx { readonly lines: string[]; indent: number; @@ -70,14 +90,71 @@ interface Ctx { /// `throw` (or a throwing call) breaks to it; null means the throw /// propagates (`return error.Thrown`) to the caller. tryLabel?: string | null; + /// R20: true while emitting inside a try body whose local catch has some + /// route back into the function. There ANY statement containing a + /// throwing call is a resuming route — it lands in the catch, and the + /// catch's fall-back carries the region's narrowing kills to a merge + /// point — so kill dropping (allRoutesLeaveFunction) treats each one as + /// a route that does not leave. A catch whose every route leaves the + /// function (including its own throws, judged against ITS enclosing + /// handler) closes that path, and the flag stays off for its body. + catchResumes?: boolean; + /// R20: the ENCLOSING try's pending exception kills — kills travel the + /// same edges control does (allRoutesLeaveFunction's route destinations). + /// A scope whose only in-function routes are throws caught by that try's + /// catch never falls through into the try body's remaining statements, + /// so its kills must not merge into intra-try flow; they wait here and + /// emitTryCore hands them to the catch as its ENTRY state (the catch + /// body's reads see them), where they ride the catch's own kill-frame + /// routing out — post-try on fall-through, the edge stage on a + /// break/continue, dropped when every catch route leaves the function + /// (see popNarrowKillFrame's route form). The throw edge is one instance + /// of the general rule; break/continue/lowered-return edges stage the + /// same way on edgeKills below. + pendingCatchKills?: Set | null; + /// The enclosing edge-target constructs of the emitted function, + /// innermost last: loops, switches, labeled blocks, and lifted-callback + /// value blocks. A scope that exits ONLY along break/continue/lowered- + /// return edges stages its kills on the target construct's set + /// (popNarrowKillFrame's route form, the sibling of pendingCatchKills), + /// and the construct's emitter applies them where those edges land — the + /// post-construct state for breaks and callback returns, and for + /// continue edges the back-edge join, which the emitter also realizes as + /// the post-loop state: the loop-entry model delegates in-body + /// next-iteration reads to tsc (the checker rejects a read relying on a + /// narrow the back edge kills), so the only emission the kill must still + /// reach is the normal-completion exit. Applying a stage records into + /// the then-innermost kill frame, so staged kills keep propagating + /// outward exactly like merge-class kills. + readonly edgeKills: EdgeKillStage[]; /// decl node -> zig identifier readonly names: Map; readonly used: Set; /// normalized source text of an expression -> replacement zig expr /// (optional-narrowing captures, union payload captures) readonly memberSubst: Map; + /// The subset of memberSubst entries that RENAME without narrowing: a + /// union payload capture of an optional-typed field holds the payload + /// as-is (`.got => |parsed|` with `got: ?f64` binds `parsed: ?f64`), so + /// type queries must keep the optional. Maps key -> that replacement; + /// zTypeOfExpr only unwraps when the ACTIVE replacement differs (a + /// null-guard capture overwriting the key installs a fresh name, so the + /// identity check stays correct through save/restore cycles). + readonly stillOptionalSubst: Map; /// source text of a union-typed expr -> active arm tag (kind guards) readonly narrowedUnion: Map; + /// Invalidation frames, innermost last: every scope that snapshots and + /// restores the narrowing maps pushes a Set here, and the assignment path + /// records into the innermost frame each memberSubst key whose narrowing + /// it permanently deleted (the assigned value may be null again). The + /// snapshot restore at scope exit gives CONTAINMENT — narrowings added + /// inside the scope die there — but restoring alone would also resurrect + /// narrowings an assignment inside the scope killed; re-applying the + /// frame's deletions after the restore keeps those dead. Frames merge + /// outward on pop so an inner branch's kill reaches every enclosing exit + /// it can fall through to; a scope that always leaves the function drops + /// its kills instead (see popNarrowKillFrame). + readonly narrowKilled: Set[]; /// declared/inferred types of locals readonly localTypes: Map; /// Locally-owned arrays with length-changing mutations (the push-builder @@ -225,6 +302,10 @@ export class Emitter { private readonly genericNames = new Set(); private readonly sourceName: string; private readonly capacities: KernelCapacities; + /// Per-switch memo for the defaultless value-switch exhaustiveness + /// consult (alwaysExits re-asks along every kill-routing scan). + private switchCoversTypeMemo = new WeakMap(); + /// Computed once at the top of emitModule (before any type emission). private helpers: ModelHelper[] = []; private unbound: { model: string[]; msg: string[] } = { model: [], msg: [] }; @@ -972,7 +1053,10 @@ export class Emitter { names: new Map(), used: this.moduleScopeNames(), memberSubst: new Map(), + stillOptionalSubst: new Map(), narrowedUnion: new Map(), + narrowKilled: [], + edgeKills: [], localTypes: new Map(), builders: new Map(), retLabel: null, @@ -1349,7 +1433,10 @@ export class Emitter { names: new Map(), used: new Set(), memberSubst: new Map(), + stillOptionalSubst: new Map(), narrowedUnion: new Map(), + narrowKilled: [], + edgeKills: [], localTypes: new Map(), builders: new Map(), retLabel: null, @@ -1605,7 +1692,10 @@ export class Emitter { names: new Map(), used: this.moduleScopeNames(), memberSubst: new Map(), + stillOptionalSubst: new Map(), narrowedUnion: new Map(), + narrowKilled: [], + edgeKills: [], localTypes: new Map(), builders: new Map(), retLabel: null, @@ -1829,7 +1919,10 @@ export class Emitter { names: new Map(), used: this.moduleScopeNames(), memberSubst: new Map(), + stillOptionalSubst: new Map(), narrowedUnion: new Map(), + narrowKilled: [], + edgeKills: [], localTypes: new Map(), builders: new Map(), retLabel: null, @@ -2903,6 +2996,115 @@ export class Emitter { return this.table.zigTypeRef(t); } + /// Per-declaration ids for narrowing keys (see narrowKey). A WeakMap + /// identity id rather than the declaration's source position: positions + /// can collide across files, and imported consts narrow too. + private readonly narrowKeyIds = new WeakMap(); + private narrowKeyNextId = 1; + + /// Explicit node-scoped keys: a lowering that re-emits one specific + /// expression NODE under a capture (the optional-call receiver) can + /// register a key for it even when the expression has no canonical + /// narrowing key. The key is unique to the node, so it can never match + /// a different occurrence — it carries a single node's rewrite, not a + /// narrowing fact. + private readonly nodeNarrowKeys = new WeakMap(); + + /// The key an expression narrows/substitutes under: normalized source + /// text with the base identifier DECLARATION-qualified (`q#3`, member + /// chains `q#3.v`). Emission FLATTENS plain lexical blocks and callback + /// bodies share the enclosing maps, so raw text would let two + /// same-named declarations collide — a block-local `const q` shadow + /// leaves its entries live for the OUTER q after the block, and an + /// outer capture rewrites a callback parameter's reads. Symbol identity + /// makes the collision impossible; the maps' values and mechanics are + /// unchanged. Wrapper spellings collide by design: parentheses, + /// non-null assertions, `as`, and `satisfies` all erase at emission + /// (`(q)`, `q!`, and `q as Quote` are the tested `q`), so every + /// consumer comparing or looking up keys sees through them — an arm + /// spelled `q!` matches its guard's `q`, and a narrow installed for `q` + /// rewrites the `q!` read. A base the checker cannot resolve keys by + /// plain text — same behavior as before, and such bases are never + /// locals. + /// + /// Element accesses qualify recursively, like property chains — the + /// grammar is `base[index]` where the base is this same key and the + /// index is a canonical argument form: a literal index canonicalizes by + /// VALUE (`xs#3[0]`, so `xs[0]`, `xs[0x0]`, and `xs["0"]` are one key, + /// exactly the one element JS reads), and an identifier index qualifies + /// by the index's own declaration (`xs#3[i#7]` — a shadowed `i` is a + /// different element). Anything non-canonical — a computed index, a + /// call — has NO narrowing key: two spellings of `xs[f()]` need not + /// read the same element, so nothing may narrow, substitute, or match + /// through one. Returning null here is the decline answer every + /// consumer honors: no substitution installs, capture gates see no + /// read, and the expression's reads stay live optionals. Raw source + /// text is never a key — emission flattens lexical blocks, so text + /// would let a shadowed declaration's narrow leak onto the outer name. + private narrowKey(expr: ts.Node): string | null { + const override = this.nodeNarrowKeys.get(expr); + if (override !== undefined) return override; + let e: ts.Node = expr; + while ( + ts.isParenthesizedExpression(e) || + ts.isNonNullExpression(e) || + ts.isAsExpression(e) || + ts.isSatisfiesExpression(e) + ) { + e = e.expression; + } + if (ts.isPropertyAccessExpression(e)) { + const base = this.narrowKey(e.expression); + return base === null ? null : `${base}${e.questionDotToken ? "?." : "."}${e.name.text}`; + } + if (ts.isElementAccessExpression(e)) { + const base = this.narrowKey(e.expression); + if (base === null) return null; + const index = this.canonicalIndexKey(e.argumentExpression); + if (index === null) return null; + return `${base}${e.questionDotToken ? "?." : ""}[${index}]`; + } + if (ts.isIdentifier(e)) { + const decl = this.tast.declarationOf(e); + if (decl) { + let id = this.narrowKeyIds.get(decl); + if (id === undefined) { + id = this.narrowKeyNextId++; + this.narrowKeyIds.set(decl, id); + } + return `${e.text}#${id}`; + } + return e.text; + } + // `this` is one object per member body (arrows keep it), never + // shadowable — the keyword is its own key. + if (e.kind === ts.SyntaxKind.ThisKeyword) return "this"; + return null; + } + + /// The canonical form of an element-access index, or null when the + /// index is not a stable reference to one element (see narrowKey). + /// Literals canonicalize by the VALUE JS coerces to a property key — + /// a numeric string index is the numeric index (`xs["0"]` is `xs[0]`). + private canonicalIndexKey(arg: ts.Expression): string | null { + let a: ts.Expression = arg; + while ( + ts.isParenthesizedExpression(a) || + ts.isNonNullExpression(a) || + ts.isAsExpression(a) || + ts.isSatisfiesExpression(a) + ) { + a = a.expression; + } + if (ts.isNumericLiteral(a)) return String(Number(a.text)); + if (ts.isStringLiteral(a) || ts.isNoSubstitutionTemplateLiteral(a)) { + const n = Number(a.text); + return String(n) === a.text ? String(n) : JSON.stringify(a.text); + } + if (ts.isIdentifier(a)) return this.narrowKey(a); + return null; + } + private identifierUsed(body: ts.Node, decl: ts.Node): boolean { let used = false; const visit = (n: ts.Node): void => { @@ -2964,7 +3166,326 @@ export class Emitter { return this.uniqueName(ctx, hint); } - private emitBlockStatements(stmts: readonly ts.Statement[], ctx: Ctx): void { + /// Open an invalidation frame: every scope that snapshots/restores the + /// narrowing maps brackets its emission with this pair, so assignment + /// kills recorded during the scope survive the restore (see Ctx.narrowKilled). + private pushNarrowKillFrame(ctx: Ctx): void { + ctx.narrowKilled.push(new Set()); + } + + /// Close the innermost invalidation frame: re-apply its recorded kills to + /// the just-restored narrowing maps and merge them one frame outward (an + /// inner branch's kill must reach every enclosing exit it can fall + /// through to). A kill on ANY fall-through path inside the scope deletes + /// the narrow at the merge point, so a path that kept the value non-null + /// pays a re-check — never wrong code. Only memberSubst is touched, + /// mirroring the assignment-invalidation path (a stillOptionalSubst entry + /// is inert once its memberSubst entry is gone, and narrowedUnion is not + /// what `.?` substitutions live in). + /// + /// `drop` discards the frame's kills instead: the caller has proven the + /// scope always leaves the emitted function, so no path carrying those + /// kills reaches the merge point — merging them would delete narrowings + /// tsc keeps on the surviving flow, and a read there relies on the + /// substitution's unwrap spelling (losing it emits a field access on an + /// optional, not a re-check). + /// + /// A destination array routes the kills along the scope's non-local + /// edges instead: the scope's only in-function routes are caught throws + /// and/or escaping break/continue/lowered-return edges, so no path + /// carrying the kills reaches THIS merge point either — but each edge + /// resumes somewhere in the function, and the kills must be live there. + /// Kills travel the same edges control does: they wait in the + /// destination sets (the enclosing try's pendingCatchKills, the target + /// constructs' edgeKills stages) and the owning emitters apply them + /// where the edges land — never mid-flight, where tsc keeps the + /// surviving paths' narrows. + private popNarrowKillFrame(ctx: Ctx, routing: KillRouting = "merge"): Set { + const killed = ctx.narrowKilled.pop() ?? new Set(); + if (routing === "drop") return killed; + if (routing !== "merge") { + for (const dest of routing) for (const key of killed) dest.add(key); + return killed; + } + for (const key of killed) ctx.memberSubst.delete(key); + const parent = ctx.narrowKilled[ctx.narrowKilled.length - 1]; + if (parent) for (const key of killed) parent.add(key); + return killed; + } + + /// Bracket `emit` in one narrowing flow scope: on exit, restore the three + /// narrowing maps to their entry snapshot (containment), then re-apply and + /// merge — or drop, or route to the enclosing try's pending set — the + /// kills recorded inside (see popNarrowKillFrame). + /// + /// `joinInto` defers merge-class kills for a JOIN instead of applying + /// them here: a multi-alternative construct (if/else arms, an else-if + /// chain, switch clauses) must emit every alternative from the shared + /// ENTRY state — tsc types the arms as alternatives, not a sequence, so + /// one arm's kill applied mid-construct would strip a sibling of a + /// narrowing tsc keeps there. The caller collects each arm's kills and + /// applies the union once via applyJoinedNarrowKills, after the last + /// alternative. Only merge-class kills defer: an always-leaving arm + /// still drops its kills, and an arm whose only in-function routes are + /// non-local edges still routes them to those edges' destination sets — + /// those edges never reach a sibling. + private withNarrowScope( + ctx: Ctx, + mode: KillRouting, + emit: () => T, + joinInto?: Set, + ): T { + const savedSubst = new Map(ctx.memberSubst); + const savedStillOptional = new Map(ctx.stillOptionalSubst); + const savedNarrowed = new Map(ctx.narrowedUnion); + this.pushNarrowKillFrame(ctx); + try { + return emit(); + } finally { + ctx.memberSubst.clear(); + for (const [k, v] of savedSubst) ctx.memberSubst.set(k, v); + ctx.stillOptionalSubst.clear(); + for (const [k, v] of savedStillOptional) ctx.stillOptionalSubst.set(k, v); + ctx.narrowedUnion.clear(); + for (const [k, v] of savedNarrowed) ctx.narrowedUnion.set(k, v); + if (mode === "merge" && joinInto) { + for (const key of this.popNarrowKillFrame(ctx, "drop")) joinInto.add(key); + } else { + this.popNarrowKillFrame(ctx, mode); + } + } + } + + /// The JOIN of a multi-alternative construct: apply the union of the + /// alternatives' merge-class kills to the surviving flow, once, after + /// every alternative has emitted from the shared entry state (see + /// withNarrowScope's `joinInto`). Same application as a plain merge — + /// delete the narrow, propagate the kill one frame outward so enclosing + /// exits keep it dead past their restores. + private applyJoinedNarrowKills(ctx: Ctx, join: ReadonlySet): void { + for (const key of join) { + ctx.memberSubst.delete(key); + ctx.narrowKilled[ctx.narrowKilled.length - 1]?.add(key); + } + } + + /// Bracket a construct's emission with an edge-kill stage (Ctx.edgeKills): + /// scopes inside that exit only along edges bound to this construct stage + /// their kills here, and the returned set is what the caller applies at + /// the point those edges land — applyJoinedNarrowKills after the closing + /// brace for loops, labeled blocks, and callback value blocks; folded + /// through the clause join for switches (the two mechanisms share one + /// application point, so a kill is neither applied twice mid-construct + /// nor dropped between them). The application records into the + /// then-innermost kill frame, so a staged kill keeps propagating outward + /// — through enclosing joins and post-narrow subtractions — exactly like + /// a merge-class kill applied at the same spot. + private stagedEdgeKills( + ctx: Ctx, + kind: EdgeKillStage["kind"], + label: string | null, + emit: () => void, + ): Set { + const stage: EdgeKillStage = { kind, label, kills: new Set() }; + ctx.edgeKills.push(stage); + try { + emit(); + } finally { + ctx.edgeKills.pop(); + } + return stage.kills; + } + + private emitBlockStatements( + stmts: readonly ts.Statement[], + ctx: Ctx, + joinInto?: Set, + entryKills?: ReadonlySet, + trailing?: () => void, + ): void { + // Narrowing is flow-scoped the way tsc scopes it: an early-exit guard + // narrows the statements after it in ITS list, and whatever those + // statements nest — never code beyond the list. The narrowing maps ride + // the shared ctx, so the scope's entry/exit snapshot is what bounds + // them: a `break`/`continue`/`return` guard inside a loop body or a + // branch cannot leak its captures into code after the construct (where + // the capture is out of scope in Zig and the narrowing wrong in TS). + // + // Invalidation survives the restore — an assignment inside the block + // that killed a narrowing (assigned a possibly-null value) must stay + // dead past the merge — UNLESS every route out of the list leaves the + // emitted function (allRoutesLeaveFunction): then no path carrying the + // kills reaches the merge, tsc keeps the narrow on the surviving flow, + // and reads there depend on it. + const env = { + returnLeaves: ctx.retLabel === null, + throwResumes: ctx.catchResumes === true, + }; + const leavesFn = this.allRoutesLeaveFunction(stmts, env); + // Edge-kills channel: when the list's only in-function routes are + // non-local edges — throws the enclosing catch hands back, escaping + // break/continue edges, lowered callback returns — no path carrying + // the kills falls through into the statements after this list, so + // merging them here would poison flow tsc keeps narrowed (the killing + // paths cannot reach it). Kills travel the same edges control does: + // each class stages at its destination — the try's pending set for + // caught throws, the target construct's stage for break/continue, the + // callback stage for lowered returns — and the owning emitter applies + // it where the edge lands. Re-asking the route question with every + // edge class treated as leaving isolates exactly this case: any true + // fall-through still forces the plain merge (which is a conservative + // superset — its kills persist into all downstream states, the edge + // destinations included). + // `entryKills` are kills that arrived WITH control (a catch entered on + // throw edges that carried them): they widen the list's entry state — + // its reads see them — and then ride this frame's routing out, exactly + // like a kill the first statement made. Routing is what makes them + // land where flow says: a fall-through carries them to the code after + // the construct, an escaping break/continue stages them at that edge's + // destination, and a list whose every route leaves the function drops + // them (no surviving path carries them anywhere). + // `trailing` is emission that tsc types under the list's END state — a + // do-while's trailing exit test evaluates after the body, so a terminal + // guard in the body narrows it. It runs inside this same scope: a scope + // closed between the list and the test would restore the maps before + // the read they guard (the callback trailing-return fix, applied to the + // loop lowering). + this.withNarrowScope( + ctx, + leavesFn ? "drop" : this.edgeKillRouting(stmts, env, ctx), + () => { + if (entryKills) { + for (const key of entryKills) { + ctx.memberSubst.delete(key); + ctx.narrowKilled[ctx.narrowKilled.length - 1]?.add(key); + } + } + this.emitStatementList(stmts, ctx); + trailing?.(); + }, + joinInto, + ); + } + + /// The routing for a list that does NOT always leave the function: the + /// non-local-edges-only route form when it applies, else "merge". A + /// destination that fails to resolve (a caught throw without a pending + /// set, an edge whose target construct has no stage) falls back to the + /// plain merge — over-merging costs a re-check, never wrong code. + private edgeKillRouting( + stmts: readonly ts.Statement[], + env: { returnLeaves: boolean; throwResumes: boolean }, + ctx: Ctx, + ): KillRouting { + if (!this.allRoutesLeaveFunction(stmts, { ...env, edgesLeave: true, throwResumes: false })) { + return "merge"; + } + const targets: Set[] = []; + if (env.throwResumes && !this.allRoutesLeaveFunction(stmts, { ...env, edgesLeave: true })) { + // With edges leaving but caught throws resuming, any remaining + // resuming route is a caught throw: this list exits along the + // throw edge into the enclosing catch. + if (ctx.pendingCatchKills == null) return "merge"; + targets.push(ctx.pendingCatchKills); + } + const edges = this.escapingEdgesOf(stmts, env.returnLeaves); + const innermost = (pred: (s: EdgeKillStage) => boolean): EdgeKillStage | null => { + for (let i = ctx.edgeKills.length - 1; i >= 0; i--) { + if (pred(ctx.edgeKills[i])) return ctx.edgeKills[i]; + } + return null; + }; + for (const label of edges.breaks) { + const stage = innermost((s) => + label === null ? s.kind === "loop" || s.kind === "switch" : s.label === label, + ); + if (stage === null) return "merge"; + targets.push(stage.kills); + } + for (const label of edges.continues) { + const stage = innermost((s) => s.kind === "loop" && (label === null || s.label === label)); + if (stage === null) return "merge"; + targets.push(stage.kills); + } + if (edges.callbackReturn) { + const stage = innermost((s) => s.kind === "callback"); + if (stage === null) return "merge"; + targets.push(stage.kills); + } + // No resolvable destination at all would leave resuming routes with + // nowhere to carry the kills — that only happens when the two route + // analyses disagree, so keep the conservative merge. + return targets.length > 0 ? targets : "merge"; + } + + /// The escaping non-local edges out of a statement list: the labels (null + /// = unlabeled) of break/continue statements whose target sits at or + /// outside the list, and whether any lowered `return` exits it (lifted + /// callbacks: returnLeaves is false). Binding mirrors + /// allRoutesLeaveFunction's target tracking — a loop binds unlabeled + /// break and continue for its subtree, a switch binds unlabeled break, a + /// labeled statement binds its label — and callback bodies are theirs + /// alone (tsc rejects a break/continue crossing a function boundary; + /// their returns are analyzed at their own emission). + private escapingEdgesOf( + stmts: readonly ts.Statement[], + returnLeaves: boolean, + ): { breaks: Set; continues: Set; callbackReturn: boolean } { + const breaks = new Set(); + const continues = new Set(); + let callbackReturn = false; + interface Bound { + readonly labels: ReadonlySet; + readonly breakBound: boolean; + readonly continueBound: boolean; + } + const visit = (n: ts.Node, st: Bound): void => { + if (ts.isBreakStatement(n)) { + if (n.label ? !st.labels.has(n.label.text) : !st.breakBound) breaks.add(n.label?.text ?? null); + return; + } + if (ts.isContinueStatement(n)) { + if (n.label ? !st.labels.has(n.label.text) : !st.continueBound) continues.add(n.label?.text ?? null); + return; + } + if (ts.isReturnStatement(n)) { + if (!returnLeaves) callbackReturn = true; + return; + } + if (ts.isFunctionDeclaration(n) || ts.isArrowFunction(n) || ts.isFunctionExpression(n)) return; + if (ts.isLabeledStatement(n)) { + visit(n.statement, { ...st, labels: new Set(st.labels).add(n.label.text) }); + return; + } + if ( + ts.isWhileStatement(n) || + ts.isDoStatement(n) || + ts.isForStatement(n) || + ts.isForOfStatement(n) || + ts.isForInStatement(n) + ) { + ts.forEachChild(n, (c) => { + if (!this.tscExcludedArm(n, c)) visit(c, { ...st, breakBound: true, continueBound: true }); + }); + return; + } + if (ts.isSwitchStatement(n)) { + ts.forEachChild(n, (c) => visit(c, { ...st, breakBound: true })); + return; + } + // tsc-excluded arms carry no escaping edges: an edge the CFA cannot + // take must not stage kills at its would-be destination + // (tscExcludedArm — mirrors allRoutesLeaveFunction's route walk). + ts.forEachChild(n, (c) => { + if (!this.tscExcludedArm(n, c)) visit(c, st); + }); + }; + const start: Bound = { labels: new Set(), breakBound: false, continueBound: false }; + for (const s of stmts) visit(s, start); + return { breaks, continues, callbackReturn }; + } + + private emitStatementList(stmts: readonly ts.Statement[], ctx: Ctx): void { for (let i = 0; i < stmts.length; i++) { const stmt = stmts[i]; for (const c of this.leadingComments(stmt)) this.push(ctx, c); @@ -2982,8 +3503,16 @@ export class Emitter { !next.elseStatement && test !== null && ts.isIdentifier(test.target) && - test.target.text === decl.name.text && - this.alwaysExits(next.thenStatement) + // Declaration identity, not name text: the guard must test THIS + // declaration for the fusion's const to stand in for it (a + // same-named outer local tested here narrows the wrong slot). + this.tast.declarationOf(test.target) === decl && + this.alwaysExits(next.thenStatement) && + // A reassigned `let` cannot fuse: the fusion emits `const` typed by + // the non-optional payload, so a later `p = ...` (or a legal + // `p = null`) has no binding to land in. The plain path already + // types it `var p: ?T` and guards with a real `if`. + !this.isReassigned(decl, ctx) ) { const initType = this.zTypeOfExpr(decl.initializer, ctx); if (initType.k === "optional") { @@ -3005,10 +3534,19 @@ export class Emitter { cond: ts.Expression, op: ts.SyntaxKind = ts.SyntaxKind.EqualsEqualsEqualsToken, ): { target: ts.Expression; flavor: "null" | "undefined" } | null { - if (!ts.isBinaryExpression(cond) || cond.operatorToken.kind !== op) return null; + // Wrapper spellings must not change what the test matches: the + // condition, either operand, and the returned target all strip + // parentheses, non-null assertions, `as`, and `satisfies` — emission + // erases every one of them, and tsc's own narrowing sees through them + // too (`(q) === null` and `q! !== null` both test `q`), so downstream + // shape checks and key derivations see the identifier itself. + const c = unwrapExpr(cond); + if (!ts.isBinaryExpression(c) || c.operatorToken.kind !== op) return null; + const left = unwrapExpr(c.left); + const right = unwrapExpr(c.right); const sides: Array<[ts.Expression, ts.Expression]> = [ - [cond.left, cond.right], - [cond.right, cond.left], + [left, right], + [right, left], ]; for (const [target, emptySide] of sides) { const flavor = this.emptyFlavorOf(emptySide); @@ -3029,6 +3567,17 @@ export class Emitter { return null; } + /// A literal JS empty in VALUE position, through the value-preserving + /// wrappers emission erases (parens, `as`, `satisfies`, non-null + /// assertions): the `null` keyword or the global `undefined` identifier. + /// Every ternary-arm and nullish EMPTINESS decision must use this, never + /// a ZType check — `undefined`'s internal type is void, so a type check + /// reads an `undefined` arm as a non-empty value and drops the + /// optionality it carries, skipping the later guard's unwrap. + private isEmptyLiteral(e: ts.Expression): boolean { + return this.emptyFlavorOf(unwrapExpr(e)) !== null; + } + /// R7c: JS keeps null and undefined distinct; the native optional folds /// both into one empty. An empty test therefore only maps when the /// target's own type carries exactly the tested empty — testing the wrong @@ -3051,8 +3600,20 @@ export class Emitter { } } + /// Whether a statement NEVER falls through to the statement after it. + /// tsc's control-flow analysis narrows after any such statement — return, + /// throw, break, and continue all qualify (a break/continue jumps to an + /// enclosing construct's boundary, never to the next statement in + /// sequence), so an early-exit guard narrows the remainder of its block + /// no matter which exit the guard takes. A constant-true loop that no + /// break binds qualifies too: it never completes normally, so tsc treats + /// the statement after it as unreachable (and Zig types such a loop + /// noreturn — the two terminality judgments must stay aligned). + /// Kill-frame drops need the stronger whole-function question; that is + /// allRoutesLeaveFunction. private alwaysExits(stmt: ts.Statement): boolean { if (ts.isReturnStatement(stmt)) return true; + if (ts.isBreakStatement(stmt) || ts.isContinueStatement(stmt)) return true; // R20: a throw never falls through (it unwinds to a catch or out of // the function), and a try/catch exits when both arms do — any throw // mid-try lands in the catch, which exits. NS1058 keeps `finally` @@ -3060,7 +3621,8 @@ export class Emitter { if (ts.isThrowStatement(stmt)) return true; if (ts.isTryStatement(stmt)) { const tryExits = this.alwaysExits(stmt.tryBlock); - const catchExits = stmt.catchClause === undefined || this.alwaysExits(stmt.catchClause.block); + const catchExits = + stmt.catchClause === undefined || this.alwaysExits(stmt.catchClause.block); return tryExits && catchExits; } if (ts.isBlock(stmt)) { @@ -3074,43 +3636,486 @@ export class Emitter { this.alwaysExits(stmt.elseStatement) ); } + // A constant-true loop with no break bound to it never completes + // normally, so the statement after it is unreachable — tsc's CFA types + // post-loop code off the paths that never get there. The recognition + // mirrors tsc's scope for our subset: the literal `true` keyword (and + // the omitted / literal-true `for` condition) only, never arbitrary + // constant-foldable expressions. Returns and throws inside the loop + // leave the function without completing the loop, so they don't make + // its end reachable (allRoutesLeaveFunction classifies them as routes + // of their own); `continue` stays inside. Only a break bound to the + // loop resumes right after it. + if (ts.isWhileStatement(stmt) || ts.isDoStatement(stmt)) { + return stmt.expression.kind === ts.SyntaxKind.TrueKeyword && !this.bindsBreak(stmt); + } + if (ts.isForStatement(stmt)) { + return ( + (stmt.condition === undefined || stmt.condition.kind === ts.SyntaxKind.TrueKeyword) && + !this.bindsBreak(stmt) + ); + } if (ts.isSwitchStatement(stmt)) { - // A value switch (R13d) without a default may skip every clause, so - // only kind switches (exhaustive by NS1015) and defaulted switches - // count as exiting. Exhaustiveness itself is enforced at emission; - // here the question is only whether every written clause exits. + // A break bound to THIS switch resumes right after it, so the switch + // completes normally even when every clause body ends in an exit + // statement (the ubiquitous clause-terminating `break;` is exactly + // such a break). + if (this.bindsBreak(stmt)) return false; + // A value switch (R13d) without a default may skip every clause — + // unless its case labels cover the scrutinee's literal-union type, + // in which case tsc's CFA (which is what this predicate mirrors) + // treats the switch as always entering a clause. Kind switches are + // exhaustive by NS1015; defaulted switches always enter a clause; + // everything else consults the type (conservative false when the + // scrutinee is not an enumerable literal union). const kindSwitch = ts.isPropertyAccessExpression(stmt.expression) && stmt.expression.name.text === "kind"; const hasDefault = stmt.caseBlock.clauses.some((c) => ts.isDefaultClause(c)); - if (!kindSwitch && !hasDefault) return false; - return stmt.caseBlock.clauses.every((c) => { + if (!kindSwitch && !hasDefault && !this.valueSwitchCoversScrutineeType(stmt)) return false; + // Stacked case labels (`case "a": case "b": body`) share one body: + // JS falls through an empty clause onto the next clause's statements + // (the switch emitters coalesce the labels into one arm the same + // way), so an empty clause's terminality is its group's tail's — the + // next statement-bearing clause at or after it. A trailing run of + // empty clauses has no body at all: control falls out of the switch, + // so the switch completes normally. + const clauses = stmt.caseBlock.clauses; + return clauses.every((c, i) => { let stmts: readonly ts.Statement[] = c.statements; + for (let j = i + 1; stmts.length === 0 && j < clauses.length; j++) { + stmts = clauses[j].statements; + } if (stmts.length === 1 && ts.isBlock(stmts[0])) stmts = (stmts[0] as ts.Block).statements; const last = stmts[stmts.length - 1]; return last !== undefined && this.alwaysExits(last); }); } + if (ts.isLabeledStatement(stmt)) { + // A label adds exactly one edge: `break label` resumes right AFTER + // the labeled statement, making its end reachable. So the statement + // is terminal iff the wrapped statement is terminal AND nothing + // inside breaks to the label. The check is additive: a wrapped + // loop's unlabeled breaks are already the loop's own concern + // (bindsBreak, which also sees wrapping labels), and a labeled + // BLOCK's inner terminality comes from the block rule above. + return ( + this.alwaysExits(stmt.statement) && + !this.breaksToLabel(stmt.statement, stmt.label.text) + ); + } return false; } + /// Whether a defaultless VALUE switch's case labels cover every member of + /// the scrutinee's literal-union type. Enumerable scrutinees are + /// string/number/boolean literal unions ONLY: any wider type, any + /// non-literal member, or any case label that is not a literal after + /// wrapper-stripping answers false — the conservative side (a false merely + /// merges a kill tsc kept and completes the lowered fallthrough; a wrong + /// true emits an `else => unreachable` real execution can REACH). The type + /// consulted is scrutineeCoverageType's SOUND type, never the checker's + /// raw flow type: tsc keeps a local's narrowing across callback bodies + /// that assign it, so its flow-exhaustiveness can be false at runtime. + /// alwaysExits stays syntax-only on every other construct, and every + /// caller is an emission path where `this.tast` is live, so the capability + /// is unconditional. THE AGREEMENT INVARIANT: the terminality claim and + /// the emitted shape must never disagree — no switch construct may be + /// claimed terminal while its lowering emits a completable fallthrough. + /// Each lowering either closes or declines: + /// - emitSwitch (kind): a Zig enum switch is exhaustive by construction + /// (NS1015 gates uncovered arms), and a terminal claim requires every + /// arm group to exit, so the shape closes by construction; + /// - emitValueSwitch: a covered-by-type defaultless switch whose arms + /// all exit closes its never-reached fallthrough with + /// `else => unreachable` (numeric) or emits no else at all (string + /// enums, already Zig-exhaustive), and asserts the agreement; + /// - emitPlainSwitch: the lowered if/else chain closes a + /// claimed-terminal defaultless switch with an `unreachable` else + /// (its `claimsTerminal`), and asserts the agreement. + /// All consumers read this one memoized judgment and demote together. + private valueSwitchCoversScrutineeType(stmt: ts.SwitchStatement): boolean { + const memo = this.switchCoversTypeMemo.get(stmt); + if (memo !== undefined) return memo; + const answer = this.computeValueSwitchCoversScrutineeType(stmt); + this.switchCoversTypeMemo.set(stmt, answer); + return answer; + } + + /// The type a defaultless switch's exhaustiveness may be judged against — + /// null when no sound basis exists (the caller then answers false). tsc's + /// flow type at the scrutinee is NOT such a basis on its own: tsc never + /// widens a local's narrowing for assignments inside callbacks (it types + /// `let k: "a" | "b" = "a"; xs.map(() => { k = "b"; }); switch (k)` with + /// k still "a"), so a flow-exhaustive switch can be skipped by real + /// execution. Sound bases, in order: + /// - an identifier local/param never assigned inside any nested + /// function of the switch's own enclosing function keeps the flow + /// type: locals cannot alias, so a scan over nested-function scopes + /// for assignments is exact, and without a capture-site assignment + /// the straight-line CFA is; + /// - any other narrowable reference (identifier, property read) is + /// judged by its DECLARED type — a symbol can hold any member of its + /// declared union at runtime whatever the flow claims; + /// - a non-reference scrutinee (call result, arithmetic, literal) has + /// no flow claims to distrust: its own resolved type stands. + private scrutineeCoverageType(expr: ts.Expression): ts.Type | null { + const e = unwrapExpr(expr); + if (ts.isIdentifier(e)) { + if (this.scrutineeFlowTrustable(e)) return this.tast.typeOf(e); + return this.declaredTypeOfReference(e); + } + if (ts.isPropertyAccessExpression(e)) return this.declaredTypeOfReference(e.name); + if (ts.isElementAccessExpression(e)) return null; + return this.tast.typeOf(e); + } + + /// The declared (declaration-site) type of a reference: the annotation + /// when the declaration carries one, else the checker's type AT the + /// declaration name — the symbol's initial type, which no flow narrowing + /// has touched. Null when the declaration shape is not one whose declared + /// type is recoverable; callers treat null as "cannot judge". + private declaredTypeOfReference(name: ts.Node): ts.Type | null { + const decl = this.tast.declarationOf(name); + if (!decl) return null; + if ( + (ts.isVariableDeclaration(decl) || + ts.isParameter(decl) || + ts.isPropertySignature(decl) || + ts.isPropertyDeclaration(decl)) && + decl.type + ) { + return this.tast.typeFromTypeNode(decl.type); + } + if (ts.isVariableDeclaration(decl) && ts.isIdentifier(decl.name)) { + return this.tast.typeOf(decl.name); + } + return null; + } + + /// Whether the flow type of this identifier may be trusted for an + /// exhaustiveness claim: it names a local/param declared in the SAME + /// enclosing function, and no nested function/callback in that function's + /// body assigns it BEFORE the read can execute. Locals cannot alias in + /// TypeScript, so the only escape hatch from the straight-line CFA is a + /// capture-site assignment — the exact hole tsc's optimistic callback + /// model leaves open. Declines when the declaration lives in an outer + /// scope (the local IS a capture there) or when any nested function that + /// can have RUN before the read (nestedFnRunsBeforeUse: position for + /// arrows/function expressions, always for hoisted declarations and + /// class members, back edges for shared loops) assigns or ++/--s it. + private scrutineeFlowTrustable(id: ts.Identifier): boolean { + const decl = this.tast.declarationOf(id); + if (!decl || (!ts.isVariableDeclaration(decl) && !ts.isParameter(decl))) return false; + const isFnScope = (n: ts.Node): boolean => + ts.isArrowFunction(n) || + ts.isFunctionExpression(n) || + ts.isFunctionDeclaration(n) || + ts.isMethodDeclaration(n) || + ts.isConstructorDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n); + const enclosing = (n: ts.Node): ts.Node => { + let cur: ts.Node | undefined = n.parent; + while (cur && !isFnScope(cur)) cur = cur.parent; + return cur ?? n.getSourceFile(); + }; + const scope = enclosing(id); + if (scope !== enclosing(decl)) return false; + let assignedInNested = false; + // `root` is the OUTERMOST nested function of the current subtree: the + // reach-before-use judgment belongs to it (an arrow inside a hoisted + // declaration runs whenever the declaration does, so the inner arrow's + // own position proves nothing). + const visit = (n: ts.Node, root: ts.Node | null): void => { + if (assignedInNested) return; + const enter = root ?? (n !== scope && isFnScope(n) ? n : null); + if (enter) { + if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + const target = unwrapExpr(n.left); + if ( + ts.isIdentifier(target) && + this.tast.declarationOf(target) === decl && + this.nestedFnRunsBeforeUse(enter, id) + ) { + assignedInNested = true; + return; + } + } + if ( + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken) + ) { + const target = unwrapExpr(n.operand as ts.Expression); + if ( + ts.isIdentifier(target) && + this.tast.declarationOf(target) === decl && + this.nestedFnRunsBeforeUse(enter, id) + ) { + assignedInNested = true; + return; + } + } + } + ts.forEachChild(n, (c) => visit(c, enter)); + }; + visit(scope, null); + return !assignedInNested; + } + + /// Whether an assignment inside nested function `fn` can have EXECUTED + /// before the flow-trusted read at `use`. Labels the position rule with + /// JS evaluation semantics: + /// - function DECLARATIONS hoist — they exist from scope start, so an + /// earlier call can run them wherever they sit — and class-member + /// bodies (methods, accessors, constructors) exist from their + /// container's creation: all count regardless of position; + /// - arrow functions and function EXPRESSIONS do not exist as values + /// before their definition evaluates, so one whose definition sits + /// textually after the read cannot have run before it — UNLESS a + /// loop encloses both, whose back edge re-enters the read after the + /// definition evaluated on an earlier iteration. An arrow defined + /// before the read counts even when only a variable holds it + /// (conservative-correct: whether anything calls it is not asked). + private nestedFnRunsBeforeUse(fn: ts.Node, use: ts.Identifier): boolean { + if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return true; + if (fn.getStart() < use.getStart()) return true; + for (let cur: ts.Node | undefined = fn.parent; cur && !ts.isSourceFile(cur); cur = cur.parent) { + if (ts.isFunctionLike(cur)) break; + if ( + ts.isIterationStatement(cur, false) && + cur.getStart() <= use.getStart() && + use.getEnd() <= cur.getEnd() + ) { + return true; + } + } + return false; + } + + private computeValueSwitchCoversScrutineeType(stmt: ts.SwitchStatement): boolean { + const type = this.scrutineeCoverageType(stmt.expression); + if (type === null) return false; + const members = type.isUnion() ? type.types : [type]; + const wanted = new Set(); + for (const member of members) { + if (member.isStringLiteral()) wanted.add(`s:${member.value}`); + else if (member.isNumberLiteral()) wanted.add(`n:${member.value}`); + else if (member.flags & ts.TypeFlags.BooleanLiteral) { + wanted.add(`b:${this.tast.typeToString(member)}`); + } else return false; + } + for (const clause of stmt.caseBlock.clauses) { + if (ts.isDefaultClause(clause)) continue; + const label = unwrapExpr(clause.expression); + if (ts.isStringLiteral(label)) wanted.delete(`s:${label.text}`); + else if (label.kind === ts.SyntaxKind.TrueKeyword) wanted.delete("b:true"); + else if (label.kind === ts.SyntaxKind.FalseKeyword) wanted.delete("b:false"); + else { + const v = this.tast.constEvalNumber(label); + if (v === null) return false; + wanted.delete(`n:${v}`); + } + } + return wanted.size === 0; + } + + /// Kill-frame drop eligibility: whether EVERY route out of this statement + /// list leaves the emitted function. Only then may the list's narrowing + /// kills drop (emitBlockStatements) or a catch arm close the exception + /// paths over its try body (emitTryCore) — the final statement alone + /// cannot answer this, because earlier statements open routes of their + /// own. The routes, and when each one resumes in-function instead of + /// leaving: + /// - fallthrough off the end of the list — always resumes; + /// - `return` — resumes where returns lower to labeled breaks (lifted + /// callbacks: env.returnLeaves is false); + /// - `break`/`continue` at any nesting depth whose target sits at or + /// outside the list — always resumes (after the target construct, + /// inside the function). One bound to a loop/switch/label WHOLLY + /// inside the list stays inside it and is not a route out, so the + /// walk tracks locally-bound targets, never spellings; + /// - `throw`, or any statement containing a throwing call, whose + /// handler can fall back into the function — the handler is the + /// nearest catch inside the list, and the route's destination is + /// that catch PLUS its continuation: a catch that falls through + /// resumes at the statements following its try (transitively out + /// through enclosing blocks), so the route leaves iff every route + /// out of the catch leaves AND that continuation leaves. Outside + /// any local try, whatever env.throwResumes says about the + /// surroundings. + /// When a route resists classification the answer is false (merge): an + /// over-merge costs a narrow tsc would have kept, an under-merge + /// resurrects a dead one and emits an unwrap Zig rejects. The + /// continuation after a try is known positionally inside statement + /// lists (the top list and nested blocks); inside loop bodies, switch + /// clauses, and inline callbacks a catch's fallthrough re-enters the + /// construct, so the continuation is treated as resuming there. + /// `env.edgesLeave` re-asks the question with escaping break/continue + /// edges and lowered returns treated as LEAVING: emitBlockStatements uses + /// the difference to isolate lists whose only in-function routes are + /// non-local edges (their kills stage at the edges' destinations rather + /// than merging — see edgeKillRouting). A leaving return's expression + /// still walks: throwing calls inside it ride the throw edges, not the + /// return edge. + private allRoutesLeaveFunction( + stmts: readonly ts.Statement[], + env: { returnLeaves: boolean; throwResumes: boolean; edgesLeave?: boolean }, + ): boolean { + // `labels` / `breaks` / `continues` carry the targets bound inside the + // list so far on this path; `throwResumes` is the handler visibility at + // this point (re-derived at every try/catch met on the way down); + // `inCallback` marks inline (unlifted) callback bodies, whose returns + // are their own but whose throwing calls act at this site. + interface State { + readonly labels: ReadonlySet; + readonly breaks: number; + readonly continues: number; + readonly throwResumes: boolean; + readonly inCallback: boolean; + } + // `tailLeaves`: whether control falling through past the node (to the + // rest of its enclosing list, transitively outward) leaves the + // function on every route — the positional continuation a + // fallthrough catch hands a caught throw. + const resumes = (n: ts.Node, st: State, tailLeaves: boolean): boolean => { + const walk = (c: ts.Node): boolean => resumes(c, st, tailLeaves); + if (ts.isBreakStatement(n)) { + if (env.edgesLeave) return false; + return !st.inCallback && (n.label ? !st.labels.has(n.label.text) : st.breaks === 0); + } + if (ts.isContinueStatement(n)) { + if (env.edgesLeave) return false; + return !st.inCallback && (n.label ? !st.labels.has(n.label.text) : st.continues === 0); + } + if (ts.isReturnStatement(n)) { + if (!st.inCallback && !env.returnLeaves && !env.edgesLeave) return true; + return n.expression !== undefined && walk(n.expression); + } + if (ts.isThrowStatement(n)) { + return st.throwResumes || walk(n.expression); + } + if ((ts.isCallExpression(n) || ts.isNewExpression(n)) && st.throwResumes) { + const target = this.calleeOwnerOf(n); + if (target && this.leakingFns.has(target)) return true; + } + if (ts.isBlock(n)) { + // A block's own fallthrough continues wherever the block's does, + // so its statements scan positionally against the same tail. + return scanList(n.statements, st, tailLeaves).resumes; + } + if (ts.isTryStatement(n) && n.catchClause !== undefined) { + // Throws in the body land in this catch, whose destination is the + // catch block plus its continuation: a fallthrough catch resumes + // at the statements after this try, so the caught throw leaves + // iff the catch-then-rest continuation leaves. The catch itself + // runs against the handler visible OUTSIDE this try (its own + // throws escape to enclosing handlers, never back into it). + const catchScan = scanList(n.catchClause.block.statements, st, tailLeaves); + const bodyThrowResumes = !catchScan.leaves; + return ( + resumes(n.tryBlock, { ...st, throwResumes: bodyThrowResumes }, tailLeaves) || + catchScan.resumes || + (n.finallyBlock !== undefined && walk(n.finallyBlock)) + ); + } + if (ts.isLabeledStatement(n)) { + const bound = new Set(st.labels); + bound.add(n.label.text); + return resumes(n.statement, { ...st, labels: bound }, tailLeaves); + } + if ( + ts.isWhileStatement(n) || + ts.isDoStatement(n) || + ts.isForStatement(n) || + ts.isForOfStatement(n) || + ts.isForInStatement(n) + ) { + // The loop binds unlabeled break/continue for its whole subtree + // (heads hold no break/continue — tsc rejects them there). A + // fallthrough inside the body resumes at the back edge, in the + // function, so the body scans against a resuming tail. A + // tsc-excluded body (while (false), for (; false;)) contributes + // no routes at all — an edge tsc's CFA cannot take is not a route + // (tscExcludedArm; the joins already give such arms no kills). + return ( + ts.forEachChild(n, (c) => + (!this.tscExcludedArm(n, c) && + resumes(c, { ...st, breaks: st.breaks + 1, continues: st.continues + 1 }, false)) || + undefined, + ) === true + ); + } + if (ts.isSwitchStatement(n)) { + // Clause fallthrough resumes at the next clause / after the + // switch — in the function — so clauses scan against a resuming + // tail too. + return ( + walk(n.expression) || + resumes(n.caseBlock, { ...st, breaks: st.breaks + 1 }, false) + ); + } + // Declarations don't run here; lifted callbacks are analyzed at + // their own emission (their bodies get a retLabel of their own). + if (ts.isFunctionDeclaration(n)) return false; + if (ts.isArrowFunction(n) || ts.isFunctionExpression(n)) { + if ([...this.hoistedFns.values()].includes(n)) return false; + return resumes(n.body, { ...st, inCallback: true }, false); + } + // tsc-excluded arms (`if (false)` then, `if (true)` else) hold no + // routes tsc's CFA can take: a dead `break` must not read as a + // loop-escaping edge (tscExcludedArm). + return ts.forEachChild(n, (c) => (!this.tscExcludedArm(n, c) && walk(c)) || undefined) === true; + }; + // One right-to-left pass over a statement list: `resumes` is whether + // any route out of any statement resumes in-function (each statement + // classified against the continuation after ITS position); `leaves` + // is whether control entering the list leaves the function on every + // route — no route resumes, and fallthrough (which stops at the first + // always-exiting statement) bottoms out in an exit or a leaving tail. + // The fallthrough chain deliberately ignores in-list resuming routes: + // any such route already forces `resumes`, and every consumer of + // `leaves` (the verdict below, a caught throw's continuation) sits + // under a scan whose `resumes` it feeds. + const scanList = ( + list: readonly ts.Statement[], + st: State, + tailLeaves: boolean, + ): { leaves: boolean; resumes: boolean } => { + let cont = tailLeaves; + let any = false; + for (let i = list.length - 1; i >= 0; i--) { + if (resumes(list[i], st, cont)) any = true; + cont = this.alwaysExits(list[i]) || cont; + } + return { leaves: !any && cont, resumes: any }; + }; + const start: State = { + labels: new Set(), + breaks: 0, + continues: 0, + throwResumes: env.throwResumes, + inCallback: false, + }; + // Fallthrough off the end of the whole list always resumes. + return scanList(stmts, start, false).leaves; + } + private emitOrelseFusion(decl: ts.VariableDeclaration, exit: ts.Statement, inner: ZType, ctx: Ctx): void { const name = this.claim(ctx, decl, zigLocalName((decl.name as ts.Identifier).text)); ctx.localTypes.set(decl, inner); const init = orelseOperand(this.emitExpr(decl.initializer!, ctx).code); const exitStmts = ts.isBlock(exit) ? exit.statements : [exit]; - if (exitStmts.length === 1 && ts.isReturnStatement(exitStmts[0])) { - const r = exitStmts[0]; - if (!r.expression) { - this.push(ctx, `const ${name} = ${init} orelse ${this.returnText(ctx, null, r)}`); - return; - } - // Simple return value -> inline orelse; value needing statements -> block. - const sub = this.childCtx(ctx); - const v = this.emitReturn(r.expression, sub); - if (sub.lines.length === 0) { - this.push(ctx, `const ${name} = ${init} orelse ${this.returnText(ctx, v, r)}`); - return; - } + // A one-statement exit inlines (`orelse return v` / `orelse break` / + // `orelse continue`); anything needing statements takes the block form + // below — the block never falls through, so Zig types it noreturn. + const inline = this.singleExitText(exit, ctx); + if (inline !== null) { + this.push(ctx, `const ${name} = ${init} orelse ${inline}`); + return; } this.push(ctx, `const ${name} = ${init} orelse {`); const sub = this.nestedCtx(ctx); @@ -3119,6 +4124,23 @@ export class Emitter { this.push(ctx, `};`); } + /// Clone a ctx for a probe or a same-indent sub-emission. ISOLATION LAW: + /// the clone shares every mutable flow structure with its parent BY + /// REFERENCE — memberSubst, stillOptionalSubst, narrowedUnion, the + /// narrowKilled frame stack, the edgeKills stages, and pendingCatchKills + /// — it isolates only the output lines. A PROBE (an emission whose lines + /// may be discarded to pick a lowering shape) must therefore bracket + /// itself with withNarrowScope: a probe that processes an assignment to + /// an outer narrowed local (an inline map callback inside a ternary arm) + /// would otherwise delete the narrowing for everything emitted after it + /// — the SIBLING arm then reads the optional raw, invalid Zig. Sibling + /// arms of one expression construct follow the statement-level law: each + /// arm probes AND emits from the construct's entry state, and the arms' + /// real kills join once after the last arm (applyJoinedNarrowKills). + /// Kills a discarded probe stages on the shared edge sets (edgeKills, + /// pendingCatchKills) are re-staged identically when the arm re-emits, + /// so the sharing stays observationally clean — the narrowing maps and + /// kill frames are the state a probe MUST NOT touch. private childCtx(ctx: Ctx): Ctx { return { ...ctx, lines: [], indent: ctx.indent }; } @@ -3145,20 +4167,37 @@ export class Emitter { this.emitIf(stmt, ctx); return; } + // Loops (and below, labeled statements and switches) bracket their + // emission with an edge-kill stage: kills that ride a break edge bound + // here apply to the POST-construct state — the destination the break + // resumes at — and kills on a continue edge ride the back edge into + // the loop-entry join. The emitter realizes both as the post-loop + // application below: the loop-entry model delegates in-body + // next-iteration reads to tsc (a read relying on a narrow the back + // edge kills is a tsc error the checker already rejected), so the + // only remaining state a back-edge kill must reach is the loop's + // normal-completion exit — the same post-loop point. if (ts.isWhileStatement(stmt)) { - this.emitWhile(stmt, ctx, null); + const staged = this.stagedEdgeKills(ctx, "loop", null, () => this.emitWhile(stmt, ctx, null)); + // A `while (false)` body is tsc-unreachable (tscExcludedArm): kills + // its break edges staged here never execute, so they don't apply. + this.applyJoinedNarrowKills(ctx, this.tscExcludedArm(stmt, stmt.statement) ? new Set() : staged); return; } if (ts.isDoStatement(stmt)) { - this.emitDoWhile(stmt, ctx, null); + this.applyJoinedNarrowKills(ctx, this.stagedEdgeKills(ctx, "loop", null, () => this.emitDoWhile(stmt, ctx, null))); return; } if (ts.isForStatement(stmt)) { - this.emitClassicFor(stmt, ctx, null); + const staged = this.stagedEdgeKills(ctx, "loop", null, () => this.emitClassicFor(stmt, ctx, null)); + // A `for (; false;)` body is tsc-unreachable exactly like a + // `while (false)` body (tscExcludedArm): kills its break edges + // staged here never execute, so they don't apply. + this.applyJoinedNarrowKills(ctx, this.tscExcludedArm(stmt, stmt.statement) ? new Set() : staged); return; } if (ts.isForOfStatement(stmt)) { - this.emitForOf(stmt, ctx, null); + this.applyJoinedNarrowKills(ctx, this.stagedEdgeKills(ctx, "loop", null, () => this.emitForOf(stmt, ctx, null))); return; } if (ts.isLabeledStatement(stmt)) { @@ -3171,28 +4210,49 @@ export class Emitter { const label = this.labelReferenced(stmt.statement, stmt.label.text) ? loopLabel(stmt.label.text) : null; + // The stage binds by the SOURCE label: a labeled break/continue's + // kills must land at this construct's post-state even from deep + // inside nested loops (edgeKillRouting resolves labels innermost- + // out, so an inner-targeted unlabeled break never binds here). + const jsLabel = stmt.label.text; const inner = stmt.statement; if (ts.isWhileStatement(inner)) { - this.emitWhile(inner, ctx, label); + const staged = this.stagedEdgeKills(ctx, "loop", jsLabel, () => this.emitWhile(inner, ctx, label)); + // Same tsc-unreachable exclusion as the unlabeled while dispatch. + this.applyJoinedNarrowKills(ctx, this.tscExcludedArm(inner, inner.statement) ? new Set() : staged); } else if (ts.isDoStatement(inner)) { - this.emitDoWhile(inner, ctx, label); + this.applyJoinedNarrowKills(ctx, this.stagedEdgeKills(ctx, "loop", jsLabel, () => this.emitDoWhile(inner, ctx, label))); } else if (ts.isForStatement(inner)) { - this.emitClassicFor(inner, ctx, label); + const staged = this.stagedEdgeKills(ctx, "loop", jsLabel, () => this.emitClassicFor(inner, ctx, label)); + // Same tsc-unreachable exclusion as the unlabeled for dispatch. + this.applyJoinedNarrowKills(ctx, this.tscExcludedArm(inner, inner.statement) ? new Set() : staged); } else if (ts.isForOfStatement(inner)) { - this.emitForOf(inner, ctx, label); + this.applyJoinedNarrowKills(ctx, this.stagedEdgeKills(ctx, "loop", jsLabel, () => this.emitForOf(inner, ctx, label))); } else if (label === null) { this.emitStatement(inner, ctx); } else { - this.push(ctx, `${label}: {`); - const sub = this.nestedCtx(ctx); - this.emitBlockStatements(ts.isBlock(inner) ? inner.statements : [inner], sub); - ctx.lines.push(...sub.lines); - this.push(ctx, `}`); + const kills = this.stagedEdgeKills(ctx, "block", jsLabel, () => { + this.push(ctx, `${label}: {`); + const sub = this.nestedCtx(ctx); + this.emitBlockStatements(ts.isBlock(inner) ? inner.statements : [inner], sub); + ctx.lines.push(...sub.lines); + this.push(ctx, `}`); + }); + this.applyJoinedNarrowKills(ctx, kills); } return; } if (ts.isSwitchStatement(stmt)) { - this.emitSwitch(stmt, ctx); + // Clause-terminating breaks are stripped before their clause's route + // analysis, so their kills ride the sibling JOIN (the switch + // emitters' joinInto) to the same post-switch point this stage + // applies at — one application, nothing dropped between the two + // mechanisms. The stage itself keeps unlabeled-break RESOLUTION + // faithful (a break under a switch binds the switch, never a loop + // outside it; the mid-clause spellings that would stage here stop + // the build at their own emission). Labeled exits out of a labeled + // switch bind the wrapping labeled BLOCK's stage instead. + this.applyJoinedNarrowKills(ctx, this.stagedEdgeKills(ctx, "switch", null, () => this.emitSwitch(stmt, ctx))); return; } if (ts.isExpressionStatement(stmt)) { @@ -3200,14 +4260,36 @@ export class Emitter { return; } if (ts.isBlock(stmt)) { - this.push(ctx, `{`); - const sub = this.nestedCtx(ctx); - this.emitBlockStatements(stmt.statements, sub); - ctx.lines.push(...sub.lines); - this.push(ctx, `}`); + // A plain lexical block is NOT a merge boundary: tsc's narrowing is + // flow-based, so a narrow (or kill) established inside a fall-through + // block survives into the statements after it. Emission FLATTENS the + // block's statements into the enclosing list instead of bracketing + // them (no withNarrowScope, no Zig braces): name uniquing is already + // function-wide (ctx.used and the decl-keyed ctx.names ride every + // nested ctx by reference), so Zig block scope is never load-bearing + // for shadowing — and flattening is what keeps a narrowing capture + // lowered inside the block (an orelse fusion's const, a `.?` subst's + // target) in the same Zig scope as the post-block reads that rely on + // it. Merge contexts (if/else arms, loop bodies, switch clauses, + // callback and try/catch bodies, labeled blocks) never reach this + // case — their emitters unwrap the block and bracket its statement + // LIST — so control only ever falls straight through here. Nested + // plain blocks flatten recursively. + this.emitStatementList(stmt.statements, ctx); return; } if (ts.isBreakStatement(stmt)) { + // An unlabeled JS break binds the nearest loop OR switch; Zig's + // `break` binds loops only. A break that reaches statement emission + // bound to a switch (the clause-terminating `break;` never gets + // here — the switch emitters strip it) would jump past the wrong + // construct, so it stops the build instead of miscompiling. + if (!stmt.label && this.breakBindsEnclosingSwitch(stmt)) { + this.fail( + stmt, + "a `break` that exits a `switch` from inside a clause body (only a clause-ending `break` has a mapping; restructure with an early `return`, or label the switch and `break