From ac31a15a62ec33bb9514532e41410cbac3fcfe3e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 16:33:16 -0700 Subject: [PATCH 1/9] Hold locals and module globals whose declared type does not compile as checked dynamic slots Stage L1 of the localized-failure design. A local, loop variable or module global whose declared TypeScript type has no static lowering used to reject its declaration statement, and every later use of the binding failed with it. Where that failure is about to enter the build (the statement's poison in a TypeScript file, or the module global's registration), the failing type now joins the lowerer's degraded types and the unit lowers again: the type maps to the checked dynamic wherever its strict mapping fails, so the slot holds values by reference (typed capsules, live records and arrays, function thunks) and typed reads out of it are checked. Each such slot is an SC6104 performance warning, listed under coverage's statement counts and in the JSON envelope; coverage --fail-on=dynamic-slots fails on them, and --tier=static-only keeps today's refusals. A program that compiles today reaches neither commit point, so nothing degrades and its output is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/cli/usage.ts | 2 +- packages/compiler/src/coverage/report.ts | 53 ++++++- .../compiler/src/coverage/verdict-cache.ts | 6 +- .../compiler/src/diagnostics/dynamic-slots.ts | 49 ++++++ packages/compiler/src/diagnostics/envelope.ts | 9 ++ packages/compiler/src/frontend/analysis.ts | 1 + .../src/frontend/lowering/lower-modules.ts | 31 +++- .../src/frontend/lowering/lower-stmts.ts | 140 +++++++++++------- .../compiler/src/frontend/lowering/lowerer.ts | 112 +++++++++++++- packages/compiler/src/frontend/type-mapper.ts | 15 ++ tests/corpus/localized-failure-held-values.ts | 70 +++++++++ tests/corpus/localized-failure-locals.ts | 43 ++++++ .../coverage-explain-localized-failure.txt | 14 +- .../localized-failure-held-values.ts.json | 1 + .../localized-failure-locals.ts.json | 1 + tests/harness/coverage.test.ts | 43 +++++- 16 files changed, 517 insertions(+), 73 deletions(-) create mode 100644 packages/compiler/src/diagnostics/dynamic-slots.ts create mode 100644 tests/corpus/localized-failure-held-values.ts create mode 100644 tests/corpus/localized-failure-locals.ts create mode 100644 tests/harness/corpus-costs/localized-failure-held-values.ts.json create mode 100644 tests/harness/corpus-costs/localized-failure-locals.ts.json diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index f8b6a4869..7d2724810 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -31,7 +31,7 @@ Options: (native-link-info implies --emit=obj and never links; diagnostics prints the versioned JSON diagnostics envelope for build and coverage) - --fail-on + --fail-on 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 diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index dabbc9617..d78120f89 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -22,6 +22,11 @@ import { type TiersEnvelope, } from "../diagnostics/envelope.js"; import { renderDiagnostics } from "../diagnostics/render.js"; +import { + dynamicSlotLabel, + isDynamicSlotCode, + type DynamicSlotEntry, +} from "../diagnostics/dynamic-slots.js"; import type { LowerStats } from "../frontend/lowering/lowerer.js"; import type { ProvenanceSources } from "../frontend/provenance-registry.js"; import type { @@ -59,6 +64,9 @@ export interface CoverageInput { * leaves untyped. Never blockers; `--fail-on=dynamic` opts into failing * on it. */ performance?: ScrDiagnostic[]; + /** Localized failure: the checked dynamic slots (SC6104, also in + * `performance`). `--fail-on=dynamic-slots` fails on them. */ + dynamicSlots?: DynamicSlotEntry[]; /** Each dependency's native compilation outcome, with the first refusal * reason for a refused package. */ npmStatic?: NpmStaticStatus[]; @@ -238,6 +246,10 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions ` ${c(DIM, `(${skipped} function${skipped === 1 ? "" : "s"} not analyzed — signature blocked; their statements count as not static)`)}`, ); } + renderDynamicSlots(input.dynamicSlots ?? [], out, c, (loc) => { + const p = locator.position(loc.file, loc.start); + return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`; + }); out.push(""); // Report native package compilation and the first reason for each refusal. @@ -395,7 +407,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions renderRows(groupBlockers(divergences), YELLOW); }; // The performance warning: its message names the functions. - const performance = input.performance ?? []; + const performance = (input.performance ?? []).filter((d) => !isDynamicSlotCode(d.code)); const renderPerformance = () => { for (const d of performance) { out.push(""); @@ -529,6 +541,7 @@ export type CoverageFailOn = | "blockers" | "divergences" | "dynamic" + | "dynamic-slots" | "node-islands" | "eager-node-islands" | "island-gaps"; @@ -537,6 +550,7 @@ export const COVERAGE_FAIL_ON: readonly CoverageFailOn[] = [ "blockers", "divergences", "dynamic", + "dynamic-slots", "node-islands", "eager-node-islands", "island-gaps", @@ -552,7 +566,9 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b // runtime fences build (the statement throws only if it executes). if (input.diagnostics.length > 0) return false; if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false; - if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false; + if (failOn === "dynamic" && (input.performance ?? []).some((d) => !isDynamicSlotCode(d.code))) + return false; + if (failOn === "dynamic-slots" && (input.dynamicSlots?.length ?? 0) > 0) return false; // The Node island gates (report-only islands count: they are what a // build would choose). Every island evaluates eagerly until deferred // evaluation lands, so eager-node-islands equals node-islands for now. @@ -624,6 +640,19 @@ export function coverageEnvelope( percentStatic: total === 0 ? 100 : Math.floor((ok / total) * 100), }, }), + ...(input.dynamicSlots !== undefined + ? { + dynamicSlots: input.dynamicSlots.map((slot) => ({ + file: slot.loc.file, + ...locator.position(slot.loc.file, slot.loc.start), + name: slot.name, + kind: slot.slot, + declaredType: slot.declaredType, + code: slot.code, + reason: slot.reason, + })), + } + : {}), ...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}), ...(input.wasmModules !== undefined ? { wasmModules: input.wasmModules } : {}), ...(input.nodeIslands !== undefined @@ -719,6 +748,26 @@ function unitMatches(u: DemotionUnit, query: string): boolean { ); } +const DYNAMIC_SLOTS_SHOWN = 20; + +/** The dynamic slots under the statement counts: each slot's declared + * type, and why it has no static lowering. */ +function renderDynamicSlots( + slots: readonly DynamicSlotEntry[], + out: string[], + c: (code: string, s: string) => string, + where: (loc: SrcLoc) => string, +): void { + if (slots.length === 0) return; + 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)) + out.push(` ${dynamicSlotLabel(slot)} ${c(DIM, where(slot.loc))}`); + if (slots.length > DYNAMIC_SLOTS_SHOWN) + out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`); +} + const SLOTS_SHOWN = 40; /** The localized-failure section of --explain-dynamic (report-only): the diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts index c92cdc45e..dfaaa5c98 100644 --- a/packages/compiler/src/coverage/verdict-cache.ts +++ b/packages/compiler/src/coverage/verdict-cache.ts @@ -7,6 +7,7 @@ * still hold. With a semantic matcher (the Node frontend's), edits that * only touch TypeScript comments also reuse it, with every reported * location moved to the edited text. */ +import type { DynamicSlotEntry } from "../diagnostics/dynamic-slots.js"; import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -23,7 +24,7 @@ import { type FrontendInputSnapshot, } from "../frontend/input-tracker.js"; -const SCHEMA = "scriptc.coverage-verdict.v3"; +const SCHEMA = "scriptc.coverage-verdict.v4"; /** Key-value storage for verdict entries (best effort: failures miss). */ export interface VerdictStore { @@ -41,6 +42,7 @@ export interface CachedCoverage { unreached?: { stats: LowerStats; diagnostics: ScrDiagnostic[]; runtimeFences?: ScrDiagnostic[] }; divergences?: ScrDiagnostic[]; performance?: ScrDiagnostic[]; + dynamicSlots?: DynamicSlotEntry[]; npmStatic?: NpmStaticStatus[]; wasmModules?: WasmModuleReport[]; nodeIslands?: NodeIslandReport; @@ -158,6 +160,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null { if (coverage.unreached !== undefined) out.unreached = coverage.unreached; if (coverage.divergences !== undefined) out.divergences = coverage.divergences; if (coverage.performance !== undefined) out.performance = coverage.performance; + if (coverage.dynamicSlots !== undefined) out.dynamicSlots = coverage.dynamicSlots; if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic; if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules; if (coverage.nodeIslands !== undefined) out.nodeIslands = coverage.nodeIslands; @@ -177,6 +180,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult if (cached.unreached !== undefined) coverage.unreached = cached.unreached; if (cached.divergences !== undefined) coverage.divergences = cached.divergences; if (cached.performance !== undefined) coverage.performance = cached.performance; + if (cached.dynamicSlots !== undefined) coverage.dynamicSlots = cached.dynamicSlots; if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic; if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules; if (cached.nodeIslands !== undefined) coverage.nodeIslands = cached.nodeIslands; diff --git a/packages/compiler/src/diagnostics/dynamic-slots.ts b/packages/compiler/src/diagnostics/dynamic-slots.ts new file mode 100644 index 000000000..09b4cd670 --- /dev/null +++ b/packages/compiler/src/diagnostics/dynamic-slots.ts @@ -0,0 +1,49 @@ +/* SC6104, the dynamic-slot performance warning (localized-failure design + * §3.9): a slot whose declared TypeScript type has no static lowering holds + * a checked-dynamic value instead of rejecting the declaration around it. + * Its wording lives here; the slots come from the lowering's commit points + * (Lowerer.degradeAtCommit). Imports nothing heavy, like dynamic-tier.ts. */ +import type { SrcLoc } from "../ir/ir.js"; +import type { ScrDiagnostic, TypeFailureSlot } from "./diagnostic.js"; + +export const DYNAMIC_SLOT_CODE = "SC6104"; + +/** One dynamic slot, as coverage reports it. */ +export interface DynamicSlotEntry { + loc: SrcLoc; + name: string; + slot: TypeFailureSlot; + declaredType: string; + /** The diagnostic the slot's declaration would have failed with. */ + code: string; + reason: string; +} + +const SLOT_WORD: Record = { + field: "field", + param: "parameter", + return: "return value of", + local: "local", + global: "module global", + loop: "loop variable", + member: "member", + expression: "expression", +}; + +/** `'_customTools' (declared 'ToolDefinition[]')`, with the slot kind. */ +export function dynamicSlotLabel(entry: DynamicSlotEntry): string { + return `${SLOT_WORD[entry.slot]} '${entry.name}' (declared '${entry.declaredType}')`; +} + +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`, + 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", + }; +} + +export function isDynamicSlotCode(code: string): boolean { + return code === DYNAMIC_SLOT_CODE; +} diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index e64f35824..f6f8b9d3d 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -164,6 +164,15 @@ export interface DiagnosticsEnvelope { }; /** build only: the produced artifact path. */ artifact?: string; + /** coverage only, when there are any: the checked dynamic slots + * (localized failure, SC6104). */ + dynamicSlots?: (SourcePosition & { + name: string; + kind: string; + declaredType: string; + code: string; + reason: string; + })[]; /** coverage --explain-dynamic only: the report-only tier assignment. */ tiers?: TiersEnvelope; /** coverage only: the WebAssembly modules the build can see, with their diff --git a/packages/compiler/src/frontend/analysis.ts b/packages/compiler/src/frontend/analysis.ts index 848306b66..d2e881473 100644 --- a/packages/compiler/src/frontend/analysis.ts +++ b/packages/compiler/src/frontend/analysis.ts @@ -132,6 +132,7 @@ export function analyzeWithFrontend( ...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}), ...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}), ...(lowered.performance !== undefined ? { performance: lowered.performance } : {}), + ...(lowered.dynamicSlots !== undefined ? { dynamicSlots: lowered.dynamicSlots } : {}), ...(lowered.unreached ? { unreached: lowered.unreached } : {}), ...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}), // --provenance-sources: the per-package attribution inputs (the diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 3f4a70c74..dd0467bd0 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,3 +1,4 @@ +import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { THREADS_MODULE } from "../threads-import.js"; import { positionTag } from "./structural-positions.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; @@ -2048,7 +2049,15 @@ export function collectGlobals( // Destructuring declarations register EVERY bound identifier (the // desugar in the init function assigns the pre-registered globals, // exactly like plain declarations). - for (const nameNode of boundIdentifiersOf(decl.name)) { + const names = boundIdentifiersOf(decl.name); + // Localized failure: a degradable type failure at a module global + // degrades that type and the same name registers again + // (globalSlotRetry), at most MAX_GLOBAL_RETRIES times. + let retries = 0; + let retriedIndex = -1; + for (let nameIndex = 0; nameIndex < names.length; nameIndex++) { + const nameNode = names[nameIndex]!; + if (nameIndex !== retriedIndex) retries = 0; const diagsBefore = lowerer.diags.length; try { // Checked JavaScript factories can expose inferred callables by @@ -2700,6 +2709,12 @@ export function collectGlobals( } } catch (e) { if (!(e instanceof PoisonError)) throw e; + if (retries < MAX_GLOBAL_RETRIES && globalSlotRetry(lowerer, sf, e)) { + retries++; + retriedIndex = nameIndex; + nameIndex--; + continue; + } // diagnostic already recorded; lowering the statement poisons // too — and in a JS file BOTH defer to the statement's // runtime fence (deferJsCollectionDiags). @@ -2710,6 +2725,20 @@ export function collectGlobals( } } +/** Localized failure's commit point for module globals (design §3.3): + * collectGlobals commits a global's type failure directly, before any body + * lowers, so it degrades here and the registration runs again. */ +function globalSlotRetry(lowerer: Lowerer, sf: ts.SourceFile, e: PoisonError): boolean { + if (isJsSourceFile(sf)) return false; + const slot = lowerer.degradeAtCommit(e, GLOBAL_SLOTS); + if (slot === null) return false; + lowerer.noteDynamicSlot(slot); + return true; +} + +const MAX_GLOBAL_RETRIES = 16; +const GLOBAL_SLOTS: ReadonlySet = new Set(["global"]); + /** Registers the entry-init note for a `var` module global: JS hoists * module vars to `undefined` at module entry, so an undefined-armed slot * must hold the interned undefined arm BEFORE the body runs (a function diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 4cf3a342c..7d5273e07 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -40,8 +40,8 @@ import { isJsonRequireDecl } from "./json-require.js"; import { isTimeValueDateDeclaration } from "./date-values.js"; import { bindingSource } from "../binding-source.js"; import { lowerConstructorReturn } from "./constructor-returns.js"; -import type { Lowerer } from "./lowerer.js"; -import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; +import type { DynamicSlot, Lowerer } from "./lowerer.js"; +import type { ScrDiagnostic, TypeFailureSlot } from "../../diagnostics/diagnostic.js"; import { statementGroupKey } from "./type-failure.js"; import { cjsExportValueByName, @@ -754,57 +754,28 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir const sinkMark = lowerer.diagSinkMark(); const touched = new Set(); lowerer.failureTouches.push(touched); + const pendingSlots: DynamicSlot[] = []; try { - let lowered: IrStmt | IrStmt[] | null; - let appended = false; - if ( - ts.isVariableStatement(stmt) && - (stmt.declarationList.flags & ts.NodeFlags.Using) !== 0 - ) { - const registrations = lowerUsingStatement(lowerer, stmt); - const accounting: IrStmt[] = []; - for (const registration of registrations) { - out.push(...registration.acquire); - accounting.push(...registration.acquire, ...registration.cleanup); - if (registration.cleanup.length > 0) { - usingMarkers.push({ - start: out.length, - cleanup: registration.cleanup, - loc: registration.loc, - }); - } + for (let attempt = 0; ; attempt++) { + try { + lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences); + break; + } catch (e) { + // Localized failure's statement commit point (design §3.3): a + // degradable type failure at a local, loop variable or module + // global degrades that type and the statement lowers again. + const slot = + attempt < MAX_DEGRADE_RETRIES && !deferFences + ? lowerer.degradeAtCommit(e, STATEMENT_SLOTS) + : null; + if (slot === null) 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. + pendingSlots.push(slot); } - lowered = accounting; - appended = true; - } else { - lowered = lowerer.lowerStmt(stmt); - } - // The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic - // arguments that reached builtin-call slots without the checked - // coercion get their dynCheck here, and the uncoercible ones - // fence — inside this statement's poison window, so a JS fence - // becomes the statement's runtimeFence exactly like every other - // deferred rejection. - if (lowered) { - const body: IrStmt[] = []; - if (Array.isArray(lowered)) body.push(...lowered); - else body.push(lowered); - const rewritten = lowerSurplusCalls(lowerer, body); - enforceLibBoundary(lowerer, rewritten); - if (!appended) out.push(...rewritten); - } - // A JS statement that lowered CLEAN can still have flushed - // DEFERRED collection diagnostics (a symbol lookup on an alias - // resolves through flushDeferred — informational here, not a - // blocker of THIS statement): those belong to the runtime-fence - // ledger, not the build — real uses of the broken declaration - // meet their own per-site fences. - if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) { - const flushed = lowerer.diags.splice(diagsBefore); - const ice = flushed.filter((d) => d.code === "SC9001"); - lowerer.diags.push(...ice); - lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001")); } + for (const slot of pendingSlots) lowerer.noteDynamicSlot(slot); } catch (e) { // A Go panic inside tsgo, surfaced by the sync channel as a // thrown Error (server intact — the prefetch fence's finding): @@ -878,6 +849,75 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir return out; } +/** Localized failure: retries a degradable statement-level type failure + * at most this many times (each retry degrades one more type). */ +const MAX_DEGRADE_RETRIES = 16; + +/** The slot kinds lowerStmts' commit point degrades (stage L1). */ +const STATEMENT_SLOTS: ReadonlySet = new Set([ + "local", + "global", + "loop", +]); + +/** One attempt at lowering a statement of lowerStmts into `out`. */ +function lowerOneStatement( + lowerer: Lowerer, + stmt: ts.Statement, + out: IrStmt[], + usingMarkers: UsingMarker[], + diagsBefore: number, + deferFences: boolean, +): void { + let lowered: IrStmt | IrStmt[] | null; + let appended = false; + if (ts.isVariableStatement(stmt) && (stmt.declarationList.flags & ts.NodeFlags.Using) !== 0) { + const registrations = lowerUsingStatement(lowerer, stmt); + const accounting: IrStmt[] = []; + for (const registration of registrations) { + out.push(...registration.acquire); + accounting.push(...registration.acquire, ...registration.cleanup); + if (registration.cleanup.length > 0) { + usingMarkers.push({ + start: out.length, + cleanup: registration.cleanup, + loc: registration.loc, + }); + } + } + lowered = accounting; + appended = true; + } else { + lowered = lowerer.lowerStmt(stmt); + } + // The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic + // arguments that reached builtin-call slots without the checked + // coercion get their dynCheck here, and the uncoercible ones + // fence — inside this statement's poison window, so a JS fence + // becomes the statement's runtimeFence exactly like every other + // deferred rejection. + if (lowered) { + const body: IrStmt[] = []; + if (Array.isArray(lowered)) body.push(...lowered); + else body.push(lowered); + const rewritten = lowerSurplusCalls(lowerer, body); + enforceLibBoundary(lowerer, rewritten); + if (!appended) out.push(...rewritten); + } + // A JS statement that lowered CLEAN can still have flushed + // DEFERRED collection diagnostics (a symbol lookup on an alias + // resolves through flushDeferred — informational here, not a + // blocker of THIS statement): those belong to the runtime-fence + // ledger, not the build — real uses of the broken declaration + // meet their own per-site fences. + if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) { + const flushed = lowerer.diags.splice(diagsBefore); + const ice = flushed.filter((d) => d.code === "SC9001"); + lowerer.diags.push(...ice); + lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001")); + } +} + /** Whether a statement list belongs to a function body (at any block * depth) rather than to a module's top level. */ function enclosedByFunction(stmt: ts.Statement): boolean { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 65b66c298..6b1174d68 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -208,8 +208,10 @@ import type { ScrDiagnostic, TypeFailure, TypeFailureFamily, + TypeFailureSlot, } from "../../diagnostics/diagnostic.js"; import { declarationGroupKey, typeFailureOf } from "./type-failure.js"; +import { dynamicSlotDiag, type DynamicSlotEntry } from "../../diagnostics/dynamic-slots.js"; import { unsupportedAnyOperationDiag, blockedBindingUseDiag, @@ -718,14 +720,27 @@ interface GenericDemandOwner { classDemands: ClassInfo[]; } +/** A slot the localized-failure commit points turned into a checked + * dynamic slot (SC6104): where it is, what its declared type is, and the + * diagnostic it would have failed with. */ +export interface DynamicSlot { + loc: SrcLoc; + failure: TypeFailure; + code: string; + reason: string; +} + export class PoisonError extends Error { /** Set when a type with no static lowering poisoned a slot (badType and * the dyn class field policy): what failed, for the localized-failure * commit points. Report-only in stage L0. */ readonly failure: TypeFailure | undefined; - constructor(failure?: TypeFailure) { + /** The checker type that failed (with `failure`). */ + readonly failedType: ts.Type | undefined; + constructor(failure?: TypeFailure, failedType?: ts.Type) { super(); this.failure = failure; + this.failedType = failedType; } } @@ -842,6 +857,9 @@ export interface LowerResult { /** Performance warnings (SC61xx): the likely-hot functions the source * leaves untyped (lowering/dynamic-tier.ts). Never failures. */ performance?: ScrDiagnostic[]; + /** Localized failure: the checked dynamic slots the commit points + * created (each also an SC6104 in `performance`), present when any. */ + dynamicSlots?: DynamicSlotEntry[]; /** Functions lowering placed in the dynamic tier (ir/tiers.ts), present * when there are any. */ dynamicFunctions?: DynamicFunction[]; @@ -1257,6 +1275,22 @@ export function lowerToIr( timing("dynamic-tier-scan", { found: hotDynamic === null ? 0 : 1 }); if (hotDynamic !== null) result = { ...result, performance: [hotDynamic] }; } + // Localized failure (SC6104): one performance warning per dynamic slot. + if (resultLowerer.dynamicSlots.length > 0) { + const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot) => ({ + loc: slot.loc, + name: slot.failure.name, + slot: slot.failure.slot, + declaredType: slot.failure.declaredType, + code: slot.code, + reason: slot.reason, + })); + result = { + ...result, + performance: [...(result.performance ?? []), ...dynamicSlots.map(dynamicSlotDiag)], + dynamicSlots, + }; + } // Contract types (@scriptc/types): written annotations, checked against // the IR by the compile pipeline (contracts/check.ts). // Acyclic object types name the IR unit the program lowered them to; a @@ -3720,6 +3754,13 @@ export class Lowerer { * its lowering touched. A statement that then poisons attributes its * diagnostics to them (ScrDiagnostic.cause). */ readonly failureTouches: Set[] = []; + /** Localized failure: checker types (by id) whose strict mapping failed + * 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(); + /** The dynamic slots the commit points created: one SC6104 each. */ + readonly dynamicSlots: DynamicSlot[] = []; /** True while collectProgram runs: resolution helpers must not flush * deferred diagnostics (collection itself resolves symbols — extends * clauses — and collection order must not decide what reports). */ @@ -3837,6 +3878,10 @@ export class Lowerer { resolveTypeParam: this.typeParamResolver, resolveTypeParamTs: this.typeParamTsResolver, resolveBoundAliasTs: this.boundAliasTsResolver, + degradedType: (type) => { + const id = (type as { id?: number }).id; + return id === undefined ? null : (this.degradedTypes.get(id)?.lenient ?? null); + }, isErasing: () => this.erasingDepth > 0, genericClassInstance: (decl, ref, mapArgument) => this.genericClassInstanceType(decl, ref, mapArgument), @@ -9270,7 +9315,65 @@ export class Lowerer { const failure = typeFailureOf(node, this.checker.typeToString(type), family); diag.typeFailure = failure; this.pushDiag(diag); - throw new PoisonError(failure); + throw new PoisonError(failure, type); + } + + /** Localized failure's commit point (design §3.3): `e` poisoned a unit + * whose failure is about to enter the build. When it is a degradable type + * failure at a slot kind in `slots`, the failing type joins + * degradedTypes (its lenient mapping is the checked dynamic, whole slot), + * the failure's own diagnostic leaves the build, and the caller lowers + * the unit again. Returns the slot to record once that retry succeeds, + * or null to commit as today. Never inside a capture buffer (a probe or a + * deferred collection) and never under --tier=static-only. */ + degradeAtCommit(e: unknown, slots: ReadonlySet): DynamicSlot | null { + if (this.tierMode !== "auto" || this.diagSink !== null) return null; + if (!(e instanceof PoisonError)) return null; + const failure = e.failure; + const type = e.failedType; + if (failure === undefined || type === undefined) return null; + 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; + let committed: ScrDiagnostic | undefined; + for (let i = this.diags.length - 1; i >= 0; i--) { + if (this.diags[i]!.typeFailure !== failure) continue; + committed = this.diags[i]; + 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 }; + } + + /** A declaration slot whose type maps through degradedTypes is a + * dynamic slot too, though only the first such slot failed: record it + * (outside probes; the lookup is skipped while nothing is degraded). */ + 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; + const failure = typeFailureOf(node, this.checker.typeToString(type), "unsupported"); + if (failure.slot === "expression" || failure.slot === "member") return; + this.noteDynamicSlot({ + loc: locOf(node), + failure, + code: degraded.code, + reason: degraded.reason, + }); + } + + /** Records a dynamic slot whose unit lowered with it (one per site). */ + noteDynamicSlot(slot: DynamicSlot): void { + const seen = this.dynamicSlots.some( + (s) => + s.loc.file === slot.loc.file && + s.loc.start === slot.loc.start && + s.loc.end === slot.loc.end, + ); + if (!seen) this.dynamicSlots.push(slot); } /** The SC1090 refusal of checked-dynamic class fields in TypeScript @@ -9285,7 +9388,7 @@ export class Lowerer { ); diag.typeFailure = failure; this.pushDiag(diag); - throw new PoisonError(failure); + throw new PoisonError(failure, this.typeOf(node)); } badType(node: ts.Node, type: ts.Type): never { @@ -9969,6 +10072,7 @@ export class Lowerer { // f64 — but must not: pre-empt the mapping so the JS fallback below // answers instead. const mapped = neverTaintedJsType(this, node, t) ? null : this.mapTypeOf(t); + if (mapped !== null && this.degradedTypes.size > 0) this.noteDegradedSlot(node, t); if (!mapped) { const argumentsType = jsArgumentsFunctionType(this, t); if (argumentsType !== null) return argumentsType; @@ -12686,6 +12790,8 @@ 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)); 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 4b1bf2a42..d4d0ab42c 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1282,6 +1282,11 @@ export interface TypeMapperCtx { * checker type its signature instantiates, or null. Consulted only when * the symbolic form does not map. */ resolveBoundAliasTs?: TypeParamTsResolver; + /** Localized failure: the lenient mapping of a type whose strict mapping + * failed at a committed slot (Lowerer.degradedTypes), or null. Consulted + * 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; /** 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 @@ -1856,6 +1861,16 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (mapped !== null) contextResolutions++; } } + // Localized failure: a type that failed at a committed slot maps to its + // lenient form. It counts as a context resolution so neither it nor a + // type containing it is memoized: the strict form may map elsewhere. + if (mapped === null && ctx.degradedType) { + const lenient = ctx.degradedType(type); + if (lenient !== null) { + mapped = lenient; + contextResolutions++; + } + } if ( mapped !== null && memoKey !== null && diff --git a/tests/corpus/localized-failure-held-values.ts b/tests/corpus/localized-failure-held-values.ts new file mode 100644 index 000000000..ee52498c1 --- /dev/null +++ b/tests/corpus/localized-failure-held-values.ts @@ -0,0 +1,70 @@ +// Localized failure, stage L1: values of every kind held in a local or +// module global whose declared type has no static lowering keep their +// identity and behavior. +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + norm(): number { + return Math.abs(this.x) + Math.abs(this.y); + } +} +interface Cfg { + name: string; + depth: number; +} +type Anything = + | Point + | Cfg + | number[] + | Map string> + | AbortSignal + | (() => number) + | Promise; + +const p = new Point(3, -4); +const cfg: Cfg = { name: "a", depth: 1 }; +const xs = [1, 2, 3]; +let counter = 0; +const tick = (): number => ++counter; + +// A module global holding a class instance. +let held: Anything = p; +if (held instanceof Point) { + console.log("point", held.norm(), held === p); + held.x = 10; +} +console.log("typed alias", p.x, p.norm()); + +// A record: mutation through the hole is visible in the typed alias. +held = cfg; +if (!(held instanceof Point) && !Array.isArray(held) && typeof held === "object" && "depth" in held) { + held.depth = 5; + console.log("record", held.name, held === cfg); +} +console.log("typed alias", cfg.depth, JSON.stringify(cfg), Object.keys(cfg).join(",")); + +// An array: pushes through the hole show in typed code. +held = xs; +if (Array.isArray(held)) { + held.push(4); + console.log("array", held.length, held === xs); +} +console.log("typed alias", xs.length, xs.join("+")); + +function main(): void { + // A local holding a closure, called through the hole. + let local: Anything = tick; + if (typeof local === "function") { + console.log("closure", local(), local(), counter, local === tick); + } + // A promise, awaited through the hole. + local = Promise.resolve(42); + if (local instanceof Promise) { + local.then((v) => console.log("promise", v)); + } +} +main(); diff --git a/tests/corpus/localized-failure-locals.ts b/tests/corpus/localized-failure-locals.ts new file mode 100644 index 000000000..418b86d87 --- /dev/null +++ b/tests/corpus/localized-failure-locals.ts @@ -0,0 +1,43 @@ +// Localized failure, stage L1: locals, loop variables and module globals +// whose declared type has no static lowering hold checked-dynamic values. +type Progress = (done: number, total: number) => void; +type Callbacks = Progress | AbortSignal | Map string> | Set; + +const report: Progress = (done, total) => console.log(`progress ${done}/${total}`); +const seen = new Set([1, 2, 3]); + +// A module global, written with typed values of several arms. +let current: Callbacks | undefined = report; + +function describe(label: string): void { + // A local of the same declared type. + const local: Callbacks | undefined = current; + if (local === undefined) { + console.log(label, "none"); + } else if (typeof local === "function") { + local(1, 2); + console.log(label, "function", local === report); + } else if (local instanceof Set) { + console.log(label, "set", local.size, local.has(2), local === seen); + local.add(4); + } else { + console.log(label, "other"); + } +} + +describe("first"); +current = seen; +describe("second"); +console.log("typed alias sees the write", seen.size, seen.has(4)); +current = undefined; +describe("third"); + +// A loop variable over an array whose element type does not compile. +const all: (Callbacks | number)[] = [report, seen, 7]; +for (const item of all) { + if (typeof item === "number") console.log("number", item + 1); + else if (typeof item === "function") item(3, 4); + else console.log("object", item instanceof Set); +} +all.push(9); +console.log(all.length); diff --git a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt index ed727b604..ab79ac05e 100644 --- a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt +++ b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt @@ -1,18 +1,16 @@ dynamic tier (tier assignment, report-only; --tier=auto) 0 functions already operate on dynamic values (0 statements) - 11 demotion requests (refusals and fences the dynamic tier would replace) + 9 demotion requests (refusals and fences the dynamic tier would replace) - 4 would-be dynamic slots (localized failure, report-only: type failures a checked dynamic slot would hold) - 11 errors: 9 clear with declaration slots (L1-L2), 9 with member and expression slots too (L3-L4); 6 cascades attributed; 1 type failure stays refused + 3 would-be dynamic slots (localized failure, report-only: type failures a checked dynamic slot would hold) + 9 errors: 7 clear with declaration slots (L1-L2), 7 with member and expression slots too (L3-L4); 5 cascades attributed; 1 type failure stays refused L2 field tools: Tool[] tests/coverage-fixtures/localized-failure.ts:13:11 (SC2009 component, clears 2) rejects class Session L2 param progress: Callbacks | undefined tests/coverage-fixtures/localized-failure.ts:23:31 (SC2009 component, clears 1) rejects function listAll L2 field state: any tests/coverage-fixtures/localized-failure.ts:29:3 (SC1090 dyn-field-policy, clears 1) rejects class Holder - L1 local callbacks: Callbacks | undefined tests/coverage-fixtures/localized-failure.ts:37:9 (SC2009 component, clears 1) - rejects statement class Session tests/coverage-fixtures/localized-failure.ts:12:1 (1 request) <- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:49:11: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet -> function turns dynamic @@ -35,15 +33,9 @@ function listAll tests/coverage-fixtures/localized-failure.ts:23:1 (1 request, 1 site inside) <- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:51:13: uses of 'listAll' inherit the blocker on its declaration -> function turns dynamic - function pick tests/coverage-fixtures/localized-failure.ts:36:1 (2 sites inside) binding progress in listAll tests/coverage-fixtures/localized-failure.ts:23:31 (1 request) <- SC2009 type at tests/coverage-fixtures/localized-failure.ts:23:31: values of type 'Callbacks | undefined' cannot be compiled: the union shape is supported, and unions in the 'string | object' family ride the checked-dynamic representation wholesale, but 'Map function listAll turns dynamic - binding callbacks in pick tests/coverage-fixtures/localized-failure.ts:37:9 (2 requests) - <- SC2009 type at tests/coverage-fixtures/localized-failure.ts:37:9: values of type 'Callbacks | undefined' cannot be compiled: the union shape is supported, and unions in the 'string | object' family ride the checked-dynamic representation wholesale, but 'Map function pick turns dynamic - <- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:38:10: uses of 'callbacks' inherit the blocker on its declaration - -> function pick turns dynamic binding s tests/coverage-fixtures/localized-failure.ts:49:7 (1 request) <- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:50:13: uses of 's' inherit the blocker on its declaration -> function turns dynamic \ No newline at end of file diff --git a/tests/harness/corpus-costs/localized-failure-held-values.ts.json b/tests/harness/corpus-costs/localized-failure-held-values.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-held-values.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/corpus-costs/localized-failure-locals.ts.json b/tests/harness/corpus-costs/localized-failure-locals.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-locals.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 3f9a60bce..df99a4ece 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -431,8 +431,11 @@ test("--explain-dynamic attributes each refusal to the unit the dynamic tier wou test("--explain-dynamic lists the type failures localized failure would turn into dynamic slots", async () => { const { coverage } = analyze(fixture("localized-failure.ts"), { explainDynamic: true }); const report = coverage.tiers!.localizedFailure; - expect(report.errors).toBe(11); - expect(report.typeFailures).toEqual({ degradable: 4, refused: 1 }); + // The local already degrades (stage L1); fields and parameters are + // listed as would-be slots with the uses each one rejects. + expect(coverage.dynamicSlots?.map((s) => [s.slot, s.name])).toEqual([["local", "callbacks"]]); + expect(report.errors).toBe(9); + expect(report.typeFailures).toEqual({ degradable: 3, refused: 1 }); expect( report.slots.map((s) => [ s.stage, @@ -446,11 +449,10 @@ test("--explain-dynamic lists the type failures localized failure would turn int ["L2", "field", "tools", "component", "class Session", 2], ["L2", "param", "progress", "component", "function listAll", 1], ["L2", "field", "state", "dyn-field-policy", "class Holder", 1], - ["L1", "local", "callbacks", "component", "statement", 1], ]); // The WeakRef field stays refused, so its class and the construction // that uses it stay errors. - expect(report.clearedThroughL2).toBe(9); + expect(report.clearedThroughL2).toBe(7); expect(report.remainingByCode).toEqual({ SC1090: 1, SC2020: 1 }); const relative = JSON.parse( JSON.stringify(coverage.tiers).replaceAll(repoRoot + "/", ""), @@ -460,6 +462,39 @@ test("--explain-dynamic lists the type failures localized failure would turn int ); }); +test("a local or module global whose declared type does not compile becomes a dynamic slot", async () => { + const program = join(repoRoot, "tests/corpus/localized-failure-locals.ts"); + const { coverage, sourceTexts } = analyze(program); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.dynamicSlots?.map((s) => [s.slot, s.name, s.declaredType, s.code])).toEqual([ + ["global", "current", "Callbacks | undefined", "SC2009"], + ["global", "all", "(number | Callbacks)[]", "SC2009"], + ["local", "local", "Callbacks | undefined", "SC2009"], + ]); + expect(coverage.performance?.filter((d) => d.code === "SC6104")).toHaveLength(3); + expect(coveragePasses(coverage, "blockers")).toBe(true); + expect(coveragePasses(coverage, "dynamic")).toBe(true); + expect(coveragePasses(coverage, "dynamic-slots")).toBe(false); + const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts }); + expect(envelope.dynamicSlots?.map((s) => [s.kind, s.name, s.line])).toEqual([ + ["global", "current", 10], + ["global", "all", 36], + ["local", "local", 14], + ]); + expect(envelope.diagnostics.filter((d) => d.code === "SC6104").map((d) => d.severity)).toEqual([ + "warning", + "warning", + "warning", + ]); + const text = renderCoverage(coverage, { sourceTexts, root: repoRoot }); + expect(text).toContain("dynamic slots 3"); + expect(text).toContain("module global 'current' (declared 'Callbacks | undefined')"); + // --tier=static-only keeps every refusal. + const strict = analyze(program, { tier: "static-only" }).coverage; + expect(strict.dynamicSlots).toBeUndefined(); + expect(strict.diagnostics.map((d) => d.code)).toContain("SC2009"); +}); + test("CLI: --tier and --explain-dynamic", () => { const scriptcCli = join(repoRoot, "packages/cli/src/main.ts"); const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-")); From 6a2c9eca213758fc36bf23663e0b017682fb5749 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 16:44:06 -0700 Subject: [PATCH 2/9] Record order-parity baselines for the localized-failure corpus programs Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/localized-failure-held-values.ts.json | 6 ++++++ .../tests/corpus/localized-failure-locals.ts.json | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-held-values.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-locals.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-held-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-held-values.ts.json new file mode 100644 index 000000000..2327ce301 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-held-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-held-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-locals.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-locals.ts.json new file mode 100644 index 000000000..9a45d0613 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-locals.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-locals.ts" + ], + "diags": [] +} From cd5607493e80c361eeeace337a40311da15fa19f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 18:57:21 -0700 Subject: [PATCH 3/9] Pin the diagnostics corpus to --tier=static-only Its programs pin today's refusals; localized failure turns several of them into checked dynamic slots in the default mode. Under static-only every snapshot stays byte-identical, which is the stage's proof that the switch restores the refusals. Co-Authored-By: Claude Opus 5.5 --- tests/harness/diagnostics.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/harness/diagnostics.test.ts b/tests/harness/diagnostics.test.ts index 77fd5937d..895ed9647 100644 --- a/tests/harness/diagnostics.test.ts +++ b/tests/harness/diagnostics.test.ts @@ -39,7 +39,14 @@ describe(`diagnostics corpus (${files.length} programs)`, () => { if (name.startsWith("checker-panic-")) process.env["SCRIPTC_CHECKER"] = "tsgo"; let result: Awaited>; try { - result = await compile(file, { outPath: join(outDir, "never"), outDir }); + // --tier=static-only: these programs pin today's refusals, which + // localized failure otherwise turns into checked dynamic slots + // (the snapshots double as its proof that static-only keeps them). + result = await compile(file, { + outPath: join(outDir, "never"), + outDir, + tier: "static-only", + }); } finally { if (checker === undefined) delete process.env["SCRIPTC_CHECKER"]; else process.env["SCRIPTC_CHECKER"] = checker; From 8b0779032551ce75f8b0ee99cc96d7434c411210 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 16:53:13 -0700 Subject: [PATCH 4/9] Register classes and functions with dynamic slots when a reference flushes their type failures Stage L2 of the localized-failure design. A class whose field (or parameter property) type has no static lowering, or a function or method whose parameter or return type has none, used to be rejected, and every use of it failed on its own line. Its collection deferred the diagnostics until a reachable reference flushed them into the build. That flush is now a commit point: when every deferred diagnostic is a degradable type failure at one of the declaration's slots, the failing types degrade and the collection runs again, up to 16 rounds. The declaration registers with checked dynamic slots (one SC6104 each), the reference proceeds, and emitReachable (or run) lowers the bodies it had skipped. A derived class whose base registers this way retries with it. The 'any'-typed class field refusal is lifted: a TypeScript class field whose type maps to the checked dynamic stores it, as JavaScript classes already do. A program that compiles today flushes no deferred diagnostics into its build, so nothing degrades and its output is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/diagnostics/diagnostic.ts | 3 +- .../lowering/builtins/disposable-stack.ts | 3 +- .../lowering/expressions/identifiers.ts | 8 +- .../lowering/expressions/property-access.ts | 12 +- .../lowering/expressions/relational.ts | 9 +- .../src/frontend/lowering/lower-calls.ts | 11 +- .../src/frontend/lowering/lower-classes.ts | 64 ++---- .../src/frontend/lowering/lower-exprs.ts | 8 +- .../src/frontend/lowering/lower-modules.ts | 12 +- .../src/frontend/lowering/lower-stmts.ts | 8 +- .../compiler/src/frontend/lowering/lowerer.ts | 210 +++++++++++++++--- .../src/frontend/lowering/type-failure.ts | 1 - tests/corpus/localized-failure-cycles.ts | 29 +++ .../corpus/localized-failure-declarations.ts | 64 ++++++ tests/corpus/localized-failure-pi-shapes.ts | 37 +++ .../coverage-explain-localized-failure.txt | 41 +--- .../localized-failure-cycles.ts.json | 1 + .../localized-failure-declarations.ts.json | 1 + .../localized-failure-pi-shapes.ts.json | 1 + tests/harness/coverage.test.ts | 32 +-- 20 files changed, 388 insertions(+), 167 deletions(-) create mode 100644 tests/corpus/localized-failure-cycles.ts create mode 100644 tests/corpus/localized-failure-declarations.ts create mode 100644 tests/corpus/localized-failure-pi-shapes.ts create mode 100644 tests/harness/corpus-costs/localized-failure-cycles.ts.json create mode 100644 tests/harness/corpus-costs/localized-failure-declarations.ts.json create mode 100644 tests/harness/corpus-costs/localized-failure-pi-shapes.ts.json diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 534e03c07..44bd92744 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -153,8 +153,7 @@ export type TypeFailureFamily = | "component" | "stdlib-nominal" | "record-member" - | "unsupported" - | "dyn-field-policy"; + | "unsupported"; /** A type failure's structured payload: what the commit points of the * localized-failure design inspect (PoisonError.failure carries the same diff --git a/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts b/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts index 375c3f1e7..866358b06 100644 --- a/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts +++ b/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts @@ -73,8 +73,7 @@ export function lowerDisposableStackUseCall( if (valueT?.kind !== "object") return null; const loc = locOf(call); const className = valueT.className; - const info = lowerer.classes.get(className); - if (!info) lowerer.flushDeferredClass(className); + const info = lowerer.classes.get(className) ?? lowerer.flushDeferredClass(className); const found = info ? lowerer.findMethodOn(info, "sym:dispose") : null; if (!found) return null; if (found.sig.params.length !== 0) { diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 62b2c7960..8519c4173 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -318,10 +318,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // that does not exist. The blocked-binding cascade names the use; // without this the validator's registration check ICEs on the live // reference (`@this {T}` inference — signature 07). - const unregistered = lowerer.unregisteredClassOf(g.type, new Set()); + let unregistered = lowerer.unregisteredClassOf(g.type, new Set()); + // The class's own rejection is the root cause the cascade names + // (unless the flush registers it with dynamic slots). + if (unregistered !== null && lowerer.flushDeferredClass(unregistered) !== undefined) + unregistered = lowerer.unregisteredClassOf(g.type, new Set()); if (unregistered !== null) { - // The class's own rejection is the root cause the cascade names. - lowerer.flushDeferredClass(unregistered); lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc)); throw new PoisonError(); } diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 6466857a5..ae4eea8ad 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3644,13 +3644,13 @@ export function classFieldTarget( ) { return { container: "dynamic", obj: represented, field: fieldName, fieldType: DYN }; } - const info = lowerer.classes.get(receiverType.className); - if (!info) { - // A receiver typed as a class whose collection deferred: the - // deferred diagnostics are what explains the miss. + // A receiver typed as a class whose collection deferred: the deferred + // diagnostics are what explains the miss (unless the flush registers + // the class with dynamic slots, retryDeferred). + const info = + lowerer.classes.get(receiverType.className) ?? lowerer.flushDeferredClass(receiverType.className); - return null; - } + if (!info) return null; const fieldType = info.fields.get(fieldName); const lowerObjectReceiver = (): IrExpr => { let obj = represented; diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index b323162c0..89817e057 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -535,12 +535,13 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc }; } if (rhsClassval?.kind === "classval" && !caught) { - const targetInfo = lowerer.classes.get(rhsClassval.className); + const targetInfo = + lowerer.classes.get(rhsClassval.className) ?? + lowerer.flushDeferredClass(rhsClassval.className); if (!targetInfo) { // The type world names a class the lowering never registered - // (a fenced class expression, a deferred declaration): flush - // its diagnostics and poison the test site, never an ICE. - lowerer.flushDeferredClass(rhsClassval.className); + // (a fenced class expression, a deferred declaration): its + // diagnostics flushed above; poison the test site, never an ICE. lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 0a845b3bd..964cb296c 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -10229,8 +10229,8 @@ export function lowerObjectMethodCall( ? lowerer.coerceInto(access.expression, receiver, receiverIr) : receiver; }; - const info = lowerer.classes.get(receiverIr.className); - if (!info) lowerer.flushDeferredClass(receiverIr.className); + const info = + lowerer.classes.get(receiverIr.className) ?? lowerer.flushDeferredClass(receiverIr.className); const found = info ? lowerer.findMethodOn(info, access.name.text) : null; // The zlib stream members (flush/params/close/reset) are declared on // the zlib classes; their receivers are runtime Transform streams. @@ -10583,11 +10583,8 @@ function lowerUnionObjectMethodCall( found: NonNullable>; }[] = []; for (const arm of def.arms as (IrType & { kind: "object" })[]) { - const info = lowerer.classes.get(arm.className); - if (!info) { - lowerer.flushDeferredClass(arm.className); - return null; - } + const info = lowerer.classes.get(arm.className) ?? lowerer.flushDeferredClass(arm.className); + if (!info) return null; const found = lowerer.findMethodOn(info, method); if (!found) return null; if (found.sig.abstract === true && !lowerer.overrideBelow(info, method)) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 2c0145c34..f15e0dc86 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2297,11 +2297,17 @@ export function collectClassShapeInner( base = named; if (!base) { if (adoptCallableBase(t.expression)) continue; - lowerer.unsupported( + // A base whose own collection deferred can still register later + // (a flush retry with dynamic slots): the derived class retries + // with it (Lowerer.retryDeferred). + const diag = unsupportedDiag( "SC1090", - t, + locOf(t), `extending classes not declared in the program ('${t.expression.text}')`, ); + lowerer.retriableDiags.add(diag); + lowerer.pushDiag(diag); + throw new PoisonError(); } } @@ -2535,18 +2541,6 @@ export function collectClassShapeInner( if (initializerSymbol && (initializerSymbol.flags & ts.SymbolFlags.Enum) !== 0) continue; const type = lowerer.irTypeOf(member.name); if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); - if ( - type.kind === "dyn" && - !isJsSourceFile(ts.getSourceFileOfNode(member)) && - !lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) && - (member.type !== undefined || - (lowerer.typeOf(member.name).flags & ts.TypeFlags.Any) === 0) - ) { - lowerer.dynFieldPolicyFence( - member.name, - `${dynFieldTypeSpelling(lowerer, member.name)}-typed static fields`, - ); - } staticFields.push({ name: member.name.text, type, @@ -3050,19 +3044,9 @@ export function collectClassShapeInner( if (base && !fields.has(member.name.text) && lowerer.dynConvertible(type)) type = DYN; } if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); - // Bundled JS declares fields without annotations before assigning - // them in the constructor. Use the same native checked-dynamic - // storage as implicit constructor-assigned JS fields. - if ( - type.kind === "dyn" && - !isJsSourceFile(ts.getSourceFileOfNode(member)) && - !lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) - ) { - lowerer.dynFieldPolicyFence( - member.name, - `${dynFieldTypeSpelling(lowerer, member.name)}-typed class fields`, - ); - } + // A field whose type maps to the checked dynamic (`any`, an + // option bag, a degraded type) stores it natively, as JavaScript + // classes and constructor-assigned fields do. const undefinedInitializer = type.kind === "dyn" && !member.initializer ? locOf(member) : undefined; if (fields.has(member.name.text)) { @@ -3193,13 +3177,6 @@ export function collectClassShapeInner( const shape = lowerer.paramShape(p); const type = shape.bodyType ?? shape.type; if (type.kind === "void") lowerer.badType(p.name, lowerer.typeOf(p.name)); - // The class-field dyn rule verbatim (KEEP NARROW). - if (type.kind === "dyn" && !lowerer.classFieldStoresDyn(lowerer.typeOf(p))) { - lowerer.dynFieldPolicyFence( - p.name, - `${dynFieldTypeSpelling(lowerer, p)}-typed class fields`, - ); - } // `override x` (and any same-named inherited member) would // redeclare a base slot — the declared-field rule verbatim. if (fields.has(name)) { @@ -8942,14 +8919,6 @@ export function throwingSetterFn(lowerer: Lowerer, info: ClassInfo, prop: string }; } -/** The declared type a refused checked-dynamic class field names in its - * diagnostic (`'any'`, `'WeakMap'`): `unknown` and - * `object` fields store checked-dynamic values, so the refusal is about - * whatever else the declaration spells. */ -function dynFieldTypeSpelling(lowerer: Lowerer, node: ts.Node): string { - return `'${lowerer.checker.typeToString(lowerer.typeOf(node))}'`; -} - /** The base a constructor chain actually CALLS into: generic FAMILIES are * never constructed (no `%.constructor` exists), so an * instantiation's construction-relevant base is the family's own base — @@ -11312,12 +11281,13 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { ); info = lowerer.classes.get(calleeT.className); } + // The TYPE world names a class the lowering never registered — a + // fenced class expression, an abstract/deferred declaration: flush + // its own diagnostics (they tell the real story, unless the flush + // registers it with dynamic slots) and poison this construction + // site, never an ICE. + if (!info) info = lowerer.flushDeferredClass(calleeT.className); if (!info) { - // The TYPE world names a class the lowering never registered — - // a fenced class expression, an abstract/deferred declaration: - // flush its own diagnostics (they tell the real story) and - // poison this construction site, never an ICE. - lowerer.flushDeferredClass(calleeT.className); lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 006975085..7213a37c8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -3136,11 +3136,9 @@ export function symbolFieldInfo( if (lowerer.chainBlocked(expr)) return null; const receiverIr = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); if (receiverIr?.kind !== "object") return null; - const info = lowerer.classes.get(receiverIr.className); - if (!info) { - lowerer.flushDeferredClass(receiverIr.className); - return null; - } + const info = + lowerer.classes.get(receiverIr.className) ?? lowerer.flushDeferredClass(receiverIr.className); + if (!info) return null; const key = classSymbolKeyOf(lowerer, expr.argumentExpression); if (!key) return null; const field = info.symbolFields?.get(key.identity); diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index dd0467bd0..8aa3ddb2c 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1036,13 +1036,11 @@ export function moduleArtifacts( } while (pendingClasses.length > 0) { const name = pendingClasses.pop()!; - const info = lowerer.classes.get(name); - if (!info) { - // Referenced by an emitted type but never registered: collection - // deferred its diagnostics — a reached reference makes them count. - lowerer.flushDeferredClass(name); - continue; - } + // Referenced by an emitted type but never registered: collection + // deferred its diagnostics — a reached reference makes them count + // (or registers the class with dynamic slots, retryDeferred). + const info = lowerer.classes.get(name) ?? lowerer.flushDeferredClass(name); + if (!info) continue; for (const field of info.def.fields) visitType(field.type); if (info.base) visitClass(info.base.def.name); if (info.def.genericOf) visitClass(info.def.genericOf); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 7d5273e07..3cce51817 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -417,8 +417,8 @@ function cleanupCall( ): { call: IrExpr; async: boolean } | null { const loc = locOf(node); if (value.type.kind === "object") { - const info = lowerer.classes.get(value.type.className); - if (!info) lowerer.flushDeferredClass(value.type.className); + const info = + lowerer.classes.get(value.type.className) ?? lowerer.flushDeferredClass(value.type.className); const asyncFound = awaited && info ? lowerer.findMethodOn(info, "sym:asyncDispose") : null; const syncFound = info ? lowerer.findMethodOn(info, "sym:dispose") : null; const member = asyncFound ? "sym:asyncDispose" : syncFound ? "sym:dispose" : null; @@ -3291,8 +3291,8 @@ export function lowerBindingPattern( // chain declares keep named fences (a detached method would lose its // receiver without an error; JS binds the prototype function). if (srcType.kind === "object") { - const info = lowerer.classes.get(srcType.className); - if (!info) lowerer.flushDeferredClass(srcType.className); + const info = + lowerer.classes.get(srcType.className) ?? lowerer.flushDeferredClass(srcType.className); if (info) { for (const el of pattern.elements.nodes) { if (el.name === undefined) continue; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6b1174d68..00b62dcc4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -730,6 +730,19 @@ export interface DynamicSlot { reason: string; } +/** Localized failure: rounds of the declaration flush retry. */ +const MAX_DECLARATION_RETRIES = 16; + +/** The slot kinds the declaration flush retry degrades (stage L2). */ +const DECLARATION_SLOTS: ReadonlySet = new Set([ + "field", + "param", + "return", + "local", + "global", + "loop", +]); + export class PoisonError extends Error { /** Set when a type with no static lowering poisoned a slot (badType and * the dyn class field policy): what failed, for the localized-failure @@ -971,6 +984,10 @@ export interface LowererMode { /** Symbols whose deferred diagnostics reachable emit already flushed — * the remainder must not report them a second time. */ alreadyFlushed?: ReadonlySet; + /** 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; /** LowerOptions.externalTypes, threaded through every lowering pass. */ externalTypes?: ReadonlyMap; /** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */ @@ -1378,6 +1395,7 @@ export function lowerToIr( reachable, remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, + degradedTypes: resultLowerer.degradedTypes, inlineRequireCycleCalls, targetPlatform, ffiImports, @@ -3761,6 +3779,22 @@ export class Lowerer { readonly degradedTypes = new Map(); /** The dynamic slots the commit points created: one SC6104 each. */ readonly dynamicSlots: DynamicSlot[] = []; + /** Bodies run() skipped because their declaration had not registered. */ + readonly skippedBodies = new Set(); + /** Declarations retryDeferred registered, in order: run() lowers the + * bodies it had skipped. */ + readonly retriedDeclarations: ts.Node[] = []; + /** Deferred declarations' collections, for the flush retry + * (retryDeferred). */ + readonly deferredCollectors = new Map< + ts.Symbol, + { symbolOf: () => ts.Symbol | undefined; collect: () => void } + >(); + /** Collection diagnostics that a flush retry may simply run again (a + * derived class whose base had not registered yet). */ + readonly retriableDiags = new WeakSet(); + /** The checker type behind each type failure badType committed. */ + readonly failedTypes = new WeakMap(); /** True while collectProgram runs: resolution helpers must not flush * deferred diagnostics (collection itself resolves symbols — extends * clauses — and collection order must not decide what reports). */ @@ -3814,12 +3848,12 @@ export class Lowerer { /** Bumps the per-file attribution counter (no-op unless provenance is * active and this is the emit/discovery lane — mirror the CALLER's * suppressStats guard, this method only gates remainder). */ - bumpFileStat(file: string, kind: "total" | "failed"): void { + bumpFileStat(file: string, kind: "total" | "failed", by = 1): void { if (this.remainder || !provenanceActive()) return; let s = this.statsByFile.get(file); if (!s) this.statsByFile.set(file, (s = { total: 0, failed: 0 })); - if (kind === "total") s.total += 1; - else if (kind === "failed") s.failed += 1; + if (kind === "total") s.total += by; + else if (kind === "failed") s.failed += by; } /** The program holds the declaration-typed boundary's projection @@ -3843,6 +3877,7 @@ 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); this.externalTypes = mode.externalTypes ?? new Map(); this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; this.ffiImports = mode.ffiImports ?? []; @@ -8151,9 +8186,12 @@ export class Lowerer { if (sig && !this.wantBody(sig.name)) continue; const fn = this.lowerFunction(decl); if (fn) functions.push(fn); - else if (this.countsSkips()) { - this.countSkippedBody(decl); - if (this.remainder) this.reportBlockedBody(decl); + else { + this.skippedBodies.add(decl); + if (this.countsSkips()) { + this.countSkippedBody(decl); + if (this.remainder) this.reportBlockedBody(decl); + } } } for (const decl of fp.classDecls) { @@ -8162,7 +8200,10 @@ export class Lowerer { functions.push( ...this.withClosureRoot(info.def.name, () => this.lowerClassMembers(info)), ); - else if (this.countsSkips()) this.countSkippedBody(decl); + else { + this.skippedBodies.add(decl); + if (this.countsSkips()) this.countSkippedBody(decl); + } } } @@ -8197,15 +8238,38 @@ export class Lowerer { let gc = 0; let gi = 0; let es = 0; + let rd = 0; const classDispatch = new ClassDynamicDispatch(); let dispatchChanged = !this.remainder; while ( dispatchChanged || + rd < this.retriedDeclarations.length || ec < this.exprClasses.length || gc < this.genericClassInstances.length || gi < this.instantiationQueue.length || es < this.emitSpecQueue.length ) { + // Localized failure: declarations a flush registered with dynamic + // slots (retryDeferred) after the loops above skipped their bodies. + while (rd < this.retriedDeclarations.length) { + const decl = this.retriedDeclarations[rd++]!; + if (!this.skippedBodies.delete(decl)) continue; + if (this.countsSkips()) this.uncountSkippedBody(decl); + if (ts.isFunctionDeclaration(decl)) { + const fn = this.lowerFunction(decl); + if (fn) functions.push(fn); + else { + this.skippedBodies.add(decl); + if (this.countsSkips()) this.countSkippedBody(decl); + } + } else if (ts.isClassDeclaration(decl)) { + const info = this.classes.get(this.classNamer(decl)); + if (info) + functions.push( + ...this.withClosureRoot(info.def.name, () => this.lowerClassMembers(info)), + ); + } + } while (ec < this.exprClasses.length) { functions.push(...this.lowerClassMembers(this.exprClasses[ec++]!)); } @@ -8485,6 +8549,22 @@ export class Lowerer { * percentage would overstate how much of the program compiles. Count * every statement the body holds as attempted and failed, in addition to * the skipped-function tally. */ + /** countSkippedBody undone: a flush registered the declaration after + * its body was skipped, and the body lowers now. */ + private uncountSkippedBody(decl: ts.Node): void { + this.stats.functionsSkipped--; + if (this.suppressStats) return; + const n = countBodyStatements(decl); + if (n === 0) return; + this.stats.statementsTotal -= n; + this.stats.statementsFailed -= n; + const file = ts.getSourceFileOfNode(decl).fileName; + for (let i = 0; i < n; i++) { + this.bumpFileStat(file, "total", -1); + this.bumpFileStat(file, "failed", -1); + } + } + private countSkippedBody(decl: ts.Node): void { this.stats.functionsSkipped++; if (this.suppressStats) return; @@ -8808,6 +8888,27 @@ export class Lowerer { register(`%${cName}.set:${prop}`, [], () => this.throwingSetterFn(info, prop)); } }; + // Localized failure: a declaration a flush registered with dynamic + // slots (retryDeferred) registers its units like a class expression. + this.onDeclarationRegistered = (decl: ts.Node): void => { + if (ts.isFunctionDeclaration(decl)) { + if (!decl.body) return; + const symbol = declSymbolOf(this, decl); + const sig = symbol ? this.fnSigsBySymbol.get(symbol) : undefined; + if (!sig || units.has(sig.name)) return; + units.set(sig.name, { + order: unitOrder++, + roots: functionRoots(decl), + lower: () => this.lowerFunction(decl), + }); + if (reachable.has(sig.name)) queue.push(sig.name); + metadataPriority.set(sig.name, [3, expressionMetadataOrder++]); + } else if (ts.isClassDeclaration(decl)) { + const info = this.classes.get(this.classNamer(decl)); + if (info && !info.generic && !units.has(`%${info.def.name}.constructor`)) + this.onExprClassCollected?.(info); + } + }; const onEdge = this.onEdge; for (const name of collectionEdges) onEdge(name); // Generic instances queued by the bodies above lower here (an instance @@ -9314,6 +9415,7 @@ export class Lowerer { ): never { const failure = typeFailureOf(node, this.checker.typeToString(type), family); diag.typeFailure = failure; + this.failedTypes.set(failure, type); this.pushDiag(diag); throw new PoisonError(failure, type); } @@ -9332,7 +9434,7 @@ export class Lowerer { const failure = e.failure; const type = e.failedType; if (failure === undefined || type === undefined) return null; - if (!failure.degradable || failure.family === "dyn-field-policy") return null; + if (!failure.degradable) 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. @@ -9376,21 +9478,6 @@ export class Lowerer { if (!seen) this.dynamicSlots.push(slot); } - /** The SC1090 refusal of checked-dynamic class fields in TypeScript - * (classFieldStoresDyn): a policy fence, not a missing representation, - * so its failure is recorded as degradable. */ - dynFieldPolicyFence(node: ts.Node, feature: string): never { - const diag = unsupportedDiag("SC1090", locOf(node), feature); - const failure = typeFailureOf( - node, - this.checker.typeToString(this.typeOf(node)), - "dyn-field-policy", - ); - diag.typeFailure = failure; - this.pushDiag(diag); - throw new PoisonError(failure, this.typeOf(node)); - } - badType(node: ts.Node, type: ts.Type): never { const widened = this.checker.getBaseTypeOfLiteralType(type); // Types declared by the ADOPTED @types/node (Buffer, NodeJS.Timeout, @@ -12252,6 +12339,7 @@ export class Lowerer { const list = this.deferredDiags.get(symbol) ?? []; list.push(...sink); this.deferredDiags.set(symbol, list); + this.deferredCollectors.set(symbol, { symbolOf, collect }); return symbol; } finally { this.diagSink = previousSink; @@ -12302,10 +12390,13 @@ export class Lowerer { return; } if (this.diagSink === null) { + if (this.retryDeferred(symbol, diags)) return; + // A failed retry leaves the last attempt's diagnostics deferred. + const committed = this.deferredDiags.get(symbol) ?? diags; this.deferredDiags.delete(symbol); this.flushedSymbols.add(symbol); - this.noteRejected(symbol, diags); - for (const d of diags) this.pushDiag(d); + this.noteRejected(symbol, committed); + for (const d of committed) this.pushDiag(d); this.touchRejected(symbol); return; } @@ -12331,9 +12422,73 @@ export class Lowerer { if (list.length === 0) this.deferredDiags.delete(symbol); } - flushDeferredClass(className: string): void { + /** Flushes a deferred class known by its IR name, and answers the class + * when the flush registered it (retryDeferred). */ + flushDeferredClass(className: string): ClassInfo | undefined { const symbol = this.deferredClassByName.get(className); if (symbol) this.flushDeferred(symbol); + return this.classes.get(className); + } + + /** Localized failure's declaration commit point (design §3.3): a + * reference is flushing a deferred declaration into the build. When + * every deferred diagnostic is a degradable type failure at one of the + * declaration's slots, the failing types degrade and the collection + * runs again (up to MAX_DECLARATION_RETRIES rounds, each degrading the + * types the previous round still failed on). On success the + * declaration registers, each degraded slot is an SC6104, and nothing + * reports. Otherwise the last round's diagnostics stay deferred for the + * caller to commit. */ + private retryDeferred(symbol: ts.Symbol, diags: readonly ScrDiagnostic[]): boolean { + if (this.tierMode !== "auto") return false; + const collector = this.deferredCollectors.get(symbol); + if (collector === undefined) return false; + const slots: DynamicSlot[] = []; + let current = diags; + for (let round = 0; round < MAX_DECLARATION_RETRIES; round++) { + const failing: { diag: ScrDiagnostic; failure: TypeFailure; id: number }[] = []; + let retriable = false; + for (const diag of current) { + if (this.retriableDiags.has(diag)) { + retriable = true; + continue; + } + const failure = diag.typeFailure; + const type = failure === undefined ? undefined : this.failedTypes.get(failure); + const id = (type as { id?: number } | undefined)?.id; + if ( + failure === undefined || + id === undefined || + !failure.degradable || + !DECLARATION_SLOTS.has(failure.slot) || + this.degradedTypes.has(id) + ) + return false; + failing.push({ diag, failure, id }); + } + // 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 }); + } + this.deferredDiags.delete(symbol); + if (this.collectDeferring(collector.symbolOf, collector.collect) === null) { + this.deferredCollectors.delete(symbol); + for (const [name, deferred] of this.deferredClassByName) + if (deferred === symbol) this.deferredClassByName.delete(name); + for (const slot of slots) this.noteDynamicSlot(slot); + for (const decl of symbol.declarations) + if (ts.isFunctionDeclaration(decl) || ts.isClassDeclaration(decl)) { + this.retriedDeclarations.push(decl); + this.onDeclarationRegistered?.(decl); + } + return true; + } + current = this.deferredDiags.get(symbol) ?? []; + } + return false; } collectSignature(decl: ts.FunctionDeclaration): void { @@ -12612,6 +12767,9 @@ export class Lowerer { * bodies as worklist units (the units map is otherwise built before * lowering starts). Null in the emit pass. */ onExprClassCollected: ((info: ClassInfo) => void) | null = null; + /** emitReachable's unit registration for a declaration retryDeferred + * registered after collection. */ + onDeclarationRegistered: ((decl: ts.Node) => void) | null = null; /** Mixin functions (`(Base: T) => class extends Base {…}`) by their * function-like node: recognized shape, or null for checked diff --git a/packages/compiler/src/frontend/lowering/type-failure.ts b/packages/compiler/src/frontend/lowering/type-failure.ts index 3502a9bbf..126777973 100644 --- a/packages/compiler/src/frontend/lowering/type-failure.ts +++ b/packages/compiler/src/frontend/lowering/type-failure.ts @@ -34,7 +34,6 @@ const DEGRADABLE_FAMILIES: ReadonlySet = new Set dynamic field -> capsule -> instance). +type Peer = Link | Map string> | AbortSignal | (() => number); + +class Link { + id: number; + peer: Peer | undefined; + constructor(id: number) { + this.id = id; + this.peer = undefined; + } + peerId(): number { + const p = this.peer; + return p instanceof Link ? p.id : -1; + } +} + +let total = 0; +for (let i = 0; i < 20000; i++) { + const a = new Link(i); + const b = new Link(i + 1); + a.peer = b; + b.peer = a; + total += a.peerId() - b.peerId(); +} +const keep = new Link(7); +keep.peer = () => keep.id * 2; +const f = keep.peer; +console.log(total, typeof f === "function" ? f() : 0, keep.peerId()); diff --git a/tests/corpus/localized-failure-declarations.ts b/tests/corpus/localized-failure-declarations.ts new file mode 100644 index 000000000..c4acdfbaa --- /dev/null +++ b/tests/corpus/localized-failure-declarations.ts @@ -0,0 +1,64 @@ +// Localized failure, stage L2: fields, parameters and returns whose +// declared type has no static lowering become checked dynamic slots, so +// their class or function still compiles. +interface ToolDefinition { + name: string; + execute(id: string, params: any): string; +} +type Progress = (done: number, total: number) => void; +type ProgressOrSignal = Progress | AbortSignal | Map string> | Set; + +class Session { + // A field: the class registers, the field holds the array by reference. + private tools: ToolDefinition[] = []; + state: any = { count: 0 }; + readonly label: string; + constructor(label: string) { + this.label = label; + } + add(tool: ToolDefinition): void { + this.tools.push(tool); + } + run(id: string, params: unknown): string[] { + const out: string[] = []; + for (const tool of this.tools) out.push(tool.execute(id, params)); + this.state.count = this.state.count + 1; + return out; + } + count(): number { + return this.tools.length; + } + // A return slot. + first(): ToolDefinition | ProgressOrSignal | undefined { + return this.tools[0]; + } +} + +// A parameter: the function registers and is called from typed code. +function listAll(dir: string, onProgress?: ProgressOrSignal): number { + if (typeof onProgress === "function") onProgress(1, dir.length); + else if (onProgress instanceof Set) return onProgress.size; + return dir.length; +} + +// A base class with a dynamic parameter, and an override. +class Base { + notify(target: ProgressOrSignal | undefined): string { + return target === undefined ? "base:none" : "base:" + typeof target; + } +} +class Derived extends Base { + override notify(target: ProgressOrSignal | undefined): string { + return "derived/" + super.notify(target); + } +} + +const echo: ToolDefinition = { name: "echo", execute: (id, params) => `${id}:${JSON.stringify(params)}` }; +const s = new Session("main"); +s.add(echo); +s.add({ name: "len", execute: (id) => String(id.length) }); +console.log(s.label, s.count(), s.run("abc", { k: 1 }).join(" | "), s.state.count); +console.log("same tool", s.first() === echo); +console.log(listAll("hello"), listAll("hello", (d, t) => console.log("progress", d, t)), listAll("x", new Set([1, 2]))); +const objs: Base[] = [new Base(), new Derived()]; +for (const o of objs) console.log(o.notify(undefined), o.notify((a, b) => a + b)); diff --git a/tests/corpus/localized-failure-pi-shapes.ts b/tests/corpus/localized-failure-pi-shapes.ts new file mode 100644 index 000000000..9879606eb --- /dev/null +++ b/tests/corpus/localized-failure-pi-shapes.ts @@ -0,0 +1,37 @@ +// Localized failure, stage L2: pi-shaped declarations. +interface Token { + text: string; +} +interface AgentMessage { + role: "user" | "assistant"; + content: string[]; +} +type CreateRuntime = (cwd: string) => Promise<{ session: Session; extra: Map string> | AbortSignal }>; + +class Session { + messages: AgentMessage[] = []; + // A field whose union mentions a stdlib type with no lowering: the class + // registers; constructing a WeakRef would still be refused. + cachedTokens?: WeakRef<{ source: string; tokens: Token[] }> | undefined; + rendererState: any = {}; + effectState: Record = {}; + private readonly createRuntime: CreateRuntime | undefined; + constructor(createRuntime: CreateRuntime | undefined) { + this.createRuntime = createRuntime; + } + hasRuntime(): boolean { + return this.createRuntime !== undefined; + } + clear(): AgentMessage[] { + // An inferred return over a spread clone. + return this.messages.map((message) => ({ ...message, content: [] })); + } +} + +const s = new Session(undefined); +const withRuntime = new Session(async (cwd) => ({ session: s, extra: new Map() })); +s.messages.push({ role: "user", content: ["hi"] }, { role: "assistant", content: ["yo", "!"] }); +s.rendererState.mode = "fast"; +s.effectState["k"] = 3; +console.log(s.hasRuntime(), s.cachedTokens === undefined, s.rendererState.mode, s.effectState["k"]); +console.log(JSON.stringify(s.clear()), s.messages[1]!.content.length, withRuntime.hasRuntime()); diff --git a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt index ab79ac05e..40486bb10 100644 --- a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt +++ b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt @@ -1,41 +1,18 @@ dynamic tier (tier assignment, report-only; --tier=auto) - 0 functions already operate on dynamic values (0 statements) + 4 functions already operate on dynamic values (3 statements) + Holder.constructor tests/coverage-fixtures/localized-failure.ts:28:1: dynObjLit at tests/coverage-fixtures/localized-failure.ts:29:16 + 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 + %dyn.class.property.0 tests/coverage-fixtures/localized-failure.ts:15:12: %dyn.class.readPropertiesOrEmpty at tests/coverage-fixtures/localized-failure.ts:15:12 - 9 demotion requests (refusals and fences the dynamic tier would replace) + 2 demotion requests (refusals and fences the dynamic tier would replace) - 3 would-be dynamic slots (localized failure, report-only: type failures a checked dynamic slot would hold) - 9 errors: 7 clear with declaration slots (L1-L2), 7 with member and expression slots too (L3-L4); 5 cascades attributed; 1 type failure stays refused - L2 field tools: Tool[] tests/coverage-fixtures/localized-failure.ts:13:11 (SC2009 component, clears 2) - rejects class Session - L2 param progress: Callbacks | undefined tests/coverage-fixtures/localized-failure.ts:23:31 (SC2009 component, clears 1) - rejects function listAll - L2 field state: any tests/coverage-fixtures/localized-failure.ts:29:3 (SC1090 dyn-field-policy, clears 1) - rejects class Holder - class Session tests/coverage-fixtures/localized-failure.ts:12:1 (1 request) - <- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:49:11: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet - -> function turns dynamic - class Holder tests/coverage-fixtures/localized-failure.ts:28:1 (1 request) - <- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:52:13: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet - -> function turns dynamic + 0 would-be dynamic slots (localized failure, report-only: type failures a checked dynamic slot would hold) + 2 errors: 0 clear with declaration slots (L1-L2), 0 with member and expression slots too (L3-L4); 1 cascade attributed; 1 type failure stays refused class Cache tests/coverage-fixtures/localized-failure.ts:42:1 (1 request) <- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:53:22: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet -> function turns dynamic - field Session.tools tests/coverage-fixtures/localized-failure.ts:13:3 (1 request) - <- SC2009 type at tests/coverage-fixtures/localized-failure.ts:13:11: values of type 'Tool[]' cannot be compiled: the array shape is supported, but its element type 'Tool' does not compile - -> function turns dynamic - field Holder.state tests/coverage-fixtures/localized-failure.ts:29:3 (1 request) - <- SC1090 operation at tests/coverage-fixtures/localized-failure.ts:29:3: 'any'-typed class fields are not supported yet - -> function turns dynamic field Cache.tokens tests/coverage-fixtures/localized-failure.ts:43:3 (1 request) <- SC2020 operation at tests/coverage-fixtures/localized-failure.ts:43:3: 'WeakRef' is part of the standard library types but has no scriptc lowering yet -> function turns dynamic - function tests/coverage-fixtures/localized-failure.ts:1:1 (8 sites inside) - function listAll tests/coverage-fixtures/localized-failure.ts:23:1 (1 request, 1 site inside) - <- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:51:13: uses of 'listAll' inherit the blocker on its declaration - -> function turns dynamic - binding progress in listAll tests/coverage-fixtures/localized-failure.ts:23:31 (1 request) - <- SC2009 type at tests/coverage-fixtures/localized-failure.ts:23:31: values of type 'Callbacks | undefined' cannot be compiled: the union shape is supported, and unions in the 'string | object' family ride the checked-dynamic representation wholesale, but 'Map function listAll turns dynamic - binding s tests/coverage-fixtures/localized-failure.ts:49:7 (1 request) - <- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:50:13: uses of 's' inherit the blocker on its declaration - -> function turns dynamic \ No newline at end of file + function tests/coverage-fixtures/localized-failure.ts:1:1 (2 sites inside) \ No newline at end of file diff --git a/tests/harness/corpus-costs/localized-failure-cycles.ts.json b/tests/harness/corpus-costs/localized-failure-cycles.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-cycles.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/corpus-costs/localized-failure-declarations.ts.json b/tests/harness/corpus-costs/localized-failure-declarations.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-declarations.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/corpus-costs/localized-failure-pi-shapes.ts.json b/tests/harness/corpus-costs/localized-failure-pi-shapes.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-pi-shapes.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index df99a4ece..002a84f00 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -431,28 +431,18 @@ test("--explain-dynamic attributes each refusal to the unit the dynamic tier wou test("--explain-dynamic lists the type failures localized failure would turn into dynamic slots", async () => { const { coverage } = analyze(fixture("localized-failure.ts"), { explainDynamic: true }); const report = coverage.tiers!.localizedFailure; - // The local already degrades (stage L1); fields and parameters are - // listed as would-be slots with the uses each one rejects. - expect(coverage.dynamicSlots?.map((s) => [s.slot, s.name])).toEqual([["local", "callbacks"]]); - expect(report.errors).toBe(9); - expect(report.typeFailures).toEqual({ degradable: 3, refused: 1 }); - expect( - report.slots.map((s) => [ - s.stage, - s.slot, - s.name, - s.family, - s.rejects?.split(" @")[0], - s.cascade, - ]), - ).toEqual([ - ["L2", "field", "tools", "component", "class Session", 2], - ["L2", "param", "progress", "component", "function listAll", 1], - ["L2", "field", "state", "dyn-field-policy", "class Holder", 1], + // Locals, fields and parameters degrade (stages L1 and L2), and the + // 'any' field compiles; only the refused WeakRef field and its + // construction remain. + expect(coverage.dynamicSlots?.map((s) => [s.slot, s.name])).toEqual([ + ["field", "tools"], + ["param", "progress"], + ["local", "callbacks"], ]); - // The WeakRef field stays refused, so its class and the construction - // that uses it stay errors. - expect(report.clearedThroughL2).toBe(7); + expect(report.errors).toBe(2); + expect(report.typeFailures).toEqual({ degradable: 0, refused: 1 }); + expect(report.slots).toEqual([]); + expect(report.cascades).toBe(1); expect(report.remainingByCode).toEqual({ SC1090: 1, SC2020: 1 }); const relative = JSON.parse( JSON.stringify(coverage.tiers).replaceAll(repoRoot + "/", ""), From f0eb4271522e90c6a8a5100f0effb8845fef1280 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 16:56:50 -0700 Subject: [PATCH 5/9] Retry declarations a probe flushed at the statement that commits them; keep the dyn field policy outside reached classes A flush inside a capture buffer cannot retry (a probe that later declines must see today's answer), so its copies of the declaration's diagnostics reach the build when the statement commits. That statement commit point now reopens such a declaration: when its diagnostics are retriable, they leave the build, the declaration retries, and the statement lowers again. Probe copies of a declaration that already registered are dropped. The 'any'-typed class field refusal stays a fence at collection and is lifted by the flush retry, so only reached classes change; the coverage remainder registers the declarations reachable emit registered, and maps the degraded types alike. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/diagnostics/diagnostic.ts | 3 +- .../src/frontend/lowering/lower-classes.ts | 51 ++++++++- .../src/frontend/lowering/lower-stmts.ts | 13 ++- .../compiler/src/frontend/lowering/lowerer.ts | 104 +++++++++++++++++- .../src/frontend/lowering/type-failure.ts | 1 + .../coverage-explain-localized-failure.txt | 2 +- 6 files changed, 160 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 44bd92744..534e03c07 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -153,7 +153,8 @@ export type TypeFailureFamily = | "component" | "stdlib-nominal" | "record-member" - | "unsupported"; + | "unsupported" + | "dyn-field-policy"; /** A type failure's structured payload: what the commit points of the * localized-failure design inspect (PoisonError.failure carries the same diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f15e0dc86..e25c689b5 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2541,6 +2541,19 @@ export function collectClassShapeInner( if (initializerSymbol && (initializerSymbol.flags & ts.SymbolFlags.Enum) !== 0) continue; const type = lowerer.irTypeOf(member.name); if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); + if ( + type.kind === "dyn" && + !lowerer.liftDynFieldPolicy && + !isJsSourceFile(ts.getSourceFileOfNode(member)) && + !lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) && + (member.type !== undefined || + (lowerer.typeOf(member.name).flags & ts.TypeFlags.Any) === 0) + ) { + lowerer.dynFieldPolicyFence( + member.name, + `${dynFieldTypeSpelling(lowerer, member.name)}-typed static fields`, + ); + } staticFields.push({ name: member.name.text, type, @@ -3044,9 +3057,22 @@ export function collectClassShapeInner( if (base && !fields.has(member.name.text) && lowerer.dynConvertible(type)) type = DYN; } if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name)); - // A field whose type maps to the checked dynamic (`any`, an - // option bag, a degraded type) stores it natively, as JavaScript - // classes and constructor-assigned fields do. + // Bundled JS declares fields without annotations before assigning + // them in the constructor. Use the same native checked-dynamic + // storage as implicit constructor-assigned JS fields. In + // TypeScript the refusal is a policy fence that a flush retry lifts + // (Lowerer.liftDynFieldPolicy). + if ( + type.kind === "dyn" && + !lowerer.liftDynFieldPolicy && + !isJsSourceFile(ts.getSourceFileOfNode(member)) && + !lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) + ) { + lowerer.dynFieldPolicyFence( + member.name, + `${dynFieldTypeSpelling(lowerer, member.name)}-typed class fields`, + ); + } const undefinedInitializer = type.kind === "dyn" && !member.initializer ? locOf(member) : undefined; if (fields.has(member.name.text)) { @@ -3177,6 +3203,17 @@ export function collectClassShapeInner( const shape = lowerer.paramShape(p); const type = shape.bodyType ?? shape.type; if (type.kind === "void") lowerer.badType(p.name, lowerer.typeOf(p.name)); + // The class-field dyn rule verbatim (KEEP NARROW). + if ( + type.kind === "dyn" && + !lowerer.liftDynFieldPolicy && + !lowerer.classFieldStoresDyn(lowerer.typeOf(p)) + ) { + lowerer.dynFieldPolicyFence( + p.name, + `${dynFieldTypeSpelling(lowerer, p)}-typed class fields`, + ); + } // `override x` (and any same-named inherited member) would // redeclare a base slot — the declared-field rule verbatim. if (fields.has(name)) { @@ -8919,6 +8956,14 @@ export function throwingSetterFn(lowerer: Lowerer, info: ClassInfo, prop: string }; } +/** The declared type a refused checked-dynamic class field names in its + * diagnostic (`'any'`, `'WeakMap'`): `unknown` and + * `object` fields store checked-dynamic values, so the refusal is about + * whatever else the declaration spells. */ +function dynFieldTypeSpelling(lowerer: Lowerer, node: ts.Node): string { + return `'${lowerer.checker.typeToString(lowerer.typeOf(node))}'`; +} + /** The base a constructor chain actually CALLS into: generic FAMILIES are * never constructed (no `%.constructor` exists), so an * instantiation's construction-relevant base is the family's own base — diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 3cce51817..877d6da63 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -764,15 +764,16 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // Localized failure's statement commit point (design §3.3): a // degradable type failure at a local, loop variable or module // global degrades that type and the statement lowers again. - const slot = - attempt < MAX_DEGRADE_RETRIES && !deferFences - ? lowerer.degradeAtCommit(e, STATEMENT_SLOTS) - : null; - if (slot === null) throw e; + // A declaration this statement flushed inside a probe (where + // 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; // A failed attempt adds nothing to `out` but the TDZ boxes // forward captures declared (they stay declared), so the // retry appends after them. - pendingSlots.push(slot); + lowerer.dropAttemptDiags(diagsBefore); + if (slot !== null) pendingSlots.push(slot); } } for (const slot of pendingSlots) lowerer.noteDynamicSlot(slot); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 00b62dcc4..e578e741f 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -988,6 +988,9 @@ export interface LowererMode { * remainder maps them alike, so the declarations reachable emit * registered with dynamic slots collect there too). */ degradedTypes?: ReadonlyMap; + /** Localized failure: the declarations reachable emit registered with + * dynamic slots (the coverage remainder registers them too). */ + retriedSymbols?: ReadonlySet; /** LowerOptions.externalTypes, threaded through every lowering pass. */ externalTypes?: ReadonlyMap; /** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */ @@ -1396,6 +1399,7 @@ export function lowerToIr( remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, degradedTypes: resultLowerer.degradedTypes, + retriedSymbols: resultLowerer.retriedSymbols, inlineRequireCycleCalls, targetPlatform, ffiImports, @@ -3779,6 +3783,10 @@ export class Lowerer { readonly degradedTypes = new Map(); /** The dynamic slots the commit points created: one SC6104 each. */ readonly dynamicSlots: DynamicSlot[] = []; + /** Declarations retryDeferred registered. */ + readonly retriedSymbols = new Set(); + /** LowererMode.retriedSymbols. */ + readonly priorRetriedSymbols: ReadonlySet; /** Bodies run() skipped because their declaration had not registered. */ readonly skippedBodies = new Set(); /** Declarations retryDeferred registered, in order: run() lowers the @@ -3790,6 +3798,9 @@ export class Lowerer { ts.Symbol, { symbolOf: () => ts.Symbol | undefined; collect: () => void } >(); + /** True while a flush retry collects a declaration: the dyn class + * field policy (dynFieldPolicyFence) is lifted for reached classes. */ + liftDynFieldPolicy = false; /** Collection diagnostics that a flush retry may simply run again (a * derived class whose base had not registered yet). */ readonly retriableDiags = new WeakSet(); @@ -3878,6 +3889,7 @@ 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); + this.priorRetriedSymbols = mode.retriedSymbols ?? new Set(); this.externalTypes = mode.externalTypes ?? new Map(); this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; this.ffiImports = mode.ffiImports ?? []; @@ -4949,7 +4961,14 @@ export class Lowerer { } collectProgram(parts: FileParts[]): void { - return collectProgram(this, parts); + collectProgram(this, parts); + // The coverage remainder registers the declarations reachable emit + // registered with dynamic slots (retryDeferred), so it neither reports + // them as unreached nor blocks their uses. + for (const symbol of this.priorRetriedSymbols) { + const diags = this.deferredDiags.get(symbol); + if (diags !== undefined) this.retryDeferred(symbol, diags); + } } /** Names every file's %init and registers the run-once guard globals @@ -9346,6 +9365,9 @@ export class Lowerer { this.diagSink.push(d); return; } + // A probe's copy of a declaration's flushed diagnostics, committed + // after a flush retry registered the declaration: nothing to report. + if (pending && this.retriedSymbols.has(pending.symbol)) return; if (pending) { this.commitFlush(pending.symbol, pending.source); const group = this.rejectedGroups.get(pending.symbol); @@ -9434,7 +9456,7 @@ export class Lowerer { const failure = e.failure; const type = e.failedType; if (failure === undefined || type === undefined) return null; - if (!failure.degradable) return null; + 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. @@ -9467,6 +9489,51 @@ export class Lowerer { }); } + /** Localized failure, statement commit point for declarations a probe + * flushed: a statement committing the flushed diagnostics of a deferred + * declaration (inside a capture buffer the flush could not retry) reopens + * it here, outside every probe. When the declaration's diagnostics are + * all retriable, they leave the build, the declaration retries + * (retryDeferred), and on success the caller lowers the statement again. + * Returns whether some declaration registered. */ + reopenFlushedAtCommit(diagsBefore: number): boolean { + if (this.tierMode !== "auto" || this.diagSink !== null) return false; + const groups = new Set(); + for (let i = diagsBefore; i < this.diags.length; i++) { + const group = this.diags[i]!.rejects; + if (group !== undefined && !group.startsWith("statement @")) groups.add(group); + } + if (groups.size === 0) return false; + let registered = false; + for (const [symbol, group] of [...this.rejectedGroups]) { + if (!groups.has(group) || !this.deferredCollectors.has(symbol)) continue; + const committed = this.diags.filter((d) => d.rejects === group); + this.diags.splice(0, this.diags.length, ...this.diags.filter((d) => d.rejects !== group)); + this.flushedSymbols.delete(symbol); + this.rejectedGroups.delete(symbol); + if (this.retryDeferred(symbol, committed)) { + registered = true; + continue; + } + // Not retriable: the declaration's diagnostics commit as before. + const last = this.deferredDiags.get(symbol) ?? committed; + this.deferredDiags.delete(symbol); + this.flushedSymbols.add(symbol); + this.noteRejected(symbol, last); + for (const d of last) this.pushDiag(d); + } + return registered; + } + + /** A failed attempt's own diagnostics leave the build before its unit + * lowers again; flushed declaration diagnostics (`rejects`) stay, since + * the retry will not flush them a second time. */ + dropAttemptDiags(diagsBefore: number): void { + const kept = this.diags.slice(diagsBefore).filter((d) => d.rejects !== undefined); + this.diags.length = diagsBefore; + this.diags.push(...kept); + } + /** Records a dynamic slot whose unit lowered with it (one per site). */ noteDynamicSlot(slot: DynamicSlot): void { const seen = this.dynamicSlots.some( @@ -9478,6 +9545,22 @@ export class Lowerer { if (!seen) this.dynamicSlots.push(slot); } + /** The SC1090 refusal of checked-dynamic class fields in TypeScript + * (classFieldStoresDyn): a policy fence, not a missing representation. + * A flush retry lifts it (liftDynFieldPolicy), so a reached class with + * an 'any' field registers. */ + dynFieldPolicyFence(node: ts.Node, feature: string): never { + const diag = unsupportedDiag("SC1090", locOf(node), feature); + const failure = typeFailureOf( + node, + this.checker.typeToString(this.typeOf(node)), + "dyn-field-policy", + ); + diag.typeFailure = failure; + this.pushDiag(diag); + throw new PoisonError(failure, this.typeOf(node)); + } + badType(node: ts.Node, type: ts.Type): never { const widened = this.checker.getBaseTypeOfLiteralType(type); // Types declared by the ADOPTED @types/node (Buffer, NodeJS.Timeout, @@ -12454,6 +12537,12 @@ export class Lowerer { continue; } const failure = diag.typeFailure; + // The dyn class field policy lifts during the retry; nothing to + // degrade. + if (failure?.family === "dyn-field-policy") { + retriable = true; + continue; + } const type = failure === undefined ? undefined : this.failedTypes.get(failure); const id = (type as { id?: number } | undefined)?.id; if ( @@ -12474,7 +12563,16 @@ export class Lowerer { slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message }); } this.deferredDiags.delete(symbol); - if (this.collectDeferring(collector.symbolOf, collector.collect) === null) { + const lifted = this.liftDynFieldPolicy; + this.liftDynFieldPolicy = true; + let registered: boolean; + try { + registered = this.collectDeferring(collector.symbolOf, collector.collect) === null; + } finally { + this.liftDynFieldPolicy = lifted; + } + if (registered) { + this.retriedSymbols.add(symbol); this.deferredCollectors.delete(symbol); for (const [name, deferred] of this.deferredClassByName) if (deferred === symbol) this.deferredClassByName.delete(name); diff --git a/packages/compiler/src/frontend/lowering/type-failure.ts b/packages/compiler/src/frontend/lowering/type-failure.ts index 126777973..3502a9bbf 100644 --- a/packages/compiler/src/frontend/lowering/type-failure.ts +++ b/packages/compiler/src/frontend/lowering/type-failure.ts @@ -34,6 +34,7 @@ const DEGRADABLE_FAMILIES: ReadonlySet = new Set Date: Sun, 25 Oct 2026 17:36:08 -0700 Subject: [PATCH 6/9] Degrade parameters and returns of function expressions and generic instances where they commit A function expression's or arrow's parameter and return types commit at the statement that holds it, and a generic instance's at the instance drain. Both are now localized-failure commit points for declaration slots: the failing type degrades and the statement or instance lowers again. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 8 ++- .../compiler/src/frontend/lowering/lowerer.ts | 52 +++++++++++++------ .../localized-failure-function-slots.ts | 28 ++++++++++ .../localized-failure-function-slots.ts.json | 1 + 4 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 tests/corpus/localized-failure-function-slots.ts create mode 100644 tests/harness/corpus-costs/localized-failure-function-slots.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 877d6da63..72fec97e7 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -854,11 +854,17 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir * at most this many times (each retry degrades one more type). */ const MAX_DEGRADE_RETRIES = 16; -/** The slot kinds lowerStmts' commit point degrades (stage L1). */ +/** 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). */ const STATEMENT_SLOTS: ReadonlySet = new Set([ "local", "global", "loop", + "param", + "return", + "field", ]); /** One attempt at lowering a statement of lowerStmts into `out`. */ diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e578e741f..de84fa3a9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -8301,13 +8301,10 @@ export class Lowerer { // generic method's this/super fence, a fenced parameter // default): the diagnostic is recorded — the instance skips // like a signature-blocked function (lowerFunction's rule). - try { - functions.push( - this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)), - ); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; - } + const fn = this.lowerUnitDegrading(() => + this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)), + ); + if (fn !== null) functions.push(fn); } // Emit-override specializations queued by the emit sites above (a // body can queue more — the super-forward chain — and generic @@ -8983,17 +8980,16 @@ export class Lowerer { this.genericDemandPriority.set(inst, ref); // Body-level poisons skip the instance after retaining the // diagnostic and every edge fired before poisoning. - try { - instanceFunctions.push( - this.withGenericDemandOwner(owner, () => - this.shapes.withDeclaredOrderPriority(ref, () => - this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)), - ), + const fn = this.lowerUnitDegrading(() => + this.withGenericDemandOwner(owner, () => + this.shapes.withDeclaredOrderPriority(ref, () => + this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)), ), - ); + ), + ); + if (fn !== null) { + instanceFunctions.push(fn); relieveHeapPressure(); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; } } } @@ -9534,6 +9530,30 @@ export class Lowerer { this.diags.push(...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 + * lowers again (at most MAX_DECLARATION_RETRIES times). Null when it + * still poisons; its diagnostics are then committed as before. */ + lowerUnitDegrading(lower: () => T): T | null { + const slots: DynamicSlot[] = []; + for (let attempt = 0; ; attempt++) { + const mark = this.diags.length; + try { + const result = 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); + this.dropAttemptDiags(mark); + } + } + } + /** Records a dynamic slot whose unit lowered with it (one per site). */ noteDynamicSlot(slot: DynamicSlot): void { const seen = this.dynamicSlots.some( diff --git a/tests/corpus/localized-failure-function-slots.ts b/tests/corpus/localized-failure-function-slots.ts new file mode 100644 index 000000000..0a7fdf641 --- /dev/null +++ b/tests/corpus/localized-failure-function-slots.ts @@ -0,0 +1,28 @@ +// Localized failure, stage L2: parameters and returns of arrows, function +// expressions and generic instances become checked dynamic slots. +type Hook = Map string> | AbortSignal | (() => number) | Set; + +interface Model { + id: T; + hook: Hook; +} + +function retry(label: string, request: () => Promise | Hook): T | string { + const r = request(); + return typeof r === "function" ? label + ":" + String(r()) : label + ":other"; +} + +function describe(model: Model): string { + return `${String(model.id)}/${model.hook instanceof Set ? model.hook.size : typeof model.hook}`; +} + +const run = (hook: Hook, n: number): string => (typeof hook === "function" ? String(hook() + n) : "set"); +const make = function (size: number): Hook { + return new Set(Array.from({ length: size }, (_, i) => `k${i}`)); +}; + +console.log(run(() => 40, 2), run(new Set(["a"]), 1)); +const h = make(3); +console.log(h instanceof Set ? h.size : -1); +console.log(retry("a", () => () => 7), retry("b", () => new Set())); +console.log(describe({ id: 5, hook: new Set(["x", "y"]) }), describe({ id: "s", hook: () => 1 })); diff --git a/tests/harness/corpus-costs/localized-failure-function-slots.ts.json b/tests/harness/corpus-costs/localized-failure-function-slots.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-function-slots.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } From 624d2bf8734c5e422d180c211c0e4328a2ba38a0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 17:54:37 -0700 Subject: [PATCH 7/9] Record order-parity baselines for the stage L2 localized-failure corpus programs Co-Authored-By: Claude Opus 5.5 --- .../tests/corpus/localized-failure-cycles.ts.json | 6 ++++++ .../tests/corpus/localized-failure-declarations.ts.json | 6 ++++++ .../tests/corpus/localized-failure-function-slots.ts.json | 6 ++++++ .../tests/corpus/localized-failure-pi-shapes.ts.json | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-cycles.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-declarations.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-function-slots.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-pi-shapes.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-cycles.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-cycles.ts.json new file mode 100644 index 000000000..7df4fd38f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-cycles.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-cycles.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-declarations.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-declarations.ts.json new file mode 100644 index 000000000..0530e8e13 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-declarations.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-declarations.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-function-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-function-slots.ts.json new file mode 100644 index 000000000..88890074d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-function-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-function-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-pi-shapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-pi-shapes.ts.json new file mode 100644 index 000000000..d1603f029 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-pi-shapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-pi-shapes.ts" + ], + "diags": [] +} From a76b7e77b410dc459c6826a745ff1e12d1e65471 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 21:37:55 -0500 Subject: [PATCH 8/9] Address review feedback --- .../src/frontend/lowering/lower-stmts.ts | 7 +++++++ .../localized-failure-forward-capture.ts.json | 6 ++++++ .../localized-failure-forward-capture.ts | 19 +++++++++++++++++++ .../localized-failure-forward-capture.ts.json | 1 + 4 files changed, 33 insertions(+) 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 7d5273e07..6e3a7a8de 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -755,6 +755,10 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir const touched = new Set(); lowerer.failureTouches.push(touched); const pendingSlots: DynamicSlot[] = []; + // A declarator that initializes a forward-captured TDZ box takes it + // out of tdzPredeclared; a retry after a later declarator degrades + // must initialize the same box again. + const tdzBefore = new Map(lowerer.ctx.tdzPredeclared); try { for (let attempt = 0; ; attempt++) { try { @@ -773,6 +777,9 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir // forward captures declared (they stay declared), so the // retry appends after them. pendingSlots.push(slot); + for (const [symbol, local] of tdzBefore) + if (!lowerer.ctx.tdzPredeclared.has(symbol)) + lowerer.ctx.tdzPredeclared.set(symbol, local); } } for (const slot of pendingSlots) lowerer.noteDynamicSlot(slot); 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..ba43a037d --- /dev/null +++ b/tests/corpus/localized-failure-forward-capture.ts @@ -0,0 +1,19 @@ +// Localized failure, stage L1: a statement that declares a binding an +// earlier closure captures, and then a binding whose declared type has no +// static lowering, still initializes the captured binding. +type Callbacks = ((done: number) => void) | AbortSignal | Map string> | Set; + +function run(): void { + const read = (): number => total; + const total = 3, + hook: Callbacks = new Set([1, 2]); + console.log(read(), hook instanceof Set, hook instanceof Set && hook.size); + let late = (): string => label; + let label = "first", + other: Callbacks = new Set([3]); + console.log(late(), other instanceof Set); + label = "second"; + late = (): string => label + "!"; + console.log(late()); +} +run(); 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..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-forward-capture.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } From 984737596642748c6aa2b68144f800dd449f867f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 23:22:35 -0500 Subject: [PATCH 9/9] Run registered classes' static initializers in place and keep retried statements' diagnostics - A deferred class with static field initializers is reached where its statement evaluates: its file's %init retries it there (retryDeferredAtDeclaration), so its initializers lower in place. A failed retry reports nothing, restores the types it degraded, and leaves the class deferred; no later flush registers it, so it reports at its reference instead of registering after its initializers' place. Before, a class a later reference registered never ran its static initializers. - A generic class that registers on retry registers the units of its static methods. Before, a call to one was an undeclared function (SC9001). - A statement that lowers again after degrading a type keeps the diagnostics of the function-local class bodies it lowered the first time: the retry reuses those bodies instead of lowering them again. - retriableDiags and failedTypes are a Set and a Map owned by the lowerer instead of weak collections, and the degraded types of a mode are copied without an empty-array fallback, which the self-hosted compiler could not lower. - The coverage test of a class rejected behind a receiver probe pins its refusal under --tier=static-only, as the diagnostics corpus does; under localized failure the class compiles. --- .../src/frontend/lowering/lower-classes.ts | 3 + .../src/frontend/lowering/lower-modules.ts | 70 +++++++++++----- .../compiler/src/frontend/lowering/lowerer.ts | 84 +++++++++++++++++-- .../test/localized-failure-retries.test.ts | 70 ++++++++++++++++ ...calized-failure-registered-statics.ts.json | 6 ++ .../localized-failure-registered-statics.ts | 46 ++++++++++ ...calized-failure-registered-statics.ts.json | 1 + tests/harness/coverage.test.ts | 7 +- 8 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 packages/compiler/test/localized-failure-retries.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-registered-statics.ts.json create mode 100644 tests/corpus/localized-failure-registered-statics.ts create mode 100644 tests/harness/corpus-costs/localized-failure-registered-statics.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index e25c689b5..e5c7d590b 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6216,6 +6216,7 @@ function lowerClassValue( const context = newFnCtx(true, null, null, VOID); info.localClass.context = context; lowerer.fnStack.push(context); + const diagsBefore = lowerer.diags.length; try { const bodies = lowerClassMembers(lowerer, info); for (const body of bodies) info.localClass.bodies.set(body.name, body); @@ -6230,6 +6231,8 @@ function lowerClassValue( }); } info.localClass.ready = true; + // A retry of the enclosing statement reuses these bodies. + for (const d of lowerer.diags.slice(diagsBefore)) lowerer.loweredOnceDiags.add(d); lowerer.onExprClassCollected?.(info); } finally { lowerer.fnStack.pop(); diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 8aa3ddb2c..57c58a760 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -179,6 +179,17 @@ export interface AppendGraph { * the require() edges preflight resolved — a CommonJS file's only edges * (the compiled CommonJS package a workspace module loaded on demand * imports, and the sibling packages and files IT requires). */ +/** Whether a class declaration has a static field with an initializer, + * which Node runs where the class statement evaluates. */ +function hasStaticFieldInitializer(decl: ts.ClassDeclaration): boolean { + return decl.members.nodes.some( + (member) => + ts.isPropertyDeclaration(member) && + member.initializer !== undefined && + member.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true, + ); +} + function appendedEdgesOf( program: ts.Program, graph: AppendGraph | undefined, @@ -3229,7 +3240,19 @@ export function lowerFileInit( // by position) — exactly when JS evaluates decorators, static // initializers, and blocks, so code reading an earlier module // binding sees its assigned value. - const statics = [...lowerer.classes.values()] + const runsStatics = (c: ClassInfo): boolean => + lowerer.classInitFlags.has(c.def.name) || + c.callableBase !== undefined || + c.def.baseValueGlobal !== undefined || + c.runtimeStatics !== undefined || + c.staticFields.length > 0 || + (c.staticBlocks?.length ?? 0) > 0 || + c.classDecorators !== undefined; + const statics: { + pos: number; + info: ClassInfo | null; + deferred?: { symbol: ts.Symbol; decl: ts.ClassDeclaration }; + }[] = [...lowerer.classes.values()] .filter( (c) => // Class DECLARATIONS only: expression classes run their static @@ -3241,15 +3264,30 @@ export function lowerFileInit( ts.isClassDeclaration(c.decl) && !c.mixinInstance && ts.getSourceFileOfNode(c.decl) === sf && - (lowerer.classInitFlags.has(c.def.name) || - c.callableBase !== undefined || - c.def.baseValueGlobal !== undefined || - c.runtimeStatics !== undefined || - c.staticFields.length > 0 || - (c.staticBlocks?.length ?? 0) > 0 || - c.classDecorators !== undefined), + runsStatics(c), ) .map((c) => ({ pos: ts.tokenStartOfNode(c.decl!), info: c })); + // Localized failure: a deferred class with static field initializers + // is reached at its statement, where Node runs them. Its place retries + // it (retryDeferredAtDeclaration), and once it has registered there or + // through an earlier flush, its initializers lower in place. + for (const symbol of lowerer.deferredCollectors.keys()) + for (const decl of symbol.declarations) + if ( + ts.isClassDeclaration(decl) && + ts.getSourceFileOfNode(decl) === sf && + hasStaticFieldInitializer(decl) + ) + statics.push({ pos: ts.tokenStartOfNode(decl), info: null, deferred: { symbol, decl } }); + const lowerStaticsAt = (entry: (typeof statics)[number]): IrStmt[] => { + let info = entry.info; + if (entry.deferred) { + if (!lowerer.retryDeferredAtDeclaration(entry.deferred.symbol)) return []; + info = lowerer.classes.get(lowerer.classNamer(entry.deferred.decl)) ?? null; + if (info === null || !runsStatics(info)) return []; + } + return withClassInitFlag(lowerer, info!, lowerer.lowerStaticFieldInits(info!)); + }; // Statics-bearing MIXIN instantiations whose call evaluates in THIS // file: their declaration-time code runs when the call does — the // top-level statement lower-mixins recorded. Insertion order is @@ -3270,13 +3308,7 @@ export function lowerFileInit( // declarations never share a kept statement's start, so `<=` // changes nothing for them. while (at < statics.length && statics[at]!.pos <= ts.tokenStartOfNode(stmt)) { - body.push( - ...withClassInitFlag( - lowerer, - statics[at]!.info, - lowerer.lowerStaticFieldInits(statics[at]!.info), - ), - ); + body.push(...lowerStaticsAt(statics[at]!)); at++; } // A cycle binding's declaration stays at its position: its @@ -3295,13 +3327,7 @@ export function lowerFileInit( body.push(...lowerer.pendingClassExprInits.splice(0), ...stmtIr); } while (at < statics.length) { - body.push( - ...withClassInitFlag( - lowerer, - statics[at]!.info, - lowerer.lowerStaticFieldInits(statics[at]!.info), - ), - ); + body.push(...lowerStaticsAt(statics[at]!)); at++; } for (const { at, g } of unionEntryInits) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index de84fa3a9..3a855eb59 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3792,6 +3792,14 @@ export class Lowerer { /** Declarations retryDeferred registered, in order: run() lowers the * bodies it had skipped. */ readonly retriedDeclarations: ts.Node[] = []; + /** Diagnostics of lowering that a statement's retry does not repeat (a + * function-local class's member bodies, lowered once and kept): + * dropAttemptDiags keeps them. */ + readonly loweredOnceDiags = new Set(); + /** Deferred classes whose static field initializers' place in %init + * has been lowered (retryDeferredAtDeclaration): a later flush must not + * register them, since their initializers would never run. */ + readonly staticInitsPassed = new Set(); /** Deferred declarations' collections, for the flush retry * (retryDeferred). */ readonly deferredCollectors = new Map< @@ -3803,9 +3811,9 @@ export class Lowerer { liftDynFieldPolicy = false; /** Collection diagnostics that a flush retry may simply run again (a * derived class whose base had not registered yet). */ - readonly retriableDiags = new WeakSet(); + readonly retriableDiags = new Set(); /** The checker type behind each type failure badType committed. */ - readonly failedTypes = new WeakMap(); + readonly failedTypes = new Map(); /** True while collectProgram runs: resolution helpers must not flush * deferred diagnostics (collection itself resolves symbols — extends * clauses — and collection order must not decide what reports). */ @@ -3888,7 +3896,8 @@ 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); + if (mode.degradedTypes) + for (const [id, degraded] of mode.degradedTypes) this.degradedTypes.set(id, degraded); this.priorRetriedSymbols = mode.retriedSymbols ?? new Set(); this.externalTypes = mode.externalTypes ?? new Map(); this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null; @@ -8921,8 +8930,25 @@ export class Lowerer { metadataPriority.set(sig.name, [3, expressionMetadataOrder++]); } else if (ts.isClassDeclaration(decl)) { const info = this.classes.get(this.classNamer(decl)); - if (info && !info.generic && !units.has(`%${info.def.name}.constructor`)) - this.onExprClassCollected?.(info); + if (!info) return; + if (!info.generic) { + if (!units.has(`%${info.def.name}.constructor`)) this.onExprClassCollected?.(info); + return; + } + // A generic class FAMILY's only units are its statics; its + // instantiations lower in the instance drain. + if (!info.staticMethods) return; + for (const [name, entry] of info.staticMethods) { + const unit = `%${info.def.name}.static:${name}`; + if (units.has(unit)) continue; + units.set(unit, { + order: unitOrder++, + roots: functionRoots(entry.member), + lower: () => lowerStaticMethod(this, info, name), + }); + if (reachable.has(unit)) queue.push(unit); + metadataPriority.set(unit, [3, expressionMetadataOrder++]); + } } }; const onEdge = this.onEdge; @@ -9525,7 +9551,9 @@ export class Lowerer { * lowers again; flushed declaration diagnostics (`rejects`) stay, since * the retry will not flush them a second time. */ dropAttemptDiags(diagsBefore: number): void { - const kept = this.diags.slice(diagsBefore).filter((d) => d.rejects !== undefined); + const kept = this.diags + .slice(diagsBefore) + .filter((d) => d.rejects !== undefined || this.loweredOnceDiags.has(d)); this.diags.length = diagsBefore; this.diags.push(...kept); } @@ -12544,9 +12572,27 @@ export class Lowerer { * caller to commit. */ private retryDeferred(symbol: ts.Symbol, diags: readonly ScrDiagnostic[]): boolean { if (this.tierMode !== "auto") return false; + if (this.staticInitsPassed.has(symbol)) return false; const collector = this.deferredCollectors.get(symbol); if (collector === undefined) return false; const slots: DynamicSlot[] = []; + // The types this retry degraded (not those of retries its collections + // ran), for retryDeferredAtDeclaration to restore when it fails. + const degraded: number[] = []; + try { + return this.retryDeferredRounds(symbol, collector, diags, slots, degraded); + } finally { + this.retryDegraded = degraded; + } + } + + private retryDeferredRounds( + symbol: ts.Symbol, + collector: { symbolOf: () => ts.Symbol | undefined; collect: () => void }, + diags: readonly ScrDiagnostic[], + slots: DynamicSlot[], + degraded: number[], + ): boolean { let current = diags; for (let round = 0; round < MAX_DECLARATION_RETRIES; round++) { const failing: { diag: ScrDiagnostic; failure: TypeFailure; id: number }[] = []; @@ -12580,6 +12626,7 @@ export class Lowerer { 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 }); + degraded.push(id); slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message }); } this.deferredDiags.delete(symbol); @@ -12609,6 +12656,31 @@ export class Lowerer { return false; } + private retryDegraded: number[] = []; + + /** A deferred class with static field initializers is reached where its + * statement evaluates: Node runs the initializers there, referenced or + * not. Its file's %init calls this at that place, which retries the class + * as a flush would, so the initializers lower in place. A failed retry + * reports nothing and leaves the class deferred as it was, and from here + * on no flush registers it (staticInitsPassed): it reports instead. + * Returns whether the class has registered. */ + retryDeferredAtDeclaration(symbol: ts.Symbol): boolean { + const diags = this.deferredDiags.get(symbol); + let registered = !this.deferredCollectors.has(symbol); + if (!registered && diags !== undefined) { + this.retryDegraded = []; + registered = this.retryDeferred(symbol, diags); + if (!registered) { + for (const id of this.retryDegraded) this.degradedTypes.delete(id); + this.deferredDiags.set(symbol, [...diags]); + } + this.retryDegraded = []; + } + this.staticInitsPassed.add(symbol); + return registered; + } + collectSignature(decl: ts.FunctionDeclaration): void { return collectSignature(this, decl); } diff --git a/packages/compiler/test/localized-failure-retries.test.ts b/packages/compiler/test/localized-failure-retries.test.ts new file mode 100644 index 000000000..6bf1b1e23 --- /dev/null +++ b/packages/compiler/test/localized-failure-retries.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function compileSource(source: string) { + const directory = mkdtempSync(join(tmpdir(), "scriptc-localized-")); + try { + const entry = join(directory, "main.ts"); + writeFileSync(entry, source); + return await compile(entry, { + outDir: directory, + outPath: join(directory, "program.ir"), + outputKind: "ir", + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +// A statement that lowers again after a later declarator degrades a type +// reuses the member bodies of a function-local class it lowered the first +// time, so the diagnostics of those bodies still report. +test("a degrading retry keeps the diagnostics of a local class lowered before it", async () => { + const result = await compileSource(`type Progress = (done: number, total: number) => void; +type Callbacks = Progress | AbortSignal | Map string> | Set; +function make(): number { + const K = class { + m(): number { + const a = [1, 2]; + (a as any).foo = 1; + return a.length; + } + }, + size = (cb: Callbacks): number => (cb instanceof Set ? cb.size : 0); + return new K().m() + size(new Set([1])); +} +console.log(make()); +`); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected the local class's diagnostic"); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SC1090", + message: expect.stringContaining("assignment to non-variables"), + }), + ]), + ); +}); + +// Without the failing statement, the same program compiles: the arrow's +// parameter is a dynamic slot. +test("the same program without the local class's failure compiles", async () => { + const result = await compileSource(`type Progress = (done: number, total: number) => void; +type Callbacks = Progress | AbortSignal | Map string> | Set; +function make(): number { + const K = class { + m(): number { + return 2; + } + }, + size = (cb: Callbacks): number => (cb instanceof Set ? cb.size : 0); + return new K().m() + size(new Set([1])); +} +console.log(make()); +`); + expect(result.ok, JSON.stringify(result.ok ? [] : result.diagnostics)).toBe(true); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-registered-statics.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-registered-statics.ts.json new file mode 100644 index 000000000..b3d375ea6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-registered-statics.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/localized-failure-registered-statics.ts" + ], + "diags": [] +} diff --git a/tests/corpus/localized-failure-registered-statics.ts b/tests/corpus/localized-failure-registered-statics.ts new file mode 100644 index 000000000..9ca49d4f2 --- /dev/null +++ b/tests/corpus/localized-failure-registered-statics.ts @@ -0,0 +1,46 @@ +// Localized failure, stage L2: a class that registers with dynamic slots +// runs its static field initializers where its statement evaluates, and a +// generic class's static methods compile once it registers. +type Progress = (done: number, total: number) => void; +type Callbacks = Progress | AbortSignal | Map string> | Set; + +function sideEffect(label: string): number { + console.log("static init", label); + return label.length; +} + +// Registers at its statement: the initializers run before the next line. +class Registry { + state: any = { n: 1 }; + static count = sideEffect("registry"); + static names: string[] = ["a", "b"]; + read(): number { + return this.state.n; + } +} + +console.log("after the class statement"); + +// Never referenced: its initializer runs all the same. +class Unused { + slot: any = null; + static ready = sideEffect("unused"); +} + +// A generic class whose static method has a dynamic parameter. +class Box { + value: T; + constructor(v: T) { + this.value = v; + } + static describe(target: Callbacks | undefined): string { + return target === undefined ? "none" : typeof target; + } + static label(): string { + return "box"; + } +} + +const r = new Registry(); +console.log(r.read(), Registry.count, Registry.names.join(",")); +console.log(Box.label(), Box.describe(undefined), Box.describe(new Set([1])), new Box(3).value); diff --git a/tests/harness/corpus-costs/localized-failure-registered-statics.ts.json b/tests/harness/corpus-costs/localized-failure-registered-statics.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/localized-failure-registered-statics.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 002a84f00..f8fa0da03 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -587,13 +587,18 @@ test("runtime-optional refined unions report blockers without crashing", () => { }); test("a rejected class reached only through a receiver probe reports its own diagnostic once", () => { - const { coverage } = analyze(join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts")); + const program = join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts"); + // The class's refusal, as the diagnostics corpus pins it (--tier=static-only). + const { coverage } = analyze(program, { tier: "static-only" }); const messages = coverage.diagnostics.map((d) => d.message); expect( messages.filter((m) => m.startsWith("'MapIterator'-typed class fields")), ).toHaveLength(1); expect(messages.some((m) => m.startsWith("constructing through a class value"))).toBe(true); expect(coverage.unreached?.diagnostics ?? []).toEqual([]); + // Localized failure retries the class where the probe's statement commits + // the flush, with the field policy lifted, and it compiles. + expect(analyze(program).coverage.diagnostics).toEqual([]); }); test("settled generic rest-order fences count each source statement once", () => {