Check every early reference to a cycle class

The references of a cycle class were found by their spelling, so a class
imported under another name (`import { Sink as Alias }`) was never seen as
reached early, and neither was a let or const imported under another
name. An `extends` clause only counted when its expression was the bare
class name. A static method read as a value (`const f = Sink.m`) built a
closure that no check covered.

The early-binding scan and the class reference scan now also match each
file's local import names that resolve to the binding, and record them:
Node's ReferenceError names the binding the access spells, so the check
names the alias. The heritage check walks the whole `extends` expression.
A closure of a flagged class's static method checks the class like a
static call does.
This commit is contained in:
Chris Tate
2026-10-20 01:55:18 -05:00
parent 29f07e15f9
commit 2fbc08e36d
5 changed files with 162 additions and 48 deletions
@@ -37,6 +37,8 @@ export interface CycleInitFlag {
/** The declaring module. */
module: ts.SourceFile;
settled: ReadonlySet<ts.SourceFile>;
/** Local names other files import the binding under, by file name. */
localNames?: ReadonlyMap<string, string>;
/** True for a binding of an import cycle. A binding flagged only for its
* own module's early calls is checked opportunistically: whatever cannot
* be checked keeps the unchecked behavior instead of refusing. */
@@ -49,11 +51,19 @@ export interface CycleInitFlag {
* static field storage (`global` is the flag itself). */
className?: string;
staticGlobals?: ReadonlySet<string>;
/** The source references to the class outside its own body. Only IR
* built for one of them is checked: compiler-generated dispatch (error
* property helpers, prototype data, reflection) also names the class
* and must keep working before the class statement ran. */
references?: readonly SrcLoc[];
/** The source references to the class outside its own body, with the
* local name each one spells (an import can rename the class; Node's
* ReferenceError names that binding). Only IR built for one of them is
* checked: compiler-generated dispatch (error property helpers,
* prototype data, reflection) also names the class and must keep
* working before the class statement ran. */
references?: readonly ClassReference[];
}
/** A source reference to a flagged class and the local name it spells. */
interface ClassReference {
loc: SrcLoc;
name: string;
}
/** Registers the initialization flags after global collection. Bindings
@@ -182,33 +192,75 @@ function registerClassInitFlag(
function earlyHeritageReference(lowerer: Lowerer, binding: CycleEarlyBinding): ts.Node | null {
const declaration = binding.name.parent;
for (const sf of lowerer.program.getImplementationSourceFiles()) {
if (
sf.isDeclarationFile ||
sf === binding.module ||
binding.settled.has(sf) ||
!sf.text.includes(binding.name.text)
)
continue;
if (sf.isDeclarationFile || sf === binding.module || binding.settled.has(sf)) continue;
const names = classLocalNames(lowerer, sf, binding.symbol, binding.name.text, true);
if (names.size === 0) continue;
let found: ts.Node | null = null;
ts.walkPreorder(sf, (node) => {
if (found !== null || node === declaration) return "skip";
if (ts.isHeritageClause(node)) {
for (const type of node.types.nodes) {
let referenced = ts.isIdentifier(type.expression)
? lowerer.checker.getSymbolAtLocation(type.expression)
: undefined;
if (referenced !== undefined && referenced.flags & ts.SymbolFlags.Alias)
referenced = lowerer.checker.getAliasedSymbol(referenced);
if (referenced === binding.symbol) found = type.expression;
}
}
return undefined;
if (!ts.isHeritageClause(node)) return undefined;
// Node evaluates the whole clause when the class statement runs:
// any reference inside it (parenthesized, a factory argument)
// reaches the class.
ts.walkPreorder(node, (inner) => {
if (found !== null) return "skip";
if (
ts.isIdentifier(inner) &&
names.has(inner.text) &&
resolvesTo(lowerer, inner, binding.symbol, true)
)
found = inner;
return undefined;
});
return "skip";
});
if (found !== null) return found;
}
return null;
}
/** Whether `node` names `symbol`, through import aliases when `aliases`. */
function resolvesTo(
lowerer: Lowerer,
node: ts.Identifier,
symbol: ts.Symbol,
aliases: boolean,
): boolean {
let referenced = lowerer.checker.getSymbolAtLocation(node);
if (aliases && referenced !== undefined && referenced.flags & ts.SymbolFlags.Alias)
referenced = lowerer.checker.getAliasedSymbol(referenced);
return referenced === symbol;
}
/** The local names under which `sf` can spell the class: its declared name
* (when the file's text contains it) and, with `aliases`, the local names
* of the file's imports that resolve to it (`import { Sink as Alias }`, a
* default import, a re-exported name). */
function classLocalNames(
lowerer: Lowerer,
sf: ts.SourceFile,
symbol: ts.Symbol,
declared: string,
aliases: boolean,
): Set<string> {
const names = new Set<string>();
if (sf.text.includes(declared)) names.add(declared);
if (!aliases) return names;
for (const statement of sf.statements.nodes) {
if (!ts.isImportDeclaration(statement)) continue;
const clause = statement.importClause;
if (clause === undefined) continue;
const locals: ts.Identifier[] = [];
if (clause.name !== undefined) locals.push(clause.name);
const bindings = clause.namedBindings;
if (bindings !== undefined && ts.isNamedImports(bindings))
for (const element of bindings.elements.nodes) locals.push(element.name);
for (const local of locals)
if (!names.has(local.text) && resolvesTo(lowerer, local, symbol, true)) names.add(local.text);
}
return names;
}
/** The source spans of the identifiers naming the class outside its own
* declaration, in every program file. For a class of an import cycle that
* includes other modules' import bindings; another module's references to a
@@ -219,31 +271,36 @@ function classReferences(
symbol: ts.Symbol,
name: ts.Identifier,
cycle: boolean,
): SrcLoc[] {
): ClassReference[] {
const declaration = name.parent;
const out: SrcLoc[] = [];
const out: ClassReference[] = [];
for (const sf of lowerer.program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile || !sf.text.includes(name.text)) continue;
if (sf.isDeclarationFile) continue;
// Another module names the class through its import bindings, under
// the class's name or a local one.
const names = classLocalNames(lowerer, sf, symbol, name.text, cycle);
if (names.size === 0) continue;
ts.walkPreorder(sf, (node) => {
if (node === declaration) return "skip";
if (ts.isIdentifier(node) && node.text === name.text) {
// Another module names the class through its import binding.
let referenced = lowerer.checker.getSymbolAtLocation(node);
if (cycle && referenced !== undefined && referenced.flags & ts.SymbolFlags.Alias)
referenced = lowerer.checker.getAliasedSymbol(referenced);
if (referenced === symbol) out.push(locOf(node));
}
if (ts.isIdentifier(node) && names.has(node.text) && resolvesTo(lowerer, node, symbol, cycle))
out.push({ loc: locOf(node), name: node.text });
return undefined;
});
}
return out;
}
/** The source reference to the flagged class that `loc`'s IR was built
* for, or undefined. */
function coveredReference(flag: CycleInitFlag, loc: SrcLoc): ClassReference | undefined {
return (flag.references ?? []).find(
(r) => r.loc.file === loc.file && loc.start <= r.loc.start && r.loc.end <= loc.end,
);
}
/** Whether `loc` is the IR of a source reference to the flagged class. */
function coversReference(flag: CycleInitFlag, loc: SrcLoc): boolean {
return (flag.references ?? []).some(
(r) => r.file === loc.file && loc.start <= r.start && r.end <= loc.end,
);
return coveredReference(flag, loc) !== undefined;
}
/** The class statement's static initialization, followed by the store of
@@ -271,6 +328,7 @@ function registerInitFlag(
name: binding.name.text,
module: binding.module,
settled: binding.settled,
...(binding.localNames ? { localNames: binding.localNames } : {}),
cycle,
};
lowerer.cycleInitFlags.set(global.id, flag);
@@ -444,18 +502,30 @@ function rewriteFunction(
return flag;
}
return undefined;
case "closure":
// A static method read as a value (`const f = K.m`) reads K.
for (const [className, flag] of classes) {
if (e.fnName.startsWith(`%${className}.static:`) && coversReference(flag, e.loc))
return flag;
}
return undefined;
default:
return undefined;
}
};
let temps = 0;
const check = (flag: CycleInitFlag, loc: SrcLoc): IrStmt => {
// Node's ReferenceError names the binding the reference spells.
const name =
flag.className !== undefined
? (coveredReference(flag, loc)?.name ?? flag.name)
: (flag.localNames?.get(loc.file) ?? flag.name);
const expr: IrExpr = {
kind: "intrinsic",
name: "module.tdzCheck",
args: [
{ kind: "varRef", localId: flag.flagId, type: BOOL, loc },
{ kind: "strLit", value: flag.name, type: STRING, loc },
{ kind: "strLit", value: name, type: STRING, loc },
],
type: VOID,
loc,
+29 -3
View File
@@ -3309,6 +3309,9 @@ export interface CycleEarlyBinding {
/** Modules whose TOP-LEVEL code runs only after the declaration ran:
* modules outside the cycle and cycle members evaluated later. */
settled: ReadonlySet<ts.SourceFile>;
/** The local name each importing file gives the binding when it differs
* from the declared one, by file name: Node's ReferenceError names it. */
localNames?: ReadonlyMap<string, string>;
}
/** Every binding of the program's exactly-initialized cycles that can be
@@ -3429,6 +3432,7 @@ function computeCycleEarlyBindings7(
end: number;
reference: ts.Identifier | null;
folded: ts.Identifier | null;
localNames: Map<string, string>;
}
const candidates = new Map<ts.Symbol, Candidate>();
const names = new Set<string>();
@@ -3468,6 +3472,7 @@ function computeCycleEarlyBindings7(
end: stmt.getEnd(),
reference: null,
folded: null,
localNames: new Map(),
});
names.add(name.text);
}
@@ -3476,13 +3481,33 @@ function computeCycleEarlyBindings7(
});
if (candidates.size === 0) continue;
const rankOf = new Map(evaluation.map((m, i) => [m, i] as const));
// References: every identifier spelled like a candidate, resolved
// through import aliases to the declaration's symbol.
// References: every identifier spelled like a candidate or like a
// local name the file imports one under (`import { x as y }`, a
// default import), resolved through import aliases to the
// declaration's symbol.
for (const sf of order) {
const local = new Set<string>();
for (const stmt of sf.statements.nodes) {
if (!ts.isImportDeclaration(stmt) || stmt.importClause === undefined) continue;
const clause = stmt.importClause;
const bound: ts.Identifier[] = clause.name !== undefined ? [clause.name] : [];
if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings))
for (const element of clause.namedBindings.elements.nodes) bound.push(element.name);
for (const name of bound) {
if (names.has(name.text)) continue;
const imported = checker.getSymbolAtLocation(name);
const candidate =
imported === undefined ? undefined : candidates.get(checker.getAliasedSymbol(imported));
if (candidate === undefined) continue;
local.add(name.text);
candidate.localNames.set(sf.fileName, name.text);
}
}
const refs: ts.Identifier[] = [];
ts.walkPreorder(sf, (node) => {
if (ts.isImportDeclaration(node)) return "skip";
if (ts.isIdentifier(node) && names.has(node.text)) refs.push(node);
if (ts.isIdentifier(node) && (names.has(node.text) || local.has(node.text)))
refs.push(node);
return undefined;
});
if (refs.length === 0) continue;
@@ -3534,6 +3559,7 @@ function computeCycleEarlyBindings7(
reference: c.reference,
...(c.folded !== null ? { folded: c.folded } : {}),
settled,
...(c.localNames.size > 0 ? { localNames: c.localNames } : {}),
});
}
}
@@ -5,6 +5,11 @@ export class Sink {
constructor(tag: string) {
this.tag = tag;
}
static describe(): string {
return "sink";
}
}
export const limit = 3;
export const probed = probe();
@@ -1,8 +1,12 @@
// A class of an import cycle reached before its module body ran: Node
// throws the temporal-dead-zone ReferenceError at the access, and the
// same class works once its module finished.
import { Sink } from "./log.ts";
import { early } from "./stats.ts";
import { Sink, probed } from "./log.ts";
import { early, earlyMethod, earlyRenamed, earlyCap } from "./stats.ts";
console.log("early:", early);
console.log("late:", new Sink("late").tag);
console.log("early method:", earlyMethod);
console.log("early renamed:", earlyRenamed);
console.log("early renamed constant:", earlyCap);
console.log("probed:", probed);
console.log("late:", new Sink("late").tag, Sink.describe());
@@ -1,15 +1,24 @@
import { Sink } from "./log.ts";
import { Sink, Sink as Renamed, limit as cap } from "./log.ts";
function tryMake(): string {
function attempt(run: () => string): string {
try {
return new Sink("early").tag;
return run();
} catch (error) {
return `${(error as Error).name}: ${(error as Error).message}`;
}
}
export const early = tryMake();
// Each early access throws Node's ReferenceError, naming the binding the
// access spells: construction, a static method read as a value, the class
// and a constant through renamed imports.
export const early = attempt(() => new Sink("early").tag);
export const earlyMethod = attempt(() => {
const describe = Sink.describe;
return typeof describe;
});
export const earlyRenamed = attempt(() => new Renamed("renamed").tag);
export const earlyCap = attempt(() => String(cap));
export function probe(): string {
return new Sink("probe").tag;
return `${new Sink("probe").tag} ${Sink.describe()} ${new Renamed("probe").tag} ${cap}`;
}