Check an early read of a cycle class's name

- `C.name` on a class of an import cycle reads the name from the class
  object, so the reference gets the class's initialization check: an
  early read throws Node's ReferenceError instead of returning the name.
  Other classes keep the compile-time name.
- The static field storage of a flag without a class is a shared empty
  set, so the self-hosted frontend lowers cycle-initialization.ts.
This commit is contained in:
Chris Tate
2026-10-20 16:13:15 -05:00
parent 62aa9afd58
commit db7df36656
4 changed files with 27 additions and 7 deletions
@@ -70,6 +70,9 @@ interface ClassReference {
name: string;
}
/** The static field storage of a flag without a class. */
const NO_STATIC_GLOBALS: ReadonlySet<string> = new Set();
/** Registers the initialization flags after global collection. Bindings
* whose early access has no exact representation are refused: a `var`
* (Node reads `undefined` there, but module storage only starts when the
@@ -492,7 +495,8 @@ function rewriteFunction(
for (const [className, flag] of lowerer.classInitFlags) {
if (!runsEarly(flag) || fn.name.startsWith(`%${className}.`)) continue;
classes.set(className, flag);
for (const id of flag.staticGlobals ?? []) statics.set(id, [...(statics.get(id) ?? []), flag]);
for (const id of flag.staticGlobals ?? NO_STATIC_GLOBALS)
statics.set(id, [...(statics.get(id) ?? []), flag]);
}
if (active.size === 0 && classes.size === 0) return;
const flagOf = (localId: string, loc: SrcLoc): CycleInitFlag | undefined =>
@@ -660,7 +664,7 @@ function rewriteFunction(
for (const [className, flag] of [...classes]) {
if (flag.module !== initOf || !setsFlag(s, flag)) continue;
classes.delete(className);
for (const id of flag.staticGlobals ?? []) {
for (const id of flag.staticGlobals ?? NO_STATIC_GLOBALS) {
const rest = statics.get(id)!.filter((other) => other !== flag);
if (rest.length > 0) statics.set(id, rest);
else statics.delete(id);
@@ -5408,8 +5408,18 @@ export function lowerStaticFieldRead(
}
// `C.name` — the JS-observable class name, a compile-time constant on
// the direct spelling (tsc rejects user statics named `name`, so the
// chain above can never shadow it in TypeScript sources).
// chain above can never shadow it in TypeScript sources). A class of an
// import cycle reads it from its class object instead, whose reference
// checks the class's initialization (checkCycleBindingAccesses).
if (expr.name.text === "name" && info.def.jsName !== undefined) {
if (lowerer.classInitFlags.get(info.def.name)?.cycle === true)
return {
kind: "libCall",
fn: "class.name",
args: [classValueRef(lowerer, info, expr.expression)],
type: STRING,
loc,
};
return { kind: "strLit", value: info.def.jsName, type: STRING, loc };
}
if (info.callableBase)
@@ -7,6 +7,8 @@ import {
earlyMethod,
earlyInherited,
earlyInheritedField,
earlyName,
earlyRenamedName,
earlyRenamed,
earlyCap,
} from "./stats.ts";
@@ -15,7 +17,9 @@ console.log("early:", early);
console.log("early method:", earlyMethod);
console.log("early inherited:", earlyInherited);
console.log("early inherited field:", earlyInheritedField);
console.log("early name:", earlyName);
console.log("early renamed name:", earlyRenamedName);
console.log("early renamed:", earlyRenamed);
console.log("early renamed constant:", earlyCap);
console.log("probed:", probed);
console.log("late:", new Sink("late").tag, Sink.describe(), Sink.hello(), Sink.count);
console.log("late:", new Sink("late").tag, Sink.describe(), Sink.hello(), Sink.count, Sink.name);
@@ -10,8 +10,8 @@ function attempt(run: () => string): string {
// Each early access throws Node's ReferenceError, naming the binding the
// access spells: construction, a static method read as a value, static
// members inherited from a base class, the class and a constant through
// renamed imports.
// members inherited from a base class, the class's name, the class and a
// constant through renamed imports.
export const early = attempt(() => new Sink("early").tag);
export const earlyMethod = attempt(() => {
const describe = Sink.describe;
@@ -19,9 +19,11 @@ export const earlyMethod = attempt(() => {
});
export const earlyInherited = attempt(() => Sink.hello());
export const earlyInheritedField = attempt(() => String(Renamed.count));
export const earlyName = attempt(() => Sink.name);
export const earlyRenamedName = attempt(() => Renamed.name);
export const earlyRenamed = attempt(() => new Renamed("renamed").tag);
export const earlyCap = attempt(() => String(cap));
export function probe(): string {
return `${new Sink("probe").tag} ${Sink.describe()} ${Sink.hello()} ${Sink.count} ${new Renamed("probe").tag} ${cap}`;
return `${new Sink("probe").tag} ${Sink.describe()} ${Sink.hello()} ${Sink.count} ${Sink.name} ${new Renamed("probe").tag} ${cap}`;
}