From 1d2ab2e87471c7f82af0cb7be50d41aecc0045f9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:14:09 -0700 Subject: [PATCH] Treat aliases of an accessor class as unreadable in the effect gate Reading a class with a static accessor is refused because the accessor can run import-time effects, but `const B = A` gave the alias the kind "value", so a later read through `B` was admitted as pure and the accessor's effects could move with the deferred module. Propagate the "accessors" kind through alias chains to a fixed point and cover the alias in the refusal test. Co-Authored-By: Claude 5.5 --- .../src/node-islands/effect-gate.test.ts | 6 +++++ .../compiler/src/node-islands/effect-gate.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/compiler/src/node-islands/effect-gate.test.ts b/packages/compiler/src/node-islands/effect-gate.test.ts index 3e3e16fbe..d8c5a19af 100644 --- a/packages/compiler/src/node-islands/effect-gate.test.ts +++ b/packages/compiler/src/node-islands/effect-gate.test.ts @@ -79,6 +79,12 @@ const v = C.x;`, "a read through a class expression's static getter", `const C = class { static get x() { return 1; } }; const v = C.x;`, + ], + [ + "a read through an alias of a class with a static getter", + `class C { static get x() { return require("fs").readFileSync("/etc/hostname"); } } +const D = (C); +const v = D.x;`, ], [ "a computed key that converts an object", diff --git a/packages/compiler/src/node-islands/effect-gate.ts b/packages/compiler/src/node-islands/effect-gate.ts index d6a1a7d2a..6515424a1 100644 --- a/packages/compiler/src/node-islands/effect-gate.ts +++ b/packages/compiler/src/node-islands/effect-gate.ts @@ -280,6 +280,28 @@ function checkFile(parser: typeof ts, file: string, load: (file: string) => void else if (nb) for (const el of nb.elements) locals.set(el.name.text, "module"); } } + // Aliasing a class (`const B = A`) gives another name for the same + // static members, so a read through the alias runs the same accessor. + // Propagate to a fixed point: an alias chain can be any length, and a + // class binding is initialized before any alias of it (its TDZ). + const aliases: [string, string][] = []; + for (const stmt of sf.statements) { + if (!parser.isVariableStatement(stmt)) continue; + for (const d of stmt.declarationList.declarations) { + if (!parser.isIdentifier(d.name) || d.initializer === undefined) continue; + let init: ts.Expression = d.initializer; + while (parser.isParenthesizedExpression(init)) init = init.expression; + if (parser.isIdentifier(init)) aliases.push([d.name.text, init.text]); + } + } + for (let changed = true; changed; ) { + changed = false; + for (const [name, source] of aliases) + if (locals.get(source) === "accessors" && locals.get(name) !== "accessors") { + locals.set(name, "accessors"); + changed = true; + } + } function bindNames(name: ts.BindingName, kind: "module" | "value"): void { if (parser.isIdentifier(name)) locals.set(name.text, kind); else