From bc973cf6532f7954adbb61514537b5c4d7022b25 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 08:33:35 -0500 Subject: [PATCH 1/7] Reuse reachability lowering output - Retain reachable IR and assemble the emitted module without lowering bodies a second time - Preserve reached-only artifact filtering, coverage remainder behavior, and add lowering phase timing --- .../src/frontend/lowering/lower-modules.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 202 ++++++++++-------- 2 files changed, 118 insertions(+), 86 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index e6910654f..ced8e7a97 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -653,7 +653,7 @@ export interface FileParts { } } } - const reachable = L.reachable; + const reachable = L.reachableForArtifacts ?? L.reachable; return { classes: [...L.classes.values()] .map((c) => c.def) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7a8cf9079..31564a0a2 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -370,13 +370,13 @@ export interface LowerOptions { /** The Lowerer's pass configuration (see lowerToIr). */ export interface LowererMode { - /** Names of bodies the discovery pass reached; null lowers everything. */ + /** Names of bodies a prior reachability pass reached; null lowers everything. */ reachable?: ReadonlySet | null; /** Coverage remainder: lower ONLY bodies outside `reachable`, skip the * always-reachable init bodies and module building, and report deferred * collection diagnostics nothing flushed. */ remainder?: boolean; - /** Symbols whose deferred diagnostics the emit pass already flushed — + /** Symbols whose deferred diagnostics reachable emit already flushed — * the remainder must not report them a second time. */ alreadyFlushed?: ReadonlySet; /** The build's target platform (LowerOptions.targetPlatform — lowerToIr @@ -413,24 +413,22 @@ function directExternalTypeSpecifiersByFile( return out; } -/** Build lowering runs in two passes over the same ts.Program: +/** Build lowering runs as a reachability worklist over the ts.Program: * - * 1. DISCOVERY — a worklist computes the set of reachable bodies. Seeds are + * 1. REACHABLE EMIT — a worklist computes the set of reachable bodies. + * Seeds are * the per-file init bodies (module top-level statements always run, in * import order); lowering a body yields IR whose call/closure/new/ - * virtualCall nodes are the edges that enqueue further bodies. The - * pass's IR, diagnostics, and stats are discarded — it exists only to - * answer "which bodies does the entry reach?". - * 2. EMIT — a fresh Lowerer lowers in the HISTORICAL order (per file: - * function declarations, then class members; then file inits, %main, - * generic instances, lifted lambdas), skipping bodies the discovery - * pass did not mark. Keeping the emit order (and lambda/instance - * numbering) identical to the pre-reachability compiler means a fully - * reachable program emits byte-identical C. + * virtualCall nodes are the edges that enqueue further bodies. The pass + * retains that IR. Once the graph closes, those functions are assembled in + * deterministic declaration order beside the already-lowered init, + * generic-instance, and lifted bodies. Checker-backed IR construction + * therefore happens once instead of once for discovery and again for + * emission. * - * `coverage: true` adds a third pass — the REMAINDER — that lowers only - * the bodies discovery did NOT mark (plus deferred collection diagnostics - * nothing flushed), reported separately: whole-program analysis without + * `coverage: true` adds a second pass — the REMAINDER — that lowers only + * the bodies reachable emit did NOT mark (plus deferred collection + * diagnostics nothing flushed), reported separately: whole-program analysis without * letting unreached code fail builds. */ export function lowerToIr( program: ts.Program, @@ -438,6 +436,22 @@ export function lowerToIr( moduleOrder: ts.SourceFile[], options: LowerOptions = {}, ): LowerResult { + const phaseTiming = process.env["SCRIPTC_TIMING"] === "1"; + const phaseStarted = performance.now(); + let phaseLast = phaseStarted; + const timing = (phase: string, detail: Record = {}): void => { + if (!phaseTiming) return; + const now = performance.now(); + process.stderr.write( + `scriptc lowering ${JSON.stringify({ + phase, + phase_ms: Math.round((now - phaseLast) * 10) / 10, + total_ms: Math.round((now - phaseStarted) * 10) / 10, + ...detail, + })}\n`, + ); + phaseLast = now; + }; const dynamic = options.dynamic ?? false; const targetPlatform = options.targetPlatform ?? process.platform; const startupCrash = options.startupCrash ?? null; @@ -446,9 +460,8 @@ export function lowerToIr( // pass constructs (nothing calls their %init at startup — the import() // site's namespace builder does, on the engine microtask, Node's // evaluation point for them). Inadmissible static cycles inside the - // added subgraph are minted here and handed to the EMIT pass: the - // discovery pass's diagnostics are discarded by design, and after this - // extension of the shared array no later pass re-walks the subgraph. + // added subgraph are minted here and handed to reachable emit after this + // extension of the shared array; no later pass re-walks the subgraph. const dynamicCycleDiags: ScrDiagnostic[] = []; if (dynamic) { appendDynamicImportModules(program, moduleOrder, (cycle, reason) => { @@ -464,47 +477,39 @@ export function lowerToIr( directExternalTypeSpecifiersByFile(externalTypes); const validation = new Lowerer(program, entry, moduleOrder, dynamic, { targetPlatform, + startupCrash, ffiImports, libraryCallbacks, externalTypes, externalTypeSpecifiersByFile, }); const ffiValidation = validateFfiImports(validation); - // Discovery must use the same exact-symbol ownership as emit. Otherwise a - // local function shadowing a configured ambient name is mistaken for FFI - // while computing reachability, even though emit would correctly lower it - // as ordinary TypeScript. FFI-free builds reuse the validation lowerer - // (validation is an immediate no-op there), retaining the historical - // two-pass construction cost. - const discovery = ffiImports.length === 0 + timing("ffi-validate"); + // Reachability must use the same exact-symbol ownership as FFI validation. + // Otherwise a local function shadowing a configured ambient name is mistaken for FFI + // while computing reachability, even though ordinary lowering would + // correctly handle it as TypeScript. FFI-free builds reuse the validation + // lowerer because validation is an immediate no-op there. + const reachableEmit = ffiImports.length === 0 ? validation : new Lowerer(program, entry, moduleOrder, dynamic, { targetPlatform, + startupCrash, ffiImports, libraryCallbacks, externalTypes, externalTypeSpecifiersByFile, ffiBindingSymbols: ffiValidation.symbolsByName, }); - const reachable = discovery.discover(options.libRoots); - const emit = new Lowerer(program, entry, moduleOrder, dynamic, { - reachable, - targetPlatform, - startupCrash, - ffiImports, - libraryCallbacks, - ffiBindingSymbols: ffiValidation.symbolsByName, - externalTypes, - externalTypeSpecifiersByFile, - }); - for (const d of dynamicCycleDiags) emit.pushDiag(d); - for (const d of ffiValidation.diagnostics) emit.pushDiag(d); - const result = emit.run(); + for (const d of dynamicCycleDiags) reachableEmit.pushDiag(d); + for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d); + const { reachable, result } = reachableEmit.emitReachable(options.libRoots); + timing("reachable-emit", { reachable: reachable.size }); if (options.coverage !== true) return result; const remainder = new Lowerer(program, entry, moduleOrder, dynamic, { reachable, remainder: true, - alreadyFlushed: emit.flushedSymbols, + alreadyFlushed: reachableEmit.flushedSymbols, targetPlatform, ffiImports, libraryCallbacks, @@ -1169,7 +1174,7 @@ export class Lowerer { functionsSkipped: 0, }; - /** Discovery-pass edge sink (null in the emit pass): every resolution of + /** Reachability edge sink: every resolution of * a reference to a lowerable body reports its name here — recorded even * when the enclosing statement later poisons. */ onEdge: ((name: string) => void) | null = null; @@ -1223,9 +1228,12 @@ export class Lowerer { return this.ctx.scopes; } - /** Names of bodies the discovery pass reached; null lowers everything - * (the discovery pass itself). */ + /** Names of bodies a prior reachability pass reached; null lowers everything. */ readonly reachable: ReadonlySet | null; + /** Reachability computed by this same Lowerer when retained worklist IR + * is assembled directly. The configured reachable set remains null so + * demand-driven instance lowering keeps its existing gates. */ + reachableForArtifacts: ReadonlySet | null = null; /** Coverage remainder mode: the reachability gate inverts (see wantBody) * and no module is built. */ readonly remainder: boolean; @@ -1345,9 +1353,7 @@ export class Lowerer { // --dynamic: modules reachable only through dynamic import() joined // moduleOrder BEFORE any pass constructed — lowerToIr runs // appendDynamicImportModules once on the shared array (a per-pass run - // here minted cycle refusals into the DISCOVERY pass, whose - // diagnostics are discarded by design, and the extended order left - // nothing for the emit pass to re-detect). + // here would repeatedly extend the graph and duplicate cycle reports). this.moduleOrder.forEach((sf, i) => { this.fileTag.set(sf, sf === entry ? "" : `%m${i}.`); }); @@ -1383,7 +1389,7 @@ export class Lowerer { * namespace path (nsPathPrefix), so `namespace A { export class C }` * and a top-level `class C` never collide. Class EXPRESSIONS name by * SOURCE POSITION (`%cx.`): deterministic across the - * discovery and emit passes (no counter can drift between them), + * builds (no counter can drift between invocations), * program-unique through the file qualifier, and collision-free with * user identifiers ('%'). */ readonly classNamer = (decl: ts.ClassLikeDeclaration): string => @@ -2043,8 +2049,8 @@ export class Lowerer { }; } - /** True when this body should lower: everything with no reachable set - * (discovery), the marked bodies in the emit pass, and exactly the + /** True when this body should lower: everything with no reachable set, + * the marked bodies in an externally-gated pass, and exactly the * UNMARKED bodies in the coverage remainder. */ wantBody(name: string): boolean { if (this.reachable === null) return true; @@ -2104,7 +2110,7 @@ export class Lowerer { // inline require statements call theirs mid-body, and the guards make // revisits cache hits — Node's evaluation order over the WHOLE graph // falls out of the nesting. The coverage remainder skips them — they - // are reachable by definition, already counted by the emit pass. + // are reachable by definition, already counted by reachable emit. if (!this.remainder) { for (const fp of parts) { functions.push(this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!)); @@ -2190,6 +2196,13 @@ export class Lowerer { }; } + return this.finishModule(functions); + } + + /** Final retention, pruning, and module assembly shared by ordinary emit + * and the retained reachability worklist. */ + finishModule(functions: IrFunction[]): LowerResult { + // Globals typed by a class that never REGISTERED (a JS class whose // collection fenced — Symbol-keyed fields, an unsupported base): the // declaration statement and every use compiled to runtime fences, but @@ -2373,7 +2386,7 @@ export class Lowerer { /** Whether run() counts a signature-blocked declaration in * stats.functionsSkipped: whole-program passes and the coverage - * remainder do; the reachability emit pass leaves the counting to the + * remainder do; an externally-gated emit pass leaves the counting to the * remainder (the declaration was never reached). */ countsSkips(): boolean { return this.reachable === null || this.remainder; @@ -2381,9 +2394,10 @@ export class Lowerer { /* ── reachability ─────────────────────────────────────────────────── */ - /** The discovery pass: computes the set of body names the program's entry - * reaches. Seeds are the per-file init bodies (top-level statements always - * run); edges fire from RESOLUTION sites while a body lowers (noteEdge / + /** Reachable emit: computes the set of body names the program's entry + * reaches and retains each body IR as it lowers. Seeds are the per-file + * init bodies (top-level statements always run); edges fire from + * RESOLUTION sites while a body lowers (noteEdge / * noteVirtualEdge) — direct calls, closure creation (a taken closure may * be called indirectly), `new`, super calls, accessor invocations, and * virtual dispatch. Recording at resolution time (not off the produced @@ -2393,19 +2407,20 @@ export class Lowerer { * demand-driven) and lifted lambdas lower inline with their enclosing * body; both fire edges through the same hooks and are not units * themselves. */ - discover(extraRoots?: readonly string[]): Set { + emitReachable(extraRoots?: readonly string[]): { reachable: Set; result: LowerResult } { const parts = this.splitFiles(); this.collectProgram(parts); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct - // thunks) the emit pass must see. + // thunks) reachable emit must see. for (const info of this.classes.values()) analyzeClassDecoration(this, info); this.prepareModuleInits(parts); // Every lowerable body, by emitted-function name. The names double as - // the reachable-set keys the emit pass gates on — deterministic across - // Lowerer instances by construction (qualified declaration names). - const units = new Map IrFunction | null>(); + // retained-function keys and are deterministic by construction + // (qualified declaration names). + const units = new Map IrFunction | null }>(); + let unitOrder = 0; for (const fp of parts) { for (const decl of fp.fnDecls) { // Overload signatures share the implementation's symbol (and so @@ -2415,7 +2430,7 @@ export class Lowerer { const declSymbol = declSymbolOf(this, decl); if (!declSymbol || this.genericFnsBySymbol.has(declSymbol)) continue; const sig = this.fnSigsBySymbol.get(declSymbol); - if (sig) units.set(sig.name, () => this.lowerFunction(decl)); + if (sig) units.set(sig.name, { order: unitOrder++, lower: () => this.lowerFunction(decl) }); } } for (const info of this.classes.values()) { @@ -2428,21 +2443,24 @@ export class Lowerer { // A FAMILY has no constructor function and no instance members — // only its statics are units. if (!info.generic) { - units.set(`%${cName}.constructor`, () => this.lowerClassCtor(info)); + units.set(`%${cName}.constructor`, { order: unitOrder++, lower: () => this.lowerClassCtor(info) }); for (const { mName, member } of this.classMethodMembers(info)) { - units.set(`%${cName}.${mName}`, () => this.lowerClassMethodMember(info, member)); + units.set(`%${cName}.${mName}`, { order: unitOrder++, lower: () => this.lowerClassMethodMember(info, member) }); } for (const prop of info.throwingSetters) { - units.set(`%${cName}.set:${prop}`, () => this.throwingSetterFn(info, prop)); + units.set(`%${cName}.set:${prop}`, { order: unitOrder++, lower: () => this.throwingSetterFn(info, prop) }); } } for (const name of info.staticMethods?.keys() ?? []) { - units.set(`%${cName}.static:${name}`, () => lowerStaticMethod(this, info, name)); + units.set(`%${cName}.static:${name}`, { order: unitOrder++, lower: () => lowerStaticMethod(this, info, name) }); } } const reachable = new Set(); const queue: string[] = []; + const loweredUnits = new Map(); + const initFunctions: IrFunction[] = []; + const instanceFunctions: IrFunction[] = []; this.onEdge = (name: string): void => { if (reachable.has(name)) return; reachable.add(name); @@ -2453,15 +2471,15 @@ export class Lowerer { // edge to them can fire (references require the collected class). this.onExprClassCollected = (info: ClassInfo): void => { const cName = info.def.name; - units.set(`%${cName}.constructor`, () => this.lowerClassCtor(info)); + units.set(`%${cName}.constructor`, { order: unitOrder++, lower: () => this.lowerClassCtor(info) }); for (const { mName, member } of this.classMethodMembers(info)) { - units.set(`%${cName}.${mName}`, () => this.lowerClassMethodMember(info, member)); + units.set(`%${cName}.${mName}`, { order: unitOrder++, lower: () => this.lowerClassMethodMember(info, member) }); } for (const name of info.staticMethods?.keys() ?? []) { - units.set(`%${cName}.static:${name}`, () => lowerStaticMethod(this, info, name)); + units.set(`%${cName}.static:${name}`, { order: unitOrder++, lower: () => lowerStaticMethod(this, info, name) }); } for (const prop of info.throwingSetters) { - units.set(`%${cName}.set:${prop}`, () => this.throwingSetterFn(info, prop)); + units.set(`%${cName}.set:${prop}`, { order: unitOrder++, lower: () => this.throwingSetterFn(info, prop) }); } }; // Generic instances queued by the bodies above lower here (an instance @@ -2479,24 +2497,24 @@ export class Lowerer { ) { while (instLowered < this.instantiationQueue.length) { const { info, inst } = this.instantiationQueue[instLowered++]!; - // Body-level poisons skip the instance here too (the emit pass - // re-records the diagnostic; discovery only needs the edges the - // body fired before poisoning). + // Body-level poisons skip the instance after retaining the + // diagnostic and every edge fired before poisoning. try { - this.lowerGenericInstance(info, inst); + instanceFunctions.push(this.lowerGenericInstance(info, inst)); } catch (e) { if (!(e instanceof PoisonError)) throw e; } } while (clsInstLowered < this.genericClassInstances.length) { - this.lowerClassMembers(this.genericClassInstances[clsInstLowered++]!); + instanceFunctions.push(...this.lowerClassMembers(this.genericClassInstances[clsInstLowered++]!)); } // Emit-override specialization bodies fire edges of their own // (the super-forward chain, closures, generic calls) — lower them - // for discovery exactly like generic instances. + // exactly like generic instances. while (specLowered < this.emitSpecQueue.length) { try { - lowerEmitOverrideSpec(this, this.emitSpecQueue[specLowered++]!); + const fn = lowerEmitOverrideSpec(this, this.emitSpecQueue[specLowered++]!); + if (fn) instanceFunctions.push(fn); } catch (e) { if (!(e instanceof PoisonError)) throw e; } @@ -2505,7 +2523,7 @@ export class Lowerer { }; parts.forEach((fp) => { - this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!); + initFunctions.push(this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!)); drainInstances(); }); // LIBRARY mode's extra reachability roots (LowerOptions.libRoots): the @@ -2516,20 +2534,34 @@ export class Lowerer { while (queue.length > 0) { // A body-level poison outside the per-statement catches (a fenced // constructor/method parameter default lowered by declareParams): - // discovery only needs the edges the body fired before poisoning — - // the emit pass re-records the diagnostic and skips the member. + // every edge fired before poisoning remains retained; the diagnostic + // stays recorded and the member stays omitted. try { - units.get(queue.shift()!)!(); + const name = queue.shift()!; + const fn = units.get(name)!.lower(); + if (fn) loweredUnits.set(name, fn); } catch (e) { if (!(e instanceof PoisonError)) throw e; } drainInstances(); } - return reachable; - } - - /** Discovery hook (see discover): fires when lowering resolves a - * reference to a lowerable body. Inert in the emit pass. */ + const orderedUnits = [...loweredUnits] + .sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order) + .map(([, fn]) => fn); + const functions = [ + ...orderedUnits, + ...initFunctions, + this.buildMain(), + ...instanceFunctions, + ...this.liftedFns, + ...this.implicitFns, + ]; + this.reachableForArtifacts = reachable; + return { reachable, result: this.finishModule(functions) }; + } + + /** Reachability hook (see emitReachable): fires when lowering resolves a + * reference to a lowerable body. */ noteEdge(name: string): void { if (this.onEdge) this.onEdge(name); } From 4264aca03f642786c7d2570d2cdc3ec00a601e33 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 09:18:22 -0500 Subject: [PATCH 2/7] Preserve record order in retained lowering --- .../src/frontend/lowering/lower-calls.ts | 97 ++++++++++--------- .../compiler/src/frontend/lowering/lowerer.ts | 88 +++++++++++++++-- packages/compiler/src/frontend/types.ts | 42 +++++++- .../2691-retained-lowering-record-order.ts | 11 +++ 4 files changed, 182 insertions(+), 56 deletions(-) create mode 100644 tests/corpus/2691-retained-lowering-record-order.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 3080a49b3..5b35be897 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6921,50 +6921,8 @@ export function lowerPromiseMethodCall(L: Lowerer, call: ts.CallExpression, helper = `%obj.keys.${L.arrHofHelpers.size}`; const ref: IrExpr = { kind: "varRef", localId: "r.0", type: argIr, loc }; const outRef: IrExpr = { kind: "varRef", localId: "out.0", type: resultT, loc }; - const body: IrStmt[] = [ - { kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: resultT, loc }, loc }, - ]; - const order = shape.declaredOrder ?? shape.fields.map((f) => f.name); - for (const name of order) { - const f = shape.fields.find((x) => x.name === name)!; - const pushStmt: IrStmt = { - kind: "exprStmt", - expr: { - kind: "arrIntrinsic", - method: "push", - receiver: outRef, - args: [{ kind: "strLit", value: f.name, type: STRING, loc }], - type: F64, - loc, - }, - loc, - }; - // Undefined-armed fields: the push is guarded by a tag test (the - // key exists exactly when the arm is not undefined). - const utag = f.type.kind === "union" ? L.armTag(f.type.unionId, UNDEFINED_T) : -1; - body.push( - utag >= 0 && f.type.kind === "union" - ? { - kind: "if", - cond: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: utag, - negated: true, - value: { kind: "recordGet", obj: ref, shapeId: argIr.shapeId, field: f.name, type: f.type, loc }, - type: BOOL, - loc, - }, - then: [pushStmt], - else_: null, - loc, - } - : pushStmt, - ); - } - body.push({ kind: "return", value: outRef, loc }); L.arrHofHelpers.set(key, helper); - L.liftedFns.push({ + const fn: IrFunction = { name: helper, params: [{ localId: "r.0", name: "r", type: argIr }], returnType: resultT, @@ -6972,9 +6930,58 @@ export function lowerPromiseMethodCall(L: Lowerer, call: ts.CallExpression, { id: "r.0", name: "r", type: argIr, mutable: true }, { id: "out.0", name: "out", type: resultT, mutable: false }, ], - body, + body: [], loc, - }); + }; + const finalize = (): void => { + const current = L.shapes.get(argIr.shapeId) ?? shape; + const body: IrStmt[] = [ + { kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: resultT, loc }, loc }, + ]; + const order = current.declaredOrder ?? current.fields.map((f) => f.name); + for (const name of order) { + const f = current.fields.find((x) => x.name === name)!; + const pushStmt: IrStmt = { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: outRef, + args: [{ kind: "strLit", value: f.name, type: STRING, loc }], + type: F64, + loc, + }, + loc, + }; + // Undefined-armed fields: the push is guarded by a tag test (the + // key exists exactly when Object.keys would list it). + const utag = f.type.kind === "union" ? L.armTag(f.type.unionId, UNDEFINED_T) : -1; + body.push( + utag >= 0 && f.type.kind === "union" + ? { + kind: "if", + cond: { + kind: "unionIsTag", + unionId: f.type.unionId, + tag: utag, + negated: true, + value: { kind: "recordGet", obj: ref, shapeId: argIr.shapeId, field: f.name, type: f.type, loc }, + type: BOOL, + loc, + }, + then: [pushStmt], + else_: null, + loc, + } + : pushStmt, + ); + } + body.push({ kind: "return", value: outRef, loc }); + fn.body = body; + }; + finalize(); + L.shapeOrderHelperFinalizers.push(finalize); + L.liftedFns.push(fn); } return { kind: "call", callee: helper, args: [receiver], type: resultT, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 31564a0a2..e99a53311 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -939,6 +939,10 @@ export class Lowerer { /** Synthetic array-HOF loop functions (map/filter/forEach desugar), * interned per method + element/callback-result type: key → fn name. */ readonly arrHofHelpers = new Map(); + /** Helpers that snapshot shape declaration order into their bodies. + * Reachability lowers inits before the declarations they discover, so + * these rebuild after the worklist restores historical shape metadata. */ + readonly shapeOrderHelperFinalizers: (() => void)[] = []; /** Emit-override specializations (`%C.emit:` — lower-emitter.ts's * emit-overrides block): interned names, the drive-loop queue, and the * currently-lowering specialization's context (the super-forward @@ -2455,6 +2459,43 @@ export class Lowerer { units.set(`%${cName}.static:${name}`, { order: unitOrder++, lower: () => lowerStaticMethod(this, info, name) }); } } + // The old emit pass visited each file's functions and then its classes, + // before every module init. Retained reachability discovers those + // bodies from the inits, but first-seen record metadata still has to + // follow that old order: Object.keys/JSON/inspect observe a shape's + // declaredOrder. Keep output sorting separate — this rank controls only + // that observable metadata. + const metadataPriority = new Map(); + let declarationMetadataOrder = 0; + const rank = (name: string): void => { + if (units.has(name) && !metadataPriority.has(name)) { + metadataPriority.set(name, [1, declarationMetadataOrder++]); + } + }; + for (const fp of parts) { + for (const decl of fp.fnDecls) { + if (!decl.body) continue; + const symbol = declSymbolOf(this, decl); + if (symbol && !this.genericFnsBySymbol.has(symbol)) { + const sig = this.fnSigsBySymbol.get(symbol); + if (sig) rank(sig.name); + } + } + for (const decl of fp.classDecls) { + const info = this.classes.get(this.classNamer(decl)); + if (!info) continue; + const cName = info.def.name; + rank(`%${cName}.constructor`); + for (const { mName } of this.classMethodMembers(info)) rank(`%${cName}.${mName}`); + for (const name of info.staticMethods?.keys() ?? []) rank(`%${cName}.static:${name}`); + for (const prop of info.throwingSetters) rank(`%${cName}.set:${prop}`); + } + } + // Defensive fallback for declaration-like units registered outside + // FileParts; they still precede init bodies in the old emit pass. + for (const name of units.keys()) rank(name); + let expressionMetadataOrder = 0; + let instanceMetadataOrder = 0; const reachable = new Set(); const queue: string[] = []; @@ -2471,15 +2512,19 @@ export class Lowerer { // edge to them can fire (references require the collected class). this.onExprClassCollected = (info: ClassInfo): void => { const cName = info.def.name; - units.set(`%${cName}.constructor`, { order: unitOrder++, lower: () => this.lowerClassCtor(info) }); + const register = (name: string, lower: () => IrFunction | null): void => { + units.set(name, { order: unitOrder++, lower }); + metadataPriority.set(name, [3, expressionMetadataOrder++]); + }; + register(`%${cName}.constructor`, () => this.lowerClassCtor(info)); for (const { mName, member } of this.classMethodMembers(info)) { - units.set(`%${cName}.${mName}`, { order: unitOrder++, lower: () => this.lowerClassMethodMember(info, member) }); + register(`%${cName}.${mName}`, () => this.lowerClassMethodMember(info, member)); } for (const name of info.staticMethods?.keys() ?? []) { - units.set(`%${cName}.static:${name}`, { order: unitOrder++, lower: () => lowerStaticMethod(this, info, name) }); + register(`%${cName}.static:${name}`, () => lowerStaticMethod(this, info, name)); } for (const prop of info.throwingSetters) { - units.set(`%${cName}.set:${prop}`, { order: unitOrder++, lower: () => this.throwingSetterFn(info, prop) }); + register(`%${cName}.set:${prop}`, () => this.throwingSetterFn(info, prop)); } }; // Generic instances queued by the bodies above lower here (an instance @@ -2500,20 +2545,33 @@ export class Lowerer { // Body-level poisons skip the instance after retaining the // diagnostic and every edge fired before poisoning. try { - instanceFunctions.push(this.lowerGenericInstance(info, inst)); + instanceFunctions.push( + this.shapes.withDeclaredOrderPriority( + [4, instanceMetadataOrder++], + () => this.lowerGenericInstance(info, inst), + ), + ); } catch (e) { if (!(e instanceof PoisonError)) throw e; } } while (clsInstLowered < this.genericClassInstances.length) { - instanceFunctions.push(...this.lowerClassMembers(this.genericClassInstances[clsInstLowered++]!)); + instanceFunctions.push( + ...this.shapes.withDeclaredOrderPriority( + [4, instanceMetadataOrder++], + () => this.lowerClassMembers(this.genericClassInstances[clsInstLowered++]!), + ), + ); } // Emit-override specialization bodies fire edges of their own // (the super-forward chain, closures, generic calls) — lower them // exactly like generic instances. while (specLowered < this.emitSpecQueue.length) { try { - const fn = lowerEmitOverrideSpec(this, this.emitSpecQueue[specLowered++]!); + const fn = this.shapes.withDeclaredOrderPriority( + [4, instanceMetadataOrder++], + () => lowerEmitOverrideSpec(this, this.emitSpecQueue[specLowered++]!), + ); if (fn) instanceFunctions.push(fn); } catch (e) { if (!(e instanceof PoisonError)) throw e; @@ -2522,8 +2580,13 @@ export class Lowerer { } }; - parts.forEach((fp) => { - initFunctions.push(this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!)); + parts.forEach((fp, index) => { + initFunctions.push( + this.shapes.withDeclaredOrderPriority( + [2, index], + () => this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!), + ), + ); drainInstances(); }); // LIBRARY mode's extra reachability roots (LowerOptions.libRoots): the @@ -2538,7 +2601,11 @@ export class Lowerer { // stays recorded and the member stays omitted. try { const name = queue.shift()!; - const fn = units.get(name)!.lower(); + const unit = units.get(name)!; + const fn = this.shapes.withDeclaredOrderPriority( + metadataPriority.get(name) ?? [3, expressionMetadataOrder++], + unit.lower, + ); if (fn) loweredUnits.set(name, fn); } catch (e) { if (!(e instanceof PoisonError)) throw e; @@ -2548,6 +2615,7 @@ export class Lowerer { const orderedUnits = [...loweredUnits] .sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order) .map(([, fn]) => fn); + for (const finalize of this.shapeOrderHelperFinalizers) finalize(); const functions = [ ...orderedUnits, ...initFunctions, diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index c78f8d2eb..bd2f00108 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -69,6 +69,13 @@ export function isParseArgsDynTypeName(name: string): boolean { export class ShapeRegistry { private readonly byKey = new Map(); private readonly byId = new Map(); + /** Historical emit-order rank of the declaration-order metadata each + * shape kept. Collection runs at rank 0; retained reachability bodies + * install their old emit positions while they lower. This lets a body + * reached after an init replace metadata the worklist encountered first + * when the old emitter would have lowered that body first. */ + private readonly declaredOrderPriority = new Map(); + private currentDeclaredOrderPriority: readonly [phase: number, order: number] = [0, 0]; /** All interned shapes in first-seen (`r0`, `r1`, ...) order. */ readonly shapes: IrRecordShape[] = []; /** ts.Types currently being mapped — a BACK-REFERENCE to one of these is @@ -104,6 +111,34 @@ export class ShapeRegistry { ); } + /** Runs one lowering unit under its historical emit-order rank. Shape + * ids remain demand-assigned; only first-seen declaration-order metadata + * uses this rank, because Object.keys/JSON/inspect observe it. */ + withDeclaredOrderPriority(priority: readonly [phase: number, order: number], fn: () => T): T { + const previous = this.currentDeclaredOrderPriority; + this.currentDeclaredOrderPriority = priority; + try { + return fn(); + } finally { + this.currentDeclaredOrderPriority = previous; + } + } + + private adoptDeclaredOrder(shape: IrRecordShape, declaredOrder: string[] | undefined): void { + if (declaredOrder === undefined) return; + const previous = this.declaredOrderPriority.get(shape.id); + if ( + previous !== undefined && + (previous[0] < this.currentDeclaredOrderPriority[0] || + (previous[0] === this.currentDeclaredOrderPriority[0] && + previous[1] <= this.currentDeclaredOrderPriority[1])) + ) { + return; + } + shape.declaredOrder = declaredOrder; + this.declaredOrderPriority.set(shape.id, this.currentDeclaredOrderPriority); + } + /** The shape id a back-reference to an in-progress type resolves to: * reuses the type's persistent recursive id or mints a PLACEHOLDER * entry (empty fields) the outer frame finalizes. */ @@ -145,7 +180,7 @@ export class ShapeRegistry { const shape = this.byId.get(id)!; shape.fields = fields; if (indexValue) shape.indexValue = indexValue; - if (declaredOrder) shape.declaredOrder = declaredOrder; + this.adoptDeclaredOrder(shape, declaredOrder); this.pendingRec.delete(id); const key = this.keyOf(fields, false, indexValue); if (!this.byKey.has(key)) this.byKey.set(key, id); @@ -179,6 +214,11 @@ export class ShapeRegistry { this.byKey.set(key, id); this.byId.set(id, shape); this.shapes.push(shape); + if (declaredOrder !== undefined) { + this.declaredOrderPriority.set(id, this.currentDeclaredOrderPriority); + } + } else { + this.adoptDeclaredOrder(this.byId.get(id)!, declaredOrder); } return id; } diff --git a/tests/corpus/2691-retained-lowering-record-order.ts b/tests/corpus/2691-retained-lowering-record-order.ts new file mode 100644 index 000000000..e6ad44928 --- /dev/null +++ b/tests/corpus/2691-retained-lowering-record-order.ts @@ -0,0 +1,11 @@ +// Retained reachability lowers module inits before the function bodies they +// discover. Record metadata must still use the historical emit order: this +// function's {a,b} shape precedes the init's structurally-equal {b,a} shape. +function printFunctionRecord(): void { + const value = { a: 1, b: 2 }; + console.log(Object.keys(value).join(",")); + console.log(JSON.stringify(value)); +} + +printFunctionRecord(); +console.log(Object.keys({ b: 2, a: 1 }).length); From 1f0551d76b082067302fda97e802ba6b4962971e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 09:54:51 -0500 Subject: [PATCH 3/7] Preserve record order in retained helpers --- .../src/frontend/lowering/lower-calls.ts | 195 +++++++++--------- .../src/frontend/lowering/lower-containers.ts | 60 ++++-- .../src/frontend/lowering/lower-inspect.ts | 85 +++++--- .../src/frontend/lowering/lower-stmts.ts | 12 +- .../compiler/src/frontend/lowering/lowerer.ts | 4 + packages/compiler/src/frontend/types.ts | 18 ++ .../2691-retained-lowering-record-order.ts | 67 +++++- 7 files changed, 298 insertions(+), 143 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 5b35be897..e20941124 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -7736,129 +7736,136 @@ export function lowerPromiseMethodCall(L: Lowerer, call: ts.CallExpression, valueT = tupleShape.fields.find((f) => f.name === "1")!.type; } - const order = shape.declaredOrder ?? shape.fields.map((f) => f.name); const key = `obj.${member}:${argIr.shapeId}:${typeKey(resultT)}`; let helper = L.arrHofHelpers.get(key); if (!helper) { helper = `%obj.${member}.${L.arrHofHelpers.size}`; const recT = argIr; const ref: IrExpr = { kind: "varRef", localId: "r.0", type: recT, loc }; - const body: IrStmt[] = [ - { kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: resultT, loc }, loc }, - ]; const outRef: IrExpr = { kind: "varRef", localId: "out.0", type: resultT, loc }; - for (const name of order) { - const f = shape.fields.find((x) => x.name === name)!; - const raw: IrExpr = { kind: "recordGet", obj: ref, shapeId: argIr.shapeId, field: f.name, type: f.type, loc }; - // The pushed element per member; null when the field's value - // cannot flow into the result element type. - const elemOf = (value: IrExpr, vt: IrType): IrExpr | null => { - if (!valueT) return null; - if (typeEquals(vt, valueT)) return value; - if (valueT.kind === "union" && vt.kind !== "union") { - const tag = L.armTag(valueT.unionId, vt); - if (tag >= 0) { - return { kind: "unionWrap", unionId: valueT.unionId, tag, value, type: valueT, loc }; + L.arrHofHelpers.set(key, helper); + const fn: IrFunction = { + name: helper, + params: [{ localId: "r.0", name: "r", type: recT }], + returnType: resultT, + locals: [ + { id: "r.0", name: "r", type: recT, mutable: true }, + { id: "out.0", name: "out", type: resultT, mutable: false }, + ], + body: [], + loc, + }; + const finalize = (): void => { + const current = L.shapes.get(argIr.shapeId) ?? shape; + const body: IrStmt[] = [ + { kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: resultT, loc }, loc }, + ]; + const order = current.declaredOrder ?? current.fields.map((f) => f.name); + for (const name of order) { + const f = current.fields.find((x) => x.name === name)!; + const raw: IrExpr = { kind: "recordGet", obj: ref, shapeId: argIr.shapeId, field: f.name, type: f.type, loc }; + // The pushed element per member; null when the field's value + // cannot flow into the result element type. + const elemOf = (value: IrExpr, vt: IrType): IrExpr | null => { + if (!valueT) return null; + if (typeEquals(vt, valueT)) return value; + if (valueT.kind === "union" && vt.kind !== "union") { + const tag = L.armTag(valueT.unionId, vt); + if (tag >= 0) { + return { kind: "unionWrap", unionId: valueT.unionId, tag, value, type: valueT, loc }; + } } - } - return null; - }; - // Undefined-armed fields: the push is guarded by a tag test, and - // the pushed value is the narrowed non-undefined arm. - let guardUndefTag: number | null = null; - let value: IrExpr = raw; - let vt: IrType = f.type; - if (f.type.kind === "union") { - const undefTag = L.armTag(f.type.unionId, UNDEFINED_T); - if (undefTag >= 0) { - guardUndefTag = undefTag; - const arms = L.unions.get(f.type.unionId)?.arms ?? []; - const others = arms.filter((a) => a.kind !== "undefinedT"); - if (typeEquals(f.type, valueT ?? f.type)) { + return null; + }; + // Undefined-armed fields: the push is guarded by a tag test, and + // the pushed value is the narrowed non-undefined arm. + let guardUndefTag: number | null = null; + let value: IrExpr = raw; + let vt: IrType = f.type; + if (f.type.kind === "union") { + const undefTag = L.armTag(f.type.unionId, UNDEFINED_T); + if (undefTag >= 0) { + guardUndefTag = undefTag; + const arms = L.unions.get(f.type.unionId)?.arms ?? []; + const others = arms.filter((a) => a.kind !== "undefinedT"); + if (typeEquals(f.type, valueT ?? f.type)) { // The field union IS the result union (single-field shapes): // push the raw box — but then the undefined skip must NOT // narrow. Handled below via vt === valueT. - value = raw; - vt = f.type; - } else if (others.length === 1) { - vt = others[0]!; + value = raw; + vt = f.type; + } else if (others.length === 1) { + vt = others[0]!; // A UNIT other arm (`null | undefined` fields — the mixed- // defaults spread idiom; undefined was filtered above, so // the unit is null): units carry no payload, so the guarded // push writes the unit LITERAL — unionNarrow to a unit arm // (and unionWrap of a narrowed unit) is malformed IR; the // literal is the one legal unit spelling. - value = isUnitType(vt) - ? { kind: "unitLit", unit: "null", type: vt, loc } - : { kind: "unionNarrow", unionId: f.type.unionId, tag: L.armTag(f.type.unionId, vt), value: raw, type: vt, loc }; - } else { + value = isUnitType(vt) + ? { kind: "unitLit", unit: "null", type: vt, loc } + : { kind: "unionNarrow", unionId: f.type.unionId, tag: L.armTag(f.type.unionId, vt), value: raw, type: vt, loc }; + } else { + L.unsupported( + "SC1090", + call, + `Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' is a multi-arm union that ` + + "cannot re-tag into the result element type — read the fields directly)", + ); + } + } else if (!typeEquals(f.type, valueT ?? f.type)) { L.unsupported( "SC1090", call, - `Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' is a multi-arm union that ` + - "cannot re-tag into the result element type — read the fields directly)", + `Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' is a union that cannot ` + + "re-tag into the result element type — read the fields directly)", ); } - } else if (!typeEquals(f.type, valueT ?? f.type)) { + } + const coerced = elemOf(value, vt); + if (!coerced) { L.unsupported( "SC1090", call, - `Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' is a union that cannot ` + - "re-tag into the result element type — read the fields directly)", + `Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' of type '${L.fmt(f.type)}' ` + + `cannot flow into the '${L.fmt(valueT!)}' result element — read the fields directly)`, ); } - } - const coerced = elemOf(value, vt); - if (!coerced) { - L.unsupported( - "SC1090", - call, - `Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' of type '${L.fmt(f.type)}' ` + - `cannot flow into the '${L.fmt(valueT!)}' result element — read the fields directly)`, + const pushed: IrExpr = + member === "values" + ? coerced + : { + kind: "recordLit", + fields: [ + { name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } }, + { name: "1", value: coerced }, + ], + type: tupleT!, + loc, + }; + const pushStmt: IrStmt = { + kind: "exprStmt", + expr: { kind: "arrIntrinsic", method: "push", receiver: outRef, args: [pushed], type: F64, loc }, + loc, + }; + body.push( + guardUndefTag !== null && f.type.kind === "union" + ? { + kind: "if", + cond: { kind: "unionIsTag", unionId: f.type.unionId, tag: guardUndefTag, negated: true, value: raw, type: BOOL, loc }, + then: [pushStmt], + else_: null, + loc, + } + : pushStmt, ); } - const pushed: IrExpr = - member === "values" - ? coerced - : { - kind: "recordLit", - fields: [ - { name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } }, - { name: "1", value: coerced }, - ], - type: tupleT!, - loc, - }; - const pushStmt: IrStmt = { - kind: "exprStmt", - expr: { kind: "arrIntrinsic", method: "push", receiver: outRef, args: [pushed], type: F64, loc }, - loc, - }; - body.push( - guardUndefTag !== null && f.type.kind === "union" - ? { - kind: "if", - cond: { kind: "unionIsTag", unionId: f.type.unionId, tag: guardUndefTag, negated: true, value: raw, type: BOOL, loc }, - then: [pushStmt], - else_: null, - loc, - } - : pushStmt, - ); - } - body.push({ kind: "return", value: outRef, loc }); - L.arrHofHelpers.set(key, helper); - L.liftedFns.push({ - name: helper, - params: [{ localId: "r.0", name: "r", type: recT }], - returnType: resultT, - locals: [ - { id: "r.0", name: "r", type: recT, mutable: true }, - { id: "out.0", name: "out", type: resultT, mutable: false }, - ], - body, - loc, - }); + body.push({ kind: "return", value: outRef, loc }); + fn.body = body; + }; + finalize(); + L.shapeOrderHelperFinalizers.push(finalize); + L.liftedFns.push(fn); } return { kind: "call", callee: helper, args: [receiver], type: resultT, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 5921f79b8..5eb3008ca 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -6349,6 +6349,7 @@ const DV_SETTERS: Record(); // Declared fields, in declaration order. Undefined-valued fields // skip at runtime (the unset-optional convention); values surface @@ -6417,7 +6418,7 @@ const DV_SETTERS: Record= 0 && f.type.kind === "union" ? { kind: "if", @@ -6426,8 +6427,9 @@ const DV_SETTERS: Record { + const current = L.shapes.get(argIr.shapeId) ?? shape; + const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); + fn.body = [body[0]!, ...currentOrder.flatMap((name) => { + const stmt = fieldStmts.get(name); + return stmt ? [stmt] : []; + }), ...suffix]; }); + L.liftedFns.push(fn); } return { kind: "call", callee: helper, args: [receiver], type: resultT, loc }; } @@ -6876,6 +6888,7 @@ const DV_SETTERS: Record(); // Source declared fields in declaration order, skipping unset // optionals (stance 37) and the direct-initialized (consumed) names. for (const ff of orderedFields) { @@ -6893,7 +6906,7 @@ const DV_SETTERS: Record f.name === ff.name) ? {} : { overflowOnly: true as const }), loc, }; - body.push( + const fieldStmt: IrStmt = utag >= 0 && ff.type.kind === "union" ? { kind: "if", @@ -6902,8 +6915,9 @@ const DV_SETTERS: Record { + const current = L.shapes.get(fromId) ?? from; + const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); + fn.body = [body[0]!, ...currentOrder.flatMap((field) => { + const stmt = fieldStmts.get(field); + return stmt ? [stmt] : []; + }), ...suffix]; }); + L.liftedFns.push(fn); return name; } @@ -7050,6 +7074,7 @@ const DV_SETTERS: Record lift === "dyn" ? { kind: "dynFrom", value: v, type: DYN, loc } : L.applyWidthLift(lift, v, tIv, loc); const body: IrStmt[] = []; + const fieldStmts = new Map(); for (const ff of plan.fields) { const raw: IrExpr = { kind: "recordGet", obj: sRef, shapeId: plan.fromId, field: ff.name, type: ff.type, loc }; const utag = ff.type.kind === "union" ? L.armTag(ff.type.unionId, UNDEFINED_T) : -1; @@ -7061,7 +7086,7 @@ const DV_SETTERS: Record= 0 && ff.type.kind === "union" ? { kind: "if", @@ -7070,8 +7095,9 @@ const DV_SETTERS: Record { + const current = L.shapes.get(plan.fromId) ?? fromShape; + const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); + fn.body = [...currentOrder.flatMap((field) => { + const stmt = fieldStmts.get(field); + return stmt ? [stmt] : []; + }), ...suffix]; }); + L.liftedFns.push(fn); return name; }; let acc = L.lowerExprExpecting(call.arguments[0]!, targetIr); diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index f0b610b42..3ab0a4520 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -406,7 +406,8 @@ function inspectHelper(L: Lowerer, t: IrType, loc: SrcLoc): string { { id: "r.0", name: "r", type: F64, mutable: false }, { id: "d.0", name: "d", type: F64, mutable: false }, ]; - let body: IrStmt[]; + let body: IrStmt[] = []; + let rebuildShapeOrderBody: (() => void) | null = null; switch (t.kind) { case "array": { @@ -542,21 +543,27 @@ function inspectHelper(L: Lowerer, t: IrType, loc: SrcLoc): string { body = [ret(str("{}", loc))]; break; } - // Object.keys' declared order (SEMANTICS.md 36's stance). - const order = shape.declaredOrder ?? shape.fields.map((f) => f.name); - const byName = new Map(shape.fields.map((f) => [f.name, f.type] as const)); - body = [depthGate("[Object]"), ...begin()]; - for (const fname of order) { - const ft = byName.get(fname); - if (!ft) continue; - body.push( - entry( - concatAll([str(`${inspectKey(fname)}: `, loc), child(ft, get(fname, ft))], loc), - boolLit(false, loc), - ), - ); - } - body.push(ret(end(str("", loc), str("{", loc), str("}", loc), false, boolLit(false, loc)))); + // Object.keys' declared order (SEMANTICS.md 36's stance). Retained + // reachability can settle this shape after the helper is first + // interned, so rebuild the field walk once metadata is final. + rebuildShapeOrderBody = (): void => { + const current = L.shapes.get(t.shapeId) ?? shape; + const order = current.declaredOrder ?? current.fields.map((f) => f.name); + const byName = new Map(current.fields.map((f) => [f.name, f.type] as const)); + body = [depthGate("[Object]"), ...begin()]; + for (const fname of order) { + const ft = byName.get(fname); + if (!ft) continue; + body.push( + entry( + concatAll([str(`${inspectKey(fname)}: `, loc), child(ft, get(fname, ft))], loc), + boolLit(false, loc), + ), + ); + } + body.push(ret(end(str("", loc), str("{", loc), str("}", loc), false, boolLit(false, loc)))); + }; + rebuildShapeOrderBody(); break; } case "map": @@ -711,6 +718,7 @@ function inspectHelper(L: Lowerer, t: IrType, loc: SrcLoc): string { throw new Error(`inspect helper over unexpected type ${typeKey(t)}`); } + let prependCycleGuard: (() => void) | null = null; if (onCycle) { // The circular check, FIRST (before the empty-literal and depth // answers): a value already on the traversal stack renders @@ -718,24 +726,27 @@ function inspectHelper(L: Lowerer, t: IrType, loc: SrcLoc): string { // (a circular target beyond the depth budget still says Circular). locals.push({ id: "cc.0", name: "cc", type: F64, mutable: false }); const cc = (): IrExpr => ref("cc.0", F64); - body.unshift( - { - kind: "varDecl", - localId: "cc.0", - init: { kind: "libCall", fn: "insp.circCheck", args: [v()], type: F64, loc }, - loc, - }, - { - kind: "if", - cond: { kind: "bin", op: ">", left: cc(), right: num(0, loc), type: BOOL, loc }, - then: [ret({ kind: "libCall", fn: "insp.circular", args: [cc()], type: STRING, loc })], - else_: null, - loc, - }, - ); + prependCycleGuard = (): void => { + body.unshift( + { + kind: "varDecl", + localId: "cc.0", + init: { kind: "libCall", fn: "insp.circCheck", args: [v()], type: F64, loc }, + loc, + }, + { + kind: "if", + cond: { kind: "bin", op: ">", left: cc(), right: num(0, loc), type: BOOL, loc }, + then: [ret({ kind: "libCall", fn: "insp.circular", args: [cc()], type: STRING, loc })], + else_: null, + loc, + }, + ); + }; + prependCycleGuard(); } - L.liftedFns.push({ + const fn = { name, params: [ { localId: "v.0", name: "v", type: t }, @@ -746,7 +757,15 @@ function inspectHelper(L: Lowerer, t: IrType, loc: SrcLoc): string { locals, body, loc, - }); + }; + if (rebuildShapeOrderBody !== null) { + L.shapeOrderHelperFinalizers.push(() => { + rebuildShapeOrderBody!(); + prependCycleGuard?.(); + fn.body = body; + }); + } + L.liftedFns.push(fn); return name; } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 23efadfcc..28e146070 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5380,12 +5380,22 @@ function isEsModuleStamp(expr: ts.Expression): boolean { } } const remaining = shape.fields.filter((f) => !consumed.has(f.name)); + const restOrder = shape.declaredOrder?.filter((n) => !consumed.has(n)); const restShapeId = L.shapes.intern( remaining.map((f) => ({ name: f.name, type: f.type })), false, undefined, - shape.declaredOrder?.filter((n) => !consumed.has(n)), + restOrder, ); + if (restOrder !== undefined) { + L.shapeOrderMetadataFinalizers.push( + L.shapes.declaredOrderFinalizer( + restShapeId, + restOrder, + () => L.shapes.get(srcType.shapeId)?.declaredOrder?.filter((n) => !consumed.has(n)), + ), + ); + } const packed: IrExpr = { kind: "recordLit", fields: remaining.map((f) => ({ diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e99a53311..cf8f40e4b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -939,6 +939,9 @@ export class Lowerer { /** Synthetic array-HOF loop functions (map/filter/forEach desugar), * interned per method + element/callback-result type: key → fn name. */ readonly arrHofHelpers = new Map(); + /** Derived shape metadata that depends on another shape's declaration + * order. These settle before helper bodies rebuild from that metadata. */ + readonly shapeOrderMetadataFinalizers: (() => void)[] = []; /** Helpers that snapshot shape declaration order into their bodies. * Reachability lowers inits before the declarations they discover, so * these rebuild after the worklist restores historical shape metadata. */ @@ -2615,6 +2618,7 @@ export class Lowerer { const orderedUnits = [...loweredUnits] .sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order) .map(([, fn]) => fn); + for (const finalize of this.shapeOrderMetadataFinalizers) finalize(); for (const finalize of this.shapeOrderHelperFinalizers) finalize(); const functions = [ ...orderedUnits, diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index bd2f00108..d13efb286 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -139,6 +139,24 @@ export class ShapeRegistry { this.declaredOrderPriority.set(shape.id, this.currentDeclaredOrderPriority); } + /** Captures the current historical rank for a derived shape whose order + * must be recomputed after retained reachability settles its source. */ + declaredOrderFinalizer( + shapeId: string, + originalOrder: string[], + order: () => string[] | undefined, + ): () => void { + return () => { + const shape = this.byId.get(shapeId); + // Only the writer whose array the shape actually adopted may revise + // it. Another structurally-equal type at the same historical rank + // still obeys first-writer-wins. + if (shape?.declaredOrder !== originalOrder) return; + const next = order(); + if (next !== undefined) shape.declaredOrder = next; + }; + } + /** The shape id a back-reference to an in-progress type resolves to: * reuses the type's persistent recursive id or mints a PLACEHOLDER * entry (empty fields) the outer frame finalizes. */ diff --git a/tests/corpus/2691-retained-lowering-record-order.ts b/tests/corpus/2691-retained-lowering-record-order.ts index e6ad44928..9a3229e5f 100644 --- a/tests/corpus/2691-retained-lowering-record-order.ts +++ b/tests/corpus/2691-retained-lowering-record-order.ts @@ -1,11 +1,72 @@ +import { inspect } from "node:util"; + // Retained reachability lowers module inits before the function bodies they -// discover. Record metadata must still use the historical emit order: this -// function's {a,b} shape precedes the init's structurally-equal {b,a} shape. +// discover. Record metadata and every helper that snapshots it must still use +// historical emit order: these functions' {a,b} shapes precede the inits' +// structurally-equal {b,a} shapes. function printFunctionRecord(): void { const value = { a: 1, b: 2 }; console.log(Object.keys(value).join(",")); + console.log(Object.values(value).join(",")); + console.log(Object.entries(value).map(([k, v]) => `${k}:${v}`).join(",")); console.log(JSON.stringify(value)); + console.log(inspect(value)); +} + +function printIndexRecord(): void { + const value: { a: number; b: number; [key: string]: number } = { a: 1, b: 2 }; + console.log(Object.keys(value).join(",")); + console.log(Object.values(value).join(",")); + console.log(Object.entries(value).map(([k, v]) => `${k}:${v}`).join(",")); +} + +function printCapturedRecord(): void { + const source = { a: 1, b: 2 }; + const value: Record = source; + console.log(Object.keys(value).join(",")); +} + +function printAssignedRecord(): void { + const value: Record = {}; + Object.assign(value, { a: 1, b: 2 }); + console.log(Object.keys(value).join(",")); +} + +function capturedCount(value: Record): number { + return Object.keys(value).length; +} + +function assignedCount(): number { + const value: Record = {}; + Object.assign(value, { b: 2, a: 1 }); + return Object.keys(value).length; +} + +interface OrderedNode { + a: number; + b: number; + next: OrderedNode | null; +} + +function printRecursiveRecord(): void { + const value: OrderedNode = { a: 1, b: 2, next: null }; + value.next = value; + console.log(inspect(value)); } -printFunctionRecord(); console.log(Object.keys({ b: 2, a: 1 }).length); +console.log(Object.values({ b: 2, a: 1 }).length); +console.log(Object.entries({ b: 2, a: 1 }).length); +console.log(inspect({ b: 2, a: 1 }).length); +console.log(Object.keys({ b: 2, a: 1 } as { b: number; a: number; [key: string]: number }).length); +console.log(Object.values({ b: 2, a: 1 } as { b: number; a: number; [key: string]: number }).length); +console.log(Object.entries({ b: 2, a: 1 } as { b: number; a: number; [key: string]: number }).length); +console.log(capturedCount({ b: 2, a: 1 })); +console.log(assignedCount()); +console.log(inspect({ b: 2, a: 1, next: null } as { b: number; a: number; next: OrderedNode | null }).length); + +printFunctionRecord(); +printIndexRecord(); +printCapturedRecord(); +printAssignedRecord(); +printRecursiveRecord(); From dee240efc709629c1b15863444879dd6777cead3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 11:04:31 -0500 Subject: [PATCH 4/7] Preserve generic lowering record order --- .../src/frontend/lowering/lower-calls.ts | 1 + .../src/frontend/lowering/lower-classes.ts | 2 + .../src/frontend/lowering/lower-stmts.ts | 45 +++- .../compiler/src/frontend/lowering/lowerer.ts | 240 ++++++++++++++++-- packages/compiler/src/frontend/types.ts | 62 ++++- .../2691-retained-lowering-record-order.ts | 81 ++++++ 6 files changed, 394 insertions(+), 37 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e20941124..353f6d49e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1121,6 +1121,7 @@ export function genericFnOf(L: Lowerer, ident: ts.Identifier): GenericFnInfo | n info.instances.set(key, inst); L.instantiationQueue.push({ info, inst }); } + L.noteGenericInstanceDemand(inst); return inst; } diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 52fa5f054..cad7f1437 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2406,6 +2406,7 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration const key = mapped.map(typeKey).join(","); const existing = gci.instances.get(key); if (existing) { + if (existing.info) L.noteGenericClassInstanceDemand(existing.info); return existing.poisoned ? null : { kind: "object", className: existing.name }; } // The generic-fn cap, same rationale (polymorphic recursion through @@ -2444,6 +2445,7 @@ export function collectClassShapeInner(L: Lowerer, decl: ts.ClassLikeDeclaration } entry.info = info; L.genericClassInstances.push(info); + L.noteGenericClassInstanceDemand(info); L.onLateClassCollected?.(info); return { kind: "object", className: name }; } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28e146070..a42cbf1a6 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2505,12 +2505,45 @@ export function isParseArgsDynCheckerType(L: Lowerer, type: ts.Type): boolean { } } const emitOrder = restShape.declaredOrder ?? restShape.fields.map((f) => f.name); - if (emitOrder.length !== remaining.length || emitOrder.some((n, i) => n !== remaining[i]!.name)) { - L.unsupported( - "SC1031", - blame, - "rest bindings over class instances whose packed key order cannot match Node's (Object.keys/JSON.stringify would enumerate the copied fields in a different order)", - ); + const orderMatches = (order: readonly string[]): boolean => + order.length === remaining.length && order.every((n, i) => n === remaining[i]!.name); + if (!orderMatches(emitOrder)) { + if (L.instantiationContext === null || L.onEdge === null || isJsSourceFile(blame.getSourceFile())) { + L.unsupported( + "SC1031", + blame, + "rest bindings over class instances whose packed key order cannot match Node's (Object.keys/JSON.stringify would enumerate the copied fields in a different order)", + ); + } + // Retained reachability can learn about an earlier source-order body + // only while a generic instance lowers. Defer this metadata-only fence + // until that worklist closes; unlike emitted helper bodies, there is no + // IR to rebuild here — only the settled support decision matters. + const context = L.instantiationContext; + const countFailure = !L.suppressStats; + const sf = blame.getSourceFile(); + L.shapeOrderMetadataFinalizers.push(() => { + const current = L.shapes.get(restT.shapeId) ?? restShape; + const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); + if (orderMatches(currentOrder)) return; + const previous = L.instantiationContext; + L.instantiationContext = context; + try { + if (countFailure) { + L.stats.statementsFailed++; + L.bumpFileStat(sf.fileName, "failed"); + } + L.pushDiag({ + code: "SC1031", + message: + "rest bindings over class instances whose packed key order cannot match Node's " + + "(Object.keys/JSON.stringify would enumerate the copied fields in a different order) are not supported yet", + loc: locOf(blame), + }); + } finally { + L.instantiationContext = previous; + } + }); } const fields = remaining.map((f) => { const fieldType = info.fields.get(f.name) ?? f.type; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cf8f40e4b..ebb8cd269 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -83,6 +83,7 @@ import { isUnitOnlyTsType, mapType, ShapeRegistry, + type DeclaredOrderPriorityRef, typeKey, type TypeMapperCtx, UnionRegistry, @@ -114,6 +115,12 @@ import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; * function can never collide with it (mangling is injective per prefix). */ export const ENTRY_NAME = "%main"; +interface GenericDemandOwner { + priority?: readonly [phase: number, order: number]; + functionDemands: GenericInstance[]; + classDemands: ClassInfo[]; +} + /** One step of the copy-reshape width relation (widthLiftPlan): how a * source-typed value enters a destination slot. Pure data — the plan half; * applyWidthLift is the build half. */ @@ -873,6 +880,152 @@ export class Lowerer { /** Monomorphization worklist: instances queued by call sites, drained in * run() (processing an instance body can queue more). */ readonly instantiationQueue: { info: GenericFnInfo; inst: GenericInstance }[] = []; + /** Historical emit rank of the retained declaration/init body currently + * lowering, and the earliest such owner that demanded each generic + * instance. Reachability can encounter a later caller first; the minimum + * rank recovers the old emitter's source-order monomorphization queue. */ + private genericDemandOwner: GenericDemandOwner | null = null; + private readonly genericDemandRoots: GenericDemandOwner[] = []; + private readonly genericFunctionDemandOwner = new Map(); + private readonly genericClassDemandOwner = new Map(); + private readonly genericDemandPriority = new Map(); + private readonly genericClassDemandPriority = new Map(); + + private withGenericDemandOwner( + owner: GenericDemandOwner, + fn: () => T, + ): T { + const previous = this.genericDemandOwner; + this.genericDemandOwner = owner; + try { + return fn(); + } finally { + this.genericDemandOwner = previous; + } + } + + noteGenericInstanceDemand(inst: GenericInstance): void { + const owner = this.genericDemandOwner; + owner?.functionDemands.push(inst); + const ref = this.genericDemandPriority.get(inst) ?? { rank: [4, Number.MAX_SAFE_INTEGER] }; + const currentRank = owner?.priority; + if (currentRank && ( + currentRank[0] < ref.rank[0]! || + (currentRank[0] === ref.rank[0] && currentRank[1] < (ref.rank[1] ?? 0)) + )) { + ref.rank = currentRank; + } + this.genericDemandPriority.set(inst, ref); + } + + noteGenericClassInstanceDemand(info: ClassInfo): void { + const owner = this.genericDemandOwner; + owner?.classDemands.push(info); + const ref = this.genericClassDemandPriority.get(info) ?? { rank: [4, Number.MAX_SAFE_INTEGER] }; + const currentRank = owner?.priority; + if (currentRank && ( + currentRank[0] < ref.rank[0]! || + (currentRank[0] === ref.rank[0] && currentRank[1] < (ref.rank[1] ?? 0)) + )) { + ref.rank = currentRank; + } + this.genericClassDemandPriority.set(info, ref); + } + + /** The old emitter lowered all reachable declarations in source order, + * then every init, before draining generic instances FIFO. Reorder the + * retained queue from those recorded demands before any generic body + * lowers, so immediate support decisions see the same shape metadata too. */ + private restoreGenericInstanceOrder(from = 0): void { + const tail = this.instantiationQueue.slice(from); + const discoveryOrder = new Map(tail.map((entry, index) => [entry.inst, index] as const)); + tail.sort((left, right) => { + const a = this.genericDemandPriority.get(left.inst)?.rank; + const b = this.genericDemandPriority.get(right.inst)?.rank; + if (a !== undefined && b !== undefined) { + const phase = a[0]! - b[0]!; + if (phase !== 0) return phase; + const order = a[1]! - b[1]!; + if (order !== 0) return order; + } else if (a !== undefined) { + return -1; + } else if (b !== undefined) { + return 1; + } + return discoveryOrder.get(left.inst)! - discoveryOrder.get(right.inst)!; + }); + this.instantiationQueue.splice(from, tail.length, ...tail); + } + + private restoreGenericClassInstanceOrder(from = 0): void { + const tail = this.genericClassInstances.slice(from); + const discoveryOrder = new Map(tail.map((info, index) => [info, index] as const)); + tail.sort((left, right) => { + const a = this.genericClassDemandPriority.get(left)?.rank; + const b = this.genericClassDemandPriority.get(right)?.rank; + if (a !== undefined && b !== undefined) { + const phase = a[0]! - b[0]!; + if (phase !== 0) return phase; + const order = a[1]! - b[1]!; + if (order !== 0) return order; + } else if (a !== undefined) { + return -1; + } else if (b !== undefined) { + return 1; + } + return discoveryOrder.get(left)! - discoveryOrder.get(right)!; + }); + this.genericClassInstances.splice(from, tail.length, ...tail); + } + + private settleGenericDemandPriorities(): void { + const functionQueue: GenericInstance[] = []; + const classQueue: ClassInfo[] = []; + const seenFunctions = new Set(); + const seenClasses = new Set(); + const enqueue = (owner: GenericDemandOwner): void => { + for (const info of owner.classDemands) { + if (seenClasses.has(info)) continue; + seenClasses.add(info); + classQueue.push(info); + } + for (const inst of owner.functionDemands) { + if (seenFunctions.has(inst)) continue; + seenFunctions.add(inst); + functionQueue.push(inst); + } + }; + for (const root of [...this.genericDemandRoots].sort((a, b) => { + const left = a.priority!; + const right = b.priority!; + return left[0] - right[0] || left[1] - right[1]; + })) enqueue(root); + let classIndex = 0; + let functionIndex = 0; + let order = 0; + while (classIndex < classQueue.length || functionIndex < functionQueue.length) { + while (classIndex < classQueue.length) { + const info = classQueue[classIndex++]!; + this.genericClassDemandPriority.get(info)!.rank = [4, order++]; + const owner = this.genericClassDemandOwner.get(info); + if (owner) enqueue(owner); + } + while (functionIndex < functionQueue.length) { + const inst = functionQueue[functionIndex++]!; + this.genericDemandPriority.get(inst)!.rank = [4, order++]; + const owner = this.genericFunctionDemandOwner.get(inst); + if (owner) enqueue(owner); + } + } + for (const info of this.genericClassInstances) { + const ref = this.genericClassDemandPriority.get(info); + if (ref && ref.rank[1] === Number.MAX_SAFE_INTEGER) ref.rank = [4, order++]; + } + for (const { inst } of this.instantiationQueue) { + const ref = this.genericDemandPriority.get(inst); + if (ref && ref.rank[1] === Number.MAX_SAFE_INTEGER) ref.rank = [4, order++]; + } + } /** Non-null while an instance body lowers: type-parameter symbol → * concrete IR type, consulted inside mapType's recursion. */ typeParamBindings: Map | null = null; @@ -2499,7 +2652,15 @@ export class Lowerer { for (const name of units.keys()) rank(name); let expressionMetadataOrder = 0; let instanceMetadataOrder = 0; - + const demandOwner = (priority?: readonly [number, number]): GenericDemandOwner => { + const owner: GenericDemandOwner = { + ...(priority ? { priority } : {}), + functionDemands: [], + classDemands: [], + }; + if (priority) this.genericDemandRoots.push(owner); + return owner; + }; const reachable = new Set(); const queue: string[] = []; const loweredUnits = new Map(); @@ -2543,29 +2704,30 @@ export class Lowerer { clsInstLowered < this.genericClassInstances.length || specLowered < this.emitSpecQueue.length ) { + while (clsInstLowered < this.genericClassInstances.length) { + const info = this.genericClassInstances[clsInstLowered++]!; + const owner = demandOwner(); + this.genericClassDemandOwner.set(info, owner); + const ref = this.genericClassDemandPriority.get(info) ?? { rank: [4, instanceMetadataOrder++] }; + this.genericClassDemandPriority.set(info, ref); + instanceFunctions.push(...this.withGenericDemandOwner(owner, () => + this.shapes.withDeclaredOrderPriority(ref, () => this.lowerClassMembers(info)))); + } while (instLowered < this.instantiationQueue.length) { const { info, inst } = this.instantiationQueue[instLowered++]!; + const owner = demandOwner(); + this.genericFunctionDemandOwner.set(inst, owner); + const ref = this.genericDemandPriority.get(inst) ?? { rank: [4, instanceMetadataOrder++] }; + this.genericDemandPriority.set(inst, ref); // Body-level poisons skip the instance after retaining the // diagnostic and every edge fired before poisoning. try { - instanceFunctions.push( - this.shapes.withDeclaredOrderPriority( - [4, instanceMetadataOrder++], - () => this.lowerGenericInstance(info, inst), - ), - ); + instanceFunctions.push(this.withGenericDemandOwner(owner, () => + this.shapes.withDeclaredOrderPriority(ref, () => this.lowerGenericInstance(info, inst)))); } catch (e) { if (!(e instanceof PoisonError)) throw e; } } - while (clsInstLowered < this.genericClassInstances.length) { - instanceFunctions.push( - ...this.shapes.withDeclaredOrderPriority( - [4, instanceMetadataOrder++], - () => this.lowerClassMembers(this.genericClassInstances[clsInstLowered++]!), - ), - ); - } // Emit-override specialization bodies fire edges of their own // (the super-forward chain, closures, generic calls) — lower them // exactly like generic instances. @@ -2584,13 +2746,17 @@ export class Lowerer { }; parts.forEach((fp, index) => { + const priority = [2, index] as const; + const owner = demandOwner(priority); initFunctions.push( - this.shapes.withDeclaredOrderPriority( - [2, index], - () => this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!), + this.withGenericDemandOwner( + owner, + () => this.shapes.withDeclaredOrderPriority( + priority, + () => this.lowerFileInit(fp.sf, fp.topStmts, this.initNameOf.get(fp.sf)!), + ), ), ); - drainInstances(); }); // LIBRARY mode's extra reachability roots (LowerOptions.libRoots): the // profile-mapped exports are called from outside the graph, so they @@ -2605,19 +2771,49 @@ export class Lowerer { try { const name = queue.shift()!; const unit = units.get(name)!; - const fn = this.shapes.withDeclaredOrderPriority( - metadataPriority.get(name) ?? [3, expressionMetadataOrder++], - unit.lower, + const priority = metadataPriority.get(name) ?? [3, expressionMetadataOrder++]; + const owner = demandOwner(priority); + const fn = this.withGenericDemandOwner( + owner, + () => this.shapes.withDeclaredOrderPriority(priority, unit.lower), ); if (fn) loweredUnits.set(name, fn); } catch (e) { if (!(e instanceof PoisonError)) throw e; } + } + this.restoreGenericInstanceOrder(); + this.restoreGenericClassInstanceOrder(); + // Generic bodies can reach ordinary declarations, whose bodies can in + // turn queue more instances. Continue to the joint fixpoint; the initial + // queue above is the only portion whose discovery order differed from + // historical emit order. + for (;;) { drainInstances(); + if (queue.length === 0) break; + while (queue.length > 0) { + try { + const name = queue.shift()!; + const unit = units.get(name)!; + const priority = metadataPriority.get(name) ?? [3, expressionMetadataOrder++]; + const owner = demandOwner(priority); + const fn = this.withGenericDemandOwner( + owner, + () => this.shapes.withDeclaredOrderPriority(priority, unit.lower), + ); + if (fn) loweredUnits.set(name, fn); + } catch (e) { + if (!(e instanceof PoisonError)) throw e; + } + } + this.restoreGenericInstanceOrder(instLowered); + this.restoreGenericClassInstanceOrder(clsInstLowered); } const orderedUnits = [...loweredUnits] .sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order) .map(([, fn]) => fn); + this.settleGenericDemandPriorities(); + this.shapes.settleDeclaredOrderPriorities(); for (const finalize of this.shapeOrderMetadataFinalizers) finalize(); for (const finalize of this.shapeOrderHelperFinalizers) finalize(); const functions = [ diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index d13efb286..6865c2ebd 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -61,6 +61,27 @@ export function isParseArgsDynTypeName(name: string): boolean { return PARSE_ARGS_DYN_TYPES.has(name); } +export interface DeclaredOrderPriorityRef { + rank: readonly number[]; +} + +export type DeclaredOrderPriority = readonly number[] | DeclaredOrderPriorityRef; + +function priorityRank(priority: DeclaredOrderPriority): readonly number[] { + return "rank" in priority ? priority.rank : priority; +} + +function comparePriority(left: DeclaredOrderPriority, right: DeclaredOrderPriority): number { + const a = priorityRank(left); + const b = priorityRank(right); + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + /** The frontend's record-shape interner. Records are monomorphic structural * shapes: fields sorted by name form the canonical identity, and two types * with the same canonical field list share one shapeId (and later one C @@ -74,8 +95,12 @@ export class ShapeRegistry { * install their old emit positions while they lower. This lets a body * reached after an init replace metadata the worklist encountered first * when the old emitter would have lowered that body first. */ - private readonly declaredOrderPriority = new Map(); - private currentDeclaredOrderPriority: readonly [phase: number, order: number] = [0, 0]; + private readonly declaredOrderPriority = new Map(); + private readonly declaredOrderCandidates = new Map< + string, + { order: string[]; priority: DeclaredOrderPriority }[] + >(); + private currentDeclaredOrderPriority: DeclaredOrderPriority = [0, 0]; /** All interned shapes in first-seen (`r0`, `r1`, ...) order. */ readonly shapes: IrRecordShape[] = []; /** ts.Types currently being mapped — a BACK-REFERENCE to one of these is @@ -114,7 +139,7 @@ export class ShapeRegistry { /** Runs one lowering unit under its historical emit-order rank. Shape * ids remain demand-assigned; only first-seen declaration-order metadata * uses this rank, because Object.keys/JSON/inspect observe it. */ - withDeclaredOrderPriority(priority: readonly [phase: number, order: number], fn: () => T): T { + withDeclaredOrderPriority(priority: DeclaredOrderPriority, fn: () => T): T { const previous = this.currentDeclaredOrderPriority; this.currentDeclaredOrderPriority = priority; try { @@ -126,19 +151,35 @@ export class ShapeRegistry { private adoptDeclaredOrder(shape: IrRecordShape, declaredOrder: string[] | undefined): void { if (declaredOrder === undefined) return; + const candidates = this.declaredOrderCandidates.get(shape.id); + const candidate = { order: declaredOrder, priority: this.currentDeclaredOrderPriority }; + if (candidates) candidates.push(candidate); + else this.declaredOrderCandidates.set(shape.id, [candidate]); const previous = this.declaredOrderPriority.get(shape.id); - if ( - previous !== undefined && - (previous[0] < this.currentDeclaredOrderPriority[0] || - (previous[0] === this.currentDeclaredOrderPriority[0] && - previous[1] <= this.currentDeclaredOrderPriority[1])) - ) { + if (previous !== undefined && comparePriority(previous, this.currentDeclaredOrderPriority) <= 0) { return; } shape.declaredOrder = declaredOrder; this.declaredOrderPriority.set(shape.id, this.currentDeclaredOrderPriority); } + /** Mutable generic-instance ranks settle only after reachability closes. + * Re-choose each shape's first historical writer from the retained + * candidates before derived metadata and helper bodies finalize. */ + settleDeclaredOrderPriorities(): void { + for (const [shapeId, candidates] of this.declaredOrderCandidates) { + let best = candidates[0]; + if (!best) continue; + for (const candidate of candidates.slice(1)) { + if (comparePriority(candidate.priority, best.priority) < 0) best = candidate; + } + const shape = this.byId.get(shapeId); + if (!shape) continue; + shape.declaredOrder = best.order; + this.declaredOrderPriority.set(shapeId, best.priority); + } + } + /** Captures the current historical rank for a derived shape whose order * must be recomputed after retained reachability settles its source. */ declaredOrderFinalizer( @@ -233,6 +274,9 @@ export class ShapeRegistry { this.byId.set(id, shape); this.shapes.push(shape); if (declaredOrder !== undefined) { + this.declaredOrderCandidates.set(id, [ + { order: declaredOrder, priority: this.currentDeclaredOrderPriority }, + ]); this.declaredOrderPriority.set(id, this.currentDeclaredOrderPriority); } } else { diff --git a/tests/corpus/2691-retained-lowering-record-order.ts b/tests/corpus/2691-retained-lowering-record-order.ts index 9a3229e5f..ffb6e0930 100644 --- a/tests/corpus/2691-retained-lowering-record-order.ts +++ b/tests/corpus/2691-retained-lowering-record-order.ts @@ -54,6 +54,78 @@ function printRecursiveRecord(): void { console.log(inspect(value)); } +class GenericOrderedPair { + genericFirst = 1; + genericSecond = 2; +} + +function genericSourceFirst(_: T): void { + // This rest lowering checks order immediately; a helper finalizer cannot + // repair it after the wrong generic instance has already fenced. + const { ...value } = new GenericOrderedPair(); + console.log(Object.keys(value).join(",")); +} + +function genericSourceSecond(_: T): void { + const value = { genericSecond: 2, genericFirst: 1 }; + console.log(Object.keys(value).length); +} + +function callGenericSourceFirst(): void { + genericSourceFirst(1); +} + +function callGenericSourceSecond(): void { + genericSourceSecond(1); +} + +class GenericClassSourceFirst { + constructor(_: T) { + const { ...value } = new GenericOrderedPair(); + console.log(Object.keys(value).join(",")); + } +} + +class GenericClassSourceSecond { + constructor(_: T) { + const value = { genericSecond: 2, genericFirst: 1 }; + console.log(Object.keys(value).length); + } +} + +function constructGenericClassSourceFirst(): void { + new GenericClassSourceFirst(1); +} + +function constructGenericClassSourceSecond(): void { + new GenericClassSourceSecond(1); +} + +class NestedGenericOrderedPair { + nestedFirst = 1; + nestedSecond = 2; +} + +function nestedGenericInstanceSourceFirst(_: T): void { + const value = { nestedFirst: 1, nestedSecond: 2 }; + console.log(Object.keys(value).length); +} + +function nestedGenericSourceFirst(): void { + nestedGenericInstanceSourceFirst(1); +} + +function genericReachesEarlierSource(_: T): void { + const value = { nestedSecond: 2, nestedFirst: 1 }; + console.log(Object.keys(value).length); + // Reachability learns about this earlier declaration only while this + // instance lowers. Its metadata must still settle before the rest-order + // support decision becomes final. + nestedGenericSourceFirst(); + const { ...rest } = new NestedGenericOrderedPair(); + console.log(Object.keys(rest).join(",")); +} + console.log(Object.keys({ b: 2, a: 1 }).length); console.log(Object.values({ b: 2, a: 1 }).length); console.log(Object.entries({ b: 2, a: 1 }).length); @@ -64,6 +136,15 @@ console.log(Object.entries({ b: 2, a: 1 } as { b: number; a: number; [key: strin console.log(capturedCount({ b: 2, a: 1 })); console.log(assignedCount()); console.log(inspect({ b: 2, a: 1, next: null } as { b: number; a: number; next: OrderedNode | null }).length); +// Runtime reachability encounters the second caller first. The historical +// emitter visited these caller bodies in source order before it drained the +// generic-instance queue, so the first instance owns the shared shape's key +// order. +callGenericSourceSecond(); +callGenericSourceFirst(); +constructGenericClassSourceSecond(); +constructGenericClassSourceFirst(); +genericReachesEarlierSource(1); printFunctionRecord(); printIndexRecord(); From f669e7269b9ade2809077a4197fc93ee09e45405 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 12:11:20 -0500 Subject: [PATCH 5/7] Recheck retained generic rest order --- .../src/frontend/lowering/lower-stmts.ts | 17 ++++++++++---- .../retained-generic-rest-order.ts | 23 +++++++++++++++++++ .../retained-generic-rest-order.ts.txt | 6 +++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 tests/diagnostics/retained-generic-rest-order.ts create mode 100644 tests/harness/__snapshots__/retained-generic-rest-order.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index a42cbf1a6..77baf0faf 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2507,18 +2507,25 @@ export function isParseArgsDynCheckerType(L: Lowerer, type: ts.Type): boolean { const emitOrder = restShape.declaredOrder ?? restShape.fields.map((f) => f.name); const orderMatches = (order: readonly string[]): boolean => order.length === remaining.length && order.every((n, i) => n === remaining[i]!.name); - if (!orderMatches(emitOrder)) { - if (L.instantiationContext === null || L.onEdge === null || isJsSourceFile(blame.getSourceFile())) { + const deferOrderCheck = + L.instantiationContext !== null && + L.onEdge !== null && + !isJsSourceFile(blame.getSourceFile()); + if (!deferOrderCheck) { + if (!orderMatches(emitOrder)) { L.unsupported( "SC1031", blame, "rest bindings over class instances whose packed key order cannot match Node's (Object.keys/JSON.stringify would enumerate the copied fields in a different order)", ); } + } else { // Retained reachability can learn about an earlier source-order body - // only while a generic instance lowers. Defer this metadata-only fence - // until that worklist closes; unlike emitted helper bodies, there is no - // IR to rebuild here — only the settled support decision matters. + // only while a generic instance lowers. Always defer this metadata-only + // check until that worklist closes: the initial order can settle from + // wrong to right OR from right to wrong. Unlike emitted helper bodies, + // there is no IR to rebuild here — only the settled support decision + // matters. const context = L.instantiationContext; const countFailure = !L.suppressStats; const sf = blame.getSourceFile(); diff --git a/tests/diagnostics/retained-generic-rest-order.ts b/tests/diagnostics/retained-generic-rest-order.ts new file mode 100644 index 000000000..f212faa6e --- /dev/null +++ b/tests/diagnostics/retained-generic-rest-order.ts @@ -0,0 +1,23 @@ +class Pair { + a = 1; + b = 2; +} + +function earlierGenericSource(_: T): void { + const value = { b: 2, a: 1 }; + console.log(Object.keys(value).length); +} + +function earlierOrdinary(): void { + earlierGenericSource(1); +} + +function laterGeneric(_: T): void { + const sameShape = { a: 1, b: 2 }; + console.log(Object.keys(sameShape).length); + earlierOrdinary(); + const { ...rest } = new Pair(); + console.log(Object.keys(rest).join(",")); +} + +laterGeneric(1); diff --git a/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt b/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt new file mode 100644 index 000000000..f4a183a81 --- /dev/null +++ b/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt @@ -0,0 +1,6 @@ +retained-generic-rest-order.ts:19:11 - error SC1031: rest bindings over class instances whose packed key order cannot match Node's (Object.keys/JSON.stringify would enumerate the copied fields in a different order) are not supported yet (instantiating 'laterGeneric' with ) + + 18 | earlierOrdinary(); + 19 | const { ...rest } = new Pair(); + | ^~~~~~~ + 20 | console.log(Object.keys(rest).join(",")); \ No newline at end of file From 73a98dbdf721044b05bea22e3fe6b4c94612f25b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 13:06:23 -0500 Subject: [PATCH 6/7] Record retained lowering parity fixtures --- .../compiler/test/ts7/baselines/order-parity.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index d95e96e39..1291f1c21 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5646,6 +5646,12 @@ ], "diags": [] }, + "/tests/corpus/2691-retained-lowering-record-order.ts": { + "order": [ + "/tests/corpus/2691-retained-lowering-record-order.ts" + ], + "diags": [] + }, "/tests/corpus/2700-wasi-core.ts": { "order": [ "/tests/corpus/2700-wasi-core.ts" @@ -7702,6 +7708,12 @@ ], "diags": [] }, + "/tests/diagnostics/retained-generic-rest-order.ts": { + "order": [ + "/tests/diagnostics/retained-generic-rest-order.ts" + ], + "diags": [] + }, "/tests/diagnostics/runtime-optional-capture-chain.ts": { "order": [ "/tests/diagnostics/runtime-optional-capture-chain.ts" From 664219caa69a7be2698fde49eb1eb6aa23ec2789 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 13:44:00 -0500 Subject: [PATCH 7/7] Preserve retained lowering failure parity --- .../src/frontend/lowering/lower-stmts.ts | 1 + .../compiler/src/frontend/lowering/lowerer.ts | 34 +++++++++++++++++-- packages/compiler/src/frontend/types.ts | 26 +++++++++++++- .../2691-retained-lowering-record-order.ts | 9 +++++ .../retained-generic-rest-order.ts | 7 ++-- .../retained-generic-rest-order.ts.txt | 17 +++++++--- tests/harness/coverage.test.ts | 6 ++++ 7 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 77baf0faf..bdb290629 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2533,6 +2533,7 @@ export function isParseArgsDynCheckerType(L: Lowerer, type: ts.Type): boolean { const current = L.shapes.get(restT.shapeId) ?? restShape; const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); if (orderMatches(currentOrder)) return; + L.requiresHistoricalOrderRelower = true; const previous = L.instantiationContext; L.instantiationContext = context; try { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ebb8cd269..0f34c32bf 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -510,13 +510,40 @@ export function lowerToIr( }); for (const d of dynamicCycleDiags) reachableEmit.pushDiag(d); for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d); - const { reachable, result } = reachableEmit.emitReachable(options.libRoots); + const emitted = reachableEmit.emitReachable(options.libRoots); + const { reachable } = emitted; + let result = emitted.result; + let resultLowerer = reachableEmit; timing("reachable-emit", { reachable: reachable.size }); + // A generic class-rest support decision can depend on record metadata + // whose historical owner is discovered only while retained bodies lower. + // If the settled answer is a fence, rerun the ordinary reachable emit so + // the PoisonError occurs in its original statement window: later + // declarators stay unvisited, bindings block, cascades and stats match the + // historical compiler. This is a rare compatibility fallback; programs + // without such a settled fence retain checker-backed IR exactly once. + if (reachableEmit.requiresHistoricalOrderRelower) { + const emit = new Lowerer(program, entry, moduleOrder, dynamic, { + reachable, + targetPlatform, + startupCrash, + ffiImports, + libraryCallbacks, + ffiBindingSymbols: ffiValidation.symbolsByName, + externalTypes, + externalTypeSpecifiersByFile, + }); + for (const d of dynamicCycleDiags) emit.pushDiag(d); + for (const d of ffiValidation.diagnostics) emit.pushDiag(d); + result = emit.run(); + resultLowerer = emit; + timing("historical-order-relower"); + } if (options.coverage !== true) return result; const remainder = new Lowerer(program, entry, moduleOrder, dynamic, { reachable, remainder: true, - alreadyFlushed: reachableEmit.flushedSymbols, + alreadyFlushed: resultLowerer.flushedSymbols, targetPlatform, ffiImports, libraryCallbacks, @@ -1095,6 +1122,9 @@ export class Lowerer { /** Derived shape metadata that depends on another shape's declaration * order. These settle before helper bodies rebuild from that metadata. */ readonly shapeOrderMetadataFinalizers: (() => void)[] = []; + /** Settled generic class-rest metadata requires the historical emit + * fallback so its fence can poison the original statement atomically. */ + requiresHistoricalOrderRelower = false; /** Helpers that snapshot shape declaration order into their bodies. * Reachability lowers inits before the declarations they discover, so * these rebuild after the worklist restores historical shape metadata. */ diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index 6865c2ebd..850dcd16a 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -100,6 +100,11 @@ export class ShapeRegistry { string, { order: string[]; priority: DeclaredOrderPriority }[] >(); + /** Derived shapes whose order is inherited from another shape. The + * source can settle after the derived shape was interned (notably + * Partial inside a retained generic body), so refresh the adopted + * writer after all priority candidates have closed. */ + private readonly derivedDeclaredOrderFinalizers: (() => void)[] = []; private currentDeclaredOrderPriority: DeclaredOrderPriority = [0, 0]; /** All interned shapes in first-seen (`r0`, `r1`, ...) order. */ readonly shapes: IrRecordShape[] = []; @@ -178,6 +183,20 @@ export class ShapeRegistry { shape.declaredOrder = best.order; this.declaredOrderPriority.set(shapeId, best.priority); } + for (const finalize of this.derivedDeclaredOrderFinalizers) finalize(); + } + + /** Tracks a derived shape that inherits its key order unchanged from a + * source shape. Identity-gating preserves first-writer-wins when an + * equivalent derived shape was already adopted from another source. */ + inheritDeclaredOrder(shapeId: string, originalOrder: string[], sourceShapeId: string): void { + this.derivedDeclaredOrderFinalizers.push( + this.declaredOrderFinalizer( + shapeId, + originalOrder, + () => this.get(sourceShapeId)?.declaredOrder, + ), + ); } /** Captures the current historical rank for a derived shape whose order @@ -2966,7 +2985,12 @@ function mapGenericUtilityAlias(widened: ts.Type, ctx: TypeMapperCtx): IrType | } // Fields inherit the source shape's canonical (name-sorted) order — and // its declaration order (mapped types preserve property order in TS). - return { kind: "record", shapeId: shapes.intern(fields, false, undefined, shape.declaredOrder) }; + const originalOrder = shape.declaredOrder; + const shapeId = shapes.intern(fields, false, undefined, originalOrder); + if (originalOrder !== undefined) { + shapes.inheritDeclaredOrder(shapeId, originalOrder, bound.shapeId); + } + return { kind: "record", shapeId }; } /** The UNIT-ONLY slot type: the interned `null | undefined` union, the one diff --git a/tests/corpus/2691-retained-lowering-record-order.ts b/tests/corpus/2691-retained-lowering-record-order.ts index ffb6e0930..155d8a098 100644 --- a/tests/corpus/2691-retained-lowering-record-order.ts +++ b/tests/corpus/2691-retained-lowering-record-order.ts @@ -126,6 +126,14 @@ function genericReachesEarlierSource(_: T): void { console.log(Object.keys(rest).join(",")); } +function printPartialRecord(value: T): void { + // Partial derives a fresh shape from T. Its declaration order must + // follow T when retained lowering later settles T to the earlier + // printFunctionRecord body's {a,b} order. + const partial = value as Partial; + console.log(Object.keys(partial).join(",")); +} + console.log(Object.keys({ b: 2, a: 1 }).length); console.log(Object.values({ b: 2, a: 1 }).length); console.log(Object.entries({ b: 2, a: 1 }).length); @@ -136,6 +144,7 @@ console.log(Object.entries({ b: 2, a: 1 } as { b: number; a: number; [key: strin console.log(capturedCount({ b: 2, a: 1 })); console.log(assignedCount()); console.log(inspect({ b: 2, a: 1, next: null } as { b: number; a: number; next: OrderedNode | null }).length); +printPartialRecord({ a: 1, b: 2 }); // Runtime reachability encounters the second caller first. The historical // emitter visited these caller bodies in source order before it drained the // generic-instance queue, so the first instance owns the shared shape's key diff --git a/tests/diagnostics/retained-generic-rest-order.ts b/tests/diagnostics/retained-generic-rest-order.ts index f212faa6e..27e22b549 100644 --- a/tests/diagnostics/retained-generic-rest-order.ts +++ b/tests/diagnostics/retained-generic-rest-order.ts @@ -16,8 +16,11 @@ function laterGeneric(_: T): void { const sameShape = { a: 1, b: 2 }; console.log(Object.keys(sameShape).length); earlierOrdinary(); - const { ...rest } = new Pair(); - console.log(Object.keys(rest).join(",")); + // The deferred rest-order rejection must poison this whole statement: + // the later WeakMap declarator is never visited, and the next statement + // sees rest as a blocked binding just like the historical emit pass. + const { ...rest } = new Pair(), blocked = new WeakMap(); + console.log(Object.keys(rest).join(","), blocked); } laterGeneric(1); diff --git a/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt b/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt index f4a183a81..d82caec21 100644 --- a/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt +++ b/tests/harness/__snapshots__/retained-generic-rest-order.ts.txt @@ -1,6 +1,15 @@ -retained-generic-rest-order.ts:19:11 - error SC1031: rest bindings over class instances whose packed key order cannot match Node's (Object.keys/JSON.stringify would enumerate the copied fields in a different order) are not supported yet (instantiating 'laterGeneric' with ) +retained-generic-rest-order.ts:22:11 - error SC1031: rest bindings over class instances whose packed key order cannot match Node's (Object.keys/JSON.stringify would enumerate the copied fields in a different order) are not supported yet (instantiating 'laterGeneric' with ) - 18 | earlierOrdinary(); - 19 | const { ...rest } = new Pair(); + 21 | // sees rest as a blocked binding just like the historical emit pass. + 22 | const { ...rest } = new Pair(), blocked = new WeakMap(); | ^~~~~~~ - 20 | console.log(Object.keys(rest).join(",")); \ No newline at end of file + 23 | console.log(Object.keys(rest).join(","), blocked); + +retained-generic-rest-order.ts:23:27 - error SC2004: uses of 'rest' inherit the blocker on its declaration (instantiating 'laterGeneric' with ) + + 22 | const { ...rest } = new Pair(), blocked = new WeakMap(); + 23 | console.log(Object.keys(rest).join(","), blocked); + | ^~~~ + 24 | } + + hint: the declaration of 'rest' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 720a8ebc9..56da37e6a 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -58,6 +58,12 @@ test("fully static JavaScript program reports 100%", () => { expect(out).toContain("fully static"); }); +test("settled generic rest-order fences count each source statement once", () => { + const { coverage } = analyze(join(repoRoot, "tests/diagnostics/retained-generic-rest-order.ts")); + expect(coverage.diagnostics.map((d) => d.code)).toEqual(["SC1031", "SC2004"]); + expect(coverage.stats.statementsFailed).toBe(2); +}); + test("JS inference gaps land where 'any' lands: SC2011 static, island dynamic", async () => { // The js-gap fixture's tsconfig turns noImplicitAny off, so the untyped // parameter types `any` — the static analysis reports the site as