mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Push localized failure down to the failing component of a declared type (#2307)
This commit is contained in:
@@ -139,8 +139,8 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
|
||||
<dt><code>--print <native-link-info|diagnostics></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 <blockers|divergences|dynamic></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 <blockers|divergences|dynamic|dynamic-slots></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 <file></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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-degraded-initializer.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-expressions.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -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 }
|
||||
@@ -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-"));
|
||||
|
||||
Reference in New Issue
Block a user