diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index 0a82775ca..62ccd0d0d 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -139,8 +139,8 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--print <native-link-info|diagnostics>
native-link-info: build an object (equivalent to --emit=obj) and print its machine-readable external link recipe as JSON instead of printing the artifact path. The document names the exact installed precompiled runtime pack and all link inputs, but does not invoke a linker. diagnostics: for build and coverage, print the versioned JSON diagnostics document described in Coverage Reports instead of the human output.
- --fail-on <blockers|divergences|dynamic>
- Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, or with dynamic, also when it has the SC6101 warning for untyped code that likely runs often. See Gating builds and CI.
+ --fail-on <blockers|divergences|dynamic|dynamic-slots>
+ Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, with dynamic, also when it has the SC6101 warning for untyped code that likely runs often, or with dynamic-slots, also when it has dynamic slots (SC6104). See Gating builds and CI.
--ffi <file>
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx
index db1193b98..d8719d9f3 100644
--- a/docs/content/docs/coverage.mdx
+++ b/docs/content/docs/coverage.mdx
@@ -83,6 +83,39 @@ Without a profile, a dynamic-tier function counts as likely to run often when a
The warning names at most five functions, most likely to run often first, and gives the total. A function expression is named by its own name, else by the variable, property or class field it is assigned to, else as `anonymous function`. Dynamic-tier code in npm packages is summarized on one line with each package's function count. A build that comes from the cache repeats the warning. `--no-dynamic-tier-warning` leaves it out, and `--fail-on=dynamic` fails a coverage run on it.
+## Dynamic slots
+
+A declared type that has no native representation does not fail the build. The field, parameter, return value, variable or expression that holds it becomes a checked dynamic slot: the parts of the type that compile keep their native layout, and the failing parts hold checked-dynamic values. Coverage lists the slots under the statement counts, and `scriptc build` prints one `SC6104` warning per slot.
+
+```console
+$ cat plugins.ts
+type Hook = (() => void) | AbortSignal | Map string>;
+
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+
+const plugins: Plugin[] = [{ name: "log", hook: () => console.log("hook ran") }];
+for (const p of plugins) {
+ const hook = p.hook;
+ if (typeof hook === "function") hook();
+ console.log(p.name);
+}
+
+$ scriptc coverage plugins.ts
+
+ statements analyzed 6
+ compile statically 6 (100%)
+ in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them)
+ dynamic slots 1 (declared types with no static lowering, held as checked-dynamic values; SC6104)
+ module global 'plugins' (declared 'Plugin[]') (dynamic pieces) plugins.ts:8:7
+
+ fully static — all analyzed statements compile to native code.
+```
+
+Here `Plugin` keeps its native `name` field, and only `hook` is checked-dynamic. Reading a dynamic value into typed code checks it against the type that reads it, so data that contradicts the declaration still throws a `TypeError`. Slots with the most dynamic operations are listed first. `--fail-on=dynamic-slots` fails a coverage run when any slot exists, and `--tier=static-only` reports the original errors instead.
+
## Diagnostic codes
Coverage and `scriptc build` report the same diagnostic codes. Common ones include:
@@ -109,7 +142,7 @@ By default, `scriptc coverage` exits with status 0 whenever the program type-che
$ scriptc coverage src/main.ts --fail-on=blockers
```
-`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, and `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often). A program that doesn't type-check always exits with status 1.
+`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often), and `--fail-on=dynamic-slots` also fails on [dynamic slots](#dynamic-slots). A program that doesn't type-check always exits with status 1.
Coverage caches its result. Running it again on an unchanged program, or after edits that only touch comments, reuses the previous verdict. Set `SCRIPTC_NO_CACHE=1` to turn the cache off.
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index 0e7afa2b1..88b2ca5fc 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -48,6 +48,8 @@ title: "Limitations"
../limitations/types-and-class-definitions/unions.mdx
+../limitations/types-and-class-definitions/dynamic-slots.mdx
+
../limitations/types-and-class-definitions/null-and-optional-values.mdx
../limitations/types-and-class-definitions/static-field-updates.mdx
diff --git a/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx b/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx
new file mode 100644
index 000000000..102c7ce0a
--- /dev/null
+++ b/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx
@@ -0,0 +1,5 @@
+- **Dynamic slots:** A field, parameter, return value, variable or expression whose declared type has no native representation compiles as a checked dynamic slot instead of failing the build.
+ The parts of the type that compile keep their native layout, and only the failing parts hold checked-dynamic values.
+ Operations on those values run in the dynamic tier, and reading one into typed code checks it against the type that reads it, so a value that contradicts its declaration throws a `TypeError` when typed code first uses it rather than when it is stored.
+ Each slot is an `SC6104` warning; `scriptc coverage --fail-on=dynamic-slots` fails on them, and `--tier=static-only` turns them back into build errors.
+ Values that scriptc cannot produce at all, such as `WeakRef` instances, still fail where they are created.
diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts
index 5d75e65b4..13cebd877 100644
--- a/packages/compiler/src/cli/usage.ts
+++ b/packages/compiler/src/cli/usage.ts
@@ -42,9 +42,10 @@ Options:
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
- values (SC6101), any Node island (eager-node-islands is
- the same until deferred evaluation lands), or a gap
- island (a package scriptc should compile but cannot yet)
+ values (SC6101), dynamic slots (SC6104), any Node
+ island (eager-node-islands is the same until deferred
+ evaluation lands), or a gap island (a package scriptc
+ should compile but cannot yet)
--backend code generator (llvm)
--optimization
native optimization posture (default: release/-O2). dev
diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts
index d78120f89..b94f43a4f 100644
--- a/packages/compiler/src/coverage/report.ts
+++ b/packages/compiler/src/coverage/report.ts
@@ -650,6 +650,8 @@ export function coverageEnvelope(
declaredType: slot.declaredType,
code: slot.code,
reason: slot.reason,
+ ...(slot.partial === true ? { partial: true } : {}),
+ dynamicOps: slot.operations ?? 0,
})),
}
: {}),
@@ -762,8 +764,15 @@ function renderDynamicSlots(
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))}`);
+ // The costly slots first: most dynamic operations, then source order.
+ const ranked = slots
+ .map((slot, index) => ({ slot, index }))
+ .sort((a, b) => (b.slot.operations ?? 0) - (a.slot.operations ?? 0) || a.index - b.index)
+ .map(({ slot }) => slot);
+ for (const slot of ranked.slice(0, DYNAMIC_SLOTS_SHOWN))
+ out.push(
+ ` ${dynamicSlotLabel(slot)}${slot.partial === true ? " (dynamic pieces)" : ""} ${c(DIM, `${where(slot.loc)}${(slot.operations ?? 0) > 0 ? `, ${slot.operations} dynamic operation${slot.operations === 1 ? "" : "s"}` : ""}`)}`,
+ );
if (slots.length > DYNAMIC_SLOTS_SHOWN)
out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`);
}
diff --git a/packages/compiler/src/diagnostics/dynamic-slots.ts b/packages/compiler/src/diagnostics/dynamic-slots.ts
index 09b4cd670..f3c1955ee 100644
--- a/packages/compiler/src/diagnostics/dynamic-slots.ts
+++ b/packages/compiler/src/diagnostics/dynamic-slots.ts
@@ -17,6 +17,12 @@ export interface DynamicSlotEntry {
/** The diagnostic the slot's declaration would have failed with. */
code: string;
reason: string;
+ /** Push-down: the slot keeps its type's compilable shape, and only the
+ * components that do not compile are checked-dynamic. */
+ partial?: boolean;
+ /** Dynamic operations the program performs on the slot's values (the
+ * dynamic-tier sites whose cause names it). */
+ operations?: number;
}
const SLOT_WORD: Record = {
@@ -38,7 +44,10 @@ export function dynamicSlotLabel(entry: DynamicSlotEntry): string {
export function dynamicSlotDiag(entry: DynamicSlotEntry): ScrDiagnostic {
return {
code: DYNAMIC_SLOT_CODE,
- message: `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`,
+ message:
+ entry.partial === true
+ ? `${dynamicSlotLabel(entry)} keeps its compilable shape with checked-dynamic pieces where its type has no static lowering (${entry.reason}); reads of those pieces into typed code are checked`
+ : `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`,
loc: entry.loc,
hint: "restate the declared type inside the compilable set to keep this slot static; scriptc coverage lists dynamic slots, and --fail-on=dynamic-slots fails on them",
};
diff --git a/packages/compiler/src/diagnostics/dynamic-tier.ts b/packages/compiler/src/diagnostics/dynamic-tier.ts
index 7970d3f42..ff6a79a57 100644
--- a/packages/compiler/src/diagnostics/dynamic-tier.ts
+++ b/packages/compiler/src/diagnostics/dynamic-tier.ts
@@ -26,7 +26,14 @@ export type UntypedCause =
| { kind: "any"; name: string }
| { kind: "json-binding"; name: string }
| { kind: "json" }
- | { kind: "javascript" };
+ | { kind: "javascript" }
+ | {
+ kind: "slot";
+ name: string;
+ declaredType: string;
+ /** The declared type this is a dynamic piece of (push-down). */
+ owner?: string;
+ };
export function causePhrase(cause: UntypedCause): string {
switch (cause.kind) {
@@ -38,6 +45,10 @@ export function causePhrase(cause: UntypedCause): string {
return "untyped `JSON.parse` result";
case "javascript":
return "untyped JavaScript file";
+ case "slot":
+ return cause.owner === undefined
+ ? `\`${cause.name}\` is a dynamic slot (declared \`${cause.declaredType}\`, which does not compile)`
+ : `\`${cause.name}\` is a dynamic piece of \`${cause.owner}\` (declared \`${cause.declaredType}\`, which does not compile)`;
}
}
@@ -95,5 +106,8 @@ export function renderPerformanceWarnings(diags: readonly ScrDiagnostic[]): stri
const blocks = diags.map(
(d) => `warning ${d.code}: ${d.message}${d.hint === undefined ? "" : `\n hint: ${d.hint}`}`,
);
- return `${blocks.join("\n\n")}\n\nThe program compiled. Adding types to these functions lets scriptc compile them to faster code.`;
+ const closing = diags.every((d) => d.code === "SC6104")
+ ? "The program compiled. Declared types that compile keep these slots static."
+ : "The program compiled. Adding types to these functions lets scriptc compile them to faster code.";
+ return `${blocks.join("\n\n")}\n\n${closing}`;
}
diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts
index f6f8b9d3d..889434e06 100644
--- a/packages/compiler/src/diagnostics/envelope.ts
+++ b/packages/compiler/src/diagnostics/envelope.ts
@@ -172,6 +172,8 @@ export interface DiagnosticsEnvelope {
declaredType: string;
code: string;
reason: string;
+ partial?: boolean;
+ dynamicOps: number;
})[];
/** coverage --explain-dynamic only: the report-only tier assignment. */
tiers?: TiersEnvelope;
diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts
index 3e0cea20a..03994970c 100644
--- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts
+++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts
@@ -30,7 +30,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import * as ts from "../ts7/adapter.js";
import { isCheckedJsSourceFile, isJsSourceFile } from "../program.js";
import { isNodeModulesPath } from "../resolve.js";
-import type { Lowerer } from "./lowerer.js";
+import type { DynamicSlot, Lowerer } from "./lowerer.js";
export interface HotDynamicFunction {
/** The IR function name (`%Point.norm`, `parse`, ...). */
@@ -208,9 +208,41 @@ function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): Untype
return null;
}
+/** A dynamic slot (localized failure): the node's declared type degraded
+ * to the checked dynamic, wholly or in pieces. Null while nothing degraded. */
+function slotCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
+ if (lowerer.degradedTypes.size === 0) return null;
+ if (!ts.isIdentifier(n) && !ts.isPropertyAccessExpression(n)) return null;
+ // The declared type of the binding or property, not its narrowed flow
+ // type (narrowing makes new checker types).
+ let symbol = lowerer.checker.getSymbolAtLocation(ts.isIdentifier(n) ? n : n.name);
+ if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
+ symbol = lowerer.checker.getAliasedSymbol(symbol);
+ const type =
+ symbol !== undefined && (symbol.flags & VALUE_BINDING) !== 0
+ ? lowerer.checker.getTypeOfSymbol(symbol)
+ : lowerer.checker.getTypeAtLocation(n);
+ const id = (type as { id?: number }).id ?? -1;
+ // A whole degraded type, or a dynamic piece of a pushed-down one. A type
+ // that is also a slot of its own belongs to that slot, not to a container
+ // it is a piece of.
+ const owner = lowerer.degradedTypes.has(id) ? undefined : lowerer.lenientPieces.get(id);
+ if (!lowerer.degradedTypes.has(id) && owner === undefined) return null;
+ const text = ts.getTextOfNode(n);
+ const declaredType = lowerer.checker.typeToString(type);
+ return {
+ kind: "slot",
+ name: text.length > 40 ? `${text.slice(0, 37)}...` : text,
+ declaredType,
+ ...(owner !== undefined && owner !== declaredType ? { owner } : {}),
+ };
+}
+
/** Why a node's value has no static type, or null. */
function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
if (isJsonParse(n)) return { kind: "json" };
+ const slot = slotCause(lowerer, n);
+ if (slot !== null) return slot;
const id = rootIdentifier(n);
return id === null ? null : identifierCause(lowerer, id);
}
@@ -219,15 +251,28 @@ function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
* the source does not say. The site's own subexpressions are asked first
* (the operand of a dynamic `for...of`), then its ancestors. */
function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null {
+ return causeSiteAt(lowerer, loc)?.cause ?? null;
+}
+
+/** causeAt, with the node that names the cause (none for an untyped
+ * JavaScript file). */
+function causeSiteAt(
+ lowerer: Lowerer,
+ loc: SrcLoc,
+): { cause: UntypedCause; node?: ts.Node } | null {
const sf = lowerer.program.getSourceFile(loc.file);
if (sf === undefined) return null;
if (isJsSourceFile(sf) && !isCheckedJsSourceFile(sf, lowerer.projectCheckJs))
- return { kind: "javascript" };
+ return { cause: { kind: "javascript" } };
const node = nodeAt(sf, loc.start, loc.end);
- let found: UntypedCause | null = null;
+ let found: { cause: UntypedCause; node: ts.Node } | null = null;
+ const ask = (n: ts.Node): void => {
+ const cause = nodeCause(lowerer, n);
+ if (cause !== null) found = { cause, node: n };
+ };
const descend = (n: ts.Node, depth: number): void => {
if (found !== null || depth > 3) return;
- found = nodeCause(lowerer, n);
+ ask(n);
if (found === null)
ts.forEachChild(n, (child) => (descend(child, depth + 1), found !== null || undefined));
};
@@ -237,7 +282,7 @@ function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null {
found === null && n !== undefined && n !== sf;
n = n.parent
)
- found = nodeCause(lowerer, n);
+ ask(n);
return found;
}
@@ -254,6 +299,10 @@ function causeOf(lowerer: Lowerer, sites: readonly { loc: SrcLoc }[]): UntypedCa
/** Generated functions (see scanDynamicTier), after any module prefix. */
const GENERATED = /^%*(?:m\d+\.)?%*(?:dyn|caught|init)\./;
+/** Generated helpers that repeat their callers' locations: `%dyn.*` and
+ * `%caught.*`, but not a module's top level (`%init.*`). */
+const HELPERS = /^%*(?:m\d+\.)?%*(?:dyn|caught)\./;
+
/** The likely-hot dynamic-tier functions of `module` (see the header). */
export function scanDynamicTier(lowerer: Lowerer, module: IrModule): DynamicTierReport {
const facts = new Map();
@@ -382,3 +431,69 @@ export function dynamicTierWarning(
first.loc,
);
}
+
+/** Localized failure: dynamic operations attributed to each dynamic slot
+ * of `slots` (by index), over the dynamic-tier functions of the program. A
+ * site counts for the slot the SC6101 cause search names at it: the slot
+ * declared by the binding the site reads (for a dynamic piece, the binding
+ * its access starts from), or else the one slot of the declared type. */
+export function dynamicSlotOperationCounts(
+ lowerer: Lowerer,
+ bodies: readonly IrFunction[],
+ slots: readonly DynamicSlot[],
+): number[] {
+ const counts = slots.map(() => 0);
+ if (lowerer.degradedTypes.size === 0) return counts;
+ for (const fn of bodies) {
+ // Generated helpers repeat their callers' locations; top-level code
+ // (`%init.*`) counts like any function.
+ if (HELPERS.test(fn.name)) continue;
+ for (const site of dynamicOperationSites(fn)) {
+ const found = causeSiteAt(lowerer, site.loc);
+ if (found?.cause.kind !== "slot" || found.node === undefined) continue;
+ const declaredType = found.cause.owner ?? found.cause.declaredType;
+ const index = slotOfSite(lowerer, slots, found.node, declaredType, found.cause.owner);
+ if (index >= 0) counts[index]!++;
+ }
+ }
+ return counts;
+}
+
+/** The index in `slots` of the slot a dynamic operation on `node` (an
+ * identifier or property access the cause search named) belongs to, or -1
+ * when the declared type is not one slot's and no declaration says. */
+function slotOfSite(
+ lowerer: Lowerer,
+ slots: readonly DynamicSlot[],
+ node: ts.Node,
+ declaredType: string,
+ owner: string | undefined,
+): number {
+ const candidates: number[] = [];
+ slots.forEach((slot, i) => {
+ if (slot.failure.declaredType === declaredType) candidates.push(i);
+ });
+ if (candidates.length <= 1) return candidates[0] ?? -1;
+ // The binding: the identifier or property itself for a whole slot, the
+ // root of the access for a piece of a container.
+ const named =
+ owner !== undefined
+ ? rootIdentifier(node)
+ : ts.isPropertyAccessExpression(node)
+ ? node.name
+ : node;
+ if (named === null) return -1;
+ let symbol = lowerer.checker.getSymbolAtLocation(named);
+ if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
+ symbol = lowerer.checker.getAliasedSymbol(symbol);
+ for (const decl of symbol?.declarations ?? []) {
+ const file = ts.getSourceFileOfNode(decl).fileName;
+ const start = ts.tokenStartOfNode(decl);
+ const index = candidates.find((i) => {
+ const loc = slots[i]!.loc;
+ return loc.file === file && loc.start >= start && loc.start < decl.end;
+ });
+ if (index !== undefined) return index;
+ }
+ return -1;
+}
diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts
index 853e9ab48..49834eb56 100644
--- a/packages/compiler/src/frontend/lowering/lower-stmts.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts
@@ -759,10 +759,18 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
// out of tdzPredeclared; a retry after a later declarator degrades
// must initialize the same box again.
const tdzBefore = new Map(lowerer.ctx.tdzPredeclared);
+ // The first attempt's diagnostics: a statement that still fails
+ // after degrading reports them, since a retry does not repeat what
+ // lowered once (a poisoned generic instance) and its own diagnostics
+ // can miss the failure's cause.
+ let firstAttempt: ScrDiagnostic[] | null = null;
try {
for (let attempt = 0; ; attempt++) {
+ const hits = new Set();
try {
- lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences);
+ lowerer.withPartialHits(hits, () =>
+ lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences),
+ );
break;
} catch (e) {
// Localized failure's statement commit point (design §3.3): a
@@ -770,9 +778,21 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
// global degrades that type and the statement lowers again.
// A declaration this statement flushed inside a probe (where
// no retry runs) can retry here too.
- if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e;
+ firstAttempt ??= lowerer.diags.slice(diagsBefore);
+ const giveUp = (): unknown => {
+ if (attempt > 0) lowerer.restoreFirstAttemptDiags(diagsBefore, firstAttempt!);
+ return e;
+ };
+ if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw giveUp();
const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS);
- if (slot === null && !lowerer.reopenFlushedAtCommit(diagsBefore)) throw e;
+ // Pushed-down types the failed attempt mapped widen to the
+ // whole slot before anything else is given up.
+ if (
+ slot === null &&
+ !lowerer.reopenFlushedAtCommit(diagsBefore) &&
+ !lowerer.widenPartials(hits)
+ )
+ throw giveUp();
// A failed attempt adds nothing to `out` but the TDZ boxes
// forward captures declared (they stay declared), so the
// retry appends after them.
@@ -864,7 +884,8 @@ const MAX_DEGRADE_RETRIES = 16;
/** 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). */
+ * (stage L2), and the types of the statement's own expressions: casts,
+ * literals with a contextual type, spread clones, temporaries (stage L4). */
const STATEMENT_SLOTS: ReadonlySet = new Set([
"local",
"global",
@@ -872,6 +893,8 @@ const STATEMENT_SLOTS: ReadonlySet = new Set([
"param",
"return",
"field",
+ "member",
+ "expression",
]);
/** One attempt at lowering a statement of lowerStmts into `out`. */
@@ -1767,8 +1790,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
const symbol = lowerer.checker.getSymbolAtLocation(stmt.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
- lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = lowerer.coerceToExpected(lowerer.lowerClassExpression(stmt), DYN);
+ lowerer.ctx.tdzPredeclared.delete(symbol!);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(stmt) };
}
const type: IrType = { kind: "classval", className: info.def.name };
@@ -5788,8 +5811,8 @@ export function lowerVarDecl(
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
- lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = genericBindingStoredValue(lowerer, decl, info);
+ lowerer.ctx.tdzPredeclared.delete(symbol!);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(decl) };
}
return genericBindingPresenceStore(info, decl);
@@ -5962,11 +5985,13 @@ export function lowerVarDecl(
// A forward-captured binding pre-declared as a TDZ box (an earlier
// function in this scope captured it — predeclareForwardCapture): the
// binding and its scope-entry varDecl already exist; the source
- // declaration is the one initializing `assign` into the shared box.
+ // declaration is the one initializing `assign` into the shared box. The
+ // box stays pre-declared until the initializer lowers: a statement the
+ // commit point lowers again (localized failure) initializes the same box.
const pre = declSymbol ? lowerer.ctx.tdzPredeclared.get(declSymbol) : undefined;
if (pre && decl.initializer) {
- lowerer.ctx.tdzPredeclared.delete(declSymbol!);
const init = lowerer.lowerExprExpecting(decl.initializer, pre.type);
+ lowerer.ctx.tdzPredeclared.delete(declSymbol!);
return { kind: "assign", localId: pre.id, value: init, initializes: true, loc: locOf(decl) };
}
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index 5c58f729a..715500fe1 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -187,7 +187,7 @@ import { ClassProtocols } from "./class-protocols.js";
import { earlyReadVarSymbols } from "./var-hoisting.js";
import { recordTextCodecClass } from "../../ir/ir.js";
import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js";
-import { dynamicTierWarning } from "./dynamic-tier.js";
+import { dynamicSlotOperationCounts, dynamicTierWarning } from "./dynamic-tier.js";
import { promoteJsvals } from "../../ir/jsval.js";
import {
analyzeTiers,
@@ -749,6 +749,9 @@ export interface DynamicSlot {
failure: TypeFailure;
code: string;
reason: string;
+ /** The slot keeps its type's compilable shape; only the failing
+ * components are checked-dynamic (push-down, stage L3). */
+ partial?: true;
}
/** Localized failure: rounds of the declaration flush retry. */
@@ -1000,6 +1003,14 @@ type RuntimeFenceTarget =
| RuntimeFenceFunctionTarget
| RuntimeFenceClosureTarget;
+/** A degraded checker type (Lowerer.degradedTypes): its lenient mapping and
+ * the failure that degraded it. */
+export interface DegradedType {
+ lenient: IrType | null;
+ code: string;
+ reason: string;
+}
+
/** The Lowerer's pass configuration (see lowerToIr). */
export interface LowererMode {
/** Symbols whose deferred diagnostics reachable emit already flushed —
@@ -1008,10 +1019,14 @@ export interface LowererMode {
/** Localized failure: the types reachable emit degraded (the coverage
* remainder maps them alike, so the declarations reachable emit
* registered with dynamic slots collect there too). */
- degradedTypes?: ReadonlyMap;
+ degradedTypes?: ReadonlyMap;
/** Localized failure: the declarations reachable emit registered with
* dynamic slots (the coverage remainder registers them too). */
retriedSymbols?: ReadonlySet;
+ /** Localized failure: degrade every type to the whole-slot checked
+ * dynamic, never to its pushed-down shape (lowerToIr's comparison
+ * pass). */
+ wholeSlotDegradation?: boolean;
/** LowerOptions.externalTypes, threaded through every lowering pass. */
externalTypes?: ReadonlyMap;
/** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */
@@ -1229,6 +1244,39 @@ export function lowerToIr(
let result = emitted.result;
let resultLowerer = reachableEmit;
timing("reachable-emit", { reachable: reachable.size });
+ // Localized failure (stage L3): push-down must never cost a failing
+ // program more errors than the whole-slot checked dynamic. A pushed-down
+ // type is baked into the globals, fields and signatures that hold it, so
+ // when the program still fails, the emit runs once more with every
+ // degraded type held whole, and the pass with fewer errors wins (ties
+ // keep push-down). A program that compiles never takes this path.
+ if (result.diagnostics.length > 0 && reachableEmit.hasPartialDegradedTypes()) {
+ const whole = new Lowerer(program, entry, moduleOrder, {
+ frontendServices: options.frontendServices,
+ projectCheckJs: options.projectCheckJs ?? false,
+ targetPlatform,
+ startupCrash,
+ inlineRequireCycleCalls,
+ ffiImports,
+ libraryCallbacks,
+ forkTargets,
+ workerTargets,
+ externalTypes,
+ externalTypeSpecifiersByFile,
+ tierMode,
+ ffiBindingSymbols: ffiValidation.symbolsByName,
+ wholeSlotDegradation: true,
+ });
+ for (const d of extraModuleCycleDiags) whole.pushDiag(d);
+ for (const d of ffiValidation.diagnostics) whole.pushDiag(d);
+ const wholeEmitted = whole.emitReachable(options.libRoots);
+ if (wholeEmitted.result.diagnostics.length < result.diagnostics.length) {
+ reachable = wholeEmitted.reachable;
+ result = wholeEmitted.result;
+ resultLowerer = whole;
+ }
+ timing("whole-slot-relower", { reachable: wholeEmitted.reachable.size });
+ }
// The general stream representation (TypeMapperCtx.generalNodeStreams)
// is a per-program decision: only a program whose reachable code moved
// a general stream into a stdio-represented stream slot lowers again
@@ -1255,6 +1303,7 @@ export function lowerToIr(
ffiBindingSymbols: ffiValidation.symbolsByName,
generalNodeStreams: true,
...(dyn ? { dynNodeStreams: true } : {}),
+ ...(resultLowerer.wholeSlotDegradation ? { wholeSlotDegradation: true } : {}),
});
for (const d of extraModuleCycleDiags) general.pushDiag(d);
for (const d of ffiValidation.diagnostics) general.pushDiag(d);
@@ -1297,6 +1346,7 @@ export function lowerToIr(
externalTypes,
externalTypeSpecifiersByFile,
tierMode,
+ ...(resultLowerer.wholeSlotDegradation ? { wholeSlotDegradation: true } : {}),
});
for (const d of extraModuleCycleDiags) emit.pushDiag(d);
for (const d of ffiValidation.diagnostics) emit.pushDiag(d);
@@ -1318,13 +1368,20 @@ export function lowerToIr(
}
// Localized failure (SC6104): one performance warning per dynamic slot.
if (resultLowerer.dynamicSlots.length > 0) {
- const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot) => ({
+ const operations = dynamicSlotOperationCounts(
+ resultLowerer,
+ resultLowerer.dynamicBodies,
+ resultLowerer.dynamicSlots,
+ );
+ const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot, i) => ({
+ operations: operations[i]!,
loc: slot.loc,
name: slot.failure.name,
slot: slot.failure.slot,
declaredType: slot.failure.declaredType,
code: slot.code,
reason: slot.reason,
+ ...(slot.partial ? { partial: true } : {}),
}));
result = {
...result,
@@ -3887,13 +3944,24 @@ export class Lowerer {
* at a committed slot, with their lenient mapping. Filled only at the
* commit points (degradeAtCommit), so a program that compiles today
* never fills it. */
- readonly degradedTypes = new Map();
+ readonly degradedTypes = new Map();
+ /** A pushed-down (partial) degraded type was mapped (hasPartialDegradedTypes). */
+ private partialDegradedTypesMapped = false;
+ /** The components lenient mappings turned into the checked dynamic, by
+ * checker type id, with the declared type they are pieces of (SC6101
+ * causes and per-slot operation counts). */
+ readonly lenientPieces = new Map();
+ /** One frame per unit a commit point may retry: the pushed-down
+ * (partial) degraded types its lowering mapped (withPartialHits). */
+ readonly partialHits: Set[] = [];
/** 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;
+ /** LowererMode.wholeSlotDegradation. */
+ readonly wholeSlotDegradation: boolean;
/** Bodies run() skipped because their declaration had not registered. */
readonly skippedBodies = new Set();
/** Declarations retryDeferred registered, in order: run() lowers the
@@ -4073,9 +4141,16 @@ export class Lowerer {
) {
// The mode's options, sorted by name; the values derived from them follow.
this.alreadyFlushed = mode.alreadyFlushed ?? new Set();
+ // Lenient forms are IR types of the other pass's shape tables: only the
+ // whole-slot dynamic carries over as is.
if (mode.degradedTypes)
- for (const [id, degraded] of mode.degradedTypes) this.degradedTypes.set(id, degraded);
+ for (const [id, degraded] of mode.degradedTypes)
+ this.degradedTypes.set(id, {
+ ...degraded,
+ lenient: degraded.lenient?.kind === "dyn" ? DYN : null,
+ });
this.priorRetriedSymbols = mode.retriedSymbols ?? new Set();
+ this.wholeSlotDegradation = mode.wholeSlotDegradation ?? false;
this.externalTypes = mode.externalTypes ?? new Map();
this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null;
this.ffiImports = mode.ffiImports ?? [];
@@ -4114,7 +4189,21 @@ export class Lowerer {
resolveBoundAliasTs: this.boundAliasTsResolver,
degradedType: (type) => {
const id = (type as { id?: number }).id;
- return id === undefined ? null : (this.degradedTypes.get(id)?.lenient ?? null);
+ if (id === undefined) return null;
+ const degraded = this.degradedTypes.get(id);
+ if (degraded === undefined) return null;
+ // An entry handed over from another pass (the coverage remainder)
+ // re-derives its lenient form in this lowerer's shape tables; the
+ // checked dynamic cuts the knot while it does.
+ if (degraded.lenient === null) {
+ degraded.lenient = DYN;
+ degraded.lenient = this.lenientType(type);
+ }
+ if (degraded.lenient.kind !== "dyn") {
+ this.partialHits[this.partialHits.length - 1]?.add(id);
+ this.partialDegradedTypesMapped = true;
+ }
+ return degraded.lenient;
},
...(shadow !== undefined ? { canonShadow: shadow } : {}),
isErasing: () => this.erasingDepth > 0,
@@ -9763,8 +9852,9 @@ export class Lowerer {
if (!failure.degradable || failure.family === "dyn-field-policy") return null;
if (!slots.has(failure.slot)) return null;
const id = (type as { id?: number }).id;
- // Already degraded and still failing: no progress, commit as today.
- if (id === undefined || this.degradedTypes.has(id)) return null;
+ // Already degraded to the whole-slot dynamic and still failing: no
+ // progress, commit as today.
+ if (id === undefined || this.degradedTypes.get(id)?.lenient?.kind === "dyn") return null;
let committed: ScrDiagnostic | undefined;
for (let i = this.diags.length - 1; i >= 0; i--) {
if (this.diags[i]!.typeFailure !== failure) continue;
@@ -9772,8 +9862,15 @@ export class Lowerer {
this.diags.splice(i, 1);
}
if (committed === undefined) return null;
- this.degradedTypes.set(id, { lenient: DYN, code: committed.code, reason: committed.message });
- return { loc: committed.loc, failure, code: committed.code, reason: committed.message };
+ this.degradeType(type, committed.code, committed.message);
+ const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn";
+ return {
+ ...(partial ? { partial: true as const } : {}),
+ loc: committed.loc,
+ failure,
+ code: committed.code,
+ reason: committed.message,
+ };
}
/** A declaration slot whose type maps through degradedTypes is a
@@ -9782,7 +9879,9 @@ export class Lowerer {
noteDegradedSlot(node: ts.Node, type: ts.Type): void {
if (this.diagSink !== null) return;
const degraded = this.degradedTypes.get((type as { id?: number }).id ?? -1);
- if (degraded === undefined) return;
+ // A pushed-down type is one slot, reported where it degraded; only
+ // whole-slot dynamic declarations are reported one by one.
+ if (degraded === undefined || degraded.lenient?.kind !== "dyn") return;
const failure = typeFailureOf(node, this.checker.typeToString(type), "unsupported");
if (failure.slot === "expression" || failure.slot === "member") return;
this.noteDynamicSlot({
@@ -9840,6 +9939,19 @@ export class Lowerer {
this.diags.push(...kept);
}
+ /** A unit that still fails after its retries commits its first
+ * attempt's own diagnostics (`first`, taken before the first
+ * degradation) in place of the last attempt's. The diagnostics the
+ * retries carry (dropAttemptDiags) stay as they are now: a declaration
+ * reopened since may have registered. */
+ restoreFirstAttemptDiags(diagsBefore: number, first: readonly ScrDiagnostic[]): void {
+ const carried = (d: ScrDiagnostic): boolean =>
+ d.rejects !== undefined || this.loweredOnceDiags.has(d);
+ const kept = this.diags.slice(diagsBefore).filter(carried);
+ this.diags.length = diagsBefore;
+ this.diags.push(...first.filter((d) => !carried(d)), ...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
@@ -9849,21 +9961,100 @@ export class Lowerer {
const slots: DynamicSlot[] = [];
for (let attempt = 0; ; attempt++) {
const mark = this.diags.length;
+ const hits = new Set();
try {
- const result = lower();
+ const result = this.withPartialHits(hits, 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);
+ if (slot === null && !this.widenPartials(hits)) return null;
+ if (slot !== null) slots.push(slot);
this.dropAttemptDiags(mark);
}
}
}
+ /** Localized failure's lenient mapping (stage L3, push-down): the type
+ * mapped with each failing component as the checked dynamic, so a record
+ * keeps its static members, an array of records its records, and a
+ * function its arity. The whole slot is the checked dynamic when even
+ * that shape does not map. Throwaway context: nothing memoizes. */
+ lenientType(type: ts.Type): IrType {
+ const { typeMemo: _strictMemo, ...strict } = this.typeCtx;
+ const owner = this.checker.typeToString(type);
+ const mapped = mapType(type, {
+ ...strict,
+ lenient: true,
+ canMemoizeType: () => false,
+ onLenientPiece: (piece) => {
+ const id = (piece as { id?: number }).id;
+ if (id !== undefined && !this.lenientPieces.has(id)) this.lenientPieces.set(id, owner);
+ },
+ });
+ return mapped === null || mapped.kind === "void" ? DYN : mapped;
+ }
+
+ /** Degrades a type for a commit point: its lenient (pushed-down) form
+ * first; a type already degraded that still fails falls back to the
+ * whole-slot checked dynamic. False when nothing is left to degrade. */
+ private degradeType(type: ts.Type, code: string, reason: string): boolean {
+ const id = (type as { id?: number }).id;
+ if (id === undefined) return false;
+ const known = this.degradedTypes.get(id);
+ if (known !== undefined) {
+ if (known.lenient?.kind === "dyn") return false;
+ this.degradedTypes.set(id, { lenient: DYN, code, reason });
+ return true;
+ }
+ this.degradedTypes.set(id, {
+ lenient: this.wholeSlotDegradation ? DYN : this.lenientType(type),
+ code,
+ reason,
+ });
+ return true;
+ }
+
+ /** Some degraded type kept a pushed-down (partial) shape, or one was
+ * mapped before a retry widened it: the IR lowered before the retry keeps
+ * that shape. */
+ hasPartialDegradedTypes(): boolean {
+ if (this.partialDegradedTypesMapped) return true;
+ for (const degraded of this.degradedTypes.values())
+ if (degraded.lenient !== null && degraded.lenient.kind !== "dyn") return true;
+ return false;
+ }
+
+ /** Runs a unit a commit point may retry, recording the pushed-down
+ * types it maps; the frame's hits also count for the enclosing unit. */
+ withPartialHits(hits: Set, run: () => T): T {
+ this.partialHits.push(hits);
+ try {
+ return run();
+ } finally {
+ this.partialHits.pop();
+ const outer = this.partialHits[this.partialHits.length - 1];
+ if (outer !== undefined) for (const id of hits) outer.add(id);
+ }
+ }
+
+ /** Push-down is a refinement, never a new failure: a unit that still
+ * fails after mapping pushed-down types falls back to the whole-slot
+ * checked dynamic for them. True when some type widened (retry). */
+ widenPartials(hits: ReadonlySet): boolean {
+ if (this.tierMode !== "auto" || this.diagSink !== null) return false;
+ let widened = false;
+ for (const id of hits) {
+ const degraded = this.degradedTypes.get(id);
+ if (degraded === undefined || degraded.lenient?.kind === "dyn") continue;
+ degraded.lenient = DYN;
+ widened = true;
+ }
+ return widened;
+ }
+
/** Records a dynamic slot whose unit lowered with it (one per site). */
noteDynamicSlot(slot: DynamicSlot): void {
const seen = this.dynamicSlots.some(
@@ -12967,8 +13158,9 @@ export class Lowerer {
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[] = [];
+ // ran) and their entries before it, for retryDeferredAtDeclaration to
+ // restore when it fails.
+ const degraded: { id: number; prior: DegradedType | undefined }[] = [];
try {
return this.retryDeferredRounds(symbol, collector, diags, slots, degraded);
} finally {
@@ -12981,11 +13173,11 @@ export class Lowerer {
collector: { symbolOf: () => ts.Symbol | undefined; collect: () => void },
diags: readonly ScrDiagnostic[],
slots: DynamicSlot[],
- degraded: number[],
+ degraded: { id: number; prior: DegradedType | undefined }[],
): boolean {
let current = diags;
for (let round = 0; round < MAX_DECLARATION_RETRIES; round++) {
- const failing: { diag: ScrDiagnostic; failure: TypeFailure; id: number }[] = [];
+ const failing: { diag: ScrDiagnostic; failure: TypeFailure; type: ts.Type }[] = [];
let retriable = false;
for (const diag of current) {
if (this.retriableDiags.has(diag)) {
@@ -13003,28 +13195,54 @@ export class Lowerer {
const id = (type as { id?: number } | undefined)?.id;
if (
failure === undefined ||
+ type === undefined ||
id === undefined ||
!failure.degradable ||
!DECLARATION_SLOTS.has(failure.slot) ||
- this.degradedTypes.has(id)
+ this.degradedTypes.get(id)?.lenient?.kind === "dyn"
)
return false;
- failing.push({ diag, failure, id });
+ failing.push({ diag, failure, type });
}
// A round makes progress by degrading a type, or (once) by running
// a collection that only waited for another declaration.
if (failing.length === 0 && !(retriable && round === 0)) return false;
- for (const { diag, failure, id } of failing) {
- this.degradedTypes.set(id, { lenient: DYN, code: diag.code, reason: diag.message });
- degraded.push(id);
- slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message });
+ for (const { diag, failure, type } of failing) {
+ const id = (type as { id?: number }).id ?? -1;
+ const prior = this.degradedTypes.get(id);
+ degraded.push({
+ id,
+ prior:
+ prior === undefined
+ ? undefined
+ : { lenient: prior.lenient, code: prior.code, reason: prior.reason },
+ });
+ this.degradeType(type, diag.code, diag.message);
+ const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn";
+ slots.push({
+ loc: diag.loc,
+ failure,
+ code: diag.code,
+ reason: diag.message,
+ ...(partial ? { partial: true as const } : {}),
+ });
}
this.deferredDiags.delete(symbol);
const lifted = this.liftDynFieldPolicy;
this.liftDynFieldPolicy = true;
- let registered: boolean;
+ // Pushed-down types that did not help widen to the whole slot, and the
+ // collection runs again (a few times at most).
+ let registered = false;
try {
- registered = this.collectDeferring(collector.symbolOf, collector.collect) === null;
+ for (let widen = 0; widen < 4; widen++) {
+ const hits = new Set();
+ registered =
+ this.withPartialHits(hits, () =>
+ this.collectDeferring(collector.symbolOf, collector.collect),
+ ) === null;
+ if (registered || !this.widenPartials(hits)) break;
+ this.deferredDiags.delete(symbol);
+ }
} finally {
this.liftDynFieldPolicy = lifted;
}
@@ -13046,7 +13264,7 @@ export class Lowerer {
return false;
}
- private retryDegraded: number[] = [];
+ private retryDegraded: { id: number; prior: DegradedType | undefined }[] = [];
/** A deferred class with static field initializers is reached where its
* statement evaluates: Node runs the initializers there, referenced or
@@ -13062,7 +13280,11 @@ export class Lowerer {
this.retryDegraded = [];
registered = this.retryDeferred(symbol, diags);
if (!registered) {
- for (const id of this.retryDegraded) this.degradedTypes.delete(id);
+ for (let i = this.retryDegraded.length - 1; i >= 0; i--) {
+ const { id, prior } = this.retryDegraded[i]!;
+ if (prior === undefined) this.degradedTypes.delete(id);
+ else this.degradedTypes.set(id, prior);
+ }
this.deferredDiags.set(symbol, [...diags]);
}
this.retryDegraded = [];
@@ -13528,8 +13750,7 @@ export class Lowerer {
/* ── scoping and captures ─────────────────────────────────────────── */
declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal {
- if (type.kind === "dyn" && this.degradedTypes.size > 0)
- this.noteDegradedSlot(nameNode, this.typeOf(nameNode));
+ if (this.degradedTypes.size > 0) this.noteDegradedSlot(nameNode, this.typeOf(nameNode));
const symbol = this.checker.getSymbolAtLocation(nameNode);
const local = declareContextLocal(
this.ctx,
diff --git a/packages/compiler/src/frontend/lowering/type-failure.ts b/packages/compiler/src/frontend/lowering/type-failure.ts
index 3502a9bbf..31004ca4f 100644
--- a/packages/compiler/src/frontend/lowering/type-failure.ts
+++ b/packages/compiler/src/frontend/lowering/type-failure.ts
@@ -138,7 +138,11 @@ export function typeFailureSlot(node: ts.Node): { slot: TypeFailureSlot; name: s
if (!declared && !ts.isTypeNode(current) && !ts.isTypeNode(parent)) break;
current = parent;
}
- const text = ts.getTextOfNode(node);
+ // A type node names its expression (`raw as Plugin`, not `Plugin`).
+ let named: ts.Node = node;
+ while (ts.isTypeNode(named) && named.parent !== undefined && !ts.isSourceFile(named.parent))
+ named = named.parent;
+ const text = ts.getTextOfNode(named).replace(/\s+/g, " ");
return { slot: "expression", name: text.length > 60 ? `${text.slice(0, 57)}...` : text };
}
diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts
index b2fc774f2..40ca10b38 100644
--- a/packages/compiler/src/frontend/type-mapper.ts
+++ b/packages/compiler/src/frontend/type-mapper.ts
@@ -1287,6 +1287,14 @@ export interface TypeMapperCtx {
* only after the strict mapping (and its bound-alias retry) returns null,
* at every depth, so every occurrence of a degraded type maps alike. */
degradedType?: (type: ts.Type) => IrType | null;
+ /** Localized failure's lenient mapping (Lowerer.lenientType): a nested
+ * component whose mapping fails maps to the checked dynamic instead, so a
+ * failing type keeps its outer shape with dynamic pieces. Only ever set
+ * on a throwaway context without memoization. */
+ lenient?: boolean;
+ /** With `lenient`: told each component the lenient mapping turned into
+ * the checked dynamic (Lowerer.lenientPieces). */
+ onLenientPiece?: (type: ts.Type) => void;
/** Canonical normalization's shadow mode (canon-map.ts): called after
* each successful top-level mapping; installed only under
* SCRIPTC_CANON_SHADOW=1. It records, and never changes a result. */
@@ -1916,6 +1924,13 @@ function mapTypeEntry(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
contextResolutions++;
}
}
+ // Push-down (stage L3): in a lenient mapping, the smallest failing
+ // component is the checked dynamic.
+ if (mapped === null && ctx.lenient === true && !topLevel) {
+ mapped = DYN;
+ contextResolutions++;
+ ctx.onLenientPiece?.(type);
+ }
if (
mapped !== null &&
memoKey !== null &&
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-degraded-initializer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-degraded-initializer.ts.json
new file mode 100644
index 000000000..90cdeee2d
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-degraded-initializer.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/localized-failure-degraded-initializer.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json
new file mode 100644
index 000000000..97f6c7ce3
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/localized-failure-expressions.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json
new file mode 100644
index 000000000..554c74bac
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/localized-failure-push-down.ts"
+ ],
+ "diags": []
+}
diff --git a/tests/corpus/localized-failure-degraded-initializer.ts b/tests/corpus/localized-failure-degraded-initializer.ts
new file mode 100644
index 000000000..9265c113f
--- /dev/null
+++ b/tests/corpus/localized-failure-degraded-initializer.ts
@@ -0,0 +1,30 @@
+// Localized failure, stage L4, at a forward-captured binding: functions
+// declared before a const capture it, and the const's initializer has an
+// expression whose type does not compile. The statement lowers again with
+// that expression degraded, and it initializes the binding the functions
+// captured: before the declaration a call throws ReferenceError, after it
+// the call reads the value.
+type Hook = Map string> | AbortSignal | (() => number) | Set;
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+function load(text: string): unknown {
+ return JSON.parse(text);
+}
+function main(): void {
+ function nameOf(): string {
+ return plugin.name;
+ }
+ function attempt(): string {
+ try {
+ return nameOf();
+ } catch (e) {
+ return e instanceof ReferenceError ? "ReferenceError" : "other";
+ }
+ }
+ console.log(attempt());
+ const plugin = { name: (load('{"name":"tags","hook":null}') as Plugin).name };
+ console.log(attempt(), nameOf().length);
+}
+main();
diff --git a/tests/corpus/localized-failure-expressions.ts b/tests/corpus/localized-failure-expressions.ts
new file mode 100644
index 000000000..e41e16fae
--- /dev/null
+++ b/tests/corpus/localized-failure-expressions.ts
@@ -0,0 +1,24 @@
+// Localized failure, stage L4: expressions whose own type does not
+// compile (casts, literals with an unmappable contextual type, spread
+// clones, temporaries).
+type Hook = Map string> | AbortSignal | (() => number) | Set;
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+function load(text: string): unknown {
+ return JSON.parse(text);
+}
+function count(p: { name: string }): number {
+ return p.name.length;
+}
+const raw = load('{"name":"tags","hook":null}');
+// A cast to a type that does not compile.
+console.log(count(raw as Plugin));
+// A literal whose contextual type does not compile, used without a slot.
+console.log(count({ name: "lit", hook: () => 1 } as Plugin));
+// A spread clone of such a value.
+const base = { name: "base", hook: new Set(["a"]) };
+console.log(count({ ...(base as Plugin), name: "clone" }));
+// A temporary: an array literal of them, mapped.
+console.log([{ name: "a", hook: () => 2 } as Plugin, { name: "bb", hook: new Set() } as Plugin].map((p) => p.name).join("+"));
diff --git a/tests/corpus/localized-failure-push-down.ts b/tests/corpus/localized-failure-push-down.ts
new file mode 100644
index 000000000..a10a453f8
--- /dev/null
+++ b/tests/corpus/localized-failure-push-down.ts
@@ -0,0 +1,52 @@
+// Localized failure, stage L3: push-down. A declared type that does not
+// compile keeps its compilable shape; only the failing component is a
+// checked dynamic piece.
+type Hook = Map string> | AbortSignal | (() => number) | Set;
+
+interface Plugin {
+ name: string;
+ priority: number;
+ hook: Hook;
+}
+
+class Registry {
+ plugins: Plugin[] = [];
+ byName = new Map();
+ pending: Promise | undefined;
+ add(p: Plugin): void {
+ this.plugins.push(p);
+ this.byName.set(p.name, p);
+ }
+ total(): number {
+ let sum = 0;
+ for (const p of this.plugins) sum += p.priority;
+ return sum;
+ }
+ names(): string {
+ return this.plugins.map((p) => p.name).join(",");
+ }
+ run(name: string): string {
+ const p = this.byName.get(name);
+ if (p === undefined) return "missing";
+ const h = p.hook;
+ return typeof h === "function" ? `${p.name}=${h()}` : h instanceof Set ? `${p.name}#${h.size}` : "other";
+ }
+}
+
+type Handler = (event: string, extra: Hook) => number;
+function dispatch(handlers: Handler[], event: string): number {
+ let n = 0;
+ for (const h of handlers) n += h(event, () => event.length);
+ return n;
+}
+
+const r = new Registry();
+const counter: Plugin = { name: "counter", priority: 2, hook: () => 41 };
+r.add(counter);
+r.add({ name: "tags", priority: 5, hook: new Set(["a", "b"]) });
+console.log(r.total(), r.names(), r.run("counter"), r.run("tags"), r.run("x"));
+counter.priority = 10;
+console.log(r.total(), r.byName.get("counter") === counter, JSON.stringify(r.plugins.map((p) => [p.name, p.priority])));
+r.pending = Promise.resolve(counter);
+r.pending.then((p) => console.log("pending", p.name, p === counter));
+console.log(dispatch([(e, x) => e.length, (e, x) => (typeof x === "function" ? x() * 2 : 0)], "hello"));
diff --git a/tests/coverage-fixtures/dynamic-slot-hot.ts b/tests/coverage-fixtures/dynamic-slot-hot.ts
new file mode 100644
index 000000000..65672ffc1
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-slot-hot.ts
@@ -0,0 +1,27 @@
+// A dynamic slot read in a loop: SC6101 names the slot, and coverage
+// counts the dynamic operations on it.
+type Hook = (() => number) | AbortSignal | Map string>;
+
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+
+const plugins: Plugin[] = [
+ { name: "one", hook: () => 1 },
+ { name: "two", hook: () => 2 },
+];
+
+function sum(rounds: number): number {
+ let total = 0;
+ for (let i = 0; i < rounds; i++) {
+ for (const p of plugins) {
+ const hook = p.hook;
+ if (typeof hook === "function") total += hook();
+ else total += (hook as { size?: number }).size ?? 0;
+ }
+ }
+ return total;
+}
+
+console.log(sum(10));
diff --git a/tests/coverage-fixtures/dynamic-slot-same-type.ts b/tests/coverage-fixtures/dynamic-slot-same-type.ts
new file mode 100644
index 000000000..61954c6f6
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-slot-same-type.ts
@@ -0,0 +1,16 @@
+// Two dynamic slots of one declared type: the operations in the loop are
+// on `hot`, and `cold` is read once.
+type Hook = (() => number) | AbortSignal | Map string>;
+
+let cold: Hook = () => 1;
+let hot: Hook = () => 2;
+
+function run(rounds: number): number {
+ let total = 0;
+ for (let i = 0; i < rounds; i++) {
+ if (typeof hot === "function") total += hot();
+ }
+ return total;
+}
+
+console.log(run(10), typeof cold);
diff --git a/tests/coverage-fixtures/dynamic-slot-shared.ts b/tests/coverage-fixtures/dynamic-slot-shared.ts
new file mode 100644
index 000000000..21624a479
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-slot-shared.ts
@@ -0,0 +1,23 @@
+// A type that degrades as a piece of a container (`Plugin.hook`) and as a
+// slot of its own (`current`): the operations on `current` belong to
+// `current`, not to the container.
+type Hook = (() => number) | AbortSignal | Map string>;
+
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+
+const plugins: Plugin[] = [{ name: "one", hook: () => 1 }];
+
+let current: Hook = () => 2;
+
+function run(rounds: number): number {
+ let total = 0;
+ for (let i = 0; i < rounds; i++) {
+ if (typeof current === "function") total += current();
+ }
+ return total;
+}
+
+console.log(run(10), plugins.length);
diff --git a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt
index 31a933b26..9780a528e 100644
--- a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt
+++ b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt
@@ -1,10 +1,7 @@
dynamic tier (tier assignment, report-only; --tier=auto)
- 5 functions already operate on dynamic values (4 statements)
+ 2 functions already operate on dynamic values (2 statements)
Base.constructor tests/coverage-fixtures/localized-failure.ts:57:1: dynObjLit at tests/coverage-fixtures/localized-failure.ts:58: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
Holder.constructor tests/coverage-fixtures/localized-failure.ts:28:1: dynObjLit at tests/coverage-fixtures/localized-failure.ts:29:16
- %dyn.class.property.0 tests/coverage-fixtures/localized-failure.ts:15:12: %dyn.class.readPropertiesOrEmpty at tests/coverage-fixtures/localized-failure.ts:15:12
2 demotion requests (refusals and fences the dynamic tier would replace)
diff --git a/tests/harness/corpus-costs/localized-failure-degraded-initializer.ts.json b/tests/harness/corpus-costs/localized-failure-degraded-initializer.ts.json
new file mode 100644
index 000000000..3e8d4b6ac
--- /dev/null
+++ b/tests/harness/corpus-costs/localized-failure-degraded-initializer.ts.json
@@ -0,0 +1 @@
+{ "plain": 3500 }
diff --git a/tests/harness/corpus-costs/localized-failure-expressions.ts.json b/tests/harness/corpus-costs/localized-failure-expressions.ts.json
new file mode 100644
index 000000000..1254aa6e4
--- /dev/null
+++ b/tests/harness/corpus-costs/localized-failure-expressions.ts.json
@@ -0,0 +1 @@
+{ "plain": 4000 }
diff --git a/tests/harness/corpus-costs/localized-failure-push-down.ts.json b/tests/harness/corpus-costs/localized-failure-push-down.ts.json
new file mode 100644
index 000000000..1254aa6e4
--- /dev/null
+++ b/tests/harness/corpus-costs/localized-failure-push-down.ts.json
@@ -0,0 +1 @@
+{ "plain": 4000 }
diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts
index f8fa0da03..82e541140 100644
--- a/tests/harness/coverage.test.ts
+++ b/tests/harness/coverage.test.ts
@@ -461,6 +461,8 @@ test("a local or module global whose declared type does not compile becomes a dy
["global", "all", "(number | Callbacks)[]", "SC2009"],
["local", "local", "Callbacks | undefined", "SC2009"],
]);
+ // The loop over `all` is top-level code: its dynamic operations count too.
+ expect(coverage.dynamicSlots?.find((s) => s.name === "all")?.operations).toBeGreaterThan(0);
expect(coverage.performance?.filter((d) => d.code === "SC6104")).toHaveLength(3);
expect(coveragePasses(coverage, "blockers")).toBe(true);
expect(coveragePasses(coverage, "dynamic")).toBe(true);
@@ -485,6 +487,34 @@ test("a local or module global whose declared type does not compile becomes a dy
expect(strict.diagnostics.map((d) => d.code)).toContain("SC2009");
});
+test("dynamic slots rank by their dynamic operations and name themselves in SC6101", () => {
+ const { coverage } = analyze(fixture("dynamic-slot-hot.ts"));
+ const slots = coverage.dynamicSlots ?? [];
+ expect(slots.find((s) => s.name === "plugins")?.partial).toBe(true);
+ expect(slots.some((s) => (s.operations ?? 0) > 0)).toBe(true);
+ const hot = coverage.performance?.find((d) => d.code === "SC6101");
+ expect(hot?.message).toContain("`hook` is a dynamic piece of `Plugin[]` (declared `Hook`");
+});
+
+test("a type that is a dynamic piece and a slot of its own counts toward its own slot", () => {
+ const { coverage } = analyze(fixture("dynamic-slot-shared.ts"));
+ const slots = coverage.dynamicSlots ?? [];
+ expect(slots.find((s) => s.name === "current")?.operations).toBeGreaterThan(0);
+ expect(slots.find((s) => s.name === "plugins")?.operations ?? 0).toBe(0);
+ const hot = coverage.performance?.find((d) => d.code === "SC6101");
+ expect(hot?.message).toContain("`current` is a dynamic slot (declared `Hook`");
+});
+
+test("dynamic slots of one declared type count their own operations", () => {
+ const { coverage } = analyze(fixture("dynamic-slot-same-type.ts"));
+ const slots = coverage.dynamicSlots ?? [];
+ const hot = slots.find((s) => s.name === "hot")?.operations ?? 0;
+ const cold = slots.find((s) => s.name === "cold")?.operations ?? 0;
+ expect(hot).toBeGreaterThan(cold);
+ const hotWarning = coverage.performance?.find((d) => d.code === "SC6101");
+ expect(hotWarning?.message).toContain("`hot` is a dynamic slot (declared `Hook`");
+});
+
test("CLI: --tier and --explain-dynamic", () => {
const scriptcCli = join(repoRoot, "packages/cli/src/main.ts");
const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-"));