mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-02 07:43:42 +08:00
Hold locals and module globals whose declared type does not compile as checked dynamic slots (#2292)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-cycles.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-declarations.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-forward-capture.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-function-slots.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-held-values.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-locals.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-pi-shapes.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/localized-failure-registered-statics.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 }
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user