Keep the CommonJS caches on their owners and recount 6110's fences

The exports-assignment cache moves from a module-level WeakMap onto the
Lowerer (cjsExportsAssignments), and the inline require() calls that close
a require cycle move from a module-level WeakSet into preflight's result:
checkPreflight stores them on the LoadResult, and lowering receives them
with the other preflight facts (LowerOptions.inlineRequireCycleCalls).

6110's call and apply wrappers now compile `root === module.exports` and
`typeof module.exports`, which print what Node prints when the wrappers'
untaken branches run. The class base's `console.log(module.exports)` is the
one statement left as a runtime fence, and its body is lowered once, so
the program's deferred-fence count is 1.
This commit is contained in:
Chris Tate
2026-10-18 17:17:00 -05:00
parent d0182e59c8
commit 238d782fdc
6 changed files with 47 additions and 27 deletions
@@ -148,17 +148,16 @@ function truthinessOnly(node: ts.Expression): boolean {
}
/** Which of the module's exports bindings the file ever assigns (any
* nesting, compound operators included). */
const assignmentsCache = new WeakMap<ts.SourceFile, { exports: boolean; moduleExports: boolean }>();
* nesting, compound operators included), cached per file on the lowerer
* (Lowerer.cjsExportsAssignments). */
function exportsAssignments(
lowerer: Lowerer,
sf: ts.SourceFile,
): { exports: boolean; moduleExports: boolean } {
const cached = assignmentsCache.get(sf);
const cached = lowerer.cjsExportsAssignments.get(sf);
if (cached !== undefined) return cached;
const out = { exports: false, moduleExports: false };
assignmentsCache.set(sf, out);
lowerer.cjsExportsAssignments.set(sf, out);
ts.walkPreorder(sf, (node) => {
// A fresh `exports = module.exports = {}` reset leaves both bindings
// naming one (new, empty) exports object for the rest of the module.
@@ -25,7 +25,6 @@ import {
cjsExportTargetLiteral,
isCjsJsFile,
isJsSourceFile,
isInlineRequireCycleCall,
isModuleExportsAccess,
locOf,
npmStaticDepSf7,
@@ -109,7 +108,7 @@ export function inlineNamespaceRequireOf(
/** `value` evaluated after the required module's run-once %init (and its
* module-registry link) at the require's own position. */
export function withRequireInit(lowerer: Lowerer, req: InlineRequire, value: IrExpr): IrExpr {
if (isInlineRequireCycleCall(req.call)) {
if (lowerer.inlineRequireCycleCalls.has(req.call)) {
lowerer.unsupported(
"SC1090",
req.call,
@@ -753,6 +753,8 @@ export interface LowerOptions {
* resolution walk and named-import link checks): the program
* compiles to that startup crash. */
startupCrash?: StartupCrash | null;
/** LoadResult.inlineRequireCycleCalls. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node> | undefined;
/** LIBRARY mode's reachability roots: the profile-mapped exports of the
* entry module. Executable builds root at the entry's top level alone
* (an unreferenced export dead-strips); a library's exports are called from
@@ -845,6 +847,8 @@ export interface LowererMode {
* module init — Node refuses the whole graph before anything evaluates,
* so nothing runs. */
startupCrash?: StartupCrash | null;
/** LoadResult.inlineRequireCycleCalls. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node> | undefined;
/** The build's outbound native FFI declarations. */
ffiImports?: readonly IrFfiImport[];
/** LowerOptions.libraryCallbacks (see there). */
@@ -958,6 +962,7 @@ export function lowerToIr(
projectCheckJs: options.projectCheckJs ?? false,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -981,6 +986,7 @@ export function lowerToIr(
projectCheckJs: options.projectCheckJs ?? false,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1011,6 +1017,7 @@ export function lowerToIr(
reachable,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1069,6 +1076,7 @@ export function lowerToIr(
reachable,
remainder: true,
alreadyFlushed: resultLowerer.flushedSymbols,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
targetPlatform,
ffiImports,
libraryCallbacks,
@@ -1628,6 +1636,15 @@ export class Lowerer {
readonly checker: ts.TypeChecker;
readonly diags: ScrDiagnostic[] = [];
readonly fnSigsBySymbol = new Map<ts.Symbol, FnSig>();
/** Per file: whether it assigns `exports` and `module.exports`
* (cjs-exports-object.ts). */
readonly cjsExportsAssignments = new Map<
ts.SourceFile,
{ exports: boolean; moduleExports: boolean }
>();
/** Inline require() calls whose module edge closes a require cycle
* (LoadResult.inlineRequireCycleCalls). */
readonly inlineRequireCycleCalls: ReadonlySet<ts.Node>;
/** Immutable callable aliases and compiler-generated adapters. The map
* is symbol-keyed, so shadowing and cross-module imports keep the same
* exact binding discipline as globals/functions. */
@@ -2859,6 +2876,7 @@ export class Lowerer {
this.alreadyFlushed = mode.alreadyFlushed ?? new Set();
this.targetPlatform = mode.targetPlatform ?? process.platform;
this.startupCrash = mode.startupCrash ?? null;
this.inlineRequireCycleCalls = mode.inlineRequireCycleCalls ?? new Set();
this.projectCheckJs = mode.projectCheckJs ?? false;
this.ffiImports = mode.ffiImports ?? [];
this.libraryCallbacks = mode.libraryCallbacks ?? false;
@@ -593,6 +593,7 @@ function loadFrontend(
...opts,
frontendServices: finalLoad.services,
startupCrash: finalLoad.startupCrash ?? null,
inlineRequireCycleCalls: finalLoad.inlineRequireCycleCalls,
projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false,
externalTypes: finalLoad.externalTypes,
externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile,
+16 -13
View File
@@ -338,6 +338,12 @@ export interface LoadResult {
* last. Empty until checkPreflight runs (and empty when preflight fails
* structurally). */
moduleOrder: ts.SourceFile[];
/** Inline require() calls whose edge closes a require cycle. Node answers
* them with the still-evaluating module's partial exports, which the
* static export globals do not model; their lowering refuses them by name
* at run time instead of refusing the whole program. Filled by
* checkPreflight. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node>;
/** Node's startup refusal, when the graph carries an import edge Node
* rejects before ANY module evaluates — a RESOLUTION refusal (a bare
* specifier nothing installed resolves, an exports/imports map that
@@ -975,9 +981,10 @@ export function loadProgram(
* preflight diagnostics. Preserve the caller's complete load shape when
* mutating it, including its owned services and disposal callback. */
export function checkPreflight<T extends LoadResult>(load: T): ScrDiagnostic[] {
const { diags, moduleOrder, startupCrash } = preflight7(load);
const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls } = preflight7(load);
load.moduleOrder = moduleOrder;
load.startupCrash = startupCrash;
load.inlineRequireCycleCalls = inlineRequireCycleCalls;
return diags;
}
@@ -2447,17 +2454,6 @@ export interface CycleEdge {
call?: ts.CallExpression;
}
/** Inline require() calls whose edge closes a require cycle. Node answers
* them with the still-evaluating module's partial exports, which the static
* export globals do not model; their lowering refuses them by name at run
* time instead of refusing the whole program (the inline call kept its
* runtime fence before it became a module edge). */
const inlineRequireCycleCalls = new WeakSet<ts.Node>();
export function isInlineRequireCycleCall(call: ts.Node): boolean {
return inlineRequireCycleCalls.has(call);
}
/** True when the reference at `node` can only evaluate AFTER the module
* graph finished initializing: inside a function-like BODY, inside a
* parameter (default initializers evaluate at call time), or inside an
@@ -4133,6 +4129,7 @@ function preflight7(load: LoadResult): {
diags: ScrDiagnostic[];
moduleOrder: ts.SourceFile[];
startupCrash: StartupCrash | null;
inlineRequireCycleCalls: ReadonlySet<ts.Node>;
} {
const { program, entry } = load;
const diags: ScrDiagnostic[] = [...load.configDiags];
@@ -5118,6 +5115,7 @@ function preflight7(load: LoadResult): {
return all.some((e) => e.call !== undefined) ? all.filter((e) => e.call === undefined) : all;
});
const order: ts.SourceFile[] = [];
const inlineRequireCycleCalls = new Set<ts.Node>();
const state = new Map<ts.SourceFile, "visiting" | "done">();
const stack: string[] = [];
const visit = (sf: ts.SourceFile): void => {
@@ -5214,7 +5212,12 @@ function preflight7(load: LoadResult): {
esmNamedImportLinkCheck(program, entry, order, diags),
);
return { diags, moduleOrder: order, startupCrash: resolveCrash ?? linkCrash };
return {
diags,
moduleOrder: order,
startupCrash: resolveCrash ?? linkCrash,
inlineRequireCycleCalls,
};
}
/* ── the CJS named-import link check ─────────────────────────────────────
@@ -1,12 +1,12 @@
// @deferred-fences: 9
// @deferred-fences: 1
// Function expressions lowered speculatively — the `.call(this)` and
// `.apply` receivers of CommonJS wrappers, and a computed class base — keep
// every statement of their bodies. A statement with no static lowering
// (here `module.exports` read as a value) compiles to its runtime fence
// instead of vanishing; these never run, so the binary matches Node.
// Coverage counts the three fenced statements nine times: each lowering of
// a wrapper body counts its statements, and the `.call` and `.apply`
// wrapper bodies are each lowered four times.
// every statement of their bodies. The wrappers' `root === module.exports`
// and `typeof module.exports` compile; a statement with no static lowering
// (here `module.exports` passed as a value in the class base) compiles to
// its runtime fence instead of vanishing. It never runs, so the binary
// matches Node. Coverage counts that one fenced statement once: the class
// base's body is lowered once.
const verbose = process.argv.length > 99;
(function () {
var root = this;