Map a failing type exactly from its canonical form where localized failure would degrade it

Stage N2 of the localized-failure design (canonical type normalization).

At localized failure's commit points, before a failing type degrades to
its pushed-down or whole-slot dynamic form, its canonical form maps
through mapCanonical under the current bindings. When that gives a static
IR type, the type maps exactly: no dynamic slot, no SC6104. Opaque and
unbound forms, and forms that map only to the checked dynamic, degrade as
before. The coverage remainder re-derives canonical forms in its own
tables. Because it runs only at commit points, a program that compiles
today never consults it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-26 05:50:51 -07:00
co-authored by Claude Opus 5.5
parent 177b4cd326
commit c81758a08d
@@ -7,7 +7,8 @@ import {
arrayRootOf,
lowerWithArraySpecies,
} from "./builtins/array-classes.js";
import { canonShadowHook } from "../canon-map.js";
import { canonShadowHook, mapCanonical } from "../canon-map.js";
import { Normalizer } from "../canon.js";
import {
compactDeclOrigins,
inPackageFile,
@@ -750,6 +751,9 @@ export interface DynamicSlot {
/** The slot keeps its type's compilable shape; only the failing
* components are checked-dynamic (push-down, stage L3). */
partial?: true;
/** The type mapped exactly from its canonical form (stage N2): a static
* slot, never reported as dynamic. */
canonical?: true;
}
/** Localized failure: rounds of the declaration flush retry. */
@@ -1007,6 +1011,9 @@ export interface DegradedType {
lenient: IrType | null;
code: string;
reason: string;
/** The type maps exactly from its canonical form (stage N2): its slots
* are static. */
canonical?: true;
}
/** The Lowerer's pass configuration (see lowerToIr). */
@@ -4178,9 +4185,12 @@ export class Lowerer {
// checked dynamic cuts the knot while it does.
if (degraded.lenient === null) {
degraded.lenient = DYN;
degraded.lenient = this.lenientType(type);
degraded.lenient =
(degraded.canonical === true ? this.canonicalType(type) : null) ??
this.lenientType(type);
}
if (degraded.lenient.kind !== "dyn") this.partialHits[this.partialHits.length - 1]?.add(id);
if (degraded.lenient.kind !== "dyn" && degraded.canonical !== true)
this.partialHits[this.partialHits.length - 1]?.add(id);
return degraded.lenient;
},
...(shadow !== undefined ? { canonShadow: shadow } : {}),
@@ -9833,8 +9843,10 @@ export class Lowerer {
}
if (committed === undefined) return null;
this.degradeType(type, committed.code, committed.message);
const canonical = this.isCanonicalDegraded(id);
const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn";
return {
...(canonical ? { canonical: true as const } : {}),
...(partial ? { partial: true as const } : {}),
loc: committed.loc,
failure,
@@ -9966,6 +9978,14 @@ export class Lowerer {
this.degradedTypes.set(id, { lenient: DYN, code, reason });
return true;
}
// Canonical normalization (stage N2): a type the per-form rules miss
// maps exactly from its canonical form when that form maps; no dynamic
// slot then. Otherwise the lenient (pushed-down) form.
const canonical = this.canonicalType(type);
if (canonical !== null) {
this.degradedTypes.set(id, { lenient: canonical, code, reason, canonical: true });
return true;
}
this.degradedTypes.set(id, {
lenient: this.wholeSlotDegradation ? DYN : this.lenientType(type),
code,
@@ -9974,10 +9994,38 @@ export class Lowerer {
return true;
}
/** The IR type of a type's canonical form (canon-map.ts), or null when
* the form is irreducible, unbound, or only the checked dynamic. Runs
* only at localized failure's commit points, so a program that compiles
* today never consults it. */
canonicalType(type: ts.Type): IrType | null {
try {
const resolve = this.typeCtx.resolveTypeParamTs;
const c = new Normalizer(this.checker).canon(type, resolve ? (t) => resolve(t) : new Map());
if (c.k === "opaque" || c.k === "param") return null;
const { typeMemo: _strictMemo, ...strict } = this.typeCtx;
const mapped = mapCanonical(c, { ...strict, canMemoizeType: () => false });
return mapped === null || mapped.kind === "void" || mapped.kind === "dyn" ? null : mapped;
} catch {
return null;
}
}
/** The degraded type of `id` maps exactly (canonical form): its slots
* are static, not dynamic. */
isCanonicalDegraded(id: number): boolean {
return this.degradedTypes.get(id)?.canonical === 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;
if (
degraded.canonical !== true &&
degraded.lenient !== null &&
degraded.lenient.kind !== "dyn"
)
return true;
return false;
}
@@ -10011,6 +10059,7 @@ export class Lowerer {
/** Records a dynamic slot whose unit lowered with it (one per site). */
noteDynamicSlot(slot: DynamicSlot): void {
if (slot.canonical === true) return;
const seen = this.dynamicSlots.some(
(s) =>
s.loc.file === slot.loc.file &&
@@ -13166,18 +13215,17 @@ export class Lowerer {
const prior = this.degradedTypes.get(id);
degraded.push({
id,
prior:
prior === undefined
? undefined
: { lenient: prior.lenient, code: prior.code, reason: prior.reason },
prior: prior === undefined ? undefined : { ...prior },
});
this.degradeType(type, diag.code, diag.message);
const canonical = this.isCanonicalDegraded(id);
const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn";
slots.push({
loc: diag.loc,
failure,
code: diag.code,
reason: diag.message,
...(canonical ? { canonical: true as const } : {}),
...(partial ? { partial: true as const } : {}),
});
}