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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 18:35:38 -07:00
co-authored by Claude Opus 5.5
parent 744585944a
commit ac31a15a62
16 changed files with 517 additions and 73 deletions
+1 -1
View File
@@ -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 <blockers|divergences|dynamic|node-islands|eager-node-islands|island-gaps>
--fail-on <blockers|divergences|dynamic|dynamic-slots|node-islands|eager-node-islands|island-gaps>
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
+51 -2
View File
@@ -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
@@ -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;
@@ -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<TypeFailureSlot, string> = {
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;
}
@@ -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
@@ -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
@@ -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<TypeFailureSlot> = new Set<TypeFailureSlot>(["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
@@ -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<string>();
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<TypeFailureSlot> = new Set<TypeFailureSlot>([
"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 {
@@ -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<string>[] = [];
/** 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<number, { lenient: IrType; code: string; reason: string }>();
/** 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<TypeFailureSlot>): 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,
@@ -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<number>`) — 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 &&
@@ -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, (x: number) => string>
| AbortSignal
| (() => number)
| Promise<number>;
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();
+43
View File
@@ -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, (x: number) => string> | Set<number>;
const report: Progress = (done, total) => console.log(`progress ${done}/${total}`);
const seen = new Set<number>([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);
@@ -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 <top level> 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 <top level> 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<string,…
-> 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<string,…
-> 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 <top level> turns dynamic
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
+39 -4
View File
@@ -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-"));