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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 19:07:57 -07:00
co-authored by Claude Opus 5.5
parent 624d2bf873
commit bb33ec50fe
8 changed files with 162 additions and 23 deletions
+4 -1
View File
@@ -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`)}`);
}
@@ -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<TypeFailureSlot, string> = {
@@ -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",
};
@@ -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;
@@ -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<number, { lenient: IrType; code: string; reason: string }>;
degradedTypes?: ReadonlyMap<number, { lenient: IrType | null; code: string; reason: string }>;
/** Localized failure: the declarations reachable emit registered with
* dynamic slots (the coverage remainder registers them too). */
retriedSymbols?: ReadonlySet<ts.Symbol>;
@@ -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<number, { lenient: IrType; code: string; reason: string }>();
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,
@@ -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<number>`) — 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 &&
@@ -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, (x: number) => string> | AbortSignal | (() => number) | Set<string>;
interface Plugin {
name: string;
priority: number;
hook: Hook;
}
class Registry {
plugins: Plugin[] = [];
byName = new Map<string, Plugin>();
pending: Promise<Plugin> | 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"));
@@ -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)
@@ -0,0 +1 @@
{ "plain": 4000 }