Push localized failure down to the failing component of a declared type (#2307)

This commit is contained in:
Chris Tate
2026-10-26 13:10:05 -05:00
committed by GitHub
28 changed files with 734 additions and 59 deletions
+2 -2
View File
@@ -139,8 +139,8 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dt><code>--print &lt;native-link-info|diagnostics&gt;</code></dt>
<dd><code>native-link-info</code>: build an object (equivalent to <code>--emit=obj</code>) 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. <code>diagnostics</code>: for <code>build</code> and <code>coverage</code>, print the versioned JSON diagnostics document described in <a href="/docs/coverage#machine-readable-diagnostics">Coverage Reports</a> instead of the human output.</dd>
<dt><code>--fail-on &lt;blockers|divergences|dynamic&gt;</code></dt>
<dd>Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with <code>divergences</code>, also when it has Node divergence warnings, or with <code>dynamic</code>, also when it has the <code>SC6101</code> warning for untyped code that likely runs often. See <a href="/docs/coverage#gating-builds-and-ci">Gating builds and CI</a>.</dd>
<dt><code>--fail-on &lt;blockers|divergences|dynamic|dynamic-slots&gt;</code></dt>
<dd>Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with <code>divergences</code>, also when it has Node divergence warnings, with <code>dynamic</code>, also when it has the <code>SC6101</code> warning for untyped code that likely runs often, or with <code>dynamic-slots</code>, also when it has <a href="/docs/coverage#dynamic-slots">dynamic slots</a> (<code>SC6104</code>). See <a href="/docs/coverage#gating-builds-and-ci">Gating builds and CI</a>.</dd>
<dt><code>--ffi &lt;file&gt;</code></dt>
<dd>Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See <a href="/docs/ffi">Native FFI</a>.</dd>
+34 -1
View File
@@ -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, (x: number) => 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.
+2
View File
@@ -48,6 +48,8 @@ title: "Limitations"
<include>../limitations/types-and-class-definitions/unions.mdx</include>
<include>../limitations/types-and-class-definitions/dynamic-slots.mdx</include>
<include>../limitations/types-and-class-definitions/null-and-optional-values.mdx</include>
<include>../limitations/types-and-class-definitions/static-field-updates.mdx</include>
@@ -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.
+4 -3
View File
@@ -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 <b> code generator (llvm)
--optimization <release|dev|speed>
native optimization posture (default: release/-O2). dev
+11 -2
View File
@@ -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`)}`);
}
@@ -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<TypeFailureSlot, string> = {
@@ -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",
};
@@ -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}`;
}
@@ -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;
@@ -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<string, FunctionFacts>();
@@ -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;
}
@@ -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<number>();
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<TypeFailureSlot> = new Set<TypeFailureSlot>([
"local",
"global",
@@ -872,6 +893,8 @@ const STATEMENT_SLOTS: ReadonlySet<TypeFailureSlot> = new Set<TypeFailureSlot>([
"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) };
}
@@ -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<number, { lenient: IrType; code: string; reason: string }>;
degradedTypes?: ReadonlyMap<number, DegradedType>;
/** Localized failure: the declarations reachable emit registered with
* dynamic slots (the coverage remainder registers them too). */
retriedSymbols?: ReadonlySet<ts.Symbol>;
/** 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<string, string>;
/** 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<number, { lenient: IrType; code: string; reason: string }>();
readonly degradedTypes = new Map<number, DegradedType>();
/** 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<number, string>();
/** One frame per unit a commit point may retry: the pushed-down
* (partial) degraded types its lowering mapped (withPartialHits). */
readonly partialHits: Set<number>[] = [];
/** The dynamic slots the commit points created: one SC6104 each. */
readonly dynamicSlots: DynamicSlot[] = [];
/** Declarations retryDeferred registered. */
readonly retriedSymbols = new Set<ts.Symbol>();
/** LowererMode.retriedSymbols. */
readonly priorRetriedSymbols: ReadonlySet<ts.Symbol>;
/** LowererMode.wholeSlotDegradation. */
readonly wholeSlotDegradation: boolean;
/** Bodies run() skipped because their declaration had not registered. */
readonly skippedBodies = new Set<ts.Node>();
/** 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<number>();
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<T>(hits: Set<number>, 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<number>): 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<number>();
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,
@@ -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 };
}
@@ -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 &&
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-degraded-initializer.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-expressions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-push-down.ts"
],
"diags": []
}
@@ -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, (x: number) => string> | AbortSignal | (() => number) | Set<string>;
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();
@@ -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, (x: number) => string> | AbortSignal | (() => number) | Set<string>;
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<string>() } as Plugin].map((p) => p.name).join("+"));
@@ -0,0 +1,52 @@
// Localized failure, stage L3: push-down. A declared type that does not
// compile keeps its compilable shape; only the failing component is a
// checked dynamic piece.
type Hook = Map<string, (x: number) => string> | AbortSignal | (() => number) | Set<string>;
interface Plugin {
name: string;
priority: number;
hook: Hook;
}
class Registry {
plugins: Plugin[] = [];
byName = new Map<string, Plugin>();
pending: Promise<Plugin> | undefined;
add(p: Plugin): void {
this.plugins.push(p);
this.byName.set(p.name, p);
}
total(): number {
let sum = 0;
for (const p of this.plugins) sum += p.priority;
return sum;
}
names(): string {
return this.plugins.map((p) => p.name).join(",");
}
run(name: string): string {
const p = this.byName.get(name);
if (p === undefined) return "missing";
const h = p.hook;
return typeof h === "function" ? `${p.name}=${h()}` : h instanceof Set ? `${p.name}#${h.size}` : "other";
}
}
type Handler = (event: string, extra: Hook) => number;
function dispatch(handlers: Handler[], event: string): number {
let n = 0;
for (const h of handlers) n += h(event, () => event.length);
return n;
}
const r = new Registry();
const counter: Plugin = { name: "counter", priority: 2, hook: () => 41 };
r.add(counter);
r.add({ name: "tags", priority: 5, hook: new Set(["a", "b"]) });
console.log(r.total(), r.names(), r.run("counter"), r.run("tags"), r.run("x"));
counter.priority = 10;
console.log(r.total(), r.byName.get("counter") === counter, JSON.stringify(r.plugins.map((p) => [p.name, p.priority])));
r.pending = Promise.resolve(counter);
r.pending.then((p) => console.log("pending", p.name, p === counter));
console.log(dispatch([(e, x) => e.length, (e, x) => (typeof x === "function" ? x() * 2 : 0)], "hello"));
@@ -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, (x: number) => 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));
@@ -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, (x: number) => 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);
@@ -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, (x: number) => 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);
@@ -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)
@@ -0,0 +1 @@
{ "plain": 3500 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
+30
View File
@@ -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-"));