From bb33ec50fed1aff62e645f923acedeae49f337de Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:07:57 -0700 Subject: [PATCH 01/13] Push localized failure down to the failing component of a declared type Stage L3 of the localized-failure design. A degraded type used to become the checked dynamic as a whole slot. Its lenient mapping now keeps the compilable shape: the type maps again with every nested component whose mapping fails as the checked dynamic, so a record keeps its static members with one dynamic member, an array of records keeps its records, and a Map, Promise or function keeps its static parts. When even that shape does not map, or a unit still fails with it, the type falls back to the whole-slot checked dynamic (one more round). Lenient forms are IR types of one lowerer's shape tables, so the coverage remainder re-derives them. A pushed-down type is one SC6104, at the slot where it degraded, marked as keeping its shape. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/coverage/report.ts | 5 +- .../compiler/src/diagnostics/dynamic-slots.ts | 8 +- packages/compiler/src/diagnostics/envelope.ts | 1 + .../compiler/src/frontend/lowering/lowerer.ts | 102 +++++++++++++++--- packages/compiler/src/frontend/type-mapper.ts | 11 ++ tests/corpus/localized-failure-push-down.ts | 52 +++++++++ .../coverage-explain-localized-failure.txt | 5 +- .../localized-failure-push-down.ts.json | 1 + 8 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 tests/corpus/localized-failure-push-down.ts create mode 100644 tests/harness/corpus-costs/localized-failure-push-down.ts.json diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index d78120f89..343c71087 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -650,6 +650,7 @@ export function coverageEnvelope( declaredType: slot.declaredType, code: slot.code, reason: slot.reason, + ...(slot.partial === true ? { partial: true } : {}), })), } : {}), @@ -763,7 +764,9 @@ function renderDynamicSlots( ` dynamic slots ${slots.length} ${c(DIM, "(declared types with no static lowering, held as checked-dynamic values; SC6104)")}`, ); for (const slot of slots.slice(0, DYNAMIC_SLOTS_SHOWN)) - out.push(` ${dynamicSlotLabel(slot)} ${c(DIM, where(slot.loc))}`); + out.push( + ` ${dynamicSlotLabel(slot)}${slot.partial === true ? " (dynamic pieces)" : ""} ${c(DIM, where(slot.loc))}`, + ); if (slots.length > DYNAMIC_SLOTS_SHOWN) out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`); } diff --git a/packages/compiler/src/diagnostics/dynamic-slots.ts b/packages/compiler/src/diagnostics/dynamic-slots.ts index 09b4cd670..01d22eca6 100644 --- a/packages/compiler/src/diagnostics/dynamic-slots.ts +++ b/packages/compiler/src/diagnostics/dynamic-slots.ts @@ -17,6 +17,9 @@ export interface DynamicSlotEntry { /** The diagnostic the slot's declaration would have failed with. */ code: string; reason: string; + /** Push-down: the slot keeps its type's compilable shape, and only the + * components that do not compile are checked-dynamic. */ + partial?: boolean; } const SLOT_WORD: Record = { @@ -38,7 +41,10 @@ export function dynamicSlotLabel(entry: DynamicSlotEntry): string { export function dynamicSlotDiag(entry: DynamicSlotEntry): ScrDiagnostic { return { code: DYNAMIC_SLOT_CODE, - message: `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`, + message: + entry.partial === true + ? `${dynamicSlotLabel(entry)} keeps its compilable shape with checked-dynamic pieces where its type has no static lowering (${entry.reason}); reads of those pieces into typed code are checked` + : `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`, loc: entry.loc, hint: "restate the declared type inside the compilable set to keep this slot static; scriptc coverage lists dynamic slots, and --fail-on=dynamic-slots fails on them", }; diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index f6f8b9d3d..8717f9448 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -172,6 +172,7 @@ export interface DiagnosticsEnvelope { declaredType: string; code: string; reason: string; + partial?: boolean; })[]; /** coverage --explain-dynamic only: the report-only tier assignment. */ tiers?: TiersEnvelope; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index de84fa3a9..53a79195c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -728,6 +728,9 @@ export interface DynamicSlot { failure: TypeFailure; code: string; reason: string; + /** The slot keeps its type's compilable shape; only the failing + * components are checked-dynamic (push-down, stage L3). */ + partial?: true; } /** Localized failure: rounds of the declaration flush retry. */ @@ -987,7 +990,7 @@ export interface LowererMode { /** Localized failure: the types reachable emit degraded (the coverage * remainder maps them alike, so the declarations reachable emit * registered with dynamic slots collect there too). */ - degradedTypes?: ReadonlyMap; + degradedTypes?: ReadonlyMap; /** Localized failure: the declarations reachable emit registered with * dynamic slots (the coverage remainder registers them too). */ retriedSymbols?: ReadonlySet; @@ -1304,6 +1307,7 @@ export function lowerToIr( declaredType: slot.failure.declaredType, code: slot.code, reason: slot.reason, + ...(slot.partial ? { partial: true } : {}), })); result = { ...result, @@ -3780,7 +3784,10 @@ export class Lowerer { * at a committed slot, with their lenient mapping. Filled only at the * commit points (degradeAtCommit), so a program that compiles today * never fills it. */ - readonly degradedTypes = new Map(); + readonly degradedTypes = new Map< + number, + { lenient: IrType | null; code: string; reason: string } + >(); /** The dynamic slots the commit points created: one SC6104 each. */ readonly dynamicSlots: DynamicSlot[] = []; /** Declarations retryDeferred registered. */ @@ -3888,7 +3895,13 @@ export class Lowerer { ) { // The mode's options, sorted by name; the values derived from them follow. this.alreadyFlushed = mode.alreadyFlushed ?? new Set(); - for (const [id, degraded] of mode.degradedTypes ?? []) this.degradedTypes.set(id, degraded); + // Lenient forms are IR types of the other pass's shape tables: only the + // whole-slot dynamic carries over as is. + for (const [id, degraded] of mode.degradedTypes ?? []) + this.degradedTypes.set(id, { + ...degraded, + lenient: degraded.lenient?.kind === "dyn" ? DYN : null, + }); this.priorRetriedSymbols = mode.retriedSymbols ?? new Set(); this.externalTypes = mode.externalTypes ?? new Map(); this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; @@ -3927,7 +3940,17 @@ export class Lowerer { resolveBoundAliasTs: this.boundAliasTsResolver, degradedType: (type) => { const id = (type as { id?: number }).id; - return id === undefined ? null : (this.degradedTypes.get(id)?.lenient ?? null); + if (id === undefined) return null; + const degraded = this.degradedTypes.get(id); + if (degraded === undefined) return null; + // An entry handed over from another pass (the coverage remainder) + // re-derives its lenient form in this lowerer's shape tables; the + // checked dynamic cuts the knot while it does. + if (degraded.lenient === null) { + degraded.lenient = DYN; + degraded.lenient = this.lenientType(type); + } + return degraded.lenient; }, isErasing: () => this.erasingDepth > 0, genericClassInstance: (decl, ref, mapArgument) => @@ -9455,8 +9478,9 @@ export class Lowerer { if (!failure.degradable || failure.family === "dyn-field-policy") return null; if (!slots.has(failure.slot)) return null; const id = (type as { id?: number }).id; - // Already degraded and still failing: no progress, commit as today. - if (id === undefined || this.degradedTypes.has(id)) return null; + // Already degraded to the whole-slot dynamic and still failing: no + // progress, commit as today. + if (id === undefined || this.degradedTypes.get(id)?.lenient?.kind === "dyn") return null; let committed: ScrDiagnostic | undefined; for (let i = this.diags.length - 1; i >= 0; i--) { if (this.diags[i]!.typeFailure !== failure) continue; @@ -9464,8 +9488,15 @@ export class Lowerer { this.diags.splice(i, 1); } if (committed === undefined) return null; - this.degradedTypes.set(id, { lenient: DYN, code: committed.code, reason: committed.message }); - return { loc: committed.loc, failure, code: committed.code, reason: committed.message }; + this.degradeType(type, committed.code, committed.message); + const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn"; + return { + ...(partial ? { partial: true as const } : {}), + loc: committed.loc, + failure, + code: committed.code, + reason: committed.message, + }; } /** A declaration slot whose type maps through degradedTypes is a @@ -9474,7 +9505,9 @@ export class Lowerer { noteDegradedSlot(node: ts.Node, type: ts.Type): void { if (this.diagSink !== null) return; const degraded = this.degradedTypes.get((type as { id?: number }).id ?? -1); - if (degraded === undefined) return; + // A pushed-down type is one slot, reported where it degraded; only + // whole-slot dynamic declarations are reported one by one. + if (degraded === undefined || degraded.lenient?.kind !== "dyn") return; const failure = typeFailureOf(node, this.checker.typeToString(type), "unsupported"); if (failure.slot === "expression" || failure.slot === "member") return; this.noteDynamicSlot({ @@ -9554,6 +9587,33 @@ export class Lowerer { } } + /** Localized failure's lenient mapping (stage L3, push-down): the type + * mapped with each failing component as the checked dynamic, so a record + * keeps its static members, an array of records its records, and a + * function its arity. The whole slot is the checked dynamic when even + * that shape does not map. Throwaway context: nothing memoizes. */ + lenientType(type: ts.Type): IrType { + const { typeMemo: _strictMemo, ...strict } = this.typeCtx; + const mapped = mapType(type, { ...strict, lenient: true, canMemoizeType: () => false }); + return mapped === null || mapped.kind === "void" ? DYN : mapped; + } + + /** Degrades a type for a commit point: its lenient (pushed-down) form + * first; a type already degraded that still fails falls back to the + * whole-slot checked dynamic. False when nothing is left to degrade. */ + private degradeType(type: ts.Type, code: string, reason: string): boolean { + const id = (type as { id?: number }).id; + if (id === undefined) return false; + const known = this.degradedTypes.get(id); + if (known !== undefined) { + if (known.lenient?.kind === "dyn") return false; + this.degradedTypes.set(id, { lenient: DYN, code, reason }); + return true; + } + this.degradedTypes.set(id, { lenient: this.lenientType(type), code, reason }); + return true; + } + /** Records a dynamic slot whose unit lowered with it (one per site). */ noteDynamicSlot(slot: DynamicSlot): void { const seen = this.dynamicSlots.some( @@ -12549,7 +12609,7 @@ export class Lowerer { const slots: DynamicSlot[] = []; let current = diags; for (let round = 0; round < MAX_DECLARATION_RETRIES; round++) { - const failing: { diag: ScrDiagnostic; failure: TypeFailure; id: number }[] = []; + const failing: { diag: ScrDiagnostic; failure: TypeFailure; type: ts.Type }[] = []; let retriable = false; for (const diag of current) { if (this.retriableDiags.has(diag)) { @@ -12567,20 +12627,29 @@ export class Lowerer { const id = (type as { id?: number } | undefined)?.id; if ( failure === undefined || + type === undefined || id === undefined || !failure.degradable || !DECLARATION_SLOTS.has(failure.slot) || - this.degradedTypes.has(id) + this.degradedTypes.get(id)?.lenient?.kind === "dyn" ) return false; - failing.push({ diag, failure, id }); + failing.push({ diag, failure, type }); } // A round makes progress by degrading a type, or (once) by running // a collection that only waited for another declaration. if (failing.length === 0 && !(retriable && round === 0)) return false; - for (const { diag, failure, id } of failing) { - this.degradedTypes.set(id, { lenient: DYN, code: diag.code, reason: diag.message }); - slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message }); + for (const { diag, failure, type } of failing) { + this.degradeType(type, diag.code, diag.message); + const partial = + this.degradedTypes.get((type as { id?: number }).id ?? -1)?.lenient?.kind !== "dyn"; + slots.push({ + loc: diag.loc, + failure, + code: diag.code, + reason: diag.message, + ...(partial ? { partial: true as const } : {}), + }); } this.deferredDiags.delete(symbol); const lifted = this.liftDynFieldPolicy; @@ -13066,8 +13135,7 @@ export class Lowerer { /* ── scoping and captures ─────────────────────────────────────────── */ declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal { - if (type.kind === "dyn" && this.degradedTypes.size > 0) - this.noteDegradedSlot(nameNode, this.typeOf(nameNode)); + if (this.degradedTypes.size > 0) this.noteDegradedSlot(nameNode, this.typeOf(nameNode)); const symbol = this.checker.getSymbolAtLocation(nameNode); const local = declareContextLocal( this.ctx, diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index d4d0ab42c..c4a245278 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1287,6 +1287,11 @@ export interface TypeMapperCtx { * only after the strict mapping (and its bound-alias retry) returns null, * at every depth, so every occurrence of a degraded type maps alike. */ degradedType?: (type: ts.Type) => IrType | null; + /** Localized failure's lenient mapping (Lowerer.lenientType): a nested + * component whose mapping fails maps to the checked dynamic instead, so a + * failing type keeps its outer shape with dynamic pieces. Only ever set + * on a throwaway context without memoization. */ + lenient?: boolean; /** GENERIC program classes (monomorphization by flow): the mapping of a * concrete instantiation reference (`Box`) — the Lowerer * registers/reuses the instantiation (`Box%0`) and answers its object @@ -1871,6 +1876,12 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { contextResolutions++; } } + // Push-down (stage L3): in a lenient mapping, the smallest failing + // component is the checked dynamic. + if (mapped === null && ctx.lenient === true && !topLevel) { + mapped = DYN; + contextResolutions++; + } if ( mapped !== null && memoKey !== null && diff --git a/tests/corpus/localized-failure-push-down.ts b/tests/corpus/localized-failure-push-down.ts new file mode 100644 index 000000000..a10a453f8 --- /dev/null +++ b/tests/corpus/localized-failure-push-down.ts @@ -0,0 +1,52 @@ +// Localized failure, stage L3: push-down. A declared type that does not +// compile keeps its compilable shape; only the failing component is a +// checked dynamic piece. +type Hook = Map string> | AbortSignal | (() => number) | Set; + +interface Plugin { + name: string; + priority: number; + hook: Hook; +} + +class Registry { + plugins: Plugin[] = []; + byName = new Map(); + pending: Promise | undefined; + add(p: Plugin): void { + this.plugins.push(p); + this.byName.set(p.name, p); + } + total(): number { + let sum = 0; + for (const p of this.plugins) sum += p.priority; + return sum; + } + names(): string { + return this.plugins.map((p) => p.name).join(","); + } + run(name: string): string { + const p = this.byName.get(name); + if (p === undefined) return "missing"; + const h = p.hook; + return typeof h === "function" ? `${p.name}=${h()}` : h instanceof Set ? `${p.name}#${h.size}` : "other"; + } +} + +type Handler = (event: string, extra: Hook) => number; +function dispatch(handlers: Handler[], event: string): number { + let n = 0; + for (const h of handlers) n += h(event, () => event.length); + return n; +} + +const r = new Registry(); +const counter: Plugin = { name: "counter", priority: 2, hook: () => 41 }; +r.add(counter); +r.add({ name: "tags", priority: 5, hook: new Set(["a", "b"]) }); +console.log(r.total(), r.names(), r.run("counter"), r.run("tags"), r.run("x")); +counter.priority = 10; +console.log(r.total(), r.byName.get("counter") === counter, JSON.stringify(r.plugins.map((p) => [p.name, p.priority]))); +r.pending = Promise.resolve(counter); +r.pending.then((p) => console.log("pending", p.name, p === counter)); +console.log(dispatch([(e, x) => e.length, (e, x) => (typeof x === "function" ? x() * 2 : 0)], "hello")); diff --git a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt index c3253a0ee..38cfe7546 100644 --- a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt +++ b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt @@ -1,9 +1,6 @@ dynamic tier (tier assignment, report-only; --tier=auto) - 4 functions already operate on dynamic values (3 statements) - Session.constructor tests/coverage-fixtures/localized-failure.ts:12:1: dynArrLit at tests/coverage-fixtures/localized-failure.ts:13:27 - Session.count tests/coverage-fixtures/localized-failure.ts:14:3: %dyn.class.property at tests/coverage-fixtures/localized-failure.ts:15:12 + 1 function already operates on dynamic values (1 statement) Holder.constructor tests/coverage-fixtures/localized-failure.ts:28:1: dynObjLit at tests/coverage-fixtures/localized-failure.ts:29:16 - %dyn.class.property.0 tests/coverage-fixtures/localized-failure.ts:15:12: %dyn.class.readPropertiesOrEmpty at tests/coverage-fixtures/localized-failure.ts:15:12 2 demotion requests (refusals and fences the dynamic tier would replace) diff --git a/tests/harness/corpus-costs/localized-failure-push-down.ts.json b/tests/harness/corpus-costs/localized-failure-push-down.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-push-down.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } From 95f9d259b740824d86cb97b4e0a2c4e97e9c1bf4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:14:37 -0700 Subject: [PATCH 02/13] Record the order-parity baseline for the push-down corpus program Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/localized-failure-push-down.ts.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json new file mode 100644 index 000000000..554c74bac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-push-down.ts" + ], + "diags": [] +} From 2d63ad13a5a69db9bb803b71ff4b17d346b3493d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:28:46 -0700 Subject: [PATCH 03/13] Fall back to the whole-slot dynamic when a unit fails with a pushed-down type Push-down is a refinement and must never add a failure: a type's lenient shape can meet a conversion the whole-slot checked dynamic would have taken (a width copy, a library member on a partial promise). Each retryable unit (a statement, a flushed declaration, a generic instance) records the pushed-down types it maps; when it still fails, those types widen to the whole-slot checked dynamic and the unit lowers again. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 14 ++++- .../compiler/src/frontend/lowering/lowerer.ts | 53 +++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 72fec97e7..e0718158d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -757,8 +757,11 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir const pendingSlots: DynamicSlot[] = []; try { for (let attempt = 0; ; attempt++) { + const hits = new Set(); try { - lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences); + lowerer.withPartialHits(hits, () => + lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences), + ); break; } catch (e) { // Localized failure's statement commit point (design §3.3): a @@ -768,7 +771,14 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // no retry runs) can retry here too. if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e; const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS); - if (slot === null && !lowerer.reopenFlushedAtCommit(diagsBefore)) throw e; + // Pushed-down types the failed attempt mapped widen to the + // whole slot before anything else is given up. + if ( + slot === null && + !lowerer.reopenFlushedAtCommit(diagsBefore) && + !lowerer.widenPartials(hits) + ) + throw e; // A failed attempt adds nothing to `out` but the TDZ boxes // forward captures declared (they stay declared), so the // retry appends after them. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 53a79195c..5d926f69f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3788,6 +3788,9 @@ export class Lowerer { number, { lenient: IrType | null; code: string; reason: string } >(); + /** One frame per unit a commit point may retry: the pushed-down + * (partial) degraded types its lowering mapped (withPartialHits). */ + readonly partialHits: Set[] = []; /** The dynamic slots the commit points created: one SC6104 each. */ readonly dynamicSlots: DynamicSlot[] = []; /** Declarations retryDeferred registered. */ @@ -3950,6 +3953,7 @@ export class Lowerer { degraded.lenient = DYN; degraded.lenient = this.lenientType(type); } + if (degraded.lenient.kind !== "dyn") this.partialHits[this.partialHits.length - 1]?.add(id); return degraded.lenient; }, isErasing: () => this.erasingDepth > 0, @@ -9572,16 +9576,17 @@ export class Lowerer { const slots: DynamicSlot[] = []; for (let attempt = 0; ; attempt++) { const mark = this.diags.length; + const hits = new Set(); try { - const result = lower(); + const result = this.withPartialHits(hits, lower); for (const slot of slots) this.noteDynamicSlot(slot); return result; } catch (e) { if (!(e instanceof PoisonError)) throw e; if (attempt >= MAX_DECLARATION_RETRIES) return null; const slot = this.degradeAtCommit(e, DECLARATION_SLOTS); - if (slot === null) return null; - slots.push(slot); + if (slot === null && !this.widenPartials(hits)) return null; + if (slot !== null) slots.push(slot); this.dropAttemptDiags(mark); } } @@ -9614,6 +9619,34 @@ export class Lowerer { return true; } + /** Runs a unit a commit point may retry, recording the pushed-down + * types it maps; the frame's hits also count for the enclosing unit. */ + withPartialHits(hits: Set, run: () => T): T { + this.partialHits.push(hits); + try { + return run(); + } finally { + this.partialHits.pop(); + const outer = this.partialHits[this.partialHits.length - 1]; + if (outer !== undefined) for (const id of hits) outer.add(id); + } + } + + /** Push-down is a refinement, never a new failure: a unit that still + * fails after mapping pushed-down types falls back to the whole-slot + * checked dynamic for them. True when some type widened (retry). */ + widenPartials(hits: ReadonlySet): boolean { + if (this.tierMode !== "auto" || this.diagSink !== null) return false; + let widened = false; + for (const id of hits) { + const degraded = this.degradedTypes.get(id); + if (degraded === undefined || degraded.lenient?.kind === "dyn") continue; + degraded.lenient = DYN; + widened = true; + } + return widened; + } + /** Records a dynamic slot whose unit lowered with it (one per site). */ noteDynamicSlot(slot: DynamicSlot): void { const seen = this.dynamicSlots.some( @@ -12654,9 +12687,19 @@ export class Lowerer { this.deferredDiags.delete(symbol); const lifted = this.liftDynFieldPolicy; this.liftDynFieldPolicy = true; - let registered: boolean; + // Pushed-down types that did not help widen to the whole slot, and the + // collection runs again (a few times at most). + let registered = false; try { - registered = this.collectDeferring(collector.symbolOf, collector.collect) === null; + for (let widen = 0; widen < 4; widen++) { + const hits = new Set(); + registered = + this.withPartialHits(hits, () => + this.collectDeferring(collector.symbolOf, collector.collect), + ) === null; + if (registered || !this.widenPartials(hits)) break; + this.deferredDiags.delete(symbol); + } } finally { this.liftDynFieldPolicy = lifted; } From b6af8bc0a04654a52cce93a86b93249895b8535b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:25:37 -0700 Subject: [PATCH 04/13] Degrade the types of a statement's own expressions where the statement commits Stage L4 of the localized-failure design. A cast to a declared type that does not compile, a literal whose contextual type does not compile, a spread clone of such a value or a temporary of that type used to reject the statement. The statement commit point now degrades those expression types too (with push-down), so the cast is a checked conversion to the type's compilable shape and the literal builds in it. The SC6104 of an expression names the expression. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 5 +++- .../src/frontend/lowering/type-failure.ts | 6 ++++- .../localized-failure-expressions.ts.json | 6 +++++ tests/corpus/localized-failure-expressions.ts | 24 +++++++++++++++++++ .../localized-failure-expressions.ts.json | 1 + 5 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json create mode 100644 tests/corpus/localized-failure-expressions.ts create mode 100644 tests/harness/corpus-costs/localized-failure-expressions.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index e0718158d..4ab2aae08 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -867,7 +867,8 @@ const MAX_DEGRADE_RETRIES = 16; /** The slot kinds lowerStmts' commit point degrades: locals, loop * variables and module globals (stage L1), and the parameters and returns * of the function expressions, arrows and methods a statement holds - * (stage L2). */ + * (stage L2), and the types of the statement's own expressions: casts, + * literals with a contextual type, spread clones, temporaries (stage L4). */ const STATEMENT_SLOTS: ReadonlySet = new Set([ "local", "global", @@ -875,6 +876,8 @@ const STATEMENT_SLOTS: ReadonlySet = new Set([ "param", "return", "field", + "member", + "expression", ]); /** One attempt at lowering a statement of lowerStmts into `out`. */ diff --git a/packages/compiler/src/frontend/lowering/type-failure.ts b/packages/compiler/src/frontend/lowering/type-failure.ts index 3502a9bbf..31004ca4f 100644 --- a/packages/compiler/src/frontend/lowering/type-failure.ts +++ b/packages/compiler/src/frontend/lowering/type-failure.ts @@ -138,7 +138,11 @@ export function typeFailureSlot(node: ts.Node): { slot: TypeFailureSlot; name: s if (!declared && !ts.isTypeNode(current) && !ts.isTypeNode(parent)) break; current = parent; } - const text = ts.getTextOfNode(node); + // A type node names its expression (`raw as Plugin`, not `Plugin`). + let named: ts.Node = node; + while (ts.isTypeNode(named) && named.parent !== undefined && !ts.isSourceFile(named.parent)) + named = named.parent; + const text = ts.getTextOfNode(named).replace(/\s+/g, " "); return { slot: "expression", name: text.length > 60 ? `${text.slice(0, 57)}...` : text }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json new file mode 100644 index 000000000..97f6c7ce3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-expressions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/localized-failure-expressions.ts b/tests/corpus/localized-failure-expressions.ts new file mode 100644 index 000000000..e41e16fae --- /dev/null +++ b/tests/corpus/localized-failure-expressions.ts @@ -0,0 +1,24 @@ +// Localized failure, stage L4: expressions whose own type does not +// compile (casts, literals with an unmappable contextual type, spread +// clones, temporaries). +type Hook = Map string> | AbortSignal | (() => number) | Set; +interface Plugin { + name: string; + hook: Hook; +} +function load(text: string): unknown { + return JSON.parse(text); +} +function count(p: { name: string }): number { + return p.name.length; +} +const raw = load('{"name":"tags","hook":null}'); +// A cast to a type that does not compile. +console.log(count(raw as Plugin)); +// A literal whose contextual type does not compile, used without a slot. +console.log(count({ name: "lit", hook: () => 1 } as Plugin)); +// A spread clone of such a value. +const base = { name: "base", hook: new Set(["a"]) }; +console.log(count({ ...(base as Plugin), name: "clone" })); +// A temporary: an array literal of them, mapped. +console.log([{ name: "a", hook: () => 2 } as Plugin, { name: "bb", hook: new Set() } as Plugin].map((p) => p.name).join("+")); diff --git a/tests/harness/corpus-costs/localized-failure-expressions.ts.json b/tests/harness/corpus-costs/localized-failure-expressions.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-expressions.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } From 7f77a8fb240565b4475acfdac06827576e25e93e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:48:17 -0700 Subject: [PATCH 05/13] Keep push-down only where it does not cost a failing program errors A pushed-down type is baked into the globals, fields and signatures that hold it, so a unit-level fallback cannot reach every use. When reachable emit still has errors and some degraded type kept a pushed-down shape, the emit runs once more with every degraded type held whole, and the pass with fewer errors wins (ties keep push-down). A program that compiles never takes this path. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 5d926f69f..3254e6015 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -994,6 +994,10 @@ export interface LowererMode { /** Localized failure: the declarations reachable emit registered with * dynamic slots (the coverage remainder registers them too). */ retriedSymbols?: ReadonlySet; + /** Localized failure: degrade every type to the whole-slot checked + * dynamic, never to its pushed-down shape (lowerToIr's comparison + * pass). */ + wholeSlotDegradation?: boolean; /** LowerOptions.externalTypes, threaded through every lowering pass. */ externalTypes?: ReadonlyMap; /** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */ @@ -1211,6 +1215,39 @@ export function lowerToIr( let result = emitted.result; let resultLowerer = reachableEmit; timing("reachable-emit", { reachable: reachable.size }); + // Localized failure (stage L3): push-down must never cost a failing + // program more errors than the whole-slot checked dynamic. A pushed-down + // type is baked into the globals, fields and signatures that hold it, so + // when the program still fails, the emit runs once more with every + // degraded type held whole, and the pass with fewer errors wins (ties + // keep push-down). A program that compiles never takes this path. + if (result.diagnostics.length > 0 && reachableEmit.hasPartialDegradedTypes()) { + const whole = new Lowerer(program, entry, moduleOrder, { + frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, + targetPlatform, + startupCrash, + inlineRequireCycleCalls, + ffiImports, + libraryCallbacks, + forkTargets, + workerTargets, + externalTypes, + externalTypeSpecifiersByFile, + tierMode, + ffiBindingSymbols: ffiValidation.symbolsByName, + wholeSlotDegradation: true, + }); + for (const d of extraModuleCycleDiags) whole.pushDiag(d); + for (const d of ffiValidation.diagnostics) whole.pushDiag(d); + const wholeEmitted = whole.emitReachable(options.libRoots); + if (wholeEmitted.result.diagnostics.length < result.diagnostics.length) { + reachable = wholeEmitted.reachable; + result = wholeEmitted.result; + resultLowerer = whole; + } + timing("whole-slot-relower", { reachable: wholeEmitted.reachable.size }); + } // The general stream representation (TypeMapperCtx.generalNodeStreams) // is a per-program decision: only a program whose reachable code moved // a general stream into a stdio-represented stream slot lowers again @@ -1237,6 +1274,7 @@ export function lowerToIr( ffiBindingSymbols: ffiValidation.symbolsByName, generalNodeStreams: true, ...(dyn ? { dynNodeStreams: true } : {}), + ...(resultLowerer.wholeSlotDegradation ? { wholeSlotDegradation: true } : {}), }); for (const d of extraModuleCycleDiags) general.pushDiag(d); for (const d of ffiValidation.diagnostics) general.pushDiag(d); @@ -1279,6 +1317,7 @@ export function lowerToIr( externalTypes, externalTypeSpecifiersByFile, tierMode, + ...(resultLowerer.wholeSlotDegradation ? { wholeSlotDegradation: true } : {}), }); for (const d of extraModuleCycleDiags) emit.pushDiag(d); for (const d of ffiValidation.diagnostics) emit.pushDiag(d); @@ -3797,6 +3836,8 @@ export class Lowerer { readonly retriedSymbols = new Set(); /** LowererMode.retriedSymbols. */ readonly priorRetriedSymbols: ReadonlySet; + /** LowererMode.wholeSlotDegradation. */ + readonly wholeSlotDegradation: boolean; /** Bodies run() skipped because their declaration had not registered. */ readonly skippedBodies = new Set(); /** Declarations retryDeferred registered, in order: run() lowers the @@ -3906,6 +3947,7 @@ export class Lowerer { lenient: degraded.lenient?.kind === "dyn" ? DYN : null, }); this.priorRetriedSymbols = mode.retriedSymbols ?? new Set(); + this.wholeSlotDegradation = mode.wholeSlotDegradation ?? false; this.externalTypes = mode.externalTypes ?? new Map(); this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; this.ffiImports = mode.ffiImports ?? []; @@ -9615,10 +9657,21 @@ export class Lowerer { this.degradedTypes.set(id, { lenient: DYN, code, reason }); return true; } - this.degradedTypes.set(id, { lenient: this.lenientType(type), code, reason }); + this.degradedTypes.set(id, { + lenient: this.wholeSlotDegradation ? DYN : this.lenientType(type), + code, + reason, + }); return true; } + /** Some degraded type kept a pushed-down (partial) shape. */ + hasPartialDegradedTypes(): boolean { + for (const degraded of this.degradedTypes.values()) + if (degraded.lenient !== null && degraded.lenient.kind !== "dyn") return true; + return false; + } + /** Runs a unit a commit point may retry, recording the pushed-down * types it maps; the frame's hits also count for the enclosing unit. */ withPartialHits(hits: Set, run: () => T): T { From 1ae5f105ca38545376ec47e9ed8b8d530524e059 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:32:55 -0700 Subject: [PATCH 06/13] Rank dynamic slots by their dynamic operations, name them in SC6101, and document them Stage L5 of the localized-failure design. - SC6101 names a dynamic slot as the cause of a likely-hot untyped function ('tool' is a dynamic slot, declared 'ToolDefinition', which does not compile). - Coverage counts the dynamic operations each slot's values take part in, lists the costly slots first, and puts the count in the JSON envelope. - A build whose only performance warnings are dynamic slots closes with a line about declared types instead of function types. - Docs: a Dynamic slots section in the coverage guide, the limitations page bullet, and --fail-on=dynamic-slots in the CLI reference and usage. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/cli.mdx | 4 +- docs/content/docs/coverage.mdx | 35 ++++++++++++++++- docs/content/docs/limitations.mdx | 2 + .../dynamic-slots.mdx | 5 +++ packages/compiler/src/cli/usage.ts | 7 ++-- packages/compiler/src/coverage/report.ts | 10 ++++- .../compiler/src/diagnostics/dynamic-slots.ts | 3 ++ .../compiler/src/diagnostics/dynamic-tier.ts | 10 ++++- packages/compiler/src/diagnostics/envelope.ts | 1 + .../src/frontend/lowering/dynamic-tier.ts | 38 +++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 4 +- tests/harness/coverage.test.ts | 9 +++++ 12 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 4c1b7e7c6..037182c18 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -114,8 +114,8 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--print <native-link-info|diagnostics>
native-link-info: build an object (equivalent to --emit=obj) and print its machine-readable external link recipe as JSON instead of printing the artifact path. The document names the exact installed precompiled runtime pack and all link inputs, but does not invoke a linker. diagnostics: for build and coverage, print the versioned JSON diagnostics document described in Coverage Reports instead of the human output.
-
--fail-on <blockers|divergences|dynamic>
-
Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, or with dynamic, also when it has the SC6101 warning for untyped code that likely runs often. See Gating builds and CI.
+
--fail-on <blockers|divergences|dynamic|dynamic-slots>
+
Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, with dynamic, also when it has the SC6101 warning for untyped code that likely runs often, or with dynamic-slots, also when it has dynamic slots (SC6104). See Gating builds and CI.
--ffi <file>
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx index db1193b98..d8719d9f3 100644 --- a/docs/content/docs/coverage.mdx +++ b/docs/content/docs/coverage.mdx @@ -83,6 +83,39 @@ Without a profile, a dynamic-tier function counts as likely to run often when a The warning names at most five functions, most likely to run often first, and gives the total. A function expression is named by its own name, else by the variable, property or class field it is assigned to, else as `anonymous function`. Dynamic-tier code in npm packages is summarized on one line with each package's function count. A build that comes from the cache repeats the warning. `--no-dynamic-tier-warning` leaves it out, and `--fail-on=dynamic` fails a coverage run on it. +## Dynamic slots + +A declared type that has no native representation does not fail the build. The field, parameter, return value, variable or expression that holds it becomes a checked dynamic slot: the parts of the type that compile keep their native layout, and the failing parts hold checked-dynamic values. Coverage lists the slots under the statement counts, and `scriptc build` prints one `SC6104` warning per slot. + +```console +$ cat plugins.ts +type Hook = (() => void) | AbortSignal | Map string>; + +interface Plugin { + name: string; + hook: Hook; +} + +const plugins: Plugin[] = [{ name: "log", hook: () => console.log("hook ran") }]; +for (const p of plugins) { + const hook = p.hook; + if (typeof hook === "function") hook(); + console.log(p.name); +} + +$ scriptc coverage plugins.ts + + statements analyzed 6 + compile statically 6 (100%) + in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them) + dynamic slots 1 (declared types with no static lowering, held as checked-dynamic values; SC6104) + module global 'plugins' (declared 'Plugin[]') (dynamic pieces) plugins.ts:8:7 + + fully static — all analyzed statements compile to native code. +``` + +Here `Plugin` keeps its native `name` field, and only `hook` is checked-dynamic. Reading a dynamic value into typed code checks it against the type that reads it, so data that contradicts the declaration still throws a `TypeError`. Slots with the most dynamic operations are listed first. `--fail-on=dynamic-slots` fails a coverage run when any slot exists, and `--tier=static-only` reports the original errors instead. + ## Diagnostic codes Coverage and `scriptc build` report the same diagnostic codes. Common ones include: @@ -109,7 +142,7 @@ By default, `scriptc coverage` exits with status 0 whenever the program type-che $ scriptc coverage src/main.ts --fail-on=blockers ``` -`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, and `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often). A program that doesn't type-check always exits with status 1. +`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often), and `--fail-on=dynamic-slots` also fails on [dynamic slots](#dynamic-slots). A program that doesn't type-check always exits with status 1. Coverage caches its result. Running it again on an unchanged program, or after edits that only touch comments, reuses the previous verdict. Set `SCRIPTC_NO_CACHE=1` to turn the cache off. diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index cb6b93d0d..66d3cbd91 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -48,6 +48,8 @@ title: "Limitations" ../limitations/types-and-class-definitions/unions.mdx +../limitations/types-and-class-definitions/dynamic-slots.mdx + ../limitations/types-and-class-definitions/null-and-optional-values.mdx ../limitations/types-and-class-definitions/static-field-updates.mdx diff --git a/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx b/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx new file mode 100644 index 000000000..102c7ce0a --- /dev/null +++ b/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx @@ -0,0 +1,5 @@ +- **Dynamic slots:** A field, parameter, return value, variable or expression whose declared type has no native representation compiles as a checked dynamic slot instead of failing the build. + The parts of the type that compile keep their native layout, and only the failing parts hold checked-dynamic values. + Operations on those values run in the dynamic tier, and reading one into typed code checks it against the type that reads it, so a value that contradicts its declaration throws a `TypeError` when typed code first uses it rather than when it is stored. + Each slot is an `SC6104` warning; `scriptc coverage --fail-on=dynamic-slots` fails on them, and `--tier=static-only` turns them back into build errors. + Values that scriptc cannot produce at all, such as `WeakRef` instances, still fail where they are created. diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index 7d2724810..e0b81cea5 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -35,9 +35,10 @@ Options: coverage only: exit 1 when the entry path has blockers (a build would fail), or also when it has Node divergences, likely-hot functions that use untyped - values (SC6101), any Node island (eager-node-islands is - the same until deferred evaluation lands), or a gap - island (a package scriptc should compile but cannot yet) + values (SC6101), dynamic slots (SC6104), any Node + island (eager-node-islands is the same until deferred + evaluation lands), or a gap island (a package scriptc + should compile but cannot yet) --backend code generator (llvm) --optimization native optimization posture (default: release/-O2). dev diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 343c71087..b94f43a4f 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -651,6 +651,7 @@ export function coverageEnvelope( code: slot.code, reason: slot.reason, ...(slot.partial === true ? { partial: true } : {}), + dynamicOps: slot.operations ?? 0, })), } : {}), @@ -763,9 +764,14 @@ function renderDynamicSlots( out.push( ` dynamic slots ${slots.length} ${c(DIM, "(declared types with no static lowering, held as checked-dynamic values; SC6104)")}`, ); - for (const slot of slots.slice(0, DYNAMIC_SLOTS_SHOWN)) + // The costly slots first: most dynamic operations, then source order. + const ranked = slots + .map((slot, index) => ({ slot, index })) + .sort((a, b) => (b.slot.operations ?? 0) - (a.slot.operations ?? 0) || a.index - b.index) + .map(({ slot }) => slot); + for (const slot of ranked.slice(0, DYNAMIC_SLOTS_SHOWN)) out.push( - ` ${dynamicSlotLabel(slot)}${slot.partial === true ? " (dynamic pieces)" : ""} ${c(DIM, where(slot.loc))}`, + ` ${dynamicSlotLabel(slot)}${slot.partial === true ? " (dynamic pieces)" : ""} ${c(DIM, `${where(slot.loc)}${(slot.operations ?? 0) > 0 ? `, ${slot.operations} dynamic operation${slot.operations === 1 ? "" : "s"}` : ""}`)}`, ); if (slots.length > DYNAMIC_SLOTS_SHOWN) out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`); diff --git a/packages/compiler/src/diagnostics/dynamic-slots.ts b/packages/compiler/src/diagnostics/dynamic-slots.ts index 01d22eca6..f3c1955ee 100644 --- a/packages/compiler/src/diagnostics/dynamic-slots.ts +++ b/packages/compiler/src/diagnostics/dynamic-slots.ts @@ -20,6 +20,9 @@ export interface DynamicSlotEntry { /** Push-down: the slot keeps its type's compilable shape, and only the * components that do not compile are checked-dynamic. */ partial?: boolean; + /** Dynamic operations the program performs on the slot's values (the + * dynamic-tier sites whose cause names it). */ + operations?: number; } const SLOT_WORD: Record = { diff --git a/packages/compiler/src/diagnostics/dynamic-tier.ts b/packages/compiler/src/diagnostics/dynamic-tier.ts index 7970d3f42..39820ba97 100644 --- a/packages/compiler/src/diagnostics/dynamic-tier.ts +++ b/packages/compiler/src/diagnostics/dynamic-tier.ts @@ -26,7 +26,8 @@ export type UntypedCause = | { kind: "any"; name: string } | { kind: "json-binding"; name: string } | { kind: "json" } - | { kind: "javascript" }; + | { kind: "javascript" } + | { kind: "slot"; name: string; declaredType: string }; export function causePhrase(cause: UntypedCause): string { switch (cause.kind) { @@ -38,6 +39,8 @@ export function causePhrase(cause: UntypedCause): string { return "untyped `JSON.parse` result"; case "javascript": return "untyped JavaScript file"; + case "slot": + return `\`${cause.name}\` is a dynamic slot (declared \`${cause.declaredType}\`, which does not compile)`; } } @@ -95,5 +98,8 @@ export function renderPerformanceWarnings(diags: readonly ScrDiagnostic[]): stri const blocks = diags.map( (d) => `warning ${d.code}: ${d.message}${d.hint === undefined ? "" : `\n hint: ${d.hint}`}`, ); - return `${blocks.join("\n\n")}\n\nThe program compiled. Adding types to these functions lets scriptc compile them to faster code.`; + const closing = diags.every((d) => d.code === "SC6104") + ? "The program compiled. Declared types that compile keep these slots static." + : "The program compiled. Adding types to these functions lets scriptc compile them to faster code."; + return `${blocks.join("\n\n")}\n\n${closing}`; } diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index 8717f9448..889434e06 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -173,6 +173,7 @@ export interface DiagnosticsEnvelope { code: string; reason: string; partial?: boolean; + dynamicOps: number; })[]; /** coverage --explain-dynamic only: the report-only tier assignment. */ tiers?: TiersEnvelope; diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts index 3e0cea20a..eee46150d 100644 --- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -208,9 +208,26 @@ function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): Untype return null; } +/** A dynamic slot (localized failure): the node's declared type degraded + * to the checked dynamic, wholly or in pieces. Null while nothing degraded. */ +function slotCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { + if (lowerer.degradedTypes.size === 0) return null; + if (!ts.isIdentifier(n) && !ts.isPropertyAccessExpression(n)) return null; + const type = lowerer.checker.getTypeAtLocation(n); + if (!lowerer.degradedTypes.has((type as { id?: number }).id ?? -1)) return null; + const text = ts.getTextOfNode(n); + return { + kind: "slot", + name: text.length > 40 ? `${text.slice(0, 37)}...` : text, + declaredType: lowerer.checker.typeToString(type), + }; +} + /** Why a node's value has no static type, or null. */ function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { if (isJsonParse(n)) return { kind: "json" }; + const slot = slotCause(lowerer, n); + if (slot !== null) return slot; const id = rootIdentifier(n); return id === null ? null : identifierCause(lowerer, id); } @@ -382,3 +399,24 @@ export function dynamicTierWarning( first.loc, ); } + +/** Localized failure: dynamic operations attributed to each dynamic slot + * (by declared type), over the dynamic-tier functions of the program. A + * site counts for the slot the SC6101 cause search names at it. */ +export function dynamicSlotOperationCounts( + lowerer: Lowerer, + bodies: readonly IrFunction[], +): Map { + const counts = new Map(); + if (lowerer.degradedTypes.size === 0) return counts; + for (const fn of bodies) { + // Generated helpers repeat their callers' locations. + if (GENERATED.test(fn.name)) continue; + for (const site of dynamicOperationSites(fn)) { + const cause = causeAt(lowerer, site.loc); + if (cause?.kind !== "slot") continue; + counts.set(cause.declaredType, (counts.get(cause.declaredType) ?? 0) + 1); + } + } + return counts; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3254e6015..fb5ddaf04 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -167,7 +167,7 @@ import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; -import { dynamicTierWarning } from "./dynamic-tier.js"; +import { dynamicSlotOperationCounts, dynamicTierWarning } from "./dynamic-tier.js"; import { promoteJsvals } from "../../ir/jsval.js"; import { analyzeTiers, @@ -1339,7 +1339,9 @@ export function lowerToIr( } // Localized failure (SC6104): one performance warning per dynamic slot. if (resultLowerer.dynamicSlots.length > 0) { + const operations = dynamicSlotOperationCounts(resultLowerer, resultLowerer.dynamicBodies); const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot) => ({ + operations: operations.get(slot.failure.declaredType) ?? 0, loc: slot.loc, name: slot.failure.name, slot: slot.failure.slot, diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 002a84f00..edd5a6f7e 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -485,6 +485,15 @@ test("a local or module global whose declared type does not compile becomes a dy expect(strict.diagnostics.map((d) => d.code)).toContain("SC2009"); }); +test("dynamic slots rank by their dynamic operations and name themselves in SC6101", () => { + const { coverage } = analyze(join(repoRoot, "tests/corpus/localized-failure-declarations.ts")); + const slots = coverage.dynamicSlots ?? []; + expect(slots.some((s) => (s.operations ?? 0) > 0)).toBe(true); + expect(slots.find((s) => s.name === "tools")?.partial).toBe(true); + const hot = coverage.performance?.find((d) => d.code === "SC6101"); + expect(hot?.message).toContain("is a dynamic slot (declared `ToolDefinition`"); +}); + test("CLI: --tier and --explain-dynamic", () => { const scriptcCli = join(repoRoot, "packages/cli/src/main.ts"); const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-")); From 766a732f8ee6147e326cef226289f31e7f4e120e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 23:21:20 -0500 Subject: [PATCH 07/13] Initialize a forward-captured binding's box when its statement lowers again A binding that a function declared earlier captures is pre-declared as a TDZ box, and its declaration's assign initializes the box. The declaration dropped the pre-declaration before lowering its initializer. When the initializer failed with a degradable type (a cast whose type does not compile) and the statement commit point lowered the statement again, the retry no longer saw the box: it declared a new local and initialized that, and a later call of the function threw ReferenceError. The pre-declaration is now dropped after the initializer lowers, for variable, class and generic-function declarations. --- .../src/frontend/lowering/lower-stmts.ts | 10 ++++--- .../localized-failure-forward-capture.ts.json | 6 ++++ .../localized-failure-forward-capture.ts | 30 +++++++++++++++++++ .../localized-failure-forward-capture.ts.json | 1 + 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-forward-capture.ts.json create mode 100644 tests/corpus/localized-failure-forward-capture.ts create mode 100644 tests/harness/corpus-costs/localized-failure-forward-capture.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 4ab2aae08..d4c686a90 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1772,8 +1772,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt const symbol = lowerer.checker.getSymbolAtLocation(stmt.name); const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined; if (pre) { - lowerer.ctx.tdzPredeclared.delete(symbol!); const value = lowerer.coerceToExpected(lowerer.lowerClassExpression(stmt), DYN); + lowerer.ctx.tdzPredeclared.delete(symbol!); return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(stmt) }; } const type: IrType = { kind: "classval", className: info.def.name }; @@ -5789,8 +5789,8 @@ export function lowerVarDecl( const symbol = lowerer.checker.getSymbolAtLocation(decl.name); const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined; if (pre) { - lowerer.ctx.tdzPredeclared.delete(symbol!); const value = genericBindingStoredValue(lowerer, decl, info); + lowerer.ctx.tdzPredeclared.delete(symbol!); return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(decl) }; } return genericBindingPresenceStore(info, decl); @@ -5953,11 +5953,13 @@ export function lowerVarDecl( // A forward-captured binding pre-declared as a TDZ box (an earlier // function in this scope captured it — predeclareForwardCapture): the // binding and its scope-entry varDecl already exist; the source - // declaration is the one initializing `assign` into the shared box. + // declaration is the one initializing `assign` into the shared box. The + // box stays pre-declared until the initializer lowers: a statement the + // commit point lowers again (localized failure) initializes the same box. const pre = declSymbol ? lowerer.ctx.tdzPredeclared.get(declSymbol) : undefined; if (pre && decl.initializer) { - lowerer.ctx.tdzPredeclared.delete(declSymbol!); const init = lowerer.lowerExprExpecting(decl.initializer, pre.type); + lowerer.ctx.tdzPredeclared.delete(declSymbol!); return { kind: "assign", localId: pre.id, value: init, initializes: true, loc: locOf(decl) }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-forward-capture.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-forward-capture.ts.json new file mode 100644 index 000000000..f77570166 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-forward-capture.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-forward-capture.ts" + ], + "diags": [] +} diff --git a/tests/corpus/localized-failure-forward-capture.ts b/tests/corpus/localized-failure-forward-capture.ts new file mode 100644 index 000000000..9265c113f --- /dev/null +++ b/tests/corpus/localized-failure-forward-capture.ts @@ -0,0 +1,30 @@ +// Localized failure, stage L4, at a forward-captured binding: functions +// declared before a const capture it, and the const's initializer has an +// expression whose type does not compile. The statement lowers again with +// that expression degraded, and it initializes the binding the functions +// captured: before the declaration a call throws ReferenceError, after it +// the call reads the value. +type Hook = Map string> | AbortSignal | (() => number) | Set; +interface Plugin { + name: string; + hook: Hook; +} +function load(text: string): unknown { + return JSON.parse(text); +} +function main(): void { + function nameOf(): string { + return plugin.name; + } + function attempt(): string { + try { + return nameOf(); + } catch (e) { + return e instanceof ReferenceError ? "ReferenceError" : "other"; + } + } + console.log(attempt()); + const plugin = { name: (load('{"name":"tags","hook":null}') as Plugin).name }; + console.log(attempt(), nameOf().length); +} +main(); diff --git a/tests/harness/corpus-costs/localized-failure-forward-capture.ts.json b/tests/harness/corpus-costs/localized-failure-forward-capture.ts.json new file mode 100644 index 000000000..3e8d4b6ac --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-forward-capture.ts.json @@ -0,0 +1 @@ +{ "plain": 3500 } From 4a39f8e1b759c9c9e46f434f456af8b532f550ef Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 23:55:18 -0500 Subject: [PATCH 08/13] Address review feedback --- packages/compiler/src/frontend/lowering/dynamic-tier.ts | 9 +++++++-- tests/harness/coverage.test.ts | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts index eee46150d..c8c8bbaac 100644 --- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -271,6 +271,10 @@ function causeOf(lowerer: Lowerer, sites: readonly { loc: SrcLoc }[]): UntypedCa /** Generated functions (see scanDynamicTier), after any module prefix. */ const GENERATED = /^%*(?:m\d+\.)?%*(?:dyn|caught|init)\./; +/** Generated helpers that repeat their callers' locations: `%dyn.*` and + * `%caught.*`, but not a module's top level (`%init.*`). */ +const HELPERS = /^%*(?:m\d+\.)?%*(?:dyn|caught)\./; + /** The likely-hot dynamic-tier functions of `module` (see the header). */ export function scanDynamicTier(lowerer: Lowerer, module: IrModule): DynamicTierReport { const facts = new Map(); @@ -410,8 +414,9 @@ export function dynamicSlotOperationCounts( const counts = new Map(); if (lowerer.degradedTypes.size === 0) return counts; for (const fn of bodies) { - // Generated helpers repeat their callers' locations. - if (GENERATED.test(fn.name)) continue; + // Generated helpers repeat their callers' locations; top-level code + // (`%init.*`) counts like any function. + if (HELPERS.test(fn.name)) continue; for (const site of dynamicOperationSites(fn)) { const cause = causeAt(lowerer, site.loc); if (cause?.kind !== "slot") continue; diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index edd5a6f7e..b987056e5 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -461,6 +461,8 @@ test("a local or module global whose declared type does not compile becomes a dy ["global", "all", "(number | Callbacks)[]", "SC2009"], ["local", "local", "Callbacks | undefined", "SC2009"], ]); + // The loop over `all` is top-level code: its dynamic operations count too. + expect(coverage.dynamicSlots?.find((s) => s.name === "all")?.operations).toBeGreaterThan(0); expect(coverage.performance?.filter((d) => d.code === "SC6104")).toHaveLength(3); expect(coveragePasses(coverage, "blockers")).toBe(true); expect(coveragePasses(coverage, "dynamic")).toBe(true); From ec9624997a33d449677f475938593f9a989ef5f3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 21:34:37 -0700 Subject: [PATCH 09/13] Attribute the dynamic pieces of pushed-down types in SC6101 and the slot operation counts A pushed-down slot's dynamic operations act on its failing components, whose checker types are not degraded themselves. The lenient mapping records those components with the declared type they belong to; SC6101 names them ('hook' is a dynamic piece of 'Plugin[]'), and the operation counts go to the owning slot. Causes read a binding's declared type, not its narrowed flow type. The coverage test uses its own fixture, so it does not depend on which interface members main can map. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/diagnostics/dynamic-tier.ts | 12 +++++++-- .../src/frontend/lowering/dynamic-tier.ts | 22 ++++++++++++--- .../compiler/src/frontend/lowering/lowerer.ts | 15 ++++++++++- packages/compiler/src/frontend/type-mapper.ts | 4 +++ tests/coverage-fixtures/dynamic-slot-hot.ts | 27 +++++++++++++++++++ tests/harness/coverage.test.ts | 6 ++--- 6 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 tests/coverage-fixtures/dynamic-slot-hot.ts diff --git a/packages/compiler/src/diagnostics/dynamic-tier.ts b/packages/compiler/src/diagnostics/dynamic-tier.ts index 39820ba97..ff6a79a57 100644 --- a/packages/compiler/src/diagnostics/dynamic-tier.ts +++ b/packages/compiler/src/diagnostics/dynamic-tier.ts @@ -27,7 +27,13 @@ export type UntypedCause = | { kind: "json-binding"; name: string } | { kind: "json" } | { kind: "javascript" } - | { kind: "slot"; name: string; declaredType: string }; + | { + kind: "slot"; + name: string; + declaredType: string; + /** The declared type this is a dynamic piece of (push-down). */ + owner?: string; + }; export function causePhrase(cause: UntypedCause): string { switch (cause.kind) { @@ -40,7 +46,9 @@ export function causePhrase(cause: UntypedCause): string { case "javascript": return "untyped JavaScript file"; case "slot": - return `\`${cause.name}\` is a dynamic slot (declared \`${cause.declaredType}\`, which does not compile)`; + return cause.owner === undefined + ? `\`${cause.name}\` is a dynamic slot (declared \`${cause.declaredType}\`, which does not compile)` + : `\`${cause.name}\` is a dynamic piece of \`${cause.owner}\` (declared \`${cause.declaredType}\`, which does not compile)`; } } diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts index c8c8bbaac..9c5a10760 100644 --- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -213,13 +213,26 @@ function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): Untype function slotCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { if (lowerer.degradedTypes.size === 0) return null; if (!ts.isIdentifier(n) && !ts.isPropertyAccessExpression(n)) return null; - const type = lowerer.checker.getTypeAtLocation(n); - if (!lowerer.degradedTypes.has((type as { id?: number }).id ?? -1)) return null; + // The declared type of the binding or property, not its narrowed flow + // type (narrowing makes new checker types). + let symbol = lowerer.checker.getSymbolAtLocation(ts.isIdentifier(n) ? n : n.name); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + const type = + symbol !== undefined && (symbol.flags & VALUE_BINDING) !== 0 + ? lowerer.checker.getTypeOfSymbol(symbol) + : lowerer.checker.getTypeAtLocation(n); + const id = (type as { id?: number }).id ?? -1; + // A whole degraded type, or a dynamic piece of a pushed-down one. + const owner = lowerer.lenientPieces.get(id); + if (!lowerer.degradedTypes.has(id) && owner === undefined) return null; const text = ts.getTextOfNode(n); + const declaredType = lowerer.checker.typeToString(type); return { kind: "slot", name: text.length > 40 ? `${text.slice(0, 37)}...` : text, - declaredType: lowerer.checker.typeToString(type), + declaredType, + ...(owner !== undefined && owner !== declaredType ? { owner } : {}), }; } @@ -420,7 +433,8 @@ export function dynamicSlotOperationCounts( for (const site of dynamicOperationSites(fn)) { const cause = causeAt(lowerer, site.loc); if (cause?.kind !== "slot") continue; - counts.set(cause.declaredType, (counts.get(cause.declaredType) ?? 0) + 1); + const slot = cause.owner ?? cause.declaredType; + counts.set(slot, (counts.get(slot) ?? 0) + 1); } } return counts; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index fb5ddaf04..6a0de3398 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3829,6 +3829,10 @@ export class Lowerer { number, { lenient: IrType | null; code: string; reason: string } >(); + /** The components lenient mappings turned into the checked dynamic, by + * checker type id, with the declared type they are pieces of (SC6101 + * causes and per-slot operation counts). */ + readonly lenientPieces = new Map(); /** One frame per unit a commit point may retry: the pushed-down * (partial) degraded types its lowering mapped (withPartialHits). */ readonly partialHits: Set[] = []; @@ -9643,7 +9647,16 @@ export class Lowerer { * that shape does not map. Throwaway context: nothing memoizes. */ lenientType(type: ts.Type): IrType { const { typeMemo: _strictMemo, ...strict } = this.typeCtx; - const mapped = mapType(type, { ...strict, lenient: true, canMemoizeType: () => false }); + const owner = this.checker.typeToString(type); + const mapped = mapType(type, { + ...strict, + lenient: true, + canMemoizeType: () => false, + onLenientPiece: (piece) => { + const id = (piece as { id?: number }).id; + if (id !== undefined && !this.lenientPieces.has(id)) this.lenientPieces.set(id, owner); + }, + }); return mapped === null || mapped.kind === "void" ? DYN : mapped; } diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index c4a245278..4971c40c1 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1292,6 +1292,9 @@ export interface TypeMapperCtx { * failing type keeps its outer shape with dynamic pieces. Only ever set * on a throwaway context without memoization. */ lenient?: boolean; + /** With `lenient`: told each component the lenient mapping turned into + * the checked dynamic (Lowerer.lenientPieces). */ + onLenientPiece?: (type: ts.Type) => void; /** GENERIC program classes (monomorphization by flow): the mapping of a * concrete instantiation reference (`Box`) — the Lowerer * registers/reuses the instantiation (`Box%0`) and answers its object @@ -1881,6 +1884,7 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (mapped === null && ctx.lenient === true && !topLevel) { mapped = DYN; contextResolutions++; + ctx.onLenientPiece?.(type); } if ( mapped !== null && diff --git a/tests/coverage-fixtures/dynamic-slot-hot.ts b/tests/coverage-fixtures/dynamic-slot-hot.ts new file mode 100644 index 000000000..65672ffc1 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-slot-hot.ts @@ -0,0 +1,27 @@ +// A dynamic slot read in a loop: SC6101 names the slot, and coverage +// counts the dynamic operations on it. +type Hook = (() => number) | AbortSignal | Map string>; + +interface Plugin { + name: string; + hook: Hook; +} + +const plugins: Plugin[] = [ + { name: "one", hook: () => 1 }, + { name: "two", hook: () => 2 }, +]; + +function sum(rounds: number): number { + let total = 0; + for (let i = 0; i < rounds; i++) { + for (const p of plugins) { + const hook = p.hook; + if (typeof hook === "function") total += hook(); + else total += (hook as { size?: number }).size ?? 0; + } + } + return total; +} + +console.log(sum(10)); diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index b987056e5..6b7ec9e4b 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -488,12 +488,12 @@ test("a local or module global whose declared type does not compile becomes a dy }); test("dynamic slots rank by their dynamic operations and name themselves in SC6101", () => { - const { coverage } = analyze(join(repoRoot, "tests/corpus/localized-failure-declarations.ts")); + const { coverage } = analyze(fixture("dynamic-slot-hot.ts")); const slots = coverage.dynamicSlots ?? []; + expect(slots.find((s) => s.name === "plugins")?.partial).toBe(true); expect(slots.some((s) => (s.operations ?? 0) > 0)).toBe(true); - expect(slots.find((s) => s.name === "tools")?.partial).toBe(true); const hot = coverage.performance?.find((d) => d.code === "SC6101"); - expect(hot?.message).toContain("is a dynamic slot (declared `ToolDefinition`"); + expect(hot?.message).toContain("`hook` is a dynamic piece of `Plugin[]` (declared `Hook`"); }); test("CLI: --tier and --explain-dynamic", () => { From 895763f3b651caf9b27c7cadff9545bc4f31035f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 07:38:24 -0500 Subject: [PATCH 10/13] Keep a give-up statement's first-attempt diagnostics --- .../compiler/src/frontend/lowering/lower-stmts.ts | 14 ++++++++++++-- packages/compiler/src/frontend/lowering/lowerer.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 7c0f05bfc..12b7aabcc 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -759,6 +759,11 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // out of tdzPredeclared; a retry after a later declarator degrades // must initialize the same box again. const tdzBefore = new Map(lowerer.ctx.tdzPredeclared); + // The first attempt's diagnostics: a statement that still fails + // after degrading reports them, since a retry does not repeat what + // lowered once (a poisoned generic instance) and its own diagnostics + // can miss the failure's cause. + let firstAttempt: ScrDiagnostic[] | null = null; try { for (let attempt = 0; ; attempt++) { const hits = new Set(); @@ -773,7 +778,12 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // global degrades that type and the statement lowers again. // A declaration this statement flushed inside a probe (where // no retry runs) can retry here too. - if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e; + firstAttempt ??= lowerer.diags.slice(diagsBefore); + const giveUp = (): unknown => { + if (attempt > 0) lowerer.restoreFirstAttemptDiags(diagsBefore, firstAttempt!); + return e; + }; + if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw giveUp(); const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS); // Pushed-down types the failed attempt mapped widen to the // whole slot before anything else is given up. @@ -782,7 +792,7 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir !lowerer.reopenFlushedAtCommit(diagsBefore) && !lowerer.widenPartials(hits) ) - throw e; + throw giveUp(); // A failed attempt adds nothing to `out` but the TDZ boxes // forward captures declared (they stay declared), so the // retry appends after them. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index cfa7987a6..9184a4e36 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -9853,6 +9853,19 @@ export class Lowerer { this.diags.push(...kept); } + /** A unit that still fails after its retries commits its first + * attempt's own diagnostics (`first`, taken before the first + * degradation) in place of the last attempt's. The diagnostics the + * retries carry (dropAttemptDiags) stay as they are now: a declaration + * reopened since may have registered. */ + restoreFirstAttemptDiags(diagsBefore: number, first: readonly ScrDiagnostic[]): void { + const carried = (d: ScrDiagnostic): boolean => + d.rejects !== undefined || this.loweredOnceDiags.has(d); + const kept = this.diags.slice(diagsBefore).filter(carried); + this.diags.length = diagsBefore; + this.diags.push(...first.filter((d) => !carried(d)), ...kept); + } + /** Lowers one unit whose body-level poison skips it (a generic * instance), with localized failure's commit point: a degradable type * failure at one of its declaration slots degrades the type and the unit From acb2d23df58ee197ef6de025cd2ece650d76ee3e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 07:59:53 -0500 Subject: [PATCH 11/13] Attribute slot causes to the owner only for types without their own slot --- .../src/frontend/lowering/dynamic-tier.ts | 6 +++-- .../coverage-fixtures/dynamic-slot-shared.ts | 23 +++++++++++++++++++ tests/harness/coverage.test.ts | 9 ++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/coverage-fixtures/dynamic-slot-shared.ts diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts index 9c5a10760..6307f58fa 100644 --- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -223,8 +223,10 @@ function slotCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { ? lowerer.checker.getTypeOfSymbol(symbol) : lowerer.checker.getTypeAtLocation(n); const id = (type as { id?: number }).id ?? -1; - // A whole degraded type, or a dynamic piece of a pushed-down one. - const owner = lowerer.lenientPieces.get(id); + // A whole degraded type, or a dynamic piece of a pushed-down one. A type + // that is also a slot of its own belongs to that slot, not to a container + // it is a piece of. + const owner = lowerer.degradedTypes.has(id) ? undefined : lowerer.lenientPieces.get(id); if (!lowerer.degradedTypes.has(id) && owner === undefined) return null; const text = ts.getTextOfNode(n); const declaredType = lowerer.checker.typeToString(type); diff --git a/tests/coverage-fixtures/dynamic-slot-shared.ts b/tests/coverage-fixtures/dynamic-slot-shared.ts new file mode 100644 index 000000000..21624a479 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-slot-shared.ts @@ -0,0 +1,23 @@ +// A type that degrades as a piece of a container (`Plugin.hook`) and as a +// slot of its own (`current`): the operations on `current` belong to +// `current`, not to the container. +type Hook = (() => number) | AbortSignal | Map string>; + +interface Plugin { + name: string; + hook: Hook; +} + +const plugins: Plugin[] = [{ name: "one", hook: () => 1 }]; + +let current: Hook = () => 2; + +function run(rounds: number): number { + let total = 0; + for (let i = 0; i < rounds; i++) { + if (typeof current === "function") total += current(); + } + return total; +} + +console.log(run(10), plugins.length); diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index bba240619..d8e6e156d 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -496,6 +496,15 @@ test("dynamic slots rank by their dynamic operations and name themselves in SC61 expect(hot?.message).toContain("`hook` is a dynamic piece of `Plugin[]` (declared `Hook`"); }); +test("a type that is a dynamic piece and a slot of its own counts toward its own slot", () => { + const { coverage } = analyze(fixture("dynamic-slot-shared.ts")); + const slots = coverage.dynamicSlots ?? []; + expect(slots.find((s) => s.name === "current")?.operations).toBeGreaterThan(0); + expect(slots.find((s) => s.name === "plugins")?.operations ?? 0).toBe(0); + const hot = coverage.performance?.find((d) => d.code === "SC6101"); + expect(hot?.message).toContain("`current` is a dynamic slot (declared `Hook`"); +}); + test("CLI: --tier and --explain-dynamic", () => { const scriptcCli = join(repoRoot, "packages/cli/src/main.ts"); const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-")); From 4c5c0823ce4b6c07e35ef26dc1a4533d98e5b9e9 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 10:20:11 -0500 Subject: [PATCH 12/13] Address review feedback --- .../src/frontend/lowering/dynamic-tier.ts | 82 ++++++++++++++++--- .../compiler/src/frontend/lowering/lowerer.ts | 10 ++- .../dynamic-slot-same-type.ts | 16 ++++ tests/harness/coverage.test.ts | 10 +++ 4 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 tests/coverage-fixtures/dynamic-slot-same-type.ts diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts index 6307f58fa..03994970c 100644 --- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -30,7 +30,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import * as ts from "../ts7/adapter.js"; import { isCheckedJsSourceFile, isJsSourceFile } from "../program.js"; import { isNodeModulesPath } from "../resolve.js"; -import type { Lowerer } from "./lowerer.js"; +import type { DynamicSlot, Lowerer } from "./lowerer.js"; export interface HotDynamicFunction { /** The IR function name (`%Point.norm`, `parse`, ...). */ @@ -251,15 +251,28 @@ function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { * the source does not say. The site's own subexpressions are asked first * (the operand of a dynamic `for...of`), then its ancestors. */ function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null { + return causeSiteAt(lowerer, loc)?.cause ?? null; +} + +/** causeAt, with the node that names the cause (none for an untyped + * JavaScript file). */ +function causeSiteAt( + lowerer: Lowerer, + loc: SrcLoc, +): { cause: UntypedCause; node?: ts.Node } | null { const sf = lowerer.program.getSourceFile(loc.file); if (sf === undefined) return null; if (isJsSourceFile(sf) && !isCheckedJsSourceFile(sf, lowerer.projectCheckJs)) - return { kind: "javascript" }; + return { cause: { kind: "javascript" } }; const node = nodeAt(sf, loc.start, loc.end); - let found: UntypedCause | null = null; + let found: { cause: UntypedCause; node: ts.Node } | null = null; + const ask = (n: ts.Node): void => { + const cause = nodeCause(lowerer, n); + if (cause !== null) found = { cause, node: n }; + }; const descend = (n: ts.Node, depth: number): void => { if (found !== null || depth > 3) return; - found = nodeCause(lowerer, n); + ask(n); if (found === null) ts.forEachChild(n, (child) => (descend(child, depth + 1), found !== null || undefined)); }; @@ -269,7 +282,7 @@ function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null { found === null && n !== undefined && n !== sf; n = n.parent ) - found = nodeCause(lowerer, n); + ask(n); return found; } @@ -420,24 +433,67 @@ export function dynamicTierWarning( } /** Localized failure: dynamic operations attributed to each dynamic slot - * (by declared type), over the dynamic-tier functions of the program. A - * site counts for the slot the SC6101 cause search names at it. */ + * of `slots` (by index), over the dynamic-tier functions of the program. A + * site counts for the slot the SC6101 cause search names at it: the slot + * declared by the binding the site reads (for a dynamic piece, the binding + * its access starts from), or else the one slot of the declared type. */ export function dynamicSlotOperationCounts( lowerer: Lowerer, bodies: readonly IrFunction[], -): Map { - const counts = new Map(); + slots: readonly DynamicSlot[], +): number[] { + const counts = slots.map(() => 0); if (lowerer.degradedTypes.size === 0) return counts; for (const fn of bodies) { // Generated helpers repeat their callers' locations; top-level code // (`%init.*`) counts like any function. if (HELPERS.test(fn.name)) continue; for (const site of dynamicOperationSites(fn)) { - const cause = causeAt(lowerer, site.loc); - if (cause?.kind !== "slot") continue; - const slot = cause.owner ?? cause.declaredType; - counts.set(slot, (counts.get(slot) ?? 0) + 1); + const found = causeSiteAt(lowerer, site.loc); + if (found?.cause.kind !== "slot" || found.node === undefined) continue; + const declaredType = found.cause.owner ?? found.cause.declaredType; + const index = slotOfSite(lowerer, slots, found.node, declaredType, found.cause.owner); + if (index >= 0) counts[index]!++; } } return counts; } + +/** The index in `slots` of the slot a dynamic operation on `node` (an + * identifier or property access the cause search named) belongs to, or -1 + * when the declared type is not one slot's and no declaration says. */ +function slotOfSite( + lowerer: Lowerer, + slots: readonly DynamicSlot[], + node: ts.Node, + declaredType: string, + owner: string | undefined, +): number { + const candidates: number[] = []; + slots.forEach((slot, i) => { + if (slot.failure.declaredType === declaredType) candidates.push(i); + }); + if (candidates.length <= 1) return candidates[0] ?? -1; + // The binding: the identifier or property itself for a whole slot, the + // root of the access for a piece of a container. + const named = + owner !== undefined + ? rootIdentifier(node) + : ts.isPropertyAccessExpression(node) + ? node.name + : node; + if (named === null) return -1; + let symbol = lowerer.checker.getSymbolAtLocation(named); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + for (const decl of symbol?.declarations ?? []) { + const file = ts.getSourceFileOfNode(decl).fileName; + const start = ts.tokenStartOfNode(decl); + const index = candidates.find((i) => { + const loc = slots[i]!.loc; + return loc.file === file && loc.start >= start && loc.start < decl.end; + }); + if (index !== undefined) return index; + } + return -1; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ec40be78f..225a23654 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1364,9 +1364,13 @@ export function lowerToIr( } // Localized failure (SC6104): one performance warning per dynamic slot. if (resultLowerer.dynamicSlots.length > 0) { - const operations = dynamicSlotOperationCounts(resultLowerer, resultLowerer.dynamicBodies); - const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot) => ({ - operations: operations.get(slot.failure.declaredType) ?? 0, + const operations = dynamicSlotOperationCounts( + resultLowerer, + resultLowerer.dynamicBodies, + resultLowerer.dynamicSlots, + ); + const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot, i) => ({ + operations: operations[i]!, loc: slot.loc, name: slot.failure.name, slot: slot.failure.slot, diff --git a/tests/coverage-fixtures/dynamic-slot-same-type.ts b/tests/coverage-fixtures/dynamic-slot-same-type.ts new file mode 100644 index 000000000..61954c6f6 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-slot-same-type.ts @@ -0,0 +1,16 @@ +// Two dynamic slots of one declared type: the operations in the loop are +// on `hot`, and `cold` is read once. +type Hook = (() => number) | AbortSignal | Map string>; + +let cold: Hook = () => 1; +let hot: Hook = () => 2; + +function run(rounds: number): number { + let total = 0; + for (let i = 0; i < rounds; i++) { + if (typeof hot === "function") total += hot(); + } + return total; +} + +console.log(run(10), typeof cold); diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index d8e6e156d..82e541140 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -505,6 +505,16 @@ test("a type that is a dynamic piece and a slot of its own counts toward its own expect(hot?.message).toContain("`current` is a dynamic slot (declared `Hook`"); }); +test("dynamic slots of one declared type count their own operations", () => { + const { coverage } = analyze(fixture("dynamic-slot-same-type.ts")); + const slots = coverage.dynamicSlots ?? []; + const hot = slots.find((s) => s.name === "hot")?.operations ?? 0; + const cold = slots.find((s) => s.name === "cold")?.operations ?? 0; + expect(hot).toBeGreaterThan(cold); + const hotWarning = coverage.performance?.find((d) => d.code === "SC6101"); + expect(hotWarning?.message).toContain("`hot` is a dynamic slot (declared `Hook`"); +}); + test("CLI: --tier and --explain-dynamic", () => { const scriptcCli = join(repoRoot, "packages/cli/src/main.ts"); const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-")); From 2aafe6b40638d9e3e2decef2b192aad2d3859c2f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 12:36:57 -0500 Subject: [PATCH 13/13] Address review feedback --- packages/compiler/src/frontend/lowering/lowerer.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b3b50500e..877106879 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3932,6 +3932,8 @@ export class Lowerer { * commit points (degradeAtCommit), so a program that compiles today * never fills it. */ readonly degradedTypes = new Map(); + /** A pushed-down (partial) degraded type was mapped (hasPartialDegradedTypes). */ + private partialDegradedTypesMapped = false; /** The components lenient mappings turned into the checked dynamic, by * checker type id, with the declared type they are pieces of (SC6101 * causes and per-slot operation counts). */ @@ -4184,7 +4186,10 @@ export class Lowerer { degraded.lenient = DYN; degraded.lenient = this.lenientType(type); } - if (degraded.lenient.kind !== "dyn") this.partialHits[this.partialHits.length - 1]?.add(id); + if (degraded.lenient.kind !== "dyn") { + this.partialHits[this.partialHits.length - 1]?.add(id); + this.partialDegradedTypesMapped = true; + } return degraded.lenient; }, ...(shadow !== undefined ? { canonShadow: shadow } : {}), @@ -9991,8 +9996,11 @@ export class Lowerer { return true; } - /** Some degraded type kept a pushed-down (partial) shape. */ + /** Some degraded type kept a pushed-down (partial) shape, or one was + * mapped before a retry widened it: the IR lowered before the retry keeps + * that shape. */ hasPartialDegradedTypes(): boolean { + if (this.partialDegradedTypesMapped) return true; for (const degraded of this.degradedTypes.values()) if (degraded.lenient !== null && degraded.lenient.kind !== "dyn") return true; return false;