Report which type failures localized failure would turn into dynamic slots (#2284)

This commit is contained in:
Chris Tate
2026-10-25 22:31:15 -05:00
committed by GitHub
11 changed files with 890 additions and 48 deletions
+59 -1
View File
@@ -24,7 +24,11 @@ import {
import { renderDiagnostics } from "../diagnostics/render.js";
import type { LowerStats } from "../frontend/lowering/lowerer.js";
import type { ProvenanceSources } from "../frontend/provenance-registry.js";
import type { DemotionUnit, TierReport } from "../frontend/tier-analysis.js";
import type {
DemotionUnit,
LocalizedFailureReport,
TierReport,
} from "../frontend/tier-analysis.js";
import type { WasmModuleReport } from "../wasm/discover.js";
import {
describe as describeReason,
@@ -668,6 +672,26 @@ function tiersEnvelope(tiers: TierReport, locator: SourceLocator): TiersEnvelope
...at(r.loc),
})),
units: tiers.units.map((u) => ({ ...unit(u), requests: u.requests, sites: u.sites })),
localizedFailure: {
errors: tiers.localizedFailure.errors,
typeFailures: tiers.localizedFailure.typeFailures,
cascades: tiers.localizedFailure.cascades,
clearedThroughL2: tiers.localizedFailure.clearedThroughL2,
clearedThroughL4: tiers.localizedFailure.clearedThroughL4,
remainingByCode: tiers.localizedFailure.remainingByCode,
slots: tiers.localizedFailure.slots.map((slot) => ({
...at(slot.loc),
slot: slot.slot,
name: slot.name,
declaredType: slot.declaredType,
family: slot.family,
code: slot.code,
message: slot.message,
stage: slot.stage,
...(slot.rejects !== null ? { rejects: slot.rejects } : {}),
cascade: slot.cascade,
})),
},
};
}
@@ -695,6 +719,39 @@ function unitMatches(u: DemotionUnit, query: string): boolean {
);
}
const SLOTS_SHOWN = 40;
/** The localized-failure section of --explain-dynamic (report-only): the
* committed type failures that would become checked dynamic slots, and
* the cascade each one causes today. */
function renderLocalizedFailure(
report: LocalizedFailureReport,
out: string[],
c: (code: string, s: string) => string,
where: (loc: { file: string; start: number }) => string,
): void {
if (report.typeFailures.degradable === 0 && report.typeFailures.refused === 0) return;
out.push("");
out.push(
` ${report.slots.length} would-be dynamic slot${report.slots.length === 1 ? "" : "s"} ${c(DIM, "(localized failure, report-only: type failures a checked dynamic slot would hold)")}`,
);
out.push(
` ${c(DIM, `${report.errors} errors: ${report.clearedThroughL2} clear with declaration slots (L1-L2), ${report.clearedThroughL4} with member and expression slots too (L3-L4); ${report.cascades} cascade${report.cascades === 1 ? "" : "s"} attributed; ${report.typeFailures.refused} type failure${report.typeFailures.refused === 1 ? " stays" : "s stay"} refused`)}`,
);
for (const slot of report.slots.slice(0, SLOTS_SHOWN)) {
const cascade = slot.cascade > 0 ? `, clears ${slot.cascade}` : "";
out.push(
` ${slot.stage} ${slot.slot} ${slot.name}: ${capMessage(slot.declaredType)} ${c(DIM, `${where(slot.loc)} (${slot.code} ${slot.family}${cascade})`)}`,
);
if (slot.rejects !== null)
out.push(` ${c(DIM, `rejects ${slot.rejects.split(" @")[0]}`)}`);
}
if (report.slots.length > SLOTS_SHOWN)
out.push(
` ${c(DIM, `${report.slots.length - SLOTS_SHOWN} more (--print=diagnostics lists all)`)}`,
);
}
/** `scriptc coverage --explain-dynamic[=<unit>]`: the dynamic tier today
* and the demotion chains the report-only analysis computed. With a query,
* only units whose name matches it (`x`, `Point.x`, `parse:x`). */
@@ -735,6 +792,7 @@ export function renderExplainDynamic(
(excluded > 0 ? c(DIM, `, ${excluded} excluded by policy`) : ""),
);
}
if (query === undefined) renderLocalizedFailure(tiers.localizedFailure, out, c, where);
const units = tiers.units.filter((u) => query === undefined || unitMatches(u, query));
if (query !== undefined && units.length === 0) {
out.push(` no demoted unit matches '${query}'`);
@@ -110,6 +110,64 @@ export interface ScrDiagnostic {
* and class member body: it throws when the module initializes, which is
* program startup for a module the entry imports. */
startup?: true;
/** Localized failure, report-only (stage L0): set where a type with no
* static lowering poisoned a slot (Lowerer.badType and the dyn class
* field policy). Never part of the rendered message. */
typeFailure?: TypeFailure;
/** Localized failure, report-only: the declaration this diagnostic
* rejected, as a failure-group key. Set when a reference flushes a
* deferred declaration's diagnostics, and on the diagnostics of a
* declaration statement whose bindings were blocked. */
rejects?: string;
/** Localized failure, report-only: the failure groups (rejected
* declarations, blocked statements) that the statement committing this
* diagnostic used. A cascade clears when every one of them would
* degrade instead of failing. */
cause?: readonly string[];
}
/** Where a type failure sits (localized-failure design §3.6): a
* declaration slot, a member of a declared type, or an expression's own
* type. */
export type TypeFailureSlot =
| "field"
| "param"
| "return"
| "local"
| "global"
| "loop"
| "member"
| "expression";
/** Which badType branch (or policy fence) refused the type. */
export type TypeFailureFamily =
| "node-types"
| "fetch-ambient"
| "enum-object"
| "stdlib-index"
| "index-signature"
| "package"
| "generic-signature"
| "overloads"
| "intersection"
| "component"
| "stdlib-nominal"
| "record-member"
| "unsupported"
| "dyn-field-policy";
/** A type failure's structured payload: what the commit points of the
* localized-failure design inspect (PoisonError.failure carries the same
* object). `degradable` is the §3.7 classification: a checked-dynamic slot
* would keep the program's meaning. */
export interface TypeFailure {
slot: TypeFailureSlot;
/** The slot's name (`_customTools`, `listAll`, `x`), or the expression
* text for expression slots. */
name: string;
declaredType: string;
family: TypeFailureFamily;
degradable: boolean;
}
/** SC3002–SC3004 — native-helper target, installation, and execution
@@ -114,6 +114,27 @@ export interface TiersEnvelope {
message: string;
})[];
units: (TierUnitPosition & { requests: number; sites: number })[];
/** Localized failure (report-only): committed type failures that would
* become checked dynamic slots, and what they would clear. */
localizedFailure: {
errors: number;
typeFailures: { degradable: number; refused: number };
cascades: number;
clearedThroughL2: number;
clearedThroughL4: number;
remainingByCode: Record<string, number>;
slots: (SourcePosition & {
slot: string;
name: string;
declaredType: string;
family: string;
code: string;
message: string;
stage: string;
rejects?: string;
cascade: number;
})[];
};
}
export interface DiagnosticsEnvelope {
@@ -116,6 +116,7 @@ import {
requireSpecOf,
} from "../program.js";
import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js";
import { declarationGroupKey } from "./type-failure.js";
import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
import { lowerArrayConstructor } from "./containers/array-construction.js";
@@ -2297,11 +2298,17 @@ export function collectClassShapeInner(
base = named;
if (!base) {
if (adoptCallableBase(t.expression)) continue;
lowerer.unsupported(
const diag = unsupportedDiag(
"SC1090",
t,
locOf(t),
`extending classes not declared in the program ('${t.expression.text}')`,
);
// A base the program declares but rejected: this rejection is its
// cascade (localized failure, report-only).
const rejectedBase = rejectedProgramClassGroup(lowerer, symbol);
if (rejectedBase !== null) diag.cause = [rejectedBase];
lowerer.pushDiag(diag);
throw new PoisonError();
}
}
@@ -2542,8 +2549,7 @@ export function collectClassShapeInner(
(member.type !== undefined ||
(lowerer.typeOf(member.name).flags & ts.TypeFlags.Any) === 0)
) {
lowerer.unsupported(
"SC1090",
lowerer.dynFieldPolicyFence(
member.name,
`${dynFieldTypeSpelling(lowerer, member.name)}-typed static fields`,
);
@@ -3059,8 +3065,7 @@ export function collectClassShapeInner(
!isJsSourceFile(ts.getSourceFileOfNode(member)) &&
!lowerer.classFieldStoresDyn(lowerer.typeOf(member.name))
) {
lowerer.unsupported(
"SC1090",
lowerer.dynFieldPolicyFence(
member.name,
`${dynFieldTypeSpelling(lowerer, member.name)}-typed class fields`,
);
@@ -3197,8 +3202,7 @@ export function collectClassShapeInner(
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))) {
lowerer.unsupported(
"SC1090",
lowerer.dynFieldPolicyFence(
p.name,
`${dynFieldTypeSpelling(lowerer, p)}-typed class fields`,
);
@@ -11850,3 +11854,14 @@ function definedThroughReflection(lowerer: Lowerer, decl: ts.ClassLikeDeclaratio
});
return found;
}
/** The failure group of a class the program declares but did not collect
* (its own declaration was rejected), or null for any other symbol. */
function rejectedProgramClassGroup(lowerer: Lowerer, symbol: ts.Symbol | null): string | null {
if (symbol === null) return null;
const recorded = lowerer.rejectedGroups.get(symbol);
if (recorded !== undefined) return recorded;
const decl = symbol.declarations.find((d) => ts.isClassDeclaration(d) || ts.isClassExpression(d));
if (decl === undefined || ts.getSourceFileOfNode(decl).isDeclarationFile) return null;
return declarationGroupKey(symbol);
}
@@ -41,6 +41,8 @@ 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 { statementGroupKey } from "./type-failure.js";
import {
cjsExportValueByName,
cjsNamespaceDestructureOf,
@@ -750,6 +752,8 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
if (!lowerer.suppressStats) lowerer.countStatement(stmt, sf?.fileName);
const diagsBefore = lowerer.diags.length;
const sinkMark = lowerer.diagSinkMark();
const touched = new Set<string>();
lowerer.failureTouches.push(touched);
try {
let lowered: IrStmt | IrStmt[] | null;
let appended = false;
@@ -813,7 +817,17 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
throw e;
}
if (!lowerer.suppressStats) lowerer.countStatementFailed(stmt, sf?.fileName);
lowerer.noteBlockedBindings(stmt);
// Localized failure (report-only): the statement's committed
// diagnostics that are not themselves type failures or rejected
// declarations are cascades of the rejected declarations it used.
const committed =
lowerer.diagSink === null && !deferFences ? lowerer.diags.slice(diagsBefore) : [];
if (touched.size > 0) {
const cause = [...touched];
for (const d of committed)
if (d.typeFailure === undefined && d.rejects === undefined) d.cause ??= cause;
}
lowerer.noteBlockedBindings(stmt, committed);
// The runtime-fence conversion (JS sources, direct diagnostics
// only — a capture wrapper in force means collection owns the
// report). The statement's diagnostics move OFF the build and
@@ -837,6 +851,8 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
const fence = lowerer.capturedRuntimeFence(sinkMark, stmt);
if (fence) out.push(fence);
}
} finally {
lowerer.failureTouches.pop();
}
}
// Each acquisition protects the remainder of its lexical statement
@@ -1522,7 +1538,11 @@ function noteEarlyReadVar(lowerer: Lowerer, symbol: ts.Symbol, local: IrLocal):
* lowerVarDecl's salvage path declares the local when it can, and
* resolution checks locals/globals before the fallthroughs consult
* blockedBindings. */
export function noteBlockedBindings(lowerer: Lowerer, stmt: ts.Statement): void {
export function noteBlockedBindings(
lowerer: Lowerer,
stmt: ts.Statement,
diags: readonly ScrDiagnostic[] = [],
): void {
let list: ts.VariableDeclarationList | null = null;
if (ts.isVariableStatement(stmt)) list = stmt.declarationList;
else if (
@@ -1533,12 +1553,20 @@ export function noteBlockedBindings(lowerer: Lowerer, stmt: ts.Statement): void
list = stmt.initializer;
}
if (!list) return;
// Localized failure (report-only): the bindings form one failure group,
// the statement, whose diagnostics are the group's.
const group = statementGroupKey(stmt);
let bound = false;
for (const decl of list.declarations.nodes) {
for (const nameNode of boundIdentifiersOf(decl.name)) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol) lowerer.blockedBindings.add(symbol);
if (!symbol) continue;
lowerer.blockedBindings.add(symbol);
if (!lowerer.rejectedGroups.has(symbol)) lowerer.rejectedGroups.set(symbol, group);
bound = true;
}
}
if (bound) for (const d of diags) d.rejects ??= group;
}
/** True when a reference/assignment fell through resolution because the
@@ -1548,12 +1576,13 @@ export function noteBlockedBindings(lowerer: Lowerer, stmt: ts.Statement): void
* flushed by the emit pass). */
export function isBlockedBinding(lowerer: Lowerer, symbol: ts.Symbol | null): boolean {
if (!symbol) return false;
return (
const blocked =
lowerer.blockedBindings.has(symbol) ||
lowerer.deferredDiags.has(symbol) ||
lowerer.flushedSymbols.has(symbol) ||
lowerer.alreadyFlushed.has(symbol)
);
lowerer.alreadyFlushed.has(symbol);
if (blocked) lowerer.touchRejected(symbol);
return blocked;
}
/** Lowers a statement in a fresh lexical scope (if/while/for bodies). */
@@ -212,7 +212,12 @@ import { isBundledShimForwarder } from "../package-shims.js";
import { erasedTypeOnlyImport } from "../type-only-imports.js";
import { literalValues } from "../literal-values.js";
import { discriminantOwners, literalUnionArm, type UnionLiteral } from "../union-discriminants.js";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import type {
ScrDiagnostic,
TypeFailure,
TypeFailureFamily,
} from "../../diagnostics/diagnostic.js";
import { declarationGroupKey, typeFailureOf } from "./type-failure.js";
import {
unsupportedAnyOperationDiag,
blockedBindingUseDiag,
@@ -721,7 +726,16 @@ interface GenericDemandOwner {
classDemands: ClassInfo[];
}
export class PoisonError extends Error {}
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) {
super();
this.failure = failure;
}
}
/** A function-shaped value that exists only in the compiler's static
* projection graph. Declared-function aliases point at the original
@@ -3709,6 +3723,15 @@ export class Lowerer {
* the fallthroughs report the inherited-blocker cascade (SC2004)
* instead of misattributing the reference. */
readonly blockedBindings = new Set<ts.Symbol>();
/** Localized failure, report-only: the failure group (type-failure.ts)
* of each declaration whose rejection reached the build — a flushed
* deferred declaration, or a binding of a poisoned declaration
* statement. */
readonly rejectedGroups = new Map<ts.Symbol, string>();
/** One frame per statement lowerStmts is lowering: the failure groups
* its lowering touched. A statement that then poisons attributes its
* diagnostics to them (ScrDiagnostic.cause). */
readonly failureTouches: Set<string>[] = [];
/** 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). */
@@ -9282,7 +9305,11 @@ export class Lowerer {
this.diagSink.push(d);
return;
}
if (pending) this.commitFlush(pending.symbol, pending.source);
if (pending) {
this.commitFlush(pending.symbol, pending.source);
const group = this.rejectedGroups.get(pending.symbol);
if (group !== undefined) d.rejects ??= group;
}
// One site, one report: some declarations map a type twice (module
// globals pre-register before their initializers lower) — an exact
// duplicate (code + span + message) adds noise, not information.
@@ -9337,6 +9364,35 @@ export class Lowerer {
return (this.typeOf(node).flags & ts.TypeFlags.Any) !== 0;
}
/** badType's commit: the diagnostic carries the structured type
* failure (report-only in stage L0), and so does the poison. */
private typeFail(
node: ts.Node,
type: ts.Type,
family: TypeFailureFamily,
diag: ScrDiagnostic,
): never {
const failure = typeFailureOf(node, this.checker.typeToString(type), family);
diag.typeFailure = failure;
this.pushDiag(diag);
throw new PoisonError(failure);
}
/** 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. */
dynFieldPolicyFence(node: ts.Node, feature: string): never {
const diag = unsupportedDiag("SC1090", locOf(node), feature);
const failure = typeFailureOf(
node,
this.checker.typeToString(this.typeOf(node)),
"dyn-field-policy",
);
diag.typeFailure = failure;
this.pushDiag(diag);
throw new PoisonError(failure);
}
badType(node: ts.Node, type: ts.Type): never {
const widened = this.checker.getBaseTypeOfLiteralType(type);
// Types declared by the ADOPTED @types/node (Buffer, NodeJS.Timeout,
@@ -9348,8 +9404,12 @@ export class Lowerer {
// applies to node-typed values.
const typeSym = widened.getAliasSymbol() ?? widened.getSymbol();
if (this.nodeTypesOnlySymbol(typeSym)) {
this.pushDiag(noLoweringDiag(this.checker.typeToString(type), locOf(node), undefined, true));
throw new PoisonError();
this.typeFail(
node,
type,
"node-types",
noLoweringDiag(this.checker.typeToString(type), locOf(node), undefined, true),
);
}
// Engine-backed ambient TYPES in a STATIC build report the per-site
// SC2012 rather than the generic supported-types recitation. Native
@@ -9360,10 +9420,12 @@ export class Lowerer {
(FETCH_AMBIENT_TYPES as readonly string[]).includes(ts.symbolName(typeSym)) &&
this.isStdlibSymbol(typeSym)
) {
this.pushDiag(
this.typeFail(
node,
type,
"fetch-ambient",
unsupportedNativeApiDiag(`a value of type '${ts.symbolName(typeSym)}'`, locOf(node)),
);
throw new PoisonError();
}
// ENUM OBJECTS (`typeof e` — a numeric enum's type carries the
// reverse-map index signature, a string enum's just its members):
@@ -9372,14 +9434,16 @@ export class Lowerer {
// as the per-site identifier fence — never the index-signature
// recitation.
if (typeSym && (typeSym.flags & ts.SymbolFlags.Enum) !== 0) {
this.pushDiag(
this.typeFail(
node,
type,
"enum-object",
unsupportedDiag(
"SC1090",
locOf(node),
`enum objects as values ('${ts.symbolName(typeSym)}' — member reads like '${ts.symbolName(typeSym)}.X' compile to constants; the object itself has no runtime representation)`,
),
);
throw new PoisonError();
}
// Would-be records with INDEX SIGNATURES (`Record<string, T>`,
// `{ [k: string]: T }`) get the index-signature fence (SC2006) naming
@@ -9409,19 +9473,26 @@ export class Lowerer {
this.isStdlibFile(ts.getSourceFileOfNode(d)),
)
) {
this.pushDiag(noLoweringDiag(this.checker.typeToString(type), locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"stdlib-index",
noLoweringDiag(this.checker.typeToString(type), locOf(node)),
);
}
this.pushDiag(indexSignatureTypeDiag(this.checker.typeToString(type), locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"index-signature",
indexSignatureTypeDiag(this.checker.typeToString(type), locOf(node)),
);
}
// Attribute an unsupported package type to its package.
{
const pkg = this.npmPackageOf(widened);
if (pkg) {
this.pushDiag(unsupportedPackageValueDiag(pkg, locOf(node)));
throw new PoisonError();
this.typeFail(node, type, "package", unsupportedPackageValueDiag(pkg, locOf(node)));
}
}
// A type that keeps a GENERIC call signature (`<T>(x: T) => T` slots,
@@ -9437,8 +9508,12 @@ export class Lowerer {
this.checker.getCallSignatures(p).some((s) => s.getTypeParameters().length > 0),
)
) {
this.pushDiag(genericSignatureTypeDiag(this.checker.typeToString(type), locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"generic-signature",
genericSignatureTypeDiag(this.checker.typeToString(type), locOf(node)),
);
}
}
// STANDARD-LIBRARY nominal provenance, decided once: interface/class
@@ -9458,16 +9533,24 @@ export class Lowerer {
// generic overload set keeps the monomorphization story: a value of a
// multi-signature type has no single compiled signature to hold.
if (!stdlibNominal && this.checker.getCallSignatures(widened).length > 1) {
this.pushDiag(overloadedSignatureTypeDiag(this.checker.typeToString(type), locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"overloads",
overloadedSignatureTypeDiag(this.checker.typeToString(type), locOf(node)),
);
}
// INTERSECTIONS that resolved to no lowering (SC2008). The ones that
// compile never get here: member intersections intern through the
// record path, callable hybrids map to '%call' records, and pinned
// mixin instantiations resolve by chain structure.
if (widened.isIntersectionType()) {
this.pushDiag(intersectionTypeDiag(this.checker.typeToString(type), locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"intersection",
intersectionTypeDiag(this.checker.typeToString(type), locOf(node)),
);
}
// SUPPORTED shapes over a component outside its slot (SC2009): the
// Map/Set domains, array/tuple elements, union arms, function
@@ -9479,8 +9562,12 @@ export class Lowerer {
{
const detail = describeComponentBlocker(widened, this.typeCtx);
if (detail !== null) {
this.pushDiag(componentTypeDiag(this.checker.typeToString(type), detail, locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"component",
componentTypeDiag(this.checker.typeToString(type), detail, locOf(node)),
);
}
}
// STANDARD-LIBRARY nominal types with no lowering at all: the SC2020
@@ -9510,26 +9597,36 @@ export class Lowerer {
TextDecoder:
"TextDecoder instances support recognized labels, fatal/ignoreBOM option literals, and streaming UTF-8/UTF-16/single-byte decode; runtime option objects remain unsupported",
};
this.pushDiag(
this.typeFail(
node,
type,
"stdlib-nominal",
noLoweringDiag(
this.checker.typeToString(type),
locOf(node),
typeHints[ts.symbolNameOf(stdlibOwnSym) ?? ""],
),
);
throw new PoisonError();
}
// USER record shapes blocked by ONE member (SC2009's record arm):
// name the member and its type.
{
const detail = describeRecordMemberBlocker(widened, this.typeCtx);
if (detail !== null) {
this.pushDiag(componentTypeDiag(this.checker.typeToString(type), detail, locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"record-member",
componentTypeDiag(this.checker.typeToString(type), detail, locOf(node)),
);
}
}
this.pushDiag(unsupportedTypeDiag(this.checker.typeToString(type), locOf(node)));
throw new PoisonError();
this.typeFail(
node,
type,
"unsupported",
unsupportedTypeDiag(this.checker.typeToString(type), locOf(node)),
);
}
/** The lib fence (SC2020): a reached use of standard-library surface
@@ -12196,12 +12293,35 @@ export class Lowerer {
this.reportDeferred(symbol);
}
/** A statement's lowering used a declaration: if that declaration was
* rejected, the statement's own failure (if any) is its cascade. */
touchRejected(symbol: ts.Symbol): void {
const group = this.rejectedGroups.get(symbol);
if (group === undefined) return;
const frame = this.failureTouches[this.failureTouches.length - 1];
if (frame !== undefined) frame.add(group);
}
/** Records a declaration whose deferred diagnostics reached the build:
* its failure group, on the group's diagnostics too. */
private noteRejected(symbol: ts.Symbol, diags: readonly ScrDiagnostic[]): void {
let group = this.rejectedGroups.get(symbol);
if (group === undefined) {
group = declarationGroupKey(symbol);
this.rejectedGroups.set(symbol, group);
}
for (const d of diags) d.rejects ??= group;
}
/** flushDeferred without the collection-pass guard: for declarations
* whose diagnostics must report eagerly (class statements with
* declaration-time code, extended bases — collectClassShapeDeferring). */
reportDeferred(symbol: ts.Symbol): void {
const diags = this.deferredDiags.get(symbol);
if (!diags) return;
if (!diags) {
this.touchRejected(symbol);
return;
}
if (this.alreadyFlushed.has(symbol)) {
this.deferredDiags.delete(symbol); // the emit pass reported these
return;
@@ -12209,7 +12329,9 @@ export class Lowerer {
if (this.diagSink === null) {
this.deferredDiags.delete(symbol);
this.flushedSymbols.add(symbol);
this.noteRejected(symbol, diags);
for (const d of diags) this.pushDiag(d);
this.touchRejected(symbol);
return;
}
// A capture buffer is active (a probe, or another declaration's
@@ -12226,6 +12348,7 @@ export class Lowerer {
* (the declaration counts as flushed for the coverage remainder). */
private commitFlush(symbol: ts.Symbol, source: ScrDiagnostic): void {
this.flushedSymbols.add(symbol);
this.noteRejected(symbol, [source]);
const list = this.deferredDiags.get(symbol);
if (!list) return;
const index = list.indexOf(source);
@@ -13351,8 +13474,8 @@ export class Lowerer {
return lowerStmts(this, stmts);
}
noteBlockedBindings(stmt: ts.Statement): void {
return noteBlockedBindings(this, stmt);
noteBlockedBindings(stmt: ts.Statement, diags?: readonly ScrDiagnostic[]): void {
return noteBlockedBindings(this, stmt, diags);
}
isBlockedBinding(symbol: ts.Symbol | null): boolean {
@@ -0,0 +1,195 @@
/* Localized failure, stage L0 (report-only).
*
* A type with no static lowering poisons the slot that holds it (a field,
* a parameter, a return, a local), and today the poison rejects the whole
* declaration around the slot: a class, a signature, a declaration
* statement. Every later use of that declaration then fails on its own
* line. The localized-failure design turns such slots into checked
* dynamic slots at the points where the build would otherwise commit the
* error.
*
* This module holds the bookkeeping that stage measures without changing
* any lowering: where a failing type sits (its slot), whether a dynamic
* slot would keep the program's meaning (design §3.7), and the keys that
* tie a cascade diagnostic to the rejected declaration it inherits from.
* The data rides the committed diagnostics as report-only fields
* (ScrDiagnostic.typeFailure, .rejects, .cause); tier-analysis.ts turns it
* into the would-be dynamic slot report of `--explain-dynamic`. */
import type {
TypeFailure,
TypeFailureFamily,
TypeFailureSlot,
} from "../../diagnostics/diagnostic.js";
import * as ts from "../ts7/adapter.js";
/** Families a checked-dynamic slot can hold (design §3.7). The others
* name values the compiler cannot produce (opaque stdlib and @types/node
* types, engine-backed ambient types), package value types that scope B
* routes to Node islands, enum objects with no runtime value, and generic
* implementations without an erased instance. */
const DEGRADABLE_FAMILIES: ReadonlySet<TypeFailureFamily> = new Set<TypeFailureFamily>([
"index-signature",
"overloads",
"intersection",
"component",
"record-member",
"unsupported",
"dyn-field-policy",
]);
function nameText(name: ts.Node | undefined): string | null {
if (name === undefined) return null;
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text;
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
return ts.getTextOfNode(name);
}
function isParameterProperty(param: ts.ParameterDeclaration): boolean {
return (
ts.isConstructorDeclaration(param.parent) &&
(param.modifiers?.nodes ?? []).some(
(m) =>
m.kind === ts.SyntaxKind.PublicKeyword ||
m.kind === ts.SyntaxKind.PrivateKeyword ||
m.kind === ts.SyntaxKind.ProtectedKeyword ||
m.kind === ts.SyntaxKind.ReadonlyKeyword,
)
);
}
function variableSlot(decl: ts.VariableDeclaration): TypeFailureSlot {
const list = decl.parent;
const holder = list?.parent;
if (
holder !== undefined &&
(ts.isForOfStatement(holder) || ts.isForInStatement(holder) || ts.isForStatement(holder))
)
return "loop";
if (holder !== undefined && ts.isVariableStatement(holder) && ts.isSourceFile(holder.parent))
return "global";
return "local";
}
function functionLabel(fn: ts.Node): string {
if (ts.isConstructorDeclaration(fn)) return "constructor";
if (ts.isFunctionLikeDeclaration(fn)) {
const own = nameText(fn.name);
if (own !== null) return own;
const parent = fn.parent;
if (
parent !== undefined &&
(ts.isVariableDeclaration(parent) ||
ts.isPropertyAssignment(parent) ||
ts.isPropertyDeclaration(parent))
) {
const bound = nameText(parent.name);
if (bound !== null) return bound;
}
}
return "<anonymous>";
}
/** The slot a badType blame node denotes. A declaration's own node, its
* name and its type annotation name the declaration's slot; anything
* reached through an expression is an expression slot (the type of a
* cast, a literal, a temporary). */
export function typeFailureSlot(node: ts.Node): { slot: TypeFailureSlot; name: string } {
let current: ts.Node = node;
for (let depth = 0; depth < 64; depth++) {
if (ts.isParameter(current)) {
const name = nameText(current.name) ?? "<parameter>";
return { slot: isParameterProperty(current) ? "field" : "param", name };
}
if (ts.isPropertyDeclaration(current))
return { slot: "field", name: nameText(current.name) ?? "<field>" };
if (ts.isVariableDeclaration(current))
return { slot: variableSlot(current), name: nameText(current.name) ?? "<binding>" };
if (ts.isBindingElement(current)) {
let owner: ts.Node | undefined = current.parent;
while (owner !== undefined && !ts.isVariableDeclaration(owner) && !ts.isParameter(owner))
owner = owner.parent;
const slot: TypeFailureSlot =
owner === undefined
? "local"
: ts.isParameter(owner)
? "param"
: variableSlot(owner as ts.VariableDeclaration);
return { slot, name: nameText(current.name) ?? "<binding>" };
}
if (ts.isPropertySignature(current) || ts.isMethodSignatureDeclaration(current))
return { slot: "member", name: nameText(current.name) ?? "<member>" };
if (ts.isFunctionLikeDeclaration(current))
return { slot: "return", name: functionLabel(current) };
if (ts.isCatchClause(current)) return { slot: "local", name: "<catch binding>" };
const parent: ts.Node | undefined = current.parent;
if (parent === undefined || ts.isSourceFile(parent)) break;
// Climb from a declaration's name or type annotation, or from inside a
// type node; any other position is an expression's own type.
const declared =
(ts.isParameter(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isVariableDeclaration(parent) ||
ts.isBindingElement(parent) ||
ts.isPropertySignature(parent) ||
ts.isMethodSignatureDeclaration(parent) ||
ts.isFunctionLikeDeclaration(parent)) &&
((parent as { name?: ts.Node }).name === current ||
(parent as { type?: ts.Node }).type === current);
if (!declared && !ts.isTypeNode(current) && !ts.isTypeNode(parent)) break;
current = parent;
}
const text = ts.getTextOfNode(node);
return { slot: "expression", name: text.length > 60 ? `${text.slice(0, 57)}...` : text };
}
/** Program code outside node_modules, in TypeScript: the code holes apply
* to (design §3.3, §3.8). JavaScript keeps its dynamic fallback and its
* runtime fences; npm packages keep island routing. */
function programTypeScript(node: ts.Node): boolean {
const sf = ts.getSourceFileOfNode(node);
const file = sf.fileName;
if (file.includes("/node_modules/")) return false;
if (sf.isDeclarationFile) return false;
return !/\.(?:[cm]?js|jsx)$/.test(file);
}
export function typeFailureOf(
node: ts.Node,
declaredType: string,
family: TypeFailureFamily,
): TypeFailure {
const { slot, name } = typeFailureSlot(node);
return {
slot,
name,
declaredType,
family,
degradable: DEGRADABLE_FAMILIES.has(family) && programTypeScript(node),
};
}
function locKey(node: ts.Node): string {
const sf = ts.getSourceFileOfNode(node);
return `${sf.fileName}:${ts.tokenStartOfNode(node)}`;
}
/** The failure-group key of a deferred declaration (a class shape or a
* function signature whose collection poisoned). */
export function declarationGroupKey(symbol: ts.Symbol): string {
const decl = symbol.declarations[0];
const name = ts.symbolName(symbol);
if (decl === undefined) return `declaration ${name}`;
const kind =
ts.isClassDeclaration(decl) || ts.isClassExpression(decl)
? "class"
: ts.isFunctionLikeDeclaration(decl)
? "function"
: "declaration";
return `${kind} ${name} @${locKey(decl)}`;
}
/** The failure-group key of a poisoned declaration statement whose
* bindings are blocked. */
export function statementGroupKey(stmt: ts.Statement): string {
return `statement @${locKey(stmt)}`;
}
+182 -1
View File
@@ -29,7 +29,12 @@
* Attribution is syntactic and approximate: the site's innermost node, the
* operand it acts on, and that operand's declaration. Stage S6 replaces it
* with requests the failing lowering names exactly. */
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type {
ScrDiagnostic,
TypeFailureFamily,
TypeFailureSlot,
} from "../diagnostics/diagnostic.js";
import { isWarningCode } from "../diagnostics/render.js";
import type { IrFunction, SrcLoc } from "../ir/ir.js";
import { dynamicOperationSites, type DynamicFunction } from "../ir/tiers.js";
import * as ts from "./ts7/adapter.js";
@@ -85,8 +90,70 @@ export interface TierDynamicFunction extends DynamicFunction {
statements: number;
}
/** The localized-failure stage whose slot positions include a slot kind
* (design §3.6): locals and globals degrade first, declarations next,
* members of declared types with push-down, expression types last. */
export type LocalizedFailureStage = "L1" | "L2" | "L3" | "L4";
export function slotStage(slot: TypeFailureSlot): LocalizedFailureStage {
switch (slot) {
case "local":
case "global":
case "loop":
return "L1";
case "field":
case "param":
case "return":
return "L2";
case "member":
return "L3";
case "expression":
return "L4";
}
}
/** A committed type failure that would become a checked dynamic slot
* (SC6104) instead of an error. */
export interface WouldBeSlot {
loc: SrcLoc;
slot: TypeFailureSlot;
name: string;
declaredType: string;
family: TypeFailureFamily;
code: string;
message: string;
stage: LocalizedFailureStage;
/** The declaration the failure rejects today (a failure-group key), if
* any: a class, a signature, or a declaration statement. */
rejects: string | null;
/** Other committed diagnostics that inherit from this slot's group and
* clear once every group they inherit from degrades (with push-down and
* expression slots, the full design). */
cascade: number;
}
/** Localized failure, report-only (stage L0): what the committed errors
* would become. Two scenarios: through stage L2 (declaration slots) and
* through L4 (members and expressions too). */
export interface LocalizedFailureReport {
/** Reached errors (warnings excluded). */
errors: number;
/** Type failures, by whether a checked dynamic slot keeps meaning. */
typeFailures: { degradable: number; refused: number };
slots: WouldBeSlot[];
/** Diagnostics attributed to a rejected declaration they used. */
cascades: number;
/** Errors that disappear (degraded slots plus cleared cascades). */
clearedThroughL2: number;
clearedThroughL4: number;
/** The errors that remain through L4, by code. */
remainingByCode: Record<string, number>;
}
export interface TierReport {
mode: TierMode;
/** Localized failure (report-only). */
localizedFailure: LocalizedFailureReport;
/** Reached source statements that perform a dynamic operation. */
statementsDynamic: number;
dynamicFunctions: TierDynamicFunction[];
@@ -576,6 +643,119 @@ function demotedUnits(requests: readonly DemotionRequest[]): DemotedUnit[] {
);
}
/* ── localized failure ───────────────────────────────────────────────── */
/** A diagnostic's failure-group bookkeeping (lowerer: badType, deferred
* flushes, blocked statements) turned into the would-be report. A group
* (a rejected declaration or blocked statement) clears in a scenario when
* each of its diagnostics either degrades (a degradable type failure at a
* slot the scenario covers) or is a cascade whose every cause clears. */
export function localizedFailureReport(reached: readonly ScrDiagnostic[]): LocalizedFailureReport {
const errors = reached.filter((d) => !isWarningCode(d.code));
const groups = new Map<string, ScrDiagnostic[]>();
for (const d of errors) {
if (d.rejects === undefined) continue;
const list = groups.get(d.rejects);
if (list === undefined) groups.set(d.rejects, [d]);
else list.push(d);
}
const stages: Record<"L2" | "L4", ReadonlySet<LocalizedFailureStage>> = {
L2: new Set(["L1", "L2"]),
L4: new Set(["L1", "L2", "L3", "L4"]),
};
const clears = (scenario: "L2" | "L4") => {
const covered = stages[scenario];
const memo = new Map<string, boolean>();
const degrades = (d: ScrDiagnostic): boolean =>
d.typeFailure !== undefined &&
d.typeFailure.degradable &&
covered.has(slotStage(d.typeFailure.slot));
const causeClears = (d: ScrDiagnostic): boolean =>
d.cause !== undefined && d.cause.length > 0 && d.cause.every((g) => groupClears(g));
const groupClears = (group: string): boolean => {
const known = memo.get(group);
if (known !== undefined) return known;
memo.set(group, false); // a cycle never clears
const members = groups.get(group) ?? [];
const ok = members.length > 0 && members.every((d) => degrades(d) || causeClears(d));
memo.set(group, ok);
return ok;
};
return { degrades, causeClears, groupClears };
};
const l2 = clears("L2");
const l4 = clears("L4");
const cleared = (c: ReturnType<typeof clears>, d: ScrDiagnostic) =>
c.degrades(d) || c.causeClears(d);
// Each slot's cascade: diagnostics whose causes (transitively) include
// the slot's group and that clear in the full scenario.
const reachesGroup = (d: ScrDiagnostic, group: string, seen: Set<string>): boolean => {
for (const g of d.cause ?? []) {
if (g === group) return true;
if (seen.has(g)) continue;
seen.add(g);
for (const m of groups.get(g) ?? []) if (reachesGroup(m, group, seen)) return true;
}
return false;
};
const slots: WouldBeSlot[] = [];
let degradable = 0;
let refused = 0;
for (const d of errors) {
const f = d.typeFailure;
if (f === undefined) continue;
if (!f.degradable) {
refused++;
continue;
}
degradable++;
const group = d.rejects ?? null;
let cascade = 0;
if (group !== null)
for (const e of errors)
if (
e !== d &&
e.typeFailure === undefined &&
cleared(l4, e) &&
reachesGroup(e, group, new Set())
)
cascade++;
slots.push({
loc: d.loc,
slot: f.slot,
name: f.name,
declaredType: f.declaredType,
family: f.family,
code: d.code,
message: d.message,
stage: slotStage(f.slot),
rejects: group,
cascade,
});
}
slots.sort(
(a, b) =>
b.cascade - a.cascade || a.loc.file.localeCompare(b.loc.file) || a.loc.start - b.loc.start,
);
const remainingByCode: Record<string, number> = {};
let clearedThroughL2 = 0;
let clearedThroughL4 = 0;
for (const d of errors) {
if (cleared(l2, d)) clearedThroughL2++;
if (cleared(l4, d)) clearedThroughL4++;
else remainingByCode[d.code] = (remainingByCode[d.code] ?? 0) + 1;
}
return {
errors: errors.length,
typeFailures: { degradable, refused },
slots,
cascades: errors.filter((d) => d.cause !== undefined && d.typeFailure === undefined).length,
clearedThroughL2,
clearedThroughL4,
remainingByCode,
};
}
export interface TierAnalysisInput {
mode: TierMode;
/** The dynamic-tier IR functions of the reached program. */
@@ -624,6 +804,7 @@ export function analyzeTiers(program: ts.Program, input: TierAnalysisInput): Tie
const dynamic = dynamicFunctionReports(program, input.dynamicFunctions);
return {
mode: input.mode,
localizedFailure: localizedFailureReport(input.reached),
statementsDynamic: dynamic.statements,
dynamicFunctions: dynamic.functions,
requests,
@@ -0,0 +1,68 @@
// The localized-failure report of `scriptc coverage --explain-dynamic`:
// type failures that would become checked dynamic slots, and the uses
// each one rejects today.
interface Tool {
name: string;
hooks: Map<string, (x: number) => string> | AbortSignal | (() => void) | Set<string>;
}
type Progress = (done: number, total: number) => void;
type Callbacks = Progress | AbortSignal | Map<string, (x: number) => string> | Set<number>;
// A field: rejects the class, so every use of an instance fails.
class Session {
private tools: Tool[] = [];
count(): number {
return this.tools.length;
}
label(): string {
return "s";
}
}
// A parameter: rejects the function, so every call fails.
function listAll(dir: string, progress?: Callbacks): number {
return dir.length;
}
// The dyn class field policy.
class Holder {
state: any = {};
get(): number {
return 1;
}
}
// A local: rejects its declaration statement, so its uses fail.
function pick(n: number): boolean {
const callbacks: Callbacks | undefined = n > 1 ? new Set<number>() : undefined;
return callbacks === undefined;
}
// Refused: a value the compiler cannot produce stays an error.
class Cache {
tokens: WeakRef<object> = new WeakRef({});
size(): number {
return 0;
}
}
const s = new Session();
console.log(s.count(), s.label());
console.log(listAll("abc"));
console.log(new Holder().get());
console.log(pick(1), new Cache().size());
// A class extending one the dyn class field policy rejects: its rejection
// and the uses of the derived class are that field's cascade.
class Base {
state: any = {};
get(): number {
return 1;
}
}
class Derived extends Base {
twice(): number {
return 2;
}
}
console.log(new Derived().twice());
@@ -0,0 +1,60 @@
dynamic tier (tier assignment, report-only; --tier=auto)
0 functions already operate on dynamic values (0 statements)
14 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
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
+34
View File
@@ -428,6 +428,40 @@ 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],
]);
// The WeakRef field stays refused, so its class and the construction
// that uses it stay errors.
expect(report.clearedThroughL2).toBe(12);
expect(report.remainingByCode).toEqual({ SC1090: 1, SC2020: 1 });
const relative = JSON.parse(
JSON.stringify(coverage.tiers).replaceAll(repoRoot + "/", ""),
) as typeof coverage.tiers;
await expect(renderExplainDynamic(relative!)).toMatchFileSnapshot(
"__snapshots__/coverage-explain-localized-failure.txt",
);
});
test("CLI: --tier and --explain-dynamic", () => {
const scriptcCli = join(repoRoot, "packages/cli/src/main.ts");
const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-"));