From bb33ec50fed1aff62e645f923acedeae49f337de Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:07:57 -0700 Subject: [PATCH] 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 }