Hold locals and module globals whose declared type does not compile as checked dynamic slots (#2292)

This commit is contained in:
Chris Tate
2026-10-26 02:49:06 -05:00
committed by GitHub
45 changed files with 1385 additions and 228 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Options:
(native-link-info implies --emit=obj and never links;
diagnostics prints the versioned JSON diagnostics
envelope for build and coverage)
--fail-on <blockers|divergences|dynamic|node-islands|eager-node-islands|island-gaps>
--fail-on <blockers|divergences|dynamic|dynamic-slots|node-islands|eager-node-islands|island-gaps>
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
divergences, likely-hot functions that use untyped
+51 -2
View File
@@ -22,6 +22,11 @@ import {
type TiersEnvelope,
} from "../diagnostics/envelope.js";
import { renderDiagnostics } from "../diagnostics/render.js";
import {
dynamicSlotLabel,
isDynamicSlotCode,
type DynamicSlotEntry,
} from "../diagnostics/dynamic-slots.js";
import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { ProvenanceSources } from "../frontend/provenance-registry.js";
import type {
@@ -59,6 +64,9 @@ export interface CoverageInput {
* leaves untyped. Never blockers; `--fail-on=dynamic` opts into failing
* on it. */
performance?: ScrDiagnostic[];
/** Localized failure: the checked dynamic slots (SC6104, also in
* `performance`). `--fail-on=dynamic-slots` fails on them. */
dynamicSlots?: DynamicSlotEntry[];
/** Each dependency's native compilation outcome, with the first refusal
* reason for a refused package. */
npmStatic?: NpmStaticStatus[];
@@ -238,6 +246,10 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
` ${c(DIM, `(${skipped} function${skipped === 1 ? "" : "s"} not analyzed — signature blocked; their statements count as not static)`)}`,
);
}
renderDynamicSlots(input.dynamicSlots ?? [], out, c, (loc) => {
const p = locator.position(loc.file, loc.start);
return `${displayPath(loc.file, opts.root)}:${p.line}:${p.column}`;
});
out.push("");
// Report native package compilation and the first reason for each refusal.
@@ -395,7 +407,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
renderRows(groupBlockers(divergences), YELLOW);
};
// The performance warning: its message names the functions.
const performance = input.performance ?? [];
const performance = (input.performance ?? []).filter((d) => !isDynamicSlotCode(d.code));
const renderPerformance = () => {
for (const d of performance) {
out.push("");
@@ -529,6 +541,7 @@ export type CoverageFailOn =
| "blockers"
| "divergences"
| "dynamic"
| "dynamic-slots"
| "node-islands"
| "eager-node-islands"
| "island-gaps";
@@ -537,6 +550,7 @@ export const COVERAGE_FAIL_ON: readonly CoverageFailOn[] = [
"blockers",
"divergences",
"dynamic",
"dynamic-slots",
"node-islands",
"eager-node-islands",
"island-gaps",
@@ -552,7 +566,9 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b
// runtime fences build (the statement throws only if it executes).
if (input.diagnostics.length > 0) return false;
if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance ?? []).some((d) => !isDynamicSlotCode(d.code)))
return false;
if (failOn === "dynamic-slots" && (input.dynamicSlots?.length ?? 0) > 0) return false;
// The Node island gates (report-only islands count: they are what a
// build would choose). Every island evaluates eagerly until deferred
// evaluation lands, so eager-node-islands equals node-islands for now.
@@ -624,6 +640,19 @@ export function coverageEnvelope(
percentStatic: total === 0 ? 100 : Math.floor((ok / total) * 100),
},
}),
...(input.dynamicSlots !== undefined
? {
dynamicSlots: input.dynamicSlots.map((slot) => ({
file: slot.loc.file,
...locator.position(slot.loc.file, slot.loc.start),
name: slot.name,
kind: slot.slot,
declaredType: slot.declaredType,
code: slot.code,
reason: slot.reason,
})),
}
: {}),
...(input.tiers !== undefined ? { tiers: tiersEnvelope(input.tiers, locator) } : {}),
...(input.wasmModules !== undefined ? { wasmModules: input.wasmModules } : {}),
...(input.nodeIslands !== undefined
@@ -719,6 +748,26 @@ function unitMatches(u: DemotionUnit, query: string): boolean {
);
}
const DYNAMIC_SLOTS_SHOWN = 20;
/** The dynamic slots under the statement counts: each slot's declared
* type, and why it has no static lowering. */
function renderDynamicSlots(
slots: readonly DynamicSlotEntry[],
out: string[],
c: (code: string, s: string) => string,
where: (loc: SrcLoc) => string,
): void {
if (slots.length === 0) return;
out.push(
` dynamic slots ${slots.length} ${c(DIM, "(declared types with no static lowering, held as checked-dynamic values; SC6104)")}`,
);
for (const slot of slots.slice(0, DYNAMIC_SLOTS_SHOWN))
out.push(` ${dynamicSlotLabel(slot)} ${c(DIM, where(slot.loc))}`);
if (slots.length > DYNAMIC_SLOTS_SHOWN)
out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`);
}
const SLOTS_SHOWN = 40;
/** The localized-failure section of --explain-dynamic (report-only): the
@@ -7,6 +7,7 @@
* still hold. With a semantic matcher (the Node frontend's), edits that
* only touch TypeScript comments also reuse it, with every reported
* location moved to the edited text. */
import type { DynamicSlotEntry } from "../diagnostics/dynamic-slots.js";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -23,7 +24,7 @@ import {
type FrontendInputSnapshot,
} from "../frontend/input-tracker.js";
const SCHEMA = "scriptc.coverage-verdict.v3";
const SCHEMA = "scriptc.coverage-verdict.v4";
/** Key-value storage for verdict entries (best effort: failures miss). */
export interface VerdictStore {
@@ -41,6 +42,7 @@ export interface CachedCoverage {
unreached?: { stats: LowerStats; diagnostics: ScrDiagnostic[]; runtimeFences?: ScrDiagnostic[] };
divergences?: ScrDiagnostic[];
performance?: ScrDiagnostic[];
dynamicSlots?: DynamicSlotEntry[];
npmStatic?: NpmStaticStatus[];
wasmModules?: WasmModuleReport[];
nodeIslands?: NodeIslandReport;
@@ -158,6 +160,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null {
if (coverage.unreached !== undefined) out.unreached = coverage.unreached;
if (coverage.divergences !== undefined) out.divergences = coverage.divergences;
if (coverage.performance !== undefined) out.performance = coverage.performance;
if (coverage.dynamicSlots !== undefined) out.dynamicSlots = coverage.dynamicSlots;
if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic;
if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules;
if (coverage.nodeIslands !== undefined) out.nodeIslands = coverage.nodeIslands;
@@ -177,6 +180,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult
if (cached.unreached !== undefined) coverage.unreached = cached.unreached;
if (cached.divergences !== undefined) coverage.divergences = cached.divergences;
if (cached.performance !== undefined) coverage.performance = cached.performance;
if (cached.dynamicSlots !== undefined) coverage.dynamicSlots = cached.dynamicSlots;
if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic;
if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules;
if (cached.nodeIslands !== undefined) coverage.nodeIslands = cached.nodeIslands;
@@ -0,0 +1,49 @@
/* SC6104, the dynamic-slot performance warning (localized-failure design
* §3.9): a slot whose declared TypeScript type has no static lowering holds
* a checked-dynamic value instead of rejecting the declaration around it.
* Its wording lives here; the slots come from the lowering's commit points
* (Lowerer.degradeAtCommit). Imports nothing heavy, like dynamic-tier.ts. */
import type { SrcLoc } from "../ir/ir.js";
import type { ScrDiagnostic, TypeFailureSlot } from "./diagnostic.js";
export const DYNAMIC_SLOT_CODE = "SC6104";
/** One dynamic slot, as coverage reports it. */
export interface DynamicSlotEntry {
loc: SrcLoc;
name: string;
slot: TypeFailureSlot;
declaredType: string;
/** The diagnostic the slot's declaration would have failed with. */
code: string;
reason: string;
}
const SLOT_WORD: Record<TypeFailureSlot, string> = {
field: "field",
param: "parameter",
return: "return value of",
local: "local",
global: "module global",
loop: "loop variable",
member: "member",
expression: "expression",
};
/** `'_customTools' (declared 'ToolDefinition[]')`, with the slot kind. */
export function dynamicSlotLabel(entry: DynamicSlotEntry): string {
return `${SLOT_WORD[entry.slot]} '${entry.name}' (declared '${entry.declaredType}')`;
}
export function dynamicSlotDiag(entry: DynamicSlotEntry): ScrDiagnostic {
return {
code: DYNAMIC_SLOT_CODE,
message: `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`,
loc: entry.loc,
hint: "restate the declared type inside the compilable set to keep this slot static; scriptc coverage lists dynamic slots, and --fail-on=dynamic-slots fails on them",
};
}
export function isDynamicSlotCode(code: string): boolean {
return code === DYNAMIC_SLOT_CODE;
}
@@ -164,6 +164,15 @@ export interface DiagnosticsEnvelope {
};
/** build only: the produced artifact path. */
artifact?: string;
/** coverage only, when there are any: the checked dynamic slots
* (localized failure, SC6104). */
dynamicSlots?: (SourcePosition & {
name: string;
kind: string;
declaredType: string;
code: string;
reason: string;
})[];
/** coverage --explain-dynamic only: the report-only tier assignment. */
tiers?: TiersEnvelope;
/** coverage only: the WebAssembly modules the build can see, with their
@@ -132,6 +132,7 @@ export function analyzeWithFrontend(
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}),
...(lowered.performance !== undefined ? { performance: lowered.performance } : {}),
...(lowered.dynamicSlots !== undefined ? { dynamicSlots: lowered.dynamicSlots } : {}),
...(lowered.unreached ? { unreached: lowered.unreached } : {}),
...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}),
// --provenance-sources: the per-package attribution inputs (the
@@ -73,8 +73,7 @@ export function lowerDisposableStackUseCall(
if (valueT?.kind !== "object") return null;
const loc = locOf(call);
const className = valueT.className;
const info = lowerer.classes.get(className);
if (!info) lowerer.flushDeferredClass(className);
const info = lowerer.classes.get(className) ?? lowerer.flushDeferredClass(className);
const found = info ? lowerer.findMethodOn(info, "sym:dispose") : null;
if (!found) return null;
if (found.sig.params.length !== 0) {
@@ -318,10 +318,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// that does not exist. The blocked-binding cascade names the use;
// without this the validator's registration check ICEs on the live
// reference (`@this {T}` inference — signature 07).
const unregistered = lowerer.unregisteredClassOf(g.type, new Set());
let unregistered = lowerer.unregisteredClassOf(g.type, new Set());
// The class's own rejection is the root cause the cascade names
// (unless the flush registers it with dynamic slots).
if (unregistered !== null && lowerer.flushDeferredClass(unregistered) !== undefined)
unregistered = lowerer.unregisteredClassOf(g.type, new Set());
if (unregistered !== null) {
// The class's own rejection is the root cause the cascade names.
lowerer.flushDeferredClass(unregistered);
lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc));
throw new PoisonError();
}
@@ -3651,13 +3651,13 @@ export function classFieldTarget(
) {
return { container: "dynamic", obj: represented, field: fieldName, fieldType: DYN };
}
const info = lowerer.classes.get(receiverType.className);
if (!info) {
// A receiver typed as a class whose collection deferred: the
// deferred diagnostics are what explains the miss.
// A receiver typed as a class whose collection deferred: the deferred
// diagnostics are what explains the miss (unless the flush registers
// the class with dynamic slots, retryDeferred).
const info =
lowerer.classes.get(receiverType.className) ??
lowerer.flushDeferredClass(receiverType.className);
return null;
}
if (!info) return null;
const fieldType = info.fields.get(fieldName);
const lowerObjectReceiver = (): IrExpr => {
let obj = represented;
@@ -535,12 +535,13 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
};
}
if (rhsClassval?.kind === "classval" && !caught) {
const targetInfo = lowerer.classes.get(rhsClassval.className);
const targetInfo =
lowerer.classes.get(rhsClassval.className) ??
lowerer.flushDeferredClass(rhsClassval.className);
if (!targetInfo) {
// The type world names a class the lowering never registered
// (a fenced class expression, a deferred declaration): flush
// its diagnostics and poison the test site, never an ICE.
lowerer.flushDeferredClass(rhsClassval.className);
// (a fenced class expression, a deferred declaration): its
// diagnostics flushed above; poison the test site, never an ICE.
lowerer.unsupported(
"SC1090",
expr,
@@ -10235,8 +10235,8 @@ export function lowerObjectMethodCall(
? lowerer.coerceInto(access.expression, receiver, receiverIr)
: receiver;
};
const info = lowerer.classes.get(receiverIr.className);
if (!info) lowerer.flushDeferredClass(receiverIr.className);
const info =
lowerer.classes.get(receiverIr.className) ?? lowerer.flushDeferredClass(receiverIr.className);
const found = info ? lowerer.findMethodOn(info, access.name.text) : null;
// The zlib stream members (flush/params/close/reset) are declared on
// the zlib classes; their receivers are runtime Transform streams.
@@ -10589,11 +10589,8 @@ function lowerUnionObjectMethodCall(
found: NonNullable<ReturnType<Lowerer["findMethodOn"]>>;
}[] = [];
for (const arm of def.arms as (IrType & { kind: "object" })[]) {
const info = lowerer.classes.get(arm.className);
if (!info) {
lowerer.flushDeferredClass(arm.className);
return null;
}
const info = lowerer.classes.get(arm.className) ?? lowerer.flushDeferredClass(arm.className);
if (!info) return null;
const found = lowerer.findMethodOn(info, method);
if (!found) return null;
if (found.sig.abstract === true && !lowerer.overrideBelow(info, method)) return null;
@@ -2298,11 +2298,15 @@ export function collectClassShapeInner(
base = named;
if (!base) {
if (adoptCallableBase(t.expression)) continue;
// A base whose own collection deferred can still register later
// (a flush retry with dynamic slots): the derived class retries
// with it (Lowerer.retryDeferred).
const diag = unsupportedDiag(
"SC1090",
locOf(t),
`extending classes not declared in the program ('${t.expression.text}')`,
);
lowerer.retriableDiags.add(diag);
// A base the program declares but rejected: this rejection is its
// cascade (localized failure, report-only).
const rejectedBase = rejectedProgramClassGroup(lowerer, symbol);
@@ -2544,6 +2548,7 @@ export function collectClassShapeInner(
if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name));
if (
type.kind === "dyn" &&
!lowerer.liftDynFieldPolicy &&
!isJsSourceFile(ts.getSourceFileOfNode(member)) &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(member.name)) &&
(member.type !== undefined ||
@@ -3059,9 +3064,12 @@ export function collectClassShapeInner(
if (type.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name));
// Bundled JS declares fields without annotations before assigning
// them in the constructor. Use the same native checked-dynamic
// storage as implicit constructor-assigned JS fields.
// storage as implicit constructor-assigned JS fields. In
// TypeScript the refusal is a policy fence that a flush retry lifts
// (Lowerer.liftDynFieldPolicy).
if (
type.kind === "dyn" &&
!lowerer.liftDynFieldPolicy &&
!isJsSourceFile(ts.getSourceFileOfNode(member)) &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(member.name))
) {
@@ -3201,7 +3209,11 @@ export function collectClassShapeInner(
const type = shape.bodyType ?? shape.type;
if (type.kind === "void") lowerer.badType(p.name, lowerer.typeOf(p.name));
// The class-field dyn rule verbatim (KEEP NARROW).
if (type.kind === "dyn" && !lowerer.classFieldStoresDyn(lowerer.typeOf(p))) {
if (
type.kind === "dyn" &&
!lowerer.liftDynFieldPolicy &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(p))
) {
lowerer.dynFieldPolicyFence(
p.name,
`${dynFieldTypeSpelling(lowerer, p)}-typed class fields`,
@@ -6209,6 +6221,7 @@ function lowerClassValue(
const context = newFnCtx(true, null, null, VOID);
info.localClass.context = context;
lowerer.fnStack.push(context);
const diagsBefore = lowerer.diags.length;
try {
const bodies = lowerClassMembers(lowerer, info);
for (const body of bodies) info.localClass.bodies.set(body.name, body);
@@ -6223,6 +6236,8 @@ function lowerClassValue(
});
}
info.localClass.ready = true;
// A retry of the enclosing statement reuses these bodies.
for (const d of lowerer.diags.slice(diagsBefore)) lowerer.loweredOnceDiags.add(d);
lowerer.onExprClassCollected?.(info);
} finally {
lowerer.fnStack.pop();
@@ -11319,12 +11334,13 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
);
info = lowerer.classes.get(calleeT.className);
}
// The TYPE world names a class the lowering never registered — a
// fenced class expression, an abstract/deferred declaration: flush
// its own diagnostics (they tell the real story, unless the flush
// registers it with dynamic slots) and poison this construction
// site, never an ICE.
if (!info) info = lowerer.flushDeferredClass(calleeT.className);
if (!info) {
// The TYPE world names a class the lowering never registered —
// a fenced class expression, an abstract/deferred declaration:
// flush its own diagnostics (they tell the real story) and
// poison this construction site, never an ICE.
lowerer.flushDeferredClass(calleeT.className);
lowerer.unsupported(
"SC1090",
expr,
@@ -3154,11 +3154,9 @@ export function symbolFieldInfo(
if (lowerer.chainBlocked(expr)) return null;
const receiverIr = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
if (receiverIr?.kind !== "object") return null;
const info = lowerer.classes.get(receiverIr.className);
if (!info) {
lowerer.flushDeferredClass(receiverIr.className);
return null;
}
const info =
lowerer.classes.get(receiverIr.className) ?? lowerer.flushDeferredClass(receiverIr.className);
if (!info) return null;
const key = classSymbolKeyOf(lowerer, expr.argumentExpression);
if (!key) return null;
const field = info.symbolFields?.get(key.identity);
@@ -1,3 +1,4 @@
import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js";
import { THREADS_MODULE } from "../threads-import.js";
import { positionTag } from "./structural-positions.js";
import { EXPERIMENTAL_MODULE } from "../experimental-import.js";
@@ -178,6 +179,17 @@ export interface AppendGraph {
* the require() edges preflight resolved — a CommonJS file's only edges
* (the compiled CommonJS package a workspace module loaded on demand
* imports, and the sibling packages and files IT requires). */
/** Whether a class declaration has a static field with an initializer,
* which Node runs where the class statement evaluates. */
function hasStaticFieldInitializer(decl: ts.ClassDeclaration): boolean {
return decl.members.nodes.some(
(member) =>
ts.isPropertyDeclaration(member) &&
member.initializer !== undefined &&
member.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true,
);
}
function appendedEdgesOf(
program: ts.Program,
graph: AppendGraph | undefined,
@@ -1035,13 +1047,11 @@ export function moduleArtifacts(
}
while (pendingClasses.length > 0) {
const name = pendingClasses.pop()!;
const info = lowerer.classes.get(name);
if (!info) {
// Referenced by an emitted type but never registered: collection
// deferred its diagnostics — a reached reference makes them count.
lowerer.flushDeferredClass(name);
continue;
}
// Referenced by an emitted type but never registered: collection
// deferred its diagnostics — a reached reference makes them count
// (or registers the class with dynamic slots, retryDeferred).
const info = lowerer.classes.get(name) ?? lowerer.flushDeferredClass(name);
if (!info) continue;
for (const field of info.def.fields) visitType(field.type);
if (info.base) visitClass(info.base.def.name);
if (info.def.genericOf) visitClass(info.def.genericOf);
@@ -2048,7 +2058,15 @@ export function collectGlobals(
// Destructuring declarations register EVERY bound identifier (the
// desugar in the init function assigns the pre-registered globals,
// exactly like plain declarations).
for (const nameNode of boundIdentifiersOf(decl.name)) {
const names = boundIdentifiersOf(decl.name);
// Localized failure: a degradable type failure at a module global
// degrades that type and the same name registers again
// (globalSlotRetry), at most MAX_GLOBAL_RETRIES times.
let retries = 0;
let retriedIndex = -1;
for (let nameIndex = 0; nameIndex < names.length; nameIndex++) {
const nameNode = names[nameIndex]!;
if (nameIndex !== retriedIndex) retries = 0;
const diagsBefore = lowerer.diags.length;
try {
// Checked JavaScript factories can expose inferred callables by
@@ -2700,6 +2718,12 @@ export function collectGlobals(
}
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
if (retries < MAX_GLOBAL_RETRIES && globalSlotRetry(lowerer, sf, e)) {
retries++;
retriedIndex = nameIndex;
nameIndex--;
continue;
}
// diagnostic already recorded; lowering the statement poisons
// too — and in a JS file BOTH defer to the statement's
// runtime fence (deferJsCollectionDiags).
@@ -2710,6 +2734,20 @@ export function collectGlobals(
}
}
/** Localized failure's commit point for module globals (design §3.3):
* collectGlobals commits a global's type failure directly, before any body
* lowers, so it degrades here and the registration runs again. */
function globalSlotRetry(lowerer: Lowerer, sf: ts.SourceFile, e: PoisonError): boolean {
if (isJsSourceFile(sf)) return false;
const slot = lowerer.degradeAtCommit(e, GLOBAL_SLOTS);
if (slot === null) return false;
lowerer.noteDynamicSlot(slot);
return true;
}
const MAX_GLOBAL_RETRIES = 16;
const GLOBAL_SLOTS: ReadonlySet<TypeFailureSlot> = new Set<TypeFailureSlot>(["global"]);
/** Registers the entry-init note for a `var` module global: JS hoists
* module vars to `undefined` at module entry, so an undefined-armed slot
* must hold the interned undefined arm BEFORE the body runs (a function
@@ -3202,7 +3240,19 @@ export function lowerFileInit(
// by position) — exactly when JS evaluates decorators, static
// initializers, and blocks, so code reading an earlier module
// binding sees its assigned value.
const statics = [...lowerer.classes.values()]
const runsStatics = (c: ClassInfo): boolean =>
lowerer.classInitFlags.has(c.def.name) ||
c.callableBase !== undefined ||
c.def.baseValueGlobal !== undefined ||
c.runtimeStatics !== undefined ||
c.staticFields.length > 0 ||
(c.staticBlocks?.length ?? 0) > 0 ||
c.classDecorators !== undefined;
const statics: {
pos: number;
info: ClassInfo | null;
deferred?: { symbol: ts.Symbol; decl: ts.ClassDeclaration };
}[] = [...lowerer.classes.values()]
.filter(
(c) =>
// Class DECLARATIONS only: expression classes run their static
@@ -3214,15 +3264,30 @@ export function lowerFileInit(
ts.isClassDeclaration(c.decl) &&
!c.mixinInstance &&
ts.getSourceFileOfNode(c.decl) === sf &&
(lowerer.classInitFlags.has(c.def.name) ||
c.callableBase !== undefined ||
c.def.baseValueGlobal !== undefined ||
c.runtimeStatics !== undefined ||
c.staticFields.length > 0 ||
(c.staticBlocks?.length ?? 0) > 0 ||
c.classDecorators !== undefined),
runsStatics(c),
)
.map((c) => ({ pos: ts.tokenStartOfNode(c.decl!), info: c }));
// Localized failure: a deferred class with static field initializers
// is reached at its statement, where Node runs them. Its place retries
// it (retryDeferredAtDeclaration), and once it has registered there or
// through an earlier flush, its initializers lower in place.
for (const symbol of lowerer.deferredCollectors.keys())
for (const decl of symbol.declarations)
if (
ts.isClassDeclaration(decl) &&
ts.getSourceFileOfNode(decl) === sf &&
hasStaticFieldInitializer(decl)
)
statics.push({ pos: ts.tokenStartOfNode(decl), info: null, deferred: { symbol, decl } });
const lowerStaticsAt = (entry: (typeof statics)[number]): IrStmt[] => {
let info = entry.info;
if (entry.deferred) {
if (!lowerer.retryDeferredAtDeclaration(entry.deferred.symbol)) return [];
info = lowerer.classes.get(lowerer.classNamer(entry.deferred.decl)) ?? null;
if (info === null || !runsStatics(info)) return [];
}
return withClassInitFlag(lowerer, info!, lowerer.lowerStaticFieldInits(info!));
};
// Statics-bearing MIXIN instantiations whose call evaluates in THIS
// file: their declaration-time code runs when the call does — the
// top-level statement lower-mixins recorded. Insertion order is
@@ -3243,13 +3308,7 @@ export function lowerFileInit(
// declarations never share a kept statement's start, so `<=`
// changes nothing for them.
while (at < statics.length && statics[at]!.pos <= ts.tokenStartOfNode(stmt)) {
body.push(
...withClassInitFlag(
lowerer,
statics[at]!.info,
lowerer.lowerStaticFieldInits(statics[at]!.info),
),
);
body.push(...lowerStaticsAt(statics[at]!));
at++;
}
// A cycle binding's declaration stays at its position: its
@@ -3268,13 +3327,7 @@ export function lowerFileInit(
body.push(...lowerer.pendingClassExprInits.splice(0), ...stmtIr);
}
while (at < statics.length) {
body.push(
...withClassInitFlag(
lowerer,
statics[at]!.info,
lowerer.lowerStaticFieldInits(statics[at]!.info),
),
);
body.push(...lowerStaticsAt(statics[at]!));
at++;
}
for (const { at, g } of unionEntryInits) {
@@ -40,8 +40,8 @@ import { isJsonRequireDecl } from "./json-require.js";
import { isTimeValueDateDeclaration } from "./date-values.js";
import { bindingSource } from "../binding-source.js";
import { lowerConstructorReturn } from "./constructor-returns.js";
import type { Lowerer } from "./lowerer.js";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import type { DynamicSlot, Lowerer } from "./lowerer.js";
import type { ScrDiagnostic, TypeFailureSlot } from "../../diagnostics/diagnostic.js";
import { statementGroupKey } from "./type-failure.js";
import {
cjsExportValueByName,
@@ -417,8 +417,8 @@ function cleanupCall(
): { call: IrExpr; async: boolean } | null {
const loc = locOf(node);
if (value.type.kind === "object") {
const info = lowerer.classes.get(value.type.className);
if (!info) lowerer.flushDeferredClass(value.type.className);
const info =
lowerer.classes.get(value.type.className) ?? lowerer.flushDeferredClass(value.type.className);
const asyncFound = awaited && info ? lowerer.findMethodOn(info, "sym:asyncDispose") : null;
const syncFound = info ? lowerer.findMethodOn(info, "sym:dispose") : null;
const member = asyncFound ? "sym:asyncDispose" : syncFound ? "sym:dispose" : null;
@@ -754,57 +754,36 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
const sinkMark = lowerer.diagSinkMark();
const touched = new Set<string>();
lowerer.failureTouches.push(touched);
const pendingSlots: DynamicSlot[] = [];
// A declarator that initializes a forward-captured TDZ box takes it
// out of tdzPredeclared; a retry after a later declarator degrades
// must initialize the same box again.
const tdzBefore = new Map(lowerer.ctx.tdzPredeclared);
try {
let lowered: IrStmt | IrStmt[] | null;
let appended = false;
if (
ts.isVariableStatement(stmt) &&
(stmt.declarationList.flags & ts.NodeFlags.Using) !== 0
) {
const registrations = lowerUsingStatement(lowerer, stmt);
const accounting: IrStmt[] = [];
for (const registration of registrations) {
out.push(...registration.acquire);
accounting.push(...registration.acquire, ...registration.cleanup);
if (registration.cleanup.length > 0) {
usingMarkers.push({
start: out.length,
cleanup: registration.cleanup,
loc: registration.loc,
});
}
for (let attempt = 0; ; attempt++) {
try {
lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences);
break;
} catch (e) {
// Localized failure's statement commit point (design §3.3): a
// degradable type failure at a local, loop variable or module
// global degrades that type and the statement lowers again.
// A declaration this statement flushed inside a probe (where
// no retry runs) can retry here too.
if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e;
const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS);
if (slot === null && !lowerer.reopenFlushedAtCommit(diagsBefore)) throw e;
// A failed attempt adds nothing to `out` but the TDZ boxes
// forward captures declared (they stay declared), so the
// retry appends after them.
lowerer.dropAttemptDiags(diagsBefore);
if (slot !== null) pendingSlots.push(slot);
for (const [symbol, local] of tdzBefore)
if (!lowerer.ctx.tdzPredeclared.has(symbol))
lowerer.ctx.tdzPredeclared.set(symbol, local);
}
lowered = accounting;
appended = true;
} else {
lowered = lowerer.lowerStmt(stmt);
}
// The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic
// arguments that reached builtin-call slots without the checked
// coercion get their dynCheck here, and the uncoercible ones
// fence — inside this statement's poison window, so a JS fence
// becomes the statement's runtimeFence exactly like every other
// deferred rejection.
if (lowered) {
const body: IrStmt[] = [];
if (Array.isArray(lowered)) body.push(...lowered);
else body.push(lowered);
const rewritten = lowerSurplusCalls(lowerer, body);
enforceLibBoundary(lowerer, rewritten);
if (!appended) out.push(...rewritten);
}
// A JS statement that lowered CLEAN can still have flushed
// DEFERRED collection diagnostics (a symbol lookup on an alias
// resolves through flushDeferred — informational here, not a
// blocker of THIS statement): those belong to the runtime-fence
// ledger, not the build — real uses of the broken declaration
// meet their own per-site fences.
if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) {
const flushed = lowerer.diags.splice(diagsBefore);
const ice = flushed.filter((d) => d.code === "SC9001");
lowerer.diags.push(...ice);
lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001"));
}
for (const slot of pendingSlots) lowerer.noteDynamicSlot(slot);
} catch (e) {
// A Go panic inside tsgo, surfaced by the sync channel as a
// thrown Error (server intact — the prefetch fence's finding):
@@ -878,6 +857,81 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
return out;
}
/** Localized failure: retries a degradable statement-level type failure
* at most this many times (each retry degrades one more type). */
const MAX_DEGRADE_RETRIES = 16;
/** The slot kinds lowerStmts' commit point degrades: locals, loop
* variables and module globals (stage L1), and the parameters and returns
* of the function expressions, arrows and methods a statement holds
* (stage L2). */
const STATEMENT_SLOTS: ReadonlySet<TypeFailureSlot> = new Set<TypeFailureSlot>([
"local",
"global",
"loop",
"param",
"return",
"field",
]);
/** One attempt at lowering a statement of lowerStmts into `out`. */
function lowerOneStatement(
lowerer: Lowerer,
stmt: ts.Statement,
out: IrStmt[],
usingMarkers: UsingMarker[],
diagsBefore: number,
deferFences: boolean,
): void {
let lowered: IrStmt | IrStmt[] | null;
let appended = false;
if (ts.isVariableStatement(stmt) && (stmt.declarationList.flags & ts.NodeFlags.Using) !== 0) {
const registrations = lowerUsingStatement(lowerer, stmt);
const accounting: IrStmt[] = [];
for (const registration of registrations) {
out.push(...registration.acquire);
accounting.push(...registration.acquire, ...registration.cleanup);
if (registration.cleanup.length > 0) {
usingMarkers.push({
start: out.length,
cleanup: registration.cleanup,
loc: registration.loc,
});
}
}
lowered = accounting;
appended = true;
} else {
lowered = lowerer.lowerStmt(stmt);
}
// The lib-boundary chokepoint (lib-boundary.ts): checked-dynamic
// arguments that reached builtin-call slots without the checked
// coercion get their dynCheck here, and the uncoercible ones
// fence — inside this statement's poison window, so a JS fence
// becomes the statement's runtimeFence exactly like every other
// deferred rejection.
if (lowered) {
const body: IrStmt[] = [];
if (Array.isArray(lowered)) body.push(...lowered);
else body.push(lowered);
const rewritten = lowerSurplusCalls(lowerer, body);
enforceLibBoundary(lowerer, rewritten);
if (!appended) out.push(...rewritten);
}
// A JS statement that lowered CLEAN can still have flushed
// DEFERRED collection diagnostics (a symbol lookup on an alias
// resolves through flushDeferred — informational here, not a
// blocker of THIS statement): those belong to the runtime-fence
// ledger, not the build — real uses of the broken declaration
// meet their own per-site fences.
if (deferFences && lowerer.diagSink === null && lowerer.diags.length > diagsBefore) {
const flushed = lowerer.diags.splice(diagsBefore);
const ice = flushed.filter((d) => d.code === "SC9001");
lowerer.diags.push(...ice);
lowerer.runtimeFences.push(...flushed.filter((d) => d.code !== "SC9001"));
}
}
/** Whether a statement list belongs to a function body (at any block
* depth) rather than to a module's top level. */
function enclosedByFunction(stmt: ts.Statement): boolean {
@@ -3261,8 +3315,8 @@ export function lowerBindingPattern(
// chain declares keep named fences (a detached method would lose its
// receiver without an error; JS binds the prototype function).
if (srcType.kind === "object") {
const info = lowerer.classes.get(srcType.className);
if (!info) lowerer.flushDeferredClass(srcType.className);
const info =
lowerer.classes.get(srcType.className) ?? lowerer.flushDeferredClass(srcType.className);
if (info) {
for (const el of pattern.elements.nodes) {
if (el.name === undefined) continue;
@@ -216,8 +216,10 @@ import type {
ScrDiagnostic,
TypeFailure,
TypeFailureFamily,
TypeFailureSlot,
} from "../../diagnostics/diagnostic.js";
import { declarationGroupKey, typeFailureOf } from "./type-failure.js";
import { dynamicSlotDiag, type DynamicSlotEntry } from "../../diagnostics/dynamic-slots.js";
import {
unsupportedAnyOperationDiag,
blockedBindingUseDiag,
@@ -727,14 +729,40 @@ interface GenericDemandOwner {
classDemands: ClassInfo[];
}
/** A slot the localized-failure commit points turned into a checked
* dynamic slot (SC6104): where it is, what its declared type is, and the
* diagnostic it would have failed with. */
export interface DynamicSlot {
loc: SrcLoc;
failure: TypeFailure;
code: string;
reason: string;
}
/** Localized failure: rounds of the declaration flush retry. */
const MAX_DECLARATION_RETRIES = 16;
/** The slot kinds the declaration flush retry degrades (stage L2). */
const DECLARATION_SLOTS: ReadonlySet<TypeFailureSlot> = new Set<TypeFailureSlot>([
"field",
"param",
"return",
"local",
"global",
"loop",
]);
export class PoisonError extends Error {
/** Set when a type with no static lowering poisoned a slot (badType and
* the dyn class field policy): what failed, for the localized-failure
* commit points. Report-only in stage L0. */
readonly failure: TypeFailure | undefined;
constructor(failure?: TypeFailure) {
/** The checker type that failed (with `failure`). */
readonly failedType: ts.Type | undefined;
constructor(failure?: TypeFailure, failedType?: ts.Type) {
super();
this.failure = failure;
this.failedType = failedType;
}
}
@@ -851,6 +879,9 @@ export interface LowerResult {
/** Performance warnings (SC61xx): the likely-hot functions the source
* leaves untyped (lowering/dynamic-tier.ts). Never failures. */
performance?: ScrDiagnostic[];
/** Localized failure: the checked dynamic slots the commit points
* created (each also an SC6104 in `performance`), present when any. */
dynamicSlots?: DynamicSlotEntry[];
/** Functions lowering placed in the dynamic tier (ir/tiers.ts), present
* when there are any. */
dynamicFunctions?: DynamicFunction[];
@@ -962,6 +993,13 @@ export interface LowererMode {
/** Symbols whose deferred diagnostics reachable emit already flushed —
* the remainder must not report them a second time. */
alreadyFlushed?: ReadonlySet<ts.Symbol>;
/** 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 }>;
/** Localized failure: the declarations reachable emit registered with
* dynamic slots (the coverage remainder registers them too). */
retriedSymbols?: ReadonlySet<ts.Symbol>;
/** LowerOptions.externalTypes, threaded through every lowering pass. */
externalTypes?: ReadonlyMap<string, string>;
/** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */
@@ -1266,6 +1304,22 @@ export function lowerToIr(
timing("dynamic-tier-scan", { found: hotDynamic === null ? 0 : 1 });
if (hotDynamic !== null) result = { ...result, performance: [hotDynamic] };
}
// Localized failure (SC6104): one performance warning per dynamic slot.
if (resultLowerer.dynamicSlots.length > 0) {
const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot) => ({
loc: slot.loc,
name: slot.failure.name,
slot: slot.failure.slot,
declaredType: slot.failure.declaredType,
code: slot.code,
reason: slot.reason,
}));
result = {
...result,
performance: [...(result.performance ?? []), ...dynamicSlots.map(dynamicSlotDiag)],
dynamicSlots,
};
}
// Contract types (@scriptc/types): written annotations, checked against
// the IR by the compile pipeline (contracts/check.ts).
// Acyclic object types name the IR unit the program lowered them to; a
@@ -1353,6 +1407,8 @@ export function lowerToIr(
reachable,
remainder: true,
alreadyFlushed: resultLowerer.flushedSymbols,
degradedTypes: resultLowerer.degradedTypes,
retriedSymbols: resultLowerer.retriedSymbols,
inlineRequireCycleCalls,
targetPlatform,
ffiImports,
@@ -3796,6 +3852,44 @@ export class Lowerer {
* its lowering touched. A statement that then poisons attributes its
* diagnostics to them (ScrDiagnostic.cause). */
readonly failureTouches: Set<string>[] = [];
/** Localized failure: checker types (by id) whose strict mapping failed
* at a committed slot, with their lenient mapping. Filled only at the
* commit points (degradeAtCommit), so a program that compiles today
* never fills it. */
readonly degradedTypes = new Map<number, { lenient: IrType; code: string; reason: string }>();
/** The dynamic slots the commit points created: one SC6104 each. */
readonly dynamicSlots: DynamicSlot[] = [];
/** Declarations retryDeferred registered. */
readonly retriedSymbols = new Set<ts.Symbol>();
/** LowererMode.retriedSymbols. */
readonly priorRetriedSymbols: ReadonlySet<ts.Symbol>;
/** Bodies run() skipped because their declaration had not registered. */
readonly skippedBodies = new Set<ts.Node>();
/** Declarations retryDeferred registered, in order: run() lowers the
* bodies it had skipped. */
readonly retriedDeclarations: ts.Node[] = [];
/** Diagnostics of lowering that a statement's retry does not repeat (a
* function-local class's member bodies, lowered once and kept):
* dropAttemptDiags keeps them. */
readonly loweredOnceDiags = new Set<ScrDiagnostic>();
/** Deferred classes whose static field initializers' place in %init
* has been lowered (retryDeferredAtDeclaration): a later flush must not
* register them, since their initializers would never run. */
readonly staticInitsPassed = new Set<ts.Symbol>();
/** Deferred declarations' collections, for the flush retry
* (retryDeferred). */
readonly deferredCollectors = new Map<
ts.Symbol,
{ symbolOf: () => ts.Symbol | undefined; collect: () => void }
>();
/** True while a flush retry collects a declaration: the dyn class
* field policy (dynFieldPolicyFence) is lifted for reached classes. */
liftDynFieldPolicy = false;
/** Collection diagnostics that a flush retry may simply run again (a
* derived class whose base had not registered yet). */
readonly retriableDiags = new Set<ScrDiagnostic>();
/** The checker type behind each type failure badType committed. */
readonly failedTypes = new Map<TypeFailure, ts.Type>();
/** True while collectProgram runs: resolution helpers must not flush
* deferred diagnostics (collection itself resolves symbols — extends
* clauses — and collection order must not decide what reports). */
@@ -3849,12 +3943,12 @@ export class Lowerer {
/** Bumps the per-file attribution counter (no-op unless provenance is
* active and this is the emit/discovery lane — mirror the CALLER's
* suppressStats guard, this method only gates remainder). */
bumpFileStat(file: string, kind: "total" | "failed"): void {
bumpFileStat(file: string, kind: "total" | "failed", by = 1): void {
if (this.remainder || !provenanceActive()) return;
let s = this.statsByFile.get(file);
if (!s) this.statsByFile.set(file, (s = { total: 0, failed: 0 }));
if (kind === "total") s.total += 1;
else if (kind === "failed") s.failed += 1;
if (kind === "total") s.total += by;
else if (kind === "failed") s.failed += by;
}
/** The program holds the declaration-typed boundary's projection
@@ -3948,6 +4042,9 @@ export class Lowerer {
) {
// The mode's options, sorted by name; the values derived from them follow.
this.alreadyFlushed = mode.alreadyFlushed ?? new Set();
if (mode.degradedTypes)
for (const [id, degraded] of mode.degradedTypes) this.degradedTypes.set(id, degraded);
this.priorRetriedSymbols = mode.retriedSymbols ?? new Set();
this.externalTypes = mode.externalTypes ?? new Map();
this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null;
this.ffiImports = mode.ffiImports ?? [];
@@ -3983,6 +4080,10 @@ export class Lowerer {
resolveTypeParam: this.typeParamResolver,
resolveTypeParamTs: this.typeParamTsResolver,
resolveBoundAliasTs: this.boundAliasTsResolver,
degradedType: (type) => {
const id = (type as { id?: number }).id;
return id === undefined ? null : (this.degradedTypes.get(id)?.lenient ?? null);
},
isErasing: () => this.erasingDepth > 0,
genericClassInstance: (decl, ref, mapArgument) =>
this.genericClassInstanceType(decl, ref, mapArgument),
@@ -5015,7 +5116,14 @@ export class Lowerer {
}
collectProgram(parts: FileParts[]): void {
return collectProgram(this, parts);
collectProgram(this, parts);
// The coverage remainder registers the declarations reachable emit
// registered with dynamic slots (retryDeferred), so it neither reports
// them as unreached nor blocks their uses.
for (const symbol of this.priorRetriedSymbols) {
const diags = this.deferredDiags.get(symbol);
if (diags !== undefined) this.retryDeferred(symbol, diags);
}
}
/** Names every file's %init and registers the run-once guard globals
@@ -8273,9 +8381,12 @@ export class Lowerer {
if (sig && !this.wantBody(sig.name)) continue;
const fn = this.lowerFunction(decl);
if (fn) functions.push(fn);
else if (this.countsSkips()) {
this.countSkippedBody(decl);
if (this.remainder) this.reportBlockedBody(decl);
else {
this.skippedBodies.add(decl);
if (this.countsSkips()) {
this.countSkippedBody(decl);
if (this.remainder) this.reportBlockedBody(decl);
}
}
}
for (const decl of fp.classDecls) {
@@ -8284,7 +8395,10 @@ export class Lowerer {
functions.push(
...this.withClosureRoot(info.def.name, () => this.lowerClassMembers(info)),
);
else if (this.countsSkips()) this.countSkippedBody(decl);
else {
this.skippedBodies.add(decl);
if (this.countsSkips()) this.countSkippedBody(decl);
}
}
}
@@ -8319,15 +8433,38 @@ export class Lowerer {
let gc = 0;
let gi = 0;
let es = 0;
let rd = 0;
const classDispatch = new ClassDynamicDispatch();
let dispatchChanged = !this.remainder;
while (
dispatchChanged ||
rd < this.retriedDeclarations.length ||
ec < this.exprClasses.length ||
gc < this.genericClassInstances.length ||
gi < this.instantiationQueue.length ||
es < this.emitSpecQueue.length
) {
// Localized failure: declarations a flush registered with dynamic
// slots (retryDeferred) after the loops above skipped their bodies.
while (rd < this.retriedDeclarations.length) {
const decl = this.retriedDeclarations[rd++]!;
if (!this.skippedBodies.delete(decl)) continue;
if (this.countsSkips()) this.uncountSkippedBody(decl);
if (ts.isFunctionDeclaration(decl)) {
const fn = this.lowerFunction(decl);
if (fn) functions.push(fn);
else {
this.skippedBodies.add(decl);
if (this.countsSkips()) this.countSkippedBody(decl);
}
} else if (ts.isClassDeclaration(decl)) {
const info = this.classes.get(this.classNamer(decl));
if (info)
functions.push(
...this.withClosureRoot(info.def.name, () => this.lowerClassMembers(info)),
);
}
}
while (ec < this.exprClasses.length) {
functions.push(...this.lowerClassMembers(this.exprClasses[ec++]!));
}
@@ -8340,13 +8477,10 @@ export class Lowerer {
// generic method's this/super fence, a fenced parameter
// default): the diagnostic is recorded — the instance skips
// like a signature-blocked function (lowerFunction's rule).
try {
functions.push(
this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)),
);
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
}
const fn = this.lowerUnitDegrading(() =>
this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)),
);
if (fn !== null) functions.push(fn);
}
// Emit-override specializations queued by the emit sites above (a
// body can queue more — the super-forward chain — and generic
@@ -8609,6 +8743,22 @@ export class Lowerer {
* percentage would overstate how much of the program compiles. Count
* every statement the body holds as attempted and failed, in addition to
* the skipped-function tally. */
/** countSkippedBody undone: a flush registered the declaration after
* its body was skipped, and the body lowers now. */
private uncountSkippedBody(decl: ts.Node): void {
this.stats.functionsSkipped--;
if (this.suppressStats) return;
const n = countBodyStatements(decl);
if (n === 0) return;
this.stats.statementsTotal -= n;
this.stats.statementsFailed -= n;
const file = ts.getSourceFileOfNode(decl).fileName;
for (let i = 0; i < n; i++) {
this.bumpFileStat(file, "total", -1);
this.bumpFileStat(file, "failed", -1);
}
}
private countSkippedBody(decl: ts.Node): void {
this.stats.functionsSkipped++;
if (this.suppressStats) return;
@@ -8932,6 +9082,44 @@ export class Lowerer {
register(`%${cName}.set:${prop}`, [], () => this.throwingSetterFn(info, prop));
}
};
// Localized failure: a declaration a flush registered with dynamic
// slots (retryDeferred) registers its units like a class expression.
this.onDeclarationRegistered = (decl: ts.Node): void => {
if (ts.isFunctionDeclaration(decl)) {
if (!decl.body) return;
const symbol = declSymbolOf(this, decl);
const sig = symbol ? this.fnSigsBySymbol.get(symbol) : undefined;
if (!sig || units.has(sig.name)) return;
units.set(sig.name, {
order: unitOrder++,
roots: functionRoots(decl),
lower: () => this.lowerFunction(decl),
});
if (reachable.has(sig.name)) queue.push(sig.name);
metadataPriority.set(sig.name, [3, expressionMetadataOrder++]);
} else if (ts.isClassDeclaration(decl)) {
const info = this.classes.get(this.classNamer(decl));
if (!info) return;
if (!info.generic) {
if (!units.has(`%${info.def.name}.constructor`)) this.onExprClassCollected?.(info);
return;
}
// A generic class FAMILY's only units are its statics; its
// instantiations lower in the instance drain.
if (!info.staticMethods) return;
for (const [name, entry] of info.staticMethods) {
const unit = `%${info.def.name}.static:${name}`;
if (units.has(unit)) continue;
units.set(unit, {
order: unitOrder++,
roots: functionRoots(entry.member),
lower: () => lowerStaticMethod(this, info, name),
});
if (reachable.has(unit)) queue.push(unit);
metadataPriority.set(unit, [3, expressionMetadataOrder++]);
}
}
};
const onEdge = this.onEdge;
for (const name of collectionEdges) onEdge(name);
// Generic instances queued by the bodies above lower here (an instance
@@ -8987,17 +9175,16 @@ export class Lowerer {
this.genericDemandPriority.set(inst, ref);
// Body-level poisons skip the instance after retaining the
// diagnostic and every edge fired before poisoning.
try {
instanceFunctions.push(
this.withGenericDemandOwner(owner, () =>
this.shapes.withDeclaredOrderPriority(ref, () =>
this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)),
),
const fn = this.lowerUnitDegrading(() =>
this.withGenericDemandOwner(owner, () =>
this.shapes.withDeclaredOrderPriority(ref, () =>
this.withClosureRoot(inst.name, () => this.lowerGenericInstance(info, inst)),
),
);
),
);
if (fn !== null) {
instanceFunctions.push(fn);
relieveHeapPressure();
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
}
}
}
@@ -9369,6 +9556,9 @@ export class Lowerer {
this.diagSink.push(d);
return;
}
// A probe's copy of a declaration's flushed diagnostics, committed
// after a flush retry registered the declaration: nothing to report.
if (pending && this.retriedSymbols.has(pending.symbol)) return;
if (pending) {
this.commitFlush(pending.symbol, pending.source);
const group = this.rejectedGroups.get(pending.symbol);
@@ -9438,13 +9628,144 @@ export class Lowerer {
): never {
const failure = typeFailureOf(node, this.checker.typeToString(type), family);
diag.typeFailure = failure;
this.failedTypes.set(failure, type);
this.pushDiag(diag);
throw new PoisonError(failure);
throw new PoisonError(failure, type);
}
/** Localized failure's commit point (design §3.3): `e` poisoned a unit
* whose failure is about to enter the build. When it is a degradable type
* failure at a slot kind in `slots`, the failing type joins
* degradedTypes (its lenient mapping is the checked dynamic, whole slot),
* the failure's own diagnostic leaves the build, and the caller lowers
* the unit again. Returns the slot to record once that retry succeeds,
* or null to commit as today. Never inside a capture buffer (a probe or a
* deferred collection) and never under --tier=static-only. */
degradeAtCommit(e: unknown, slots: ReadonlySet<TypeFailureSlot>): DynamicSlot | null {
if (this.tierMode !== "auto" || this.diagSink !== null) return null;
if (!(e instanceof PoisonError)) return null;
const failure = e.failure;
const type = e.failedType;
if (failure === undefined || type === undefined) return null;
if (!failure.degradable || failure.family === "dyn-field-policy") return null;
if (!slots.has(failure.slot)) return null;
const id = (type as { id?: number }).id;
// Already degraded and still failing: no progress, commit as today.
if (id === undefined || this.degradedTypes.has(id)) return null;
let committed: ScrDiagnostic | undefined;
for (let i = this.diags.length - 1; i >= 0; i--) {
if (this.diags[i]!.typeFailure !== failure) continue;
committed = this.diags[i];
this.diags.splice(i, 1);
}
if (committed === undefined) return null;
this.degradedTypes.set(id, { lenient: DYN, code: committed.code, reason: committed.message });
return { loc: committed.loc, failure, code: committed.code, reason: committed.message };
}
/** A declaration slot whose type maps through degradedTypes is a
* dynamic slot too, though only the first such slot failed: record it
* (outside probes; the lookup is skipped while nothing is degraded). */
noteDegradedSlot(node: ts.Node, type: ts.Type): void {
if (this.diagSink !== null) return;
const degraded = this.degradedTypes.get((type as { id?: number }).id ?? -1);
if (degraded === undefined) return;
const failure = typeFailureOf(node, this.checker.typeToString(type), "unsupported");
if (failure.slot === "expression" || failure.slot === "member") return;
this.noteDynamicSlot({
loc: locOf(node),
failure,
code: degraded.code,
reason: degraded.reason,
});
}
/** Localized failure, statement commit point for declarations a probe
* flushed: a statement committing the flushed diagnostics of a deferred
* declaration (inside a capture buffer the flush could not retry) reopens
* it here, outside every probe. When the declaration's diagnostics are
* all retriable, they leave the build, the declaration retries
* (retryDeferred), and on success the caller lowers the statement again.
* Returns whether some declaration registered. */
reopenFlushedAtCommit(diagsBefore: number): boolean {
if (this.tierMode !== "auto" || this.diagSink !== null) return false;
const groups = new Set<string>();
for (let i = diagsBefore; i < this.diags.length; i++) {
const group = this.diags[i]!.rejects;
if (group !== undefined && !group.startsWith("statement @")) groups.add(group);
}
if (groups.size === 0) return false;
let registered = false;
for (const [symbol, group] of [...this.rejectedGroups]) {
if (!groups.has(group) || !this.deferredCollectors.has(symbol)) continue;
const committed = this.diags.filter((d) => d.rejects === group);
this.diags.splice(0, this.diags.length, ...this.diags.filter((d) => d.rejects !== group));
this.flushedSymbols.delete(symbol);
this.rejectedGroups.delete(symbol);
if (this.retryDeferred(symbol, committed)) {
registered = true;
continue;
}
// Not retriable: the declaration's diagnostics commit as before.
const last = this.deferredDiags.get(symbol) ?? committed;
this.deferredDiags.delete(symbol);
this.flushedSymbols.add(symbol);
this.noteRejected(symbol, last);
for (const d of last) this.pushDiag(d);
}
return registered;
}
/** A failed attempt's own diagnostics leave the build before its unit
* lowers again; flushed declaration diagnostics (`rejects`) stay, since
* the retry will not flush them a second time. */
dropAttemptDiags(diagsBefore: number): void {
const kept = this.diags
.slice(diagsBefore)
.filter((d) => d.rejects !== undefined || this.loweredOnceDiags.has(d));
this.diags.length = diagsBefore;
this.diags.push(...kept);
}
/** Lowers one unit whose body-level poison skips it (a generic
* instance), with localized failure's commit point: a degradable type
* failure at one of its declaration slots degrades the type and the unit
* lowers again (at most MAX_DECLARATION_RETRIES times). Null when it
* still poisons; its diagnostics are then committed as before. */
lowerUnitDegrading<T>(lower: () => T): T | null {
const slots: DynamicSlot[] = [];
for (let attempt = 0; ; attempt++) {
const mark = this.diags.length;
try {
const result = lower();
for (const slot of slots) this.noteDynamicSlot(slot);
return result;
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
if (attempt >= MAX_DECLARATION_RETRIES) return null;
const slot = this.degradeAtCommit(e, DECLARATION_SLOTS);
if (slot === null) return null;
slots.push(slot);
this.dropAttemptDiags(mark);
}
}
}
/** Records a dynamic slot whose unit lowered with it (one per site). */
noteDynamicSlot(slot: DynamicSlot): void {
const seen = this.dynamicSlots.some(
(s) =>
s.loc.file === slot.loc.file &&
s.loc.start === slot.loc.start &&
s.loc.end === slot.loc.end,
);
if (!seen) this.dynamicSlots.push(slot);
}
/** The SC1090 refusal of checked-dynamic class fields in TypeScript
* (classFieldStoresDyn): a policy fence, not a missing representation,
* so its failure is recorded as degradable. */
* (classFieldStoresDyn): a policy fence, not a missing representation.
* A flush retry lifts it (liftDynFieldPolicy), so a reached class with
* an 'any' field registers. */
dynFieldPolicyFence(node: ts.Node, feature: string): never {
const diag = unsupportedDiag("SC1090", locOf(node), feature);
const failure = typeFailureOf(
@@ -9454,7 +9775,7 @@ export class Lowerer {
);
diag.typeFailure = failure;
this.pushDiag(diag);
throw new PoisonError(failure);
throw new PoisonError(failure, this.typeOf(node));
}
badType(node: ts.Node, type: ts.Type): never {
@@ -10170,6 +10491,7 @@ export class Lowerer {
// f64 — but must not: pre-empt the mapping so the JS fallback below
// answers instead.
const mapped = neverTaintedJsType(this, node, t) ? null : this.mapTypeOf(t);
if (mapped !== null && this.degradedTypes.size > 0) this.noteDegradedSlot(node, t);
if (!mapped) {
const argumentsType = jsArgumentsFunctionType(this, t);
if (argumentsType !== null) return argumentsType;
@@ -12374,6 +12696,7 @@ export class Lowerer {
const list = this.deferredDiags.get(symbol) ?? [];
list.push(...sink);
this.deferredDiags.set(symbol, list);
this.deferredCollectors.set(symbol, { symbolOf, collect });
return symbol;
} finally {
this.diagSink = previousSink;
@@ -12424,10 +12747,13 @@ export class Lowerer {
return;
}
if (this.diagSink === null) {
if (this.retryDeferred(symbol, diags)) return;
// A failed retry leaves the last attempt's diagnostics deferred.
const committed = this.deferredDiags.get(symbol) ?? diags;
this.deferredDiags.delete(symbol);
this.flushedSymbols.add(symbol);
this.noteRejected(symbol, diags);
for (const d of diags) this.pushDiag(d);
this.noteRejected(symbol, committed);
for (const d of committed) this.pushDiag(d);
this.touchRejected(symbol);
return;
}
@@ -12453,9 +12779,132 @@ export class Lowerer {
if (list.length === 0) this.deferredDiags.delete(symbol);
}
flushDeferredClass(className: string): void {
/** Flushes a deferred class known by its IR name, and answers the class
* when the flush registered it (retryDeferred). */
flushDeferredClass(className: string): ClassInfo | undefined {
const symbol = this.deferredClassByName.get(className);
if (symbol) this.flushDeferred(symbol);
return this.classes.get(className);
}
/** Localized failure's declaration commit point (design §3.3): a
* reference is flushing a deferred declaration into the build. When
* every deferred diagnostic is a degradable type failure at one of the
* declaration's slots, the failing types degrade and the collection
* runs again (up to MAX_DECLARATION_RETRIES rounds, each degrading the
* types the previous round still failed on). On success the
* declaration registers, each degraded slot is an SC6104, and nothing
* reports. Otherwise the last round's diagnostics stay deferred for the
* caller to commit. */
private retryDeferred(symbol: ts.Symbol, diags: readonly ScrDiagnostic[]): boolean {
if (this.tierMode !== "auto") return false;
if (this.staticInitsPassed.has(symbol)) return false;
const collector = this.deferredCollectors.get(symbol);
if (collector === undefined) return false;
const slots: DynamicSlot[] = [];
// The types this retry degraded (not those of retries its collections
// ran), for retryDeferredAtDeclaration to restore when it fails.
const degraded: number[] = [];
try {
return this.retryDeferredRounds(symbol, collector, diags, slots, degraded);
} finally {
this.retryDegraded = degraded;
}
}
private retryDeferredRounds(
symbol: ts.Symbol,
collector: { symbolOf: () => ts.Symbol | undefined; collect: () => void },
diags: readonly ScrDiagnostic[],
slots: DynamicSlot[],
degraded: number[],
): boolean {
let current = diags;
for (let round = 0; round < MAX_DECLARATION_RETRIES; round++) {
const failing: { diag: ScrDiagnostic; failure: TypeFailure; id: number }[] = [];
let retriable = false;
for (const diag of current) {
if (this.retriableDiags.has(diag)) {
retriable = true;
continue;
}
const failure = diag.typeFailure;
// The dyn class field policy lifts during the retry; nothing to
// degrade.
if (failure?.family === "dyn-field-policy") {
retriable = true;
continue;
}
const type = failure === undefined ? undefined : this.failedTypes.get(failure);
const id = (type as { id?: number } | undefined)?.id;
if (
failure === undefined ||
id === undefined ||
!failure.degradable ||
!DECLARATION_SLOTS.has(failure.slot) ||
this.degradedTypes.has(id)
)
return false;
failing.push({ diag, failure, id });
}
// A round makes progress by degrading a type, or (once) by running
// a collection that only waited for another declaration.
if (failing.length === 0 && !(retriable && round === 0)) return false;
for (const { diag, failure, id } of failing) {
this.degradedTypes.set(id, { lenient: DYN, code: diag.code, reason: diag.message });
degraded.push(id);
slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message });
}
this.deferredDiags.delete(symbol);
const lifted = this.liftDynFieldPolicy;
this.liftDynFieldPolicy = true;
let registered: boolean;
try {
registered = this.collectDeferring(collector.symbolOf, collector.collect) === null;
} finally {
this.liftDynFieldPolicy = lifted;
}
if (registered) {
this.retriedSymbols.add(symbol);
this.deferredCollectors.delete(symbol);
for (const [name, deferred] of this.deferredClassByName)
if (deferred === symbol) this.deferredClassByName.delete(name);
for (const slot of slots) this.noteDynamicSlot(slot);
for (const decl of symbol.declarations)
if (ts.isFunctionDeclaration(decl) || ts.isClassDeclaration(decl)) {
this.retriedDeclarations.push(decl);
this.onDeclarationRegistered?.(decl);
}
return true;
}
current = this.deferredDiags.get(symbol) ?? [];
}
return false;
}
private retryDegraded: number[] = [];
/** A deferred class with static field initializers is reached where its
* statement evaluates: Node runs the initializers there, referenced or
* not. Its file's %init calls this at that place, which retries the class
* as a flush would, so the initializers lower in place. A failed retry
* reports nothing and leaves the class deferred as it was, and from here
* on no flush registers it (staticInitsPassed): it reports instead.
* Returns whether the class has registered. */
retryDeferredAtDeclaration(symbol: ts.Symbol): boolean {
const diags = this.deferredDiags.get(symbol);
let registered = !this.deferredCollectors.has(symbol);
if (!registered && diags !== undefined) {
this.retryDegraded = [];
registered = this.retryDeferred(symbol, diags);
if (!registered) {
for (const id of this.retryDegraded) this.degradedTypes.delete(id);
this.deferredDiags.set(symbol, [...diags]);
}
this.retryDegraded = [];
}
this.staticInitsPassed.add(symbol);
return registered;
}
collectSignature(decl: ts.FunctionDeclaration): void {
@@ -12734,6 +13183,9 @@ export class Lowerer {
* bodies as worklist units (the units map is otherwise built before
* lowering starts). Null in the emit pass. */
onExprClassCollected: ((info: ClassInfo) => void) | null = null;
/** emitReachable's unit registration for a declaration retryDeferred
* registered after collection. */
onDeclarationRegistered: ((decl: ts.Node) => void) | null = null;
/** Mixin functions (`(Base: T) => class extends Base {…}`) by their
* function-like node: recognized shape, or null for checked
@@ -12912,6 +13364,8 @@ export class Lowerer {
/* ── scoping and captures ─────────────────────────────────────────── */
declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal {
if (type.kind === "dyn" && this.degradedTypes.size > 0)
this.noteDegradedSlot(nameNode, this.typeOf(nameNode));
const symbol = this.checker.getSymbolAtLocation(nameNode);
const local = declareContextLocal(
this.ctx,
@@ -1282,6 +1282,11 @@ export interface TypeMapperCtx {
* checker type its signature instantiates, or null. Consulted only when
* the symbolic form does not map. */
resolveBoundAliasTs?: TypeParamTsResolver;
/** Localized failure: the lenient mapping of a type whose strict mapping
* failed at a committed slot (Lowerer.degradedTypes), or null. Consulted
* only after the strict mapping (and its bound-alias retry) returns null,
* at every depth, so every occurrence of a degraded type maps alike. */
degradedType?: (type: ts.Type) => IrType | null;
/** GENERIC program classes (monomorphization by flow): the mapping of a
* concrete instantiation reference (`Box<number>`) — the Lowerer
* registers/reuses the instantiation (`Box%0`) and answers its object
@@ -1856,6 +1861,16 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (mapped !== null) contextResolutions++;
}
}
// Localized failure: a type that failed at a committed slot maps to its
// lenient form. It counts as a context resolution so neither it nor a
// type containing it is memoized: the strict form may map elsewhere.
if (mapped === null && ctx.degradedType) {
const lenient = ctx.degradedType(type);
if (lenient !== null) {
mapped = lenient;
contextResolutions++;
}
}
if (
mapped !== null &&
memoKey !== null &&
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
async function compileSource(source: string) {
const directory = mkdtempSync(join(tmpdir(), "scriptc-localized-"));
try {
const entry = join(directory, "main.ts");
writeFileSync(entry, source);
return await compile(entry, {
outDir: directory,
outPath: join(directory, "program.ir"),
outputKind: "ir",
});
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
// A statement that lowers again after a later declarator degrades a type
// reuses the member bodies of a function-local class it lowered the first
// time, so the diagnostics of those bodies still report.
test("a degrading retry keeps the diagnostics of a local class lowered before it", async () => {
const result = await compileSource(`type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
function make(): number {
const K = class {
m(): number {
const a = [1, 2];
(a as any).foo = 1;
return a.length;
}
},
size = (cb: Callbacks): number => (cb instanceof Set ? cb.size : 0);
return new K().m() + size(new Set([1]));
}
console.log(make());
`);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected the local class's diagnostic");
expect(result.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SC1090",
message: expect.stringContaining("assignment to non-variables"),
}),
]),
);
});
// Without the failing statement, the same program compiles: the arrow's
// parameter is a dynamic slot.
test("the same program without the local class's failure compiles", async () => {
const result = await compileSource(`type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
function make(): number {
const K = class {
m(): number {
return 2;
}
},
size = (cb: Callbacks): number => (cb instanceof Set ? cb.size : 0);
return new K().m() + size(new Set([1]));
}
console.log(make());
`);
expect(result.ok, JSON.stringify(result.ok ? [] : result.diagnostics)).toBe(true);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-cycles.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-declarations.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-forward-capture.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-function-slots.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-held-values.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-locals.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-pi-shapes.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-registered-statics.ts"
],
"diags": []
}
+29
View File
@@ -0,0 +1,29 @@
// Localized failure, stage L2: reference cycles through dynamic slots are
// collected (instance -> dynamic field -> capsule -> instance).
type Peer = Link | Map<string, (x: number) => string> | AbortSignal | (() => number);
class Link {
id: number;
peer: Peer | undefined;
constructor(id: number) {
this.id = id;
this.peer = undefined;
}
peerId(): number {
const p = this.peer;
return p instanceof Link ? p.id : -1;
}
}
let total = 0;
for (let i = 0; i < 20000; i++) {
const a = new Link(i);
const b = new Link(i + 1);
a.peer = b;
b.peer = a;
total += a.peerId() - b.peerId();
}
const keep = new Link(7);
keep.peer = () => keep.id * 2;
const f = keep.peer;
console.log(total, typeof f === "function" ? f() : 0, keep.peerId());
@@ -0,0 +1,64 @@
// Localized failure, stage L2: fields, parameters and returns whose
// declared type has no static lowering become checked dynamic slots, so
// their class or function still compiles.
interface ToolDefinition {
name: string;
execute(id: string, params: any): string;
}
type Progress = (done: number, total: number) => void;
type ProgressOrSignal = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
class Session {
// A field: the class registers, the field holds the array by reference.
private tools: ToolDefinition[] = [];
state: any = { count: 0 };
readonly label: string;
constructor(label: string) {
this.label = label;
}
add(tool: ToolDefinition): void {
this.tools.push(tool);
}
run(id: string, params: unknown): string[] {
const out: string[] = [];
for (const tool of this.tools) out.push(tool.execute(id, params));
this.state.count = this.state.count + 1;
return out;
}
count(): number {
return this.tools.length;
}
// A return slot.
first(): ToolDefinition | ProgressOrSignal | undefined {
return this.tools[0];
}
}
// A parameter: the function registers and is called from typed code.
function listAll(dir: string, onProgress?: ProgressOrSignal): number {
if (typeof onProgress === "function") onProgress(1, dir.length);
else if (onProgress instanceof Set) return onProgress.size;
return dir.length;
}
// A base class with a dynamic parameter, and an override.
class Base {
notify(target: ProgressOrSignal | undefined): string {
return target === undefined ? "base:none" : "base:" + typeof target;
}
}
class Derived extends Base {
override notify(target: ProgressOrSignal | undefined): string {
return "derived/" + super.notify(target);
}
}
const echo: ToolDefinition = { name: "echo", execute: (id, params) => `${id}:${JSON.stringify(params)}` };
const s = new Session("main");
s.add(echo);
s.add({ name: "len", execute: (id) => String(id.length) });
console.log(s.label, s.count(), s.run("abc", { k: 1 }).join(" | "), s.state.count);
console.log("same tool", s.first() === echo);
console.log(listAll("hello"), listAll("hello", (d, t) => console.log("progress", d, t)), listAll("x", new Set([1, 2])));
const objs: Base[] = [new Base(), new Derived()];
for (const o of objs) console.log(o.notify(undefined), o.notify((a, b) => a + b));
@@ -0,0 +1,19 @@
// Localized failure, stage L1: a statement that declares a binding an
// earlier closure captures, and then a binding whose declared type has no
// static lowering, still initializes the captured binding.
type Callbacks = ((done: number) => void) | AbortSignal | Map<string, (x: number) => string> | Set<number>;
function run(): void {
const read = (): number => total;
const total = 3,
hook: Callbacks = new Set<number>([1, 2]);
console.log(read(), hook instanceof Set, hook instanceof Set && hook.size);
let late = (): string => label;
let label = "first",
other: Callbacks = new Set<number>([3]);
console.log(late(), other instanceof Set);
label = "second";
late = (): string => label + "!";
console.log(late());
}
run();
@@ -0,0 +1,28 @@
// Localized failure, stage L2: parameters and returns of arrows, function
// expressions and generic instances become checked dynamic slots.
type Hook = Map<string, (x: number) => string> | AbortSignal | (() => number) | Set<string>;
interface Model<T> {
id: T;
hook: Hook;
}
function retry<T>(label: string, request: () => Promise<T> | Hook): T | string {
const r = request();
return typeof r === "function" ? label + ":" + String(r()) : label + ":other";
}
function describe<T>(model: Model<T>): string {
return `${String(model.id)}/${model.hook instanceof Set ? model.hook.size : typeof model.hook}`;
}
const run = (hook: Hook, n: number): string => (typeof hook === "function" ? String(hook() + n) : "set");
const make = function (size: number): Hook {
return new Set(Array.from({ length: size }, (_, i) => `k${i}`));
};
console.log(run(() => 40, 2), run(new Set(["a"]), 1));
const h = make(3);
console.log(h instanceof Set ? h.size : -1);
console.log(retry("a", () => () => 7), retry<number>("b", () => new Set<string>()));
console.log(describe({ id: 5, hook: new Set(["x", "y"]) }), describe({ id: "s", hook: () => 1 }));
@@ -0,0 +1,70 @@
// Localized failure, stage L1: values of every kind held in a local or
// module global whose declared type has no static lowering keep their
// identity and behavior.
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
norm(): number {
return Math.abs(this.x) + Math.abs(this.y);
}
}
interface Cfg {
name: string;
depth: number;
}
type Anything =
| Point
| Cfg
| number[]
| Map<string, (x: number) => string>
| AbortSignal
| (() => number)
| Promise<number>;
const p = new Point(3, -4);
const cfg: Cfg = { name: "a", depth: 1 };
const xs = [1, 2, 3];
let counter = 0;
const tick = (): number => ++counter;
// A module global holding a class instance.
let held: Anything = p;
if (held instanceof Point) {
console.log("point", held.norm(), held === p);
held.x = 10;
}
console.log("typed alias", p.x, p.norm());
// A record: mutation through the hole is visible in the typed alias.
held = cfg;
if (!(held instanceof Point) && !Array.isArray(held) && typeof held === "object" && "depth" in held) {
held.depth = 5;
console.log("record", held.name, held === cfg);
}
console.log("typed alias", cfg.depth, JSON.stringify(cfg), Object.keys(cfg).join(","));
// An array: pushes through the hole show in typed code.
held = xs;
if (Array.isArray(held)) {
held.push(4);
console.log("array", held.length, held === xs);
}
console.log("typed alias", xs.length, xs.join("+"));
function main(): void {
// A local holding a closure, called through the hole.
let local: Anything = tick;
if (typeof local === "function") {
console.log("closure", local(), local(), counter, local === tick);
}
// A promise, awaited through the hole.
local = Promise.resolve(42);
if (local instanceof Promise) {
local.then((v) => console.log("promise", v));
}
}
main();
+43
View File
@@ -0,0 +1,43 @@
// Localized failure, stage L1: locals, loop variables and module globals
// whose declared type has no static lowering hold checked-dynamic values.
type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
const report: Progress = (done, total) => console.log(`progress ${done}/${total}`);
const seen = new Set<number>([1, 2, 3]);
// A module global, written with typed values of several arms.
let current: Callbacks | undefined = report;
function describe(label: string): void {
// A local of the same declared type.
const local: Callbacks | undefined = current;
if (local === undefined) {
console.log(label, "none");
} else if (typeof local === "function") {
local(1, 2);
console.log(label, "function", local === report);
} else if (local instanceof Set) {
console.log(label, "set", local.size, local.has(2), local === seen);
local.add(4);
} else {
console.log(label, "other");
}
}
describe("first");
current = seen;
describe("second");
console.log("typed alias sees the write", seen.size, seen.has(4));
current = undefined;
describe("third");
// A loop variable over an array whose element type does not compile.
const all: (Callbacks | number)[] = [report, seen, 7];
for (const item of all) {
if (typeof item === "number") console.log("number", item + 1);
else if (typeof item === "function") item(3, 4);
else console.log("object", item instanceof Set);
}
all.push(9);
console.log(all.length);
@@ -0,0 +1,37 @@
// Localized failure, stage L2: pi-shaped declarations.
interface Token {
text: string;
}
interface AgentMessage {
role: "user" | "assistant";
content: string[];
}
type CreateRuntime = (cwd: string) => Promise<{ session: Session; extra: Map<string, (x: number) => string> | AbortSignal }>;
class Session {
messages: AgentMessage[] = [];
// A field whose union mentions a stdlib type with no lowering: the class
// registers; constructing a WeakRef would still be refused.
cachedTokens?: WeakRef<{ source: string; tokens: Token[] }> | undefined;
rendererState: any = {};
effectState: Record<string, unknown> = {};
private readonly createRuntime: CreateRuntime | undefined;
constructor(createRuntime: CreateRuntime | undefined) {
this.createRuntime = createRuntime;
}
hasRuntime(): boolean {
return this.createRuntime !== undefined;
}
clear(): AgentMessage[] {
// An inferred return over a spread clone.
return this.messages.map((message) => ({ ...message, content: [] }));
}
}
const s = new Session(undefined);
const withRuntime = new Session(async (cwd) => ({ session: s, extra: new Map() }));
s.messages.push({ role: "user", content: ["hi"] }, { role: "assistant", content: ["yo", "!"] });
s.rendererState.mode = "fast";
s.effectState["k"] = 3;
console.log(s.hasRuntime(), s.cachedTokens === undefined, s.rendererState.mode, s.effectState["k"]);
console.log(JSON.stringify(s.clear()), s.messages[1]!.content.length, withRuntime.hasRuntime());
@@ -0,0 +1,46 @@
// Localized failure, stage L2: a class that registers with dynamic slots
// runs its static field initializers where its statement evaluates, and a
// generic class's static methods compile once it registers.
type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
function sideEffect(label: string): number {
console.log("static init", label);
return label.length;
}
// Registers at its statement: the initializers run before the next line.
class Registry {
state: any = { n: 1 };
static count = sideEffect("registry");
static names: string[] = ["a", "b"];
read(): number {
return this.state.n;
}
}
console.log("after the class statement");
// Never referenced: its initializer runs all the same.
class Unused {
slot: any = null;
static ready = sideEffect("unused");
}
// A generic class whose static method has a dynamic parameter.
class Box<T> {
value: T;
constructor(v: T) {
this.value = v;
}
static describe(target: Callbacks | undefined): string {
return target === undefined ? "none" : typeof target;
}
static label(): string {
return "box";
}
}
const r = new Registry();
console.log(r.read(), Registry.count, Registry.names.join(","));
console.log(Box.label(), Box.describe(undefined), Box.describe(new Set([1])), new Box(3).value);
@@ -1,60 +1,19 @@
dynamic tier (tier assignment, report-only; --tier=auto)
0 functions already operate on dynamic values (0 statements)
5 functions already operate on dynamic values (4 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
14 demotion requests (refusals and fences the dynamic tier would replace)
2 demotion requests (refusals and fences the dynamic tier would replace)
5 would-be dynamic slots (localized failure, report-only: type failures a checked dynamic slot would hold)
14 errors: 12 clear with declaration slots (L1-L2), 12 with member and expression slots too (L3-L4); 8 cascades attributed; 1 type failure stays refused
L2 field tools: Tool[] tests/coverage-fixtures/localized-failure.ts:13:11 (SC2009 component, clears 2)
rejects class Session
L2 field state: any tests/coverage-fixtures/localized-failure.ts:58:3 (SC1090 dyn-field-policy, clears 2)
rejects class Base
L2 param progress: Callbacks | undefined tests/coverage-fixtures/localized-failure.ts:23:31 (SC2009 component, clears 1)
rejects function listAll
L2 field state: any tests/coverage-fixtures/localized-failure.ts:29:3 (SC1090 dyn-field-policy, clears 1)
rejects class Holder
L1 local callbacks: Callbacks | undefined tests/coverage-fixtures/localized-failure.ts:37:9 (SC2009 component, clears 1)
rejects statement
class Session tests/coverage-fixtures/localized-failure.ts:12:1 (1 request)
<- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:49:11: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet
-> function <top level> turns dynamic
class Holder tests/coverage-fixtures/localized-failure.ts:28:1 (1 request)
<- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:52:13: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet
-> function <top level> turns dynamic
0 would-be dynamic slots (localized failure, report-only: type failures a checked dynamic slot would hold)
2 errors: 0 clear with declaration slots (L1-L2), 0 with member and expression slots too (L3-L4); 1 cascade attributed; 1 type failure stays refused
class Cache tests/coverage-fixtures/localized-failure.ts:42:1 (1 request)
<- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:53:22: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet
-> function <top level> turns dynamic
class Base tests/coverage-fixtures/localized-failure.ts:57:1 (1 request)
<- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:63:23: extending classes not declared in the program ('Base') is not supported yet
-> function <top level> turns dynamic
class Derived tests/coverage-fixtures/localized-failure.ts:63:1 (1 request)
<- SC1090 reflection at tests/coverage-fixtures/localized-failure.ts:68:13: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet
-> function <top level> turns dynamic
field Session.tools tests/coverage-fixtures/localized-failure.ts:13:3 (1 request)
<- SC2009 type at tests/coverage-fixtures/localized-failure.ts:13:11: values of type 'Tool[]' cannot be compiled: the array shape is supported, but its element type 'Tool' does not compile
-> function <top level> turns dynamic
field Holder.state tests/coverage-fixtures/localized-failure.ts:29:3 (1 request)
<- SC1090 operation at tests/coverage-fixtures/localized-failure.ts:29:3: 'any'-typed class fields are not supported yet
-> function <top level> turns dynamic
field Cache.tokens tests/coverage-fixtures/localized-failure.ts:43:3 (1 request)
<- SC2020 operation at tests/coverage-fixtures/localized-failure.ts:43:3: 'WeakRef<object>' is part of the standard library types but has no scriptc lowering yet
-> function <top level> turns dynamic
field Base.state tests/coverage-fixtures/localized-failure.ts:58:3 (1 request)
<- SC1090 operation at tests/coverage-fixtures/localized-failure.ts:58:3: 'any'-typed class fields are not supported yet
-> function <top level> turns dynamic
function <top level> tests/coverage-fixtures/localized-failure.ts:1:1 (11 sites inside)
function listAll tests/coverage-fixtures/localized-failure.ts:23:1 (1 request, 1 site inside)
<- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:51:13: uses of 'listAll' inherit the blocker on its declaration
-> function <top level> turns dynamic
function pick tests/coverage-fixtures/localized-failure.ts:36:1 (2 sites inside)
binding progress in listAll tests/coverage-fixtures/localized-failure.ts:23:31 (1 request)
<- SC2009 type at tests/coverage-fixtures/localized-failure.ts:23:31: values of type 'Callbacks | undefined' cannot be compiled: the union shape is supported, and unions in the 'string | object' family ride the checked-dynamic representation wholesale, but 'Map<string,…
-> function listAll turns dynamic
binding callbacks in pick tests/coverage-fixtures/localized-failure.ts:37:9 (2 requests)
<- SC2009 type at tests/coverage-fixtures/localized-failure.ts:37:9: values of type 'Callbacks | undefined' cannot be compiled: the union shape is supported, and unions in the 'string | object' family ride the checked-dynamic representation wholesale, but 'Map<string,…
-> function pick turns dynamic
<- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:38:10: uses of 'callbacks' inherit the blocker on its declaration
-> function pick turns dynamic
binding s tests/coverage-fixtures/localized-failure.ts:49:7 (1 request)
<- SC2004 cascade at tests/coverage-fixtures/localized-failure.ts:50:13: uses of 's' inherit the blocker on its declaration
-> function <top level> turns dynamic
function <top level> tests/coverage-fixtures/localized-failure.ts:1:1 (2 sites inside)
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
@@ -0,0 +1 @@
{ "plain": 4000 }
+50 -22
View File
@@ -431,28 +431,18 @@ test("--explain-dynamic attributes each refusal to the unit the dynamic tier wou
test("--explain-dynamic lists the type failures localized failure would turn into dynamic slots", async () => {
const { coverage } = analyze(fixture("localized-failure.ts"), { explainDynamic: true });
const report = coverage.tiers!.localizedFailure;
expect(report.errors).toBe(14);
expect(report.typeFailures).toEqual({ degradable: 5, refused: 1 });
expect(
report.slots.map((s) => [
s.stage,
s.slot,
s.name,
s.family,
s.rejects?.split(" @")[0],
s.cascade,
]),
).toEqual([
["L2", "field", "tools", "component", "class Session", 2],
// The derived class's rejection and its construction.
["L2", "field", "state", "dyn-field-policy", "class Base", 2],
["L2", "param", "progress", "component", "function listAll", 1],
["L2", "field", "state", "dyn-field-policy", "class Holder", 1],
["L1", "local", "callbacks", "component", "statement", 1],
// Locals, fields and parameters degrade (stages L1 and L2), and the
// 'any' field compiles; only the refused WeakRef field and its
// construction remain.
expect(coverage.dynamicSlots?.map((s) => [s.slot, s.name])).toEqual([
["field", "tools"],
["param", "progress"],
["local", "callbacks"],
]);
// The WeakRef field stays refused, so its class and the construction
// that uses it stay errors.
expect(report.clearedThroughL2).toBe(12);
expect(report.errors).toBe(2);
expect(report.typeFailures).toEqual({ degradable: 0, refused: 1 });
expect(report.slots).toEqual([]);
expect(report.cascades).toBe(1);
expect(report.remainingByCode).toEqual({ SC1090: 1, SC2020: 1 });
const relative = JSON.parse(
JSON.stringify(coverage.tiers).replaceAll(repoRoot + "/", ""),
@@ -462,6 +452,39 @@ test("--explain-dynamic lists the type failures localized failure would turn int
);
});
test("a local or module global whose declared type does not compile becomes a dynamic slot", async () => {
const program = join(repoRoot, "tests/corpus/localized-failure-locals.ts");
const { coverage, sourceTexts } = analyze(program);
expect(coverage.diagnostics).toEqual([]);
expect(coverage.dynamicSlots?.map((s) => [s.slot, s.name, s.declaredType, s.code])).toEqual([
["global", "current", "Callbacks | undefined", "SC2009"],
["global", "all", "(number | Callbacks)[]", "SC2009"],
["local", "local", "Callbacks | undefined", "SC2009"],
]);
expect(coverage.performance?.filter((d) => d.code === "SC6104")).toHaveLength(3);
expect(coveragePasses(coverage, "blockers")).toBe(true);
expect(coveragePasses(coverage, "dynamic")).toBe(true);
expect(coveragePasses(coverage, "dynamic-slots")).toBe(false);
const envelope = coverageEnvelope(coverage, { compilerVersion: "test", sourceTexts });
expect(envelope.dynamicSlots?.map((s) => [s.kind, s.name, s.line])).toEqual([
["global", "current", 10],
["global", "all", 36],
["local", "local", 14],
]);
expect(envelope.diagnostics.filter((d) => d.code === "SC6104").map((d) => d.severity)).toEqual([
"warning",
"warning",
"warning",
]);
const text = renderCoverage(coverage, { sourceTexts, root: repoRoot });
expect(text).toContain("dynamic slots 3");
expect(text).toContain("module global 'current' (declared 'Callbacks | undefined')");
// --tier=static-only keeps every refusal.
const strict = analyze(program, { tier: "static-only" }).coverage;
expect(strict.dynamicSlots).toBeUndefined();
expect(strict.diagnostics.map((d) => d.code)).toContain("SC2009");
});
test("CLI: --tier and --explain-dynamic", () => {
const scriptcCli = join(repoRoot, "packages/cli/src/main.ts");
const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-"));
@@ -564,13 +587,18 @@ test("runtime-optional refined unions report blockers without crashing", () => {
});
test("a rejected class reached only through a receiver probe reports its own diagnostic once", () => {
const { coverage } = analyze(join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts"));
const program = join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts");
// The class's refusal, as the diagnostics corpus pins it (--tier=static-only).
const { coverage } = analyze(program, { tier: "static-only" });
const messages = coverage.diagnostics.map((d) => d.message);
expect(
messages.filter((m) => m.startsWith("'MapIterator<string>'-typed class fields")),
).toHaveLength(1);
expect(messages.some((m) => m.startsWith("constructing through a class value"))).toBe(true);
expect(coverage.unreached?.diagnostics ?? []).toEqual([]);
// Localized failure retries the class where the probe's statement commits
// the flush, with the field policy lifted, and it compiles.
expect(analyze(program).coverage.diagnostics).toEqual([]);
});
test("settled generic rest-order fences count each source statement once", () => {
+8 -1
View File
@@ -39,7 +39,14 @@ describe(`diagnostics corpus (${files.length} programs)`, () => {
if (name.startsWith("checker-panic-")) process.env["SCRIPTC_CHECKER"] = "tsgo";
let result: Awaited<ReturnType<typeof compile>>;
try {
result = await compile(file, { outPath: join(outDir, "never"), outDir });
// --tier=static-only: these programs pin today's refusals, which
// localized failure otherwise turns into checked dynamic slots
// (the snapshots double as its proof that static-only keeps them).
result = await compile(file, {
outPath: join(outDir, "never"),
outDir,
tier: "static-only",
});
} finally {
if (checker === undefined) delete process.env["SCRIPTC_CHECKER"];
else process.env["SCRIPTC_CHECKER"] = checker;