fix(compiler): admit safe CommonJS export prologues (#482)

- Keep CommonJS packages eligible for static compilation when export prologues precede require calls.
- Preserve initialization order and early-access safeguards with frontend and native regression coverage.

Fixes #37

Co-authored-by: vini <91087061+vinikjkkj@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-26 23:52:23 -05:00
committed by GitHub
co-authored by vini
parent 0e6a13d041
commit 2d74c03adf
14 changed files with 348 additions and 59 deletions
@@ -11,7 +11,7 @@ import { lowerForAwaitBuiltin } from "./lower-async-iteration.js";
import { BOOL, BYTES_U8, CAUGHT, DYN, F64, IrExpr, IrGlobal, IrJsOp, IrLocal, IrStmt, IrType, JSVAL, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/ir.js";
import { PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefinedExpr, importCallHandleType, neverTaintedJsType, staticImportNamespaceType, stmtUsesIsland, uncheckedOverloadHandleCall } from "./lowerer.js";
import { enforceLibBoundary } from "./lib-boundary.js";
import { cjsExportAssignmentOf, cjsExportDiscardReason, cjsExportTargetLiteral, isCjsJsFile, isJsSourceFile, locOf, requireSpecOf } from "../program.js";
import { cjsExportAssignmentOf, cjsExportDiscardReason, cjsExportTargetLiteral, isCjsJsFile, isEsModuleStamp, isJsSourceFile, locOf, requireSpecOf } from "../program.js";
import { COMPOUND_ASSIGN_OPS, CompoundOp, STR_METHODS, UNSUPPORTED_STMT, isStdlibMember, sideEffectFreeOptionValue, stdlibGlobalAliasDecl, stdlibGlobalAliasNameOf, stdlibGlobalNameOf } from "./surfaces.js";
import { isProvenanceSourceFile } from "../provenance-registry.js";
import { ambientUndefVarRootOf, lowerImportEquals, nsUndefRead, nsWritableTarget, trapDeclRootOf } from "./lower-namespaces.js";
@@ -4476,41 +4476,6 @@ function lowerBranchSwitch(
);
}
/** The exact `Object.defineProperty(exports|module.exports, "__esModule",
* { value: true })` interop stamp (see the no-op lowering above). */
function isEsModuleStamp(expr: ts.Expression): boolean {
if (!ts.isCallExpression(expr) || expr.questionDotToken !== undefined) return false;
const callee = expr.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== "Object" ||
!ts.isIdentifier(callee.name) ||
callee.name.text !== "defineProperty"
) {
return false;
}
if (expr.arguments.length !== 3) return false;
const [recv, nameArg, desc] = expr.arguments as unknown as [ts.Expression, ts.Expression, ts.Expression];
const isExports =
(ts.isIdentifier(recv) && recv.text === "exports") ||
(ts.isPropertyAccessExpression(recv) &&
ts.isIdentifier(recv.expression) &&
recv.expression.text === "module" &&
ts.isIdentifier(recv.name) &&
recv.name.text === "exports");
if (!isExports) return false;
if (!ts.isStringLiteral(nameArg) || nameArg.text !== "__esModule") return false;
if (!ts.isObjectLiteralExpression(desc) || desc.properties.length !== 1) return false;
const p = desc.properties[0]!;
return (
ts.isPropertyAssignment(p) &&
ts.isIdentifier(p.name) &&
p.name.text === "value" &&
p.initializer.kind === ts.SyntaxKind.TrueKeyword
);
}
/** A top-level CommonJS export statement (see cjsExportAssignmentOf):
* `exports.f = <expr>` and each expression-valued property of
* `module.exports = { ... }` assign their pre-registered export globals
@@ -0,0 +1,124 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { checkPreflight, loadProgram } from "./program.js";
function requireOrderDiagnostics(source: string, dependency = "exports.value = 'ready';\n") {
const dir = mkdtempSync(join(tmpdir(), "scriptc-require-order-"));
const entry = join(dir, "main.cjs");
writeFileSync(entry, source);
writeFileSync(join(dir, "dep.cjs"), dependency);
const load = loadProgram(entry);
try {
return checkPreflight(load);
} finally {
load.dispose();
rmSync(dir, { recursive: true, force: true });
}
}
test.for([
"exports.read = read;",
"module.exports.renamed = read;",
"module.exports = read;",
])("a hoisted function export before require is safe: %s", (publish) => {
expect(requireOrderDiagnostics(`
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
${publish}
const dep = require('./dep.cjs');
function read() { return helper(); }
function helper() { return dep.value; }
`)).toEqual([]);
});
test.for([
"exports.a = exports.b = void 0;",
"exports.a = undefined; exports.b = 'literal'; exports.c = 1;",
"exports.first = exports.second = read;",
])("literal and chained export prologues do not invoke functions: %s", (prefix) => {
expect(requireOrderDiagnostics(`
Object.defineProperty(exports, '__esModule', { value: true });
${prefix}
exports.read = read;
const dep = require('./dep.cjs');
function read() { return dep.value; }
`)).toEqual([]);
});
test.for([
["direct call", "read();"],
["transitive call", "helper(); function helper() { return read(); }"],
["callback escape", "[1].map(read);"],
["export call", "exports.read();"],
["export alias call", "const alias = exports; alias.read();"],
["computed export name", "exports[read()] = read;"],
["export initializer call", "exports.value = read();"],
["class static initializer", "class Early { static value = read(); }"],
["extra descriptor effect", "Object.defineProperty(exports, '__esModule', { value: true, enumerable: read() });"],
["void initializer call", "exports.value = void read();"],
["prototype mutation", "exports.__proto__ = read;"],
["read-only marker", "Object.defineProperty(exports, '__esModule', { value: true }); exports.__esModule = read;"],
["replacement export property", "module.exports = read; module.exports.name = read;"],
["non-export assignment chain", "const box = {}; exports.alias = box.read = read;"],
])("require still refuses an early read through %s", ([, early]) => {
const diagnostics = requireOrderDiagnostics(`
exports.read = read;
${early}
const dep = require('./dep.cjs');
function read() { return dep.value; }
`);
expect(diagnostics.some((diag) => diag.code === "SC1013" && diag.message.includes("binding 'dep'")))
.toBe(true);
});
test("publishing a require binding itself still reads it before initialization", () => {
const diagnostics = requireOrderDiagnostics(`
exports.read = read;
const { read } = require('./dep.cjs');
`);
expect(diagnostics.some((diag) => diag.code === "SC1013" && diag.message.includes("binding 'read'")))
.toBe(true);
});
test.for([
"var Object = null; Object.defineProperty(exports, '__esModule', { value: true }); exports.read = read;",
"var exports = null; exports.read = read;",
"var module = null; module.exports = read;",
])("source bindings cannot masquerade as a CommonJS prologue: %s", (prefix) => {
const diagnostics = requireOrderDiagnostics(`
${prefix}
const dep = require('./dep.cjs');
function read() { return dep.value; }
`);
expect(diagnostics.some((diag) => diag.code === "SC1013" && diag.message.includes("binding 'dep'")))
.toBe(true);
});
test("an earlier declarator can call an export before the require initializes", () => {
const diagnostics = requireOrderDiagnostics(`
exports.read = read;
const before = read(), dep = require('./dep.cjs');
function read() { return dep.value; }
`);
expect(diagnostics.some((diag) => diag.code === "SC1013" && diag.message.includes("binding 'dep'")))
.toBe(true);
});
test("a later declarator can call an export after the require initializes", () => {
expect(requireOrderDiagnostics(`
exports.read = read;
const dep = require('./dep.cjs'), after = read();
function read() { return dep.value; }
`)).toEqual([]);
});
test("function publication does not admit CommonJS cycles", () => {
const diagnostics = requireOrderDiagnostics(`
exports.read = read;
const dep = require('./dep.cjs');
function read() { return dep.value; }
`, "const main = require('./main.cjs'); exports.value = main.read();\n");
expect(diagnostics.some((diag) => diag.code === "SC1016")).toBe(true);
});
+132 -23
View File
@@ -835,25 +835,124 @@ function requiresOf7(
return out;
}
/** The exact `Object.defineProperty(exports|module.exports, "__esModule",
* { value: true })` interop stamp (shared by preflight and the no-op lowering). */
export function isEsModuleStamp(expr: ts.Expression): boolean {
if (!ts.isCallExpression(expr) || expr.questionDotToken !== undefined) return false;
const callee = expr.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== "Object" ||
!ts.isIdentifier(callee.name) ||
callee.name.text !== "defineProperty"
) {
return false;
}
if (expr.arguments.length !== 3) return false;
const [recv, nameArg, desc] = expr.arguments as unknown as [ts.Expression, ts.Expression, ts.Expression];
const isExports =
(ts.isIdentifier(recv) && recv.text === "exports") ||
(ts.isPropertyAccessExpression(recv) &&
ts.isIdentifier(recv.expression) &&
recv.expression.text === "module" &&
ts.isIdentifier(recv.name) &&
recv.name.text === "exports");
if (!isExports) return false;
if (!ts.isStringLiteral(nameArg) || nameArg.text !== "__esModule") return false;
if (!ts.isObjectLiteralExpression(desc) || desc.properties.length !== 1) return false;
const p = desc.properties[0]!;
return (
ts.isPropertyAssignment(p) &&
ts.isIdentifier(p.name) &&
p.name.text === "value" &&
p.initializer.kind === ts.SyntaxKind.TrueKeyword
);
}
/** Plain CommonJS prologue writes only publish values; in particular,
* publishing a hoisted function does not invoke its body. Later runnable
* statements may call the export or mutate its receiver, and still need
* the conservative TDZ scan over the whole prefix. */
function pureCjsExport7(program: ts.Program, stmt: ts.Statement): boolean {
const cjs = cjsExportAssignmentOf(stmt);
if (cjs === null) return false;
const sf = stmt.getSourceFile();
const plainMember = (left: ts.Expression): boolean => {
if (!ts.isPropertyAccessExpression(left) || left.questionDotToken) return false;
// A replacement export can have setters or non-writable function
// properties. Only the wrapper's original object gets this exception.
if (sf.statements.some((s) => cjsExportAssignmentOf(s)?.kind === "table")) return false;
if (left.name.text === "__proto__" || left.name.text === "__esModule") return false;
let receiver = left.expression;
if (isModuleExportsAccess(receiver)) {
receiver = receiver.expression;
return ts.isIdentifier(receiver) && !sourceBinding7(program, receiver);
}
return ts.isIdentifier(receiver) && receiver.text === "exports" && !sourceBinding7(program, receiver);
};
if (cjs.kind === "member") {
if (!plainMember(cjs.expr.left)) return false;
} else {
const receiver = (cjs.expr.left as ts.PropertyAccessExpression).expression as ts.Identifier;
if (sourceBinding7(program, receiver)) return false;
}
let value = cjs.expr.right;
// tsc emits chains such as exports.A = exports.B = void 0.
while (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
if (!plainMember(value.left)) return false;
value = value.right;
}
if (ts.isVoidExpression(value) && ts.isNumericLiteral(value.expression)) return true;
if (ts.isStringLiteralLike(value) || ts.isNumericLiteral(value) ||
value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword ||
value.kind === ts.SyntaxKind.NullKeyword) return true;
if (!ts.isIdentifier(value)) return false;
if (value.text === "undefined" && !sourceBinding7(program, value)) return true;
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(value);
return symbol !== undefined && checker.declarationsOf(symbol).some(
(decl) => ts.isFunctionDeclaration(decl) && decl.parent === stmt.parent,
);
}
function sourceBinding7(program: ts.Program, ident: ts.Identifier): boolean {
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(ident);
return symbol !== undefined && checker.declarationsOf(symbol).some(
(decl) => !decl.getSourceFile().isDeclarationFile && !ts.isSourceFile(decl),
);
}
function purePrefixDecl7(decl: ts.VariableDeclaration): boolean {
const init = decl.initializer;
return init === undefined || requireSpecOf7(init) !== null ||
ts.isStringLiteralLike(init) || ts.isNumericLiteral(init) ||
init.kind === ts.SyntaxKind.TrueKeyword || init.kind === ts.SyntaxKind.FalseKeyword ||
init.kind === ts.SyntaxKind.NullKeyword;
}
/** True for top-level statements that cannot run user code: directives,
* empty statements, hoisted declarations, require statements themselves,
* and literal-initialized variables. A require preceded ONLY by these can
* never have its bindings observed early — nothing above it executes. */
function purePrefixStmt7(s: ts.Statement): boolean {
* literal-initialized variables, and CommonJS function-export prologues.
* A require preceded ONLY by these cannot observe its own bindings early;
* require cycles are checked separately by the module graph fences. */
function purePrefixStmt7(program: ts.Program, s: ts.Statement): boolean {
if (ts.isEmptyStatement(s) || ts.isFunctionDeclaration(s)) return true;
if (ts.isExpressionStatement(s) && ts.isStringLiteral(s.expression)) return true; // directive
if (isRequireStatement7(s)) return true;
if (isCjsJsFile7(s.getSourceFile())) {
if (ts.isExpressionStatement(s) && isEsModuleStamp(s.expression)) {
const call = s.expression as ts.CallExpression;
const object = (call.expression as ts.PropertyAccessExpression).expression as ts.Identifier;
let receiver = call.arguments[0]!;
if (ts.isPropertyAccessExpression(receiver)) receiver = receiver.expression;
if (!sourceBinding7(program, object) && ts.isIdentifier(receiver) && !sourceBinding7(program, receiver)) return true;
}
if (pureCjsExport7(program, s)) return true;
}
if (ts.isVariableStatement(s)) {
return s.declarationList.declarations.every(
(d) =>
d.initializer === undefined ||
requireSpecOf7(d.initializer) !== null ||
ts.isStringLiteralLike(d.initializer) ||
ts.isNumericLiteral(d.initializer) ||
d.initializer.kind === ts.SyntaxKind.TrueKeyword ||
d.initializer.kind === ts.SyntaxKind.FalseKeyword ||
d.initializer.kind === ts.SyntaxKind.NullKeyword,
);
return s.declarationList.declarations.every(purePrefixDecl7);
}
return false;
}
@@ -878,6 +977,7 @@ function requireTdzRisk7(
sf: ts.SourceFile,
k: number,
decl: ts.VariableDeclaration,
precedingDecls: readonly ts.VariableDeclaration[],
): string | null {
const checker = program.getTypeChecker();
const bound: ts.Identifier[] = [];
@@ -928,13 +1028,14 @@ function requireTdzRisk7(
// These are the exact roots the TDZ analysis scans eagerly. Their files
// are phase-managed already, so warm their deferred identifiers as one
// symbol-only batch instead of paying one IPC query per occurrence.
checker.prefetchSymbolRoots(
stmts.slice(0, k).filter((stmt) => !ts.isFunctionDeclaration(stmt)),
);
for (let i = 0; i < k && hit === null; i++) {
const s = stmts[i]!;
if (ts.isFunctionDeclaration(s)) continue;
scan(s);
const roots = [
...stmts.slice(0, k).filter((stmt) => !ts.isFunctionDeclaration(stmt)),
...precedingDecls,
];
checker.prefetchSymbolRoots(roots);
for (const root of roots) {
if (hit !== null) break;
scan(root);
}
while (hit === null && work.length > 0) {
// A scanned reference can make a hoisted declaration's body reachable
@@ -2588,7 +2689,7 @@ function preflight7(load: LoadResult): {
const stmts = sf.statements;
let firstRunnable = -1;
stmts.forEach((s, i) => {
if (firstRunnable < 0 && !purePrefixStmt7(s)) firstRunnable = i;
if (firstRunnable < 0 && !purePrefixStmt7(program, s)) firstRunnable = i;
});
for (let k = 0; k < stmts.length; k++) {
const stmt = stmts[k]!;
@@ -2665,9 +2766,17 @@ function preflight7(load: LoadResult): {
diags.push(unsupportedDiag("SC1012", loc, "require() of JSON modules"));
continue;
}
// Earlier declarators run too: `const x = read(), dep =
// require('./dep')` must not lose the guard merely because both
// initializers share one VariableStatement.
const precedingDecls = req.decl && ts.isVariableStatement(stmt)
? stmt.declarationList.declarations.slice(0, stmt.declarationList.declarations.indexOf(req.decl))
: [];
const prefixCanRun = (firstRunnable >= 0 && firstRunnable < k) ||
precedingDecls.some((decl) => !purePrefixDecl7(decl));
const tdzName =
firstRunnable >= 0 && firstRunnable < k && req.decl
? requireTdzRisk7(program, sf, k, req.decl)
prefixCanRun && req.decl
? requireTdzRisk7(program, sf, k, req.decl, precedingDecls)
: null;
if (tdzName !== null) {
diags.push(
@@ -7237,6 +7237,15 @@
],
"diags": []
},
"<repo>/tests/corpus/3067-cjs-function-export-require/main.cjs": {
"order": [
"<repo>/tests/corpus/3067-cjs-function-export-require/dependency.cjs",
"<repo>/tests/corpus/3067-cjs-function-export-require/member.cjs",
"<repo>/tests/corpus/3067-cjs-function-export-require/single.cjs",
"<repo>/tests/corpus/3067-cjs-function-export-require/main.cjs"
],
"diags": []
},
"<repo>/tests/corpus/400-fib.ts": {
"order": [
"<repo>/tests/corpus/400-fib.ts"
@@ -0,0 +1,2 @@
console.log('dependency: initialized');
exports.name = 'world';
@@ -0,0 +1,9 @@
// Publishing a hoisted function does not invoke it or read its captures.
// The dependency still initializes at the require, once across both users.
console.log('main: before');
const member = require('./member.cjs');
console.log(member.describe(), member.renamed());
const read = require('./single.cjs');
console.log(read());
require('./member.cjs');
console.log('main: after');
@@ -0,0 +1,18 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.describe = describe;
module.exports.renamed = other;
const browser = require('./dependency.cjs');
console.log('member: initialized');
function describe() {
return helper();
}
function helper() {
return 'browser:' + browser.name;
}
function other() {
return browser.name.toUpperCase();
}
@@ -0,0 +1,8 @@
'use strict';
module.exports = read;
const { name } = require('./dependency.cjs');
console.log('single: initialized');
function read() {
return 'single:' + name;
}
+4
View File
@@ -0,0 +1,4 @@
import { describe } from 'early-export';
console.log(describe());
console.log(describe());
+2
View File
@@ -0,0 +1,2 @@
console.log('browser: initialized');
exports.name = 'world';
+1
View File
@@ -0,0 +1 @@
export declare function describe(): string;
+12
View File
@@ -0,0 +1,12 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.label = exports.version = void 0;
exports.describe = describe;
const browser = require('./browser.js');
exports.label = browser.name;
exports.version = '1';
console.log('early-export: initialized');
function describe() {
return 'browser:' + browser.name + ':' + exports.label + ':' + exports.version;
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "early-export",
"version": "1.0.0",
"main": "index.js",
"types": "index.d.ts"
}
+20
View File
@@ -122,6 +122,26 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
}, 120_000);
test.for(["explicit", "auto"] as const)("hoisted CommonJS function exports stay static (%s)", async (mode) => {
const entry = join(pilotRoot, "early-export-cli.ts");
const npmStatic = mode === "auto" ? "auto" : ["early-export"];
const { coverage } = analyze(entry, { npmStatic });
expect(coverage.npmStatic).toEqual([{ package: "early-export", status: "static" }]);
expect(coverage.preflightFailed).toBe(false);
expect(coverage.diagnostics).toHaveLength(0);
expect(coverage.runtimeFences ?? []).toHaveLength(0);
expect(coverage.stats.statementsFailed).toBe(0);
const binary = await buildStatic(entry, npmStatic);
const [nodeRes, nativeRes] = await Promise.all([
runBinary("node", [entry]),
runBinary(binary, []),
]);
expect(nativeRes.stdout).toEqual(nodeRes.stdout);
expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr);
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
}, 120_000);
test("picocolors compiles fully statically and byte-matches both color branches", async () => {
const entry = join(pilotRoot, "colors-cli.ts");
const { coverage } = analyze(entry, { npmStatic: ["picocolors"] });