diff --git a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts index 2b28c6533..10549a030 100644 --- a/packages/compiler/src/frontend/lowering/cjs-inline-require.ts +++ b/packages/compiler/src/frontend/lowering/cjs-inline-require.ts @@ -28,8 +28,10 @@ import { isModuleExportsAccess, locOf, npmStaticDepSf7, + requireBindingEarlyUse7, requireSpecOf, resolveImport, + splitRequireCallOf, } from "../program.js"; import { createRequireCalleeFileOf } from "./builtins/module-bindings.js"; import { isCjsRequireGlobal } from "./lower-node-module.js"; @@ -43,6 +45,332 @@ export interface InlineRequire { dep: ts.SourceFile; } +/** A top-level `var m; m = require("./x")` binding. Node evaluates the + * require at the assignment; the binding is alias plumbing exactly like + * `const m = require("./x")` for every later read. */ +export interface SplitRequireBinding { + decl: ts.VariableDeclaration; + assign: ts.BinaryExpression; + call: ts.CallExpression; + spec: string; + dep: ts.SourceFile; +} + +function symbolOfIdentifierValue(lowerer: Lowerer, ident: ts.Identifier): ts.Symbol | undefined { + const parent = ident.parent; + // A destructuring shorthand target resolves to the property symbol at + // the name, but reads/writes the variable through its value symbol. + if (ts.isShorthandPropertyAssignment(parent) && parent.name === ident) { + return ( + lowerer.checker.getShorthandAssignmentValueSymbol(parent) ?? + lowerer.checker.getSymbolAtLocation(ident) + ); + } + return lowerer.checker.getSymbolAtLocation(ident); +} + +function destructuringContainerOf(node: ts.Node): ts.Node | null { + const parent = node.parent; + if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return parent; + if (ts.isPropertyAssignment(parent) && parent.initializer === node) return parent; + if (ts.isSpreadElement(parent) && parent.expression === node) return parent; + if (ts.isSpreadAssignment(parent) && parent.expression === node) return parent; + return null; +} + +function writeTargetOf(node: ts.Node): ts.Node | null { + let target = node; + for (;;) { + const parent = target.parent; + if ( + ts.isParenthesizedExpression(parent) || + ts.isArrayLiteralExpression(parent) || + ts.isObjectLiteralExpression(parent) + ) { + target = parent; + continue; + } + const container = destructuringContainerOf(target); + if (container === null) break; + target = container; + } + const parent = target.parent; + if ( + ts.isBinaryExpression(parent) && + parent.left === target && + ts.isAssignmentOperator(parent.operatorToken.kind) + ) + return parent; + if ( + (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && + parent.operand === target && + (parent.operator === ts.SyntaxKind.PlusPlusToken || + parent.operator === ts.SyntaxKind.MinusMinusToken) + ) + return parent; + if ((ts.isForInStatement(parent) || ts.isForOfStatement(parent)) && parent.initializer === target) + return parent; + return null; +} + +/** The split form of a top-level require binding: `var m; m = require("./x")`. + * Exactly one write, at a top-level `m = require("")` statement. + * Null for the ordinary `const m = require("./x")` declaration and for + * every form that needs runtime module selection. */ +export function splitRequireBindingOf( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): SplitRequireBinding | null { + // Cheap shape gate before the cache: ordinary declarations never enter the + // per-Lowerer map. + if (decl.initializer !== undefined || !ts.isIdentifier(decl.name)) return null; + const list = decl.parent as ts.VariableDeclarationList; + if ((list.flags & ts.NodeFlags.Const) !== 0) return null; + const stmt = list.parent; + if (!ts.isVariableStatement(stmt) || !ts.isSourceFile(stmt.parent)) return null; + const cached = lowerer.cjsSplitRequireBindings.get(decl); + if (cached !== undefined) return cached; + const result = computeSplitRequireBinding(lowerer, decl); + lowerer.cjsSplitRequireBindings.set(decl, result); + return result; +} + +function computeSplitRequireBinding( + lowerer: Lowerer, + decl: ts.VariableDeclaration, +): SplitRequireBinding | null { + const sf = ts.getSourceFileOfNode(decl); + if (!isCjsJsFile(sf, lowerer.program)) return null; + if (!sf.text.includes("require")) return null; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + if (symbol === undefined) return null; + // Merged declarations make the binding's initialization order ambiguous; + // keep ordinary storage instead of guessing. + let declarations = 0; + for (const other of symbol.declarations) { + if (!ts.isVariableDeclaration(other) || ++declarations > 1) return null; + } + if (declarations !== 1) return null; + + // Find the one top-level `m = ...` statement first: most uninitialized + // variables never reach that shape, so they avoid the full-file write scan. + let assign: ts.BinaryExpression | null = null; + for (const stmt of sf.statements.nodes) { + if (!ts.isExpressionStatement(stmt)) continue; + const expr = stmt.expression; + if ( + !ts.isBinaryExpression(expr) || + expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(expr.left) || + expr.left.text !== decl.name.text || + lowerer.checker.getSymbolAtLocation(expr.left) !== symbol + ) + continue; + if (assign !== null) return null; + assign = expr; + } + if (assign === null) return null; + + const req = inlineProgramRequireOf(lowerer, assign.right); + if (req === null) return null; + // Preflight treats the same bindings as require declarations (module edge, + // cycle admission, early-read fence); a binding it does not know keeps + // ordinary storage. + if (splitRequireCallOf(lowerer.program, decl) !== req.call) return null; + + // Any other write makes the value depend on runtime control flow. + let writes = 0; + ts.walkPreorder(sf, (node) => { + if (!ts.isIdentifier(node) || node.text !== decl.name.text) return; + if (symbolOfIdentifierValue(lowerer, node) !== symbol) return; + if (writeTargetOf(node) === null) return; + if (++writes > 1) return "stop"; + }); + if (writes !== 1) return null; + + return { decl, assign, call: req.call, spec: req.spec, dep: req.dep }; +} + +/** The split binding an identifier names, or null. */ +export function splitRequireBindingOfIdent( + lowerer: Lowerer, + ident: ts.Identifier, +): SplitRequireBinding | null { + const symbol = symbolOfIdentifierValue(lowerer, ident); + return symbol === undefined ? null : splitBindingOfSymbol(lowerer, symbol); +} + +/** The exact top-level assignment statement that initializes `expr`, or null. */ +export function splitRequireAssignmentOf( + lowerer: Lowerer, + expr: ts.Expression, +): SplitRequireBinding | null { + if ( + !ts.isBinaryExpression(expr) || + expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(expr.left) + ) + return null; + const bindingOfIdent = splitRequireBindingOfIdent(lowerer, expr.left); + return bindingOfIdent !== null && bindingOfIdent.assign === expr ? bindingOfIdent : null; +} + +/** The whole-export symbol a module's `module.exports = ` names, + * or null when the module is a table. */ +export function wholeExportSymbolOfModule(lowerer: Lowerer, dep: ts.SourceFile): ts.Symbol | null { + const cached = lowerer.cjsWholeExportSymbols.get(dep); + if (cached !== undefined) return cached; + if (cjsNamespaceModule(lowerer, dep)) { + lowerer.cjsWholeExportSymbols.set(dep, null); + return null; + } + const moduleSymbol = lowerer.checker.getSymbolAtLocation(dep); + let symbol = moduleSymbol?.getExports()?.get("export=" as ts.__String); + if (symbol === undefined) { + lowerer.cjsWholeExportSymbols.set(dep, null); + return null; + } + if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol); + lowerer.cjsWholeExportSymbols.set(dep, symbol); + return symbol; +} + +function splitBindingOfSymbol(lowerer: Lowerer, symbol: ts.Symbol): SplitRequireBinding | null { + const decl = symbol.declarations.find(ts.isVariableDeclaration); + return decl === undefined ? null : splitRequireBindingOf(lowerer, decl); +} + +/** The target whole-export symbol behind a split-binding symbol. */ +function splitWholeExportSymbolOfSymbol(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null { + const binding = splitBindingOfSymbol(lowerer, symbol); + return binding === null ? null : wholeExportSymbolOfModule(lowerer, binding.dep); +} + +/** True when a CommonJS module has a live member export (`exports.x` or + * `module.exports.x`) after all `module.exports =` replacements. Those + * members need a mutable whole-export root. */ +export function cjsHasLiveMemberExports(lowerer: Lowerer, sf: ts.SourceFile): boolean { + const cached = lowerer.cjsLiveMemberExports.get(sf); + if (cached !== undefined) return cached; + const stmts = sf.statements.nodes; + let lastTable = -1; + for (let i = 0; i < stmts.length; i++) { + if (cjsExportAssignmentOf(stmts[i]!)?.kind === "table") lastTable = i; + } + let live = false; + // Only members attached after the last replacement survive in Node. + if (lastTable >= 0) { + for (let i = lastTable + 1; i < stmts.length; i++) { + const stmt = stmts[i]!; + if (cjsExportAssignmentOf(stmt)?.kind === "member" && cjsExportDiscardReason(stmt) === null) { + live = true; + break; + } + } + } + lowerer.cjsLiveMemberExports.set(sf, live); + return live; +} + +/** The wrapper's export= symbol when a split binding is re-exported whole + * (`module.exports = m`) and that assignment owns storage for later member + * writes. Importers must read the wrapper's storage, not the split + * binding's underlying value. */ +function splitWholeExportStorageSymbolOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null { + const binding = splitBindingOfSymbol(lowerer, symbol); + if (binding === null) return null; + if (wholeExportSymbolOfModule(lowerer, binding.dep) === null) return null; + const sf = ts.getSourceFileOfNode(binding.decl); + if (!cjsHasLiveMemberExports(lowerer, sf)) return null; + const whole = cjsWholeExportValueOf(sf); + if (whole === null) return null; + let rhs: ts.Expression = whole.rhs; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (!ts.isIdentifier(rhs) || lowerer.checker.getSymbolAtLocation(rhs) !== symbol) return null; + const moduleSymbol = lowerer.checker.getSymbolAtLocation(sf); + let exportSymbol = moduleSymbol?.getExports()?.get("export=" as ts.__String); + if (exportSymbol === undefined) return null; + if (exportSymbol.flags & ts.SymbolFlags.Alias) + exportSymbol = lowerer.checker.getAliasedSymbol(exportSymbol); + return exportSymbol; +} + +/** The source file whose `module.exports =` assignment declares `symbol`. */ +function moduleExportSourceFileOf(symbol: ts.Symbol): ts.SourceFile | null { + for (const decl of symbol.declarations) { + if ( + ts.isBinaryExpression(decl) && + isModuleExportsAccess(decl.left) && + ts.isExpressionStatement(decl.parent) && + ts.isSourceFile(decl.parent.parent) + ) + return decl.getSourceFile(); + } + return null; +} + +/** One hop from a wrapper's export= symbol to the value it re-exports + * (`module.exports = require(...)` or `= `), or null. A + * wrapper with live members owns storage for them, so it is not followed. */ +function wholeReexportTargetOf(lowerer: Lowerer, symbol: ts.Symbol): ts.Symbol | null { + const sf = moduleExportSourceFileOf(symbol); + if (sf === null) return null; + // A wrapper with live members owns storage for them; importers must keep + // reading that root rather than the inner value. + if (cjsHasLiveMemberExports(lowerer, sf)) return null; + const whole = cjsWholeExportValueOf(sf); + if (whole === null) return null; + const inner = inlineProgramRequireOf(lowerer, whole.rhs); + if (inner !== null) return wholeExportSymbolOfModule(lowerer, inner.dep); + let rhs: ts.Expression = whole.rhs; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (!ts.isIdentifier(rhs)) return null; + const rhsSymbol = lowerer.checker.getSymbolAtLocation(rhs); + return rhsSymbol === undefined ? null : splitWholeExportSymbolOfSymbol(lowerer, rhsSymbol); +} + +/** Follow split bindings and whole re-exports to the ultimate value symbol. */ +function followSplitWholeTarget(lowerer: Lowerer, candidate: ts.Symbol | null): ts.Symbol | null { + if (candidate === null) return null; + let symbol = candidate; + const seen = new Set(); + for (let depth = 0; depth < 32 && !seen.has(symbol); depth++) { + seen.add(symbol); + const next = + splitWholeExportSymbolOfSymbol(lowerer, symbol) ?? wholeReexportTargetOf(lowerer, symbol); + if (next === null) break; + symbol = next; + } + return symbol; +} + +/** The target whole-export symbol behind `module.exports = `, + * letting importers of that wrapper resolve to the real value symbol. */ +export function splitWholeExportAliasTargetOf( + lowerer: Lowerer, + symbol: ts.Symbol, +): ts.Symbol | null { + const storage = splitWholeExportStorageSymbolOf(lowerer, symbol); + if (storage !== null) return followSplitWholeTarget(lowerer, storage); + const direct = followSplitWholeTarget(lowerer, splitWholeExportSymbolOfSymbol(lowerer, symbol)); + if (direct !== null) return direct; + return followSplitWholeTarget(lowerer, wholeReexportTargetOf(lowerer, symbol)); +} + +/** The first read of a split binding that code before its assignment can + * reach, using the same reachability model as ordinary require bindings. */ +export function splitRequireEarlyReadOf( + lowerer: Lowerer, + binding: SplitRequireBinding, +): ts.Identifier | null { + const sf = ts.getSourceFileOfNode(binding.decl); + const stmt = binding.assign.parent; + // `binding` was recognized only from a top-level `m = require(...)` + // statement, so the statement index is always present. + const index = sf.statements.nodes.indexOf(stmt as ts.ExpressionStatement); + return requireBindingEarlyUse7(lowerer.program, sf, index, binding.decl, []); +} + /** A `require("")` call of the CommonJS global in a CommonJS JS * file whose target is a compiled program module (relative, aliased, or an * opted-in --npm-static package). Builtins, JSON documents and @@ -108,13 +436,6 @@ 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 (lowerer.inlineRequireCycleCalls.has(req.call)) { - lowerer.unsupported( - "SC1090", - req.call, - `require() of '${req.spec}' while it is still evaluating (a require cycle: Node answers its partial exports, which the static export table does not model)`, - ); - } // Every inline program require is a preflight module edge, so its module // has an initializer; never read its exports without evaluating it. if (!lowerer.initNameOf.has(req.dep)) { diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 41f915f15..f7ba7a115 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -25,6 +25,11 @@ import { } from "../../../ir/ir.js"; import { lowerNodeModuleIdentifier } from "../lower-node-module.js"; import { lowerCjsExportsObjectValue } from "../cjs-exports-object.js"; +import { + splitRequireBindingOfIdent, + wholeExportSymbolOfModule, + wholeExportValue, +} from "../cjs-inline-require.js"; import { captureContextArguments } from "../function-context.js"; import { argumentsOwner, typedArgumentsCandidate } from "../typed-arguments.js"; import { @@ -979,6 +984,27 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // Client`): the member holds that binding's value. const memberAlias = cjsMemberAliasValueOf(lowerer, expr); if (memberAlias !== null) return lowerer.lowerExpr(memberAlias); + // `var m; m = require("./x")` as a bare value: a whole-export target is + // the exported value; a table target has no first-class module object. + { + const split = splitRequireBindingOfIdent(lowerer, expr); + if (split !== null) { + if (wholeExportSymbolOfModule(lowerer, split.dep) !== null) { + const value = wholeExportValue(lowerer, split.dep, expr); + if (value !== null) return value; + lowerer.unsupported( + "SC1090", + expr, + `the whole export of '${split.spec}' as a value (its 'module.exports =' value has no static representation)`, + ); + } + lowerer.unsupported( + "SC1090", + expr, + `the exports object of '${split.spec}' as a value (its exports compile to a static table: read require(${JSON.stringify(split.spec)}).member directly)`, + ); + } + } // Preflight guarantees no unresolved identifiers; anything else here // is a blocked declaration's binding (the SC2004 cascade) or a // binding form we don't model yet. diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5238333ad..b0139aba9 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -43,10 +43,14 @@ import { } from "./cycle-initialization.js"; import { markRequireEarlyBindings } from "./require-initialization.js"; import { + cjsHasLiveMemberExports, cjsTableMethodEntry, inlineProgramRequireOf, nestedCjsExportAssignmentsOf, requireNestedPatternsOf, + splitRequireBindingOf, + splitRequireBindingOfIdent, + wholeExportSymbolOfModule, } from "./cjs-inline-require.js"; import { isDeadTscHelperDecl, tscInteropBindingOfDecl } from "./cjs-tsc-interop.js"; import { collectJsonRequires, isJsonRequireDecl } from "./json-require.js"; @@ -1597,14 +1601,26 @@ export function collectGlobals( // require() of a program module is not: the exporter re-exports // that module's exports, which requirers resolve through the // checker's alias (the statement lowers to the dep's %init). + // A split binding re-exported whole with later member writes needs + // a mutable root: `module.exports = m; module.exports.extra = 1` + // extends the SAME object Node exposes. Without later members the + // binder is pure alias plumbing (no storage). + const splitWholeBinding = ts.isIdentifier(rhs) + ? splitRequireBindingOfIdent(lowerer, rhs) + : null; + const hasMemberExports = cjsHasLiveMemberExports(lowerer, sf); + const splitWhole = + splitWholeBinding !== null && + wholeExportSymbolOfModule(lowerer, splitWholeBinding.dep) !== null && + hasMemberExports; const factoryValued = - !ts.isIdentifier(rhs) && + (!ts.isIdentifier(rhs) || splitWhole) && !ts.isClassExpression(rhs) && !ts.isObjectLiteralExpression(rhs) && !( requireSpecOf(rhs) !== null && inlineProgramRequireOf(lowerer, rhs) !== null && - !sf.statements.nodes.some((s) => cjsExportAssignmentOf(s)?.kind === "member") + !hasMemberExports ) && cjsExportTargetLiteral(rhs, sf) === null; if (cjsScalarLiteral(rhs) || fnValued || factoryValued) { @@ -1617,6 +1633,9 @@ export function collectGlobals( // A returned object remains the live CommonJS root; later // exports.member writes must update that same open object. if (factoryValued && t?.kind === "record" && lowerer.dynConvertible(t)) t = DYN; + // A split whole-export root is extended by later member writes; + // keep it checked-dynamic so those writes reach the same object. + if (splitWhole && t && t.kind !== "dyn") t = DYN; if (t && t.kind !== "void") { const g: IrGlobal = { id: `%g.${tag}exports`, @@ -2708,6 +2727,7 @@ function noteVarGlobalEntryInit(lowerer: Lowerer, sf: ts.SourceFile, g: IrGlobal * plumbing (lowerVarDeclarator): no storage, the reads resolve through to * the module. JSON documents and native FFI requires keep their storage. */ function requireAliasDecl(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if (splitRequireBindingOf(lowerer, decl) !== null) return true; return ( decl.initializer !== undefined && requireSpecOf(decl.initializer) !== null && diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 82c0dbb3e..6a76c4b94 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -39,6 +39,11 @@ import { cjsTableMethodEntry, inlineProgramRequireOf, requireNestedPatternsOf, + splitRequireAssignmentOf, + splitRequireBindingOf, + splitRequireBindingOfIdent, + splitRequireEarlyReadOf, + wholeExportSymbolOfModule, } from "./cjs-inline-require.js"; import { cjsExportMemberWriteGlobalOf, @@ -1934,6 +1939,23 @@ function lowerVarDeclarator( isConst: boolean, isLet: boolean, ): IrStmt[] { + // `var m; m = require("./x")`: the declaration is alias plumbing; the + // assignment owns the module's run-once %init (cjs-inline-require.ts). + // A read that code before the assignment can reach keeps Node's hoisted + // `undefined` divergence visible at build time, like a var require + // declaration below runnable code. + const split = splitRequireBindingOf(lowerer, decl); + if (split !== null) { + const early = splitRequireEarlyReadOf(lowerer, split); + if (early !== null) { + lowerer.unsupported( + "SC1013", + early, + `var require() binding '${(decl.name as ts.Identifier).text}' below code that can reach it (Node reads undefined before the require runs, which is not modeled — assign the require above the code that can run first)`, + ); + } + return []; + } // The canonical ESM bridge to a compiled program module. Like a // top-level CommonJS require declaration, the binding is alias // plumbing while the dependency evaluates at this statement's @@ -7537,6 +7559,26 @@ function lowerCjsSingleValueExport( return lowerer.requireInitStmt(req.spec, req.call) ?? { kind: "block", body: [], loc }; } if (ts.isIdentifier(rhs)) { + const split = splitRequireBindingOfIdent(lowerer, rhs); + if (split !== null) { + if (wholeExportSymbolOfModule(lowerer, split.dep) === null) { + lowerer.unsupported( + "SC1090", + rhs, + `the exports object of '${split.spec}' as a value (its exports compile to a static table: read require(${JSON.stringify(split.spec)}).member directly)`, + ); + } + const root = lowerer.globalsByDeclNode.get(expr); + if (root !== undefined) { + return { + kind: "assign", + localId: root.id, + value: lowerer.lowerExprExpecting(rhs, root.type), + loc: locOf(rhs), + }; + } + return { kind: "block", body: [], loc }; + } const sym = lowerer.resolveValueSymbol(rhs); if (!sym) return null; if ( @@ -8447,6 +8489,16 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm if (!ts.isIdentifier(expr.left)) { lowerer.unsupported("SC1090", expr.left, "assignment to non-variables"); } + const splitAssign = splitRequireAssignmentOf(lowerer, expr); + if (splitAssign !== null) { + return ( + lowerer.requireInitStmt(splitAssign.spec, splitAssign.call) ?? { + kind: "block", + body: [], + loc: locOf(expr), + } + ); + } const target = lowerer.resolveWritable(expr.left); if (!target) lowerer.rejectUnresolved( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 20f1bc161..14af97de7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -344,10 +344,13 @@ import { } from "./lower-modules.js"; import { prepareCjsModuleGraph, usesCjsTopLevelThis } from "./lower-node-module.js"; import { + type SplitRequireBinding, cjsNamespaceModule, cjsWholeExportValueOf, inlineNamespaceRequireOf, inlineProgramRequireOf, + splitRequireBindingOf, + splitWholeExportAliasTargetOf, } from "./cjs-inline-require.js"; import { tscInteropModuleOf } from "./cjs-tsc-interop.js"; import { usesCjsExportsIdentity } from "./cjs-exports-object.js"; @@ -2292,6 +2295,14 @@ export class Lowerer { /** Per CommonJS file using top-level `this`: the global holding the * object that stands for its original exports (lowerCjsTopLevelThis). */ readonly cjsExportsThis = new Map(); + /** `var m; m = require("./x")` alias bindings (cjs-inline-require.ts). + * Null entries cache declarations that keep ordinary storage. */ + readonly cjsSplitRequireBindings = new Map(); + /** Per-module whole-export symbol (cjs-inline-require.ts). Null means the + * module's exports are a table. */ + readonly cjsWholeExportSymbols = new Map(); + /** Per-module live member-export classification (cjs-inline-require.ts). */ + readonly cjsLiveMemberExports = new Map(); /** Inline program-module require calls whose %init the enclosing member * read or member call is already sequencing (cjs-inline-require.ts). */ readonly inlineRequireInitDone = new Set(); @@ -4136,6 +4147,11 @@ export class Lowerer { // registry (globals, fn signatures, classes) applies unchanged. const cjsValue = this.cjsExportValueSymbol(symbol); if (cjsValue) symbol = cjsValue; + // `var m; m = require("./x"); module.exports = m` over a whole-export + // target: TS types the wrapper's export= as `any`, so reconnect it to + // the target's value symbol explicitly. + const splitAlias = splitWholeExportAliasTargetOf(this, symbol); + if (splitAlias) symbol = splitAlias; this.flushDeferred(symbol); return symbol; } @@ -4529,8 +4545,8 @@ export class Lowerer { const decl = decls.find(ts.isImportClause) ?? decls[0]; if (!decl) return null; if (ts.isImportClause(decl)) return this.cjsDefaultImportDepOf(decl); - if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || !decl.initializer) - return null; + if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null; + if (decl.initializer === undefined) return splitRequireBindingOf(this, decl)?.dep ?? null; { const interop = tscInteropModuleOf(this, expr); if (interop !== null) return cjsNamespaceModule(this, interop.dep) ? interop.dep : null; @@ -4635,6 +4651,9 @@ export class Lowerer { // a binding (cjs-inline-require.ts sequences the module's %init). if (!ts.isIdentifier(expr)) return this.cjsModuleRefDepOf(expr) !== null; const sym = this.checker.getSymbolAtLocation(expr); + const splitDecl = sym?.declarations.find(ts.isVariableDeclaration); + const split = splitDecl === undefined ? null : splitRequireBindingOf(this, splitDecl); + if (split !== null) return cjsNamespaceModule(this, split.dep); if (this.localModuleBindingDepOf(expr) === null) return false; if (this.createRequireWholeExportSymbolOf(expr) !== null) return false; // SINGLE-VALUE exporters (`module.exports = Countdown` / `= double` / @@ -4942,6 +4961,13 @@ export class Lowerer { * their external dependencies by name). Null for everything else * (builtins load nothing; the rest kept its preflight fence). */ requireInitStmt(spec: string, node: ts.Node): IrStmt | null { + if (this.inlineRequireCycleCalls.has(node)) { + this.unsupported( + "SC1090", + node, + `require() of '${spec}' while it is still evaluating (a require cycle: Node answers its partial exports, which the static export table does not model)`, + ); + } if (canonicalBuiltinModule(spec) === "punycode") return this.punycodeRequireWarning(node); // Project requires resolve within the program; another bare require can // be a program-module edge when it names an opted-in --npm-static package diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 6da81dfbc..c2f528b25 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -228,7 +228,8 @@ function detectAutoPackages( } else if (mode === "lib" && !viaTransitive) { // CJS packages spell their dep edges as top-level requires; the // import-statement scan alone would miss every one of them. - for (const req of requiresOf(stmt)) edges.push({ spec: req.spec, loc: locOf(req.node) }); + for (const req of requiresOf(stmt, load.program)) + edges.push({ spec: req.spec, loc: locOf(req.node) }); } } // Transitive selection counts every literal require() and import() diff --git a/packages/compiler/src/frontend/program-analysis.ts b/packages/compiler/src/frontend/program-analysis.ts index e49b9307e..d4ec9fa98 100644 --- a/packages/compiler/src/frontend/program-analysis.ts +++ b/packages/compiler/src/frontend/program-analysis.ts @@ -1,4 +1,5 @@ import type { + CallExpression, ExportDeclaration, Identifier, SourceFile, @@ -30,6 +31,13 @@ export class ProgramAnalysis { readonly requireEarlyReferences = new Map(); /** Per file: the first top-level statement that can run user code. */ readonly requireFirstRunnable = new Map(); + /** Per file: split require bindings (`var m; m = require("./x")`), from + * declaration to the require call of their one assignment + * (splitRequireBindings7). */ + readonly splitRequireBindings = new Map< + SourceFile, + ReadonlyMap + >(); clear(): void { this.createRequireReasons.clear(); @@ -41,5 +49,6 @@ export class ProgramAnalysis { this.moduleEarlyBindings = null; this.requireEarlyReferences.clear(); this.requireFirstRunnable.clear(); + this.splitRequireBindings.clear(); } } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index c5b72d98f..8075379b8 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1423,17 +1423,31 @@ function isRequireStatement7(stmt: ts.Statement): boolean { ); } +/** A require() occurrence of a top-level statement. `split` marks the + * assignment of a split require binding (splitRequireBindings7): `node` + * is its require call and `decl` the binding's declaration. */ +interface RequireOccurrence7 { + spec: string; + node: ts.Node; + decl: ts.VariableDeclaration | null; + split?: true; +} + /** The require() occurrences of one top-level statement: the declaration - * forms and the bare side-effect call. Empty for everything else. */ -function requiresOf7( - stmt: ts.Statement, -): { spec: string; node: ts.Node; decl: ts.VariableDeclaration | null }[] { + * forms, the bare side-effect call, and the assignment of a split require + * binding. Empty for everything else. */ +function requiresOf7(stmt: ts.Statement, program: ts.Program): RequireOccurrence7[] { if (ts.isExpressionStatement(stmt)) { + const split = splitRequireOfStatement7(program, stmt); + if (split !== null) + return [ + { spec: requireSpecOf7(split.call)!, node: split.call, decl: split.decl, split: true }, + ]; const spec = requireSpecOf7(stmt.expression); return spec !== null ? [{ spec, node: stmt.expression, decl: null }] : []; } if (!ts.isVariableStatement(stmt)) return []; - const out: { spec: string; node: ts.Node; decl: ts.VariableDeclaration | null }[] = []; + const out: RequireOccurrence7[] = []; for (const decl of stmt.declarationList.declarations.nodes) { const spec = decl.initializer !== undefined ? requireSpecOf7(decl.initializer) : null; if (spec !== null) out.push({ spec, node: decl, decl }); @@ -1441,6 +1455,172 @@ function requiresOf7( return out; } +/** CommonJS require bindings split into a declaration and one later + * assignment (`var m; m = require("./x")`): a top-level `var` or `let` + * declaration without initializer in a CommonJS file, assigned by exactly one + * top-level `m = require("")` statement of the require global over + * a compiled program module, and written nowhere else. The binding holds the + * module's exports from the assignment on, as `const m = require("./x")` + * does, so preflight treats the assignment as that require: its module edge, + * its cycle admission and its early-read fence. The lowering's recognizer + * (splitRequireBindingOf in cjs-inline-require.ts) accepts only these + * bindings. Keyed by declaration, with the assignment's require call. */ +function splitRequireBindings7( + program: ts.Program, + sf: ts.SourceFile, +): ReadonlyMap { + const memo = program.analysis.splitRequireBindings; + const cached = memo.get(sf); + if (cached !== undefined) return cached; + const out = new Map(); + memo.set(sf, out); + if (sf.isDeclarationFile || !isCjsJsFile7(sf, program) || !sf.text.includes("require")) + return out; + const checker = program.getTypeChecker(); + const stmts = sf.statements.nodes; + const candidates = new Map< + ts.Symbol, + { decl: ts.VariableDeclaration; call: ts.CallExpression | null; assigns: number } + >(); + for (const stmt of stmts) { + if (!ts.isVariableStatement(stmt)) continue; + if ((stmt.declarationList.flags & (ts.NodeFlags.Const | ts.NodeFlags.Using)) !== 0) continue; + for (const decl of stmt.declarationList.declarations.nodes) { + if (!ts.isIdentifier(decl.name) || decl.initializer !== undefined) continue; + const symbol = checker.getSymbolAtLocation(decl.name); + if (symbol === undefined || symbol.declarations.length !== 1) continue; + candidates.set(symbol, { decl, call: null, assigns: 0 }); + } + } + if (candidates.size === 0) return out; + // The one top-level `m = ...` statement, whose right side is the require. + for (const stmt of stmts) { + if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) continue; + const assign = stmt.expression; + if (assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isIdentifier(assign.left)) + continue; + const symbol = checker.getSymbolAtLocation(assign.left); + const candidate = symbol !== undefined ? candidates.get(symbol) : undefined; + if (candidate === undefined) continue; + candidate.assigns++; + let right = assign.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + if (ts.isCallExpression(right) && splitRequireTarget7(program, sf, right)) + candidate.call = right; + } + const names = new Set(); + for (const candidate of candidates.values()) { + if (candidate.assigns === 1 && candidate.call !== null) + names.add((candidate.decl.name as ts.Identifier).text); + } + if (names.size === 0) return out; + // Exactly one write in the whole file: the assignment itself. + const writes = new Map(); + ts.walkPreorder(sf, (node) => { + if (!ts.isIdentifier(node) || !names.has(node.text)) return undefined; + const parent = node.parent; + const symbol = + parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node + ? (checker.getShorthandAssignmentValueSymbol(parent) ?? checker.getSymbolAtLocation(node)) + : checker.getSymbolAtLocation(node); + if (symbol === undefined || !candidates.has(symbol) || !isWriteReference7(node)) + return undefined; + writes.set(symbol, (writes.get(symbol) ?? 0) + 1); + return undefined; + }); + for (const [symbol, candidate] of candidates) { + if (candidate.assigns === 1 && candidate.call !== null && writes.get(symbol) === 1) + out.set(candidate.decl, candidate.call); + } + return out; +} + +/** True when `call` is a require of the CommonJS global over a compiled + * program module: not a builtin, a JSON document or a declaration file. */ +function splitRequireTarget7( + program: ts.Program, + sf: ts.SourceFile, + call: ts.CallExpression, +): boolean { + const spec = requireSpecOf7(call); + if (spec === null || canonicalBuiltinModule(spec) !== null) return false; + if (requireCallBindingKind7(program, call) !== "ambient") return false; + const dep = + resolveImport7(program, sf, spec, "require") ?? npmStaticDepSf7(program, sf, spec, "require"); + return dep !== null && !dep.isDeclarationFile && !dep.fileName.endsWith(".json"); +} + +/** The split require binding a top-level statement assigns, or null. */ +function splitRequireOfStatement7( + program: ts.Program, + stmt: ts.Statement, +): { decl: ts.VariableDeclaration; call: ts.CallExpression } | null { + if ( + !ts.isExpressionStatement(stmt) || + !ts.isBinaryExpression(stmt.expression) || + !ts.isSourceFile(stmt.parent) + ) + return null; + let right = stmt.expression.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + for (const [decl, call] of splitRequireBindings7(program, stmt.parent)) + if (call === right) return { decl, call }; + return null; +} + +/** The top-level statement of a split require binding's assignment, from + * its require call (`m = require("./x")` or `m = (require("./x"))`). */ +function splitRequireStatementOf7(call: ts.CallExpression): ts.Node | undefined { + let node: ts.Node = call; + while (node.parent !== undefined && ts.isParenthesizedExpression(node.parent)) node = node.parent; + return node.parent?.parent; +} + +/** The require call of a split require binding's declaration, or null. */ +function splitRequireCallOf7( + program: ts.Program, + decl: ts.VariableDeclaration, +): ts.CallExpression | null { + return splitRequireBindings7(program, ts.getSourceFileOfNode(decl)).get(decl) ?? null; +} + +/** True when `id` is written: an assignment target (compound and + * destructuring included), an update operand, or a for-in/of target. */ +function isWriteReference7(id: ts.Identifier): boolean { + let n: ts.Node = id; + for (;;) { + const p: ts.Node | undefined = n.parent; + if (p === undefined) return false; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + return ( + p.left === n && k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment + ); + } + if (ts.isPostfixUnaryExpression(p) || ts.isPrefixUnaryExpression(p)) { + return ( + (p.operator === ts.SyntaxKind.PlusPlusToken || + p.operator === ts.SyntaxKind.MinusMinusToken) && + p.operand === n + ); + } + if (ts.isForOfStatement(p) || ts.isForInStatement(p)) return p.initializer === n; + if ( + ts.isParenthesizedExpression(p) || + ts.isArrayLiteralExpression(p) || + ts.isSpreadElement(p) || + ts.isSpreadAssignment(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isObjectLiteralExpression(p) || + (ts.isPropertyAssignment(p) && p.initializer === n) + ) { + n = p; + continue; + } + return false; + } +} + /** `true`, or `!0`, its minified spelling. */ function isTrueLiteral(expr: ts.Expression): boolean { return ( @@ -1580,7 +1760,7 @@ function purePrefixDecl7(decl: ts.VariableDeclaration): boolean { 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 (isRequireStatement7(s) || splitRequireOfStatement7(program, s) !== null) return true; if (isCjsJsFile7(ts.getSourceFileOfNode(s), program)) { if (ts.isExpressionStatement(s) && isEsModuleStamp(s.expression)) { const call = s.expression as ts.CallExpression; @@ -1707,12 +1887,20 @@ function requireEarlyReference7( const cached = memo.get(decl); if (cached !== undefined) return cached; let found: ts.Identifier | null = null; - const stmt = decl.parent?.parent; const sf = ts.getSourceFileOfNode(decl); - if (stmt !== undefined && ts.isVariableStatement(stmt) && ts.isSourceFile(stmt.parent)) { + // A split require binding is bound at its assignment statement. + const splitCall = splitRequireCallOf7(program, decl); + const stmt = splitCall !== null ? splitRequireStatementOf7(splitCall) : decl.parent?.parent; + if ( + stmt !== undefined && + (ts.isVariableStatement(stmt) || splitCall !== null) && + ts.isSourceFile(stmt.parent) + ) { const stmts = sf.statements.nodes; - const k = stmts.indexOf(stmt); - const declarations = stmt.declarationList.declarations.nodes; + const k = stmts.indexOf(stmt as ts.Statement); + const declarations = ts.isVariableStatement(stmt) + ? stmt.declarationList.declarations.nodes + : []; // Earlier declarators run too: `const x = read(), dep = require('./dep')`. const precedingDecls = declarations.slice(0, declarations.indexOf(decl)); let firstRunnable = program.analysis.requireFirstRunnable.get(sf); @@ -1765,6 +1953,12 @@ export function requireBindingEarlyUse7( if (sym) bindings.add(sym); } if (bindings.size === 0) return null; + // A split require binding's assignment writes the binding; it reads nothing. + let splitWrite: ts.Node | null = splitRequireCallOf7(program, decl); + while (splitWrite !== null && ts.isParenthesizedExpression(splitWrite.parent)) + splitWrite = splitWrite.parent; + splitWrite = + splitWrite !== null && ts.isBinaryExpression(splitWrite.parent) ? splitWrite.parent.left : null; const stmts = sf.statements.nodes; const cjs = isCjsJsFile7(sf, program); const symbolOf = (id: ts.Identifier): ts.Symbol | undefined => { @@ -1988,7 +2182,7 @@ export function requireBindingEarlyUse7( const sym = symbolOf(n); if (sym === undefined) return undefined; if (bindings.has(sym)) { - if (exactEarlyRead?.(n) === true) return undefined; + if (n === splitWrite || exactEarlyRead?.(n) === true) return undefined; hit = n; return "stop"; } @@ -3499,7 +3693,7 @@ function cjsCycleRefusal7( } // Bindings of other members must stay unreachable from top-level code. for (const st of stmts) { - for (const req of requiresOf7(st)) { + for (const req of requiresOf7(st, program)) { if (req.decl === null) continue; const dep = resolveImport7(program, m, req.spec, "require"); if (dep === null || !members.has(dep)) continue; @@ -5775,7 +5969,7 @@ function preflight7(load: LoadResult): { // module aliases cannot represent; createRequire bindings have no // checked form either. const earlyRequireFence = ( - req: { decl: ts.VariableDeclaration | null }, + req: RequireOccurrence7, bindingKind: RequireCallBindingKind7, loc: { file: string; start: number; end: number }, ): boolean => { @@ -5783,6 +5977,7 @@ function preflight7(load: LoadResult): { if ( early === null || (bindingKind !== "createRequire" && + req.split === undefined && ((req.decl!.parent?.flags ?? 0) & (ts.NodeFlags.Const | ts.NodeFlags.Let)) !== 0) ) { return false; @@ -5793,14 +5988,16 @@ function preflight7(load: LoadResult): { loc, bindingKind === "createRequire" ? `createRequire() binding '${early.text}' below code that can reach it (Node throws ReferenceError on any earlier access, which is not modeled — move the require above the code that can run first)` - : `var require() binding '${early.text}' below code that can reach it (Node reads undefined before the require runs, which is not modeled — declare it with const or let, or move the require above the code that can run first)`, + : req.split !== undefined + ? `require() binding '${early.text}' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first)` + : `var require() binding '${early.text}' below code that can reach it (Node reads undefined before the require runs, which is not modeled — declare it with const or let, or move the require above the code that can run first)`, ), ); return true; }; for (let k = 0; k < stmts.length; k++) { const stmt = stmts[k]!; - for (const req of requiresOf7(stmt)) { + for (const req of requiresOf7(stmt, program)) { const loc = { file: sf.fileName, start: ts.tokenStartOfNode(req.node), @@ -5827,8 +6024,8 @@ function preflight7(load: LoadResult): { const jsonDep = resolveImport7(program, sf, req.spec, "require"); if (jsonDep !== null && jsonDep.fileName.endsWith(".json")) { if (bindingKind === "createRequire") continue; - if (ts.isVariableStatement(stmt) && requiresOf7(stmt).length > 1) { - const others = requiresOf7(stmt).some((r) => { + if (ts.isVariableStatement(stmt) && requiresOf7(stmt, program).length > 1) { + const others = requiresOf7(stmt, program).some((r) => { const d = resolveImport7(program, sf, r.spec, "require"); return d === null || !d.fileName.endsWith(".json"); }); @@ -6000,8 +6197,12 @@ function preflight7(load: LoadResult): { // counts, and targets outside the compiled graph add nothing here: // their use sites keep their existing lowering fences. if (jsRequireFile && !isNodeEsmFile7(sf, program)) { + // A split require binding's assignment is a statement-level require + // edge (requiresOf7 above), parenthesized or not. + const splitCalls = new Set(splitRequireBindings7(program, sf).values()); for (const call of inlineRequiresOf7(sf)) { if (requireCallBindingKind7(program, call) !== "ambient") continue; + if (splitCalls.has(call)) continue; const spec = requireSpecOf7(call)!; if (load.externalTypes.has(spec) || canonicalBuiltinModule(spec) !== null) continue; const lazy = inDeferredPosition7(call); @@ -6750,6 +6951,7 @@ export { requireSpecOf7 as requireSpecOf, isRequireStatement7 as isRequireStatement, requiresOf7 as requiresOf, + splitRequireCallOf7 as splitRequireCallOf, isNodeEsmFile7 as isNodeEsmFile, resolveImport7 as resolveImport, resolveNpmImport7 as resolveNpmImport, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json new file mode 100644 index 000000000..60b9b0736 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-binding/main.cjs.json @@ -0,0 +1,12 @@ +{ + "order": [ + "/tests/corpus/split-cjs-require-binding/lib.cjs", + "/tests/corpus/split-cjs-require-binding/whole.cjs", + "/tests/corpus/split-cjs-require-binding/reexport.cjs", + "/tests/corpus/split-cjs-require-binding/reexport-extra.cjs", + "/tests/corpus/split-cjs-require-binding/chain.cjs", + "/tests/corpus/split-cjs-require-binding/chain-wrap.cjs", + "/tests/corpus/split-cjs-require-binding/main.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-cycle/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-cycle/main.cjs.json new file mode 100644 index 000000000..0ea3366d3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/split-cjs-require-cycle/main.cjs.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/split-cjs-require-cycle/odd.cjs", + "/tests/corpus/split-cjs-require-cycle/even.cjs", + "/tests/corpus/split-cjs-require-cycle/main.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-cycle/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-cycle/main.js.json new file mode 100644 index 000000000..90d4a2353 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-cycle/main.js.json @@ -0,0 +1,19 @@ +{ + "order": [ + "/tests/diagnostics/split-cjs-require-cycle/b.js", + "/tests/diagnostics/split-cjs-require-cycle/main.js" + ], + "diags": [ + { + "code": "SC1016", + "message": "circular imports (/tests/diagnostics/split-cjs-require-cycle/main.js → /tests/diagnostics/split-cjs-require-cycle/b.js → /tests/diagnostics/split-cjs-require-cycle/main.js; the cycle closes through a require() edge: /tests/diagnostics/split-cjs-require-cycle/main.js uses its exports at /tests/diagnostics/split-cjs-require-cycle/main.js:10 outside the export statements that precede its first require, so a module of the cycle can observe them before they settle) are not supported yet", + "loc": { + "file": "/tests/diagnostics/split-cjs-require-cycle/main.js", + "start": 0, + "end": 0 + }, + "milestone": "later", + "hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge" + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json new file mode 100644 index 000000000..acb338494 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/split-cjs-require-early/main.js.json @@ -0,0 +1,27 @@ +{ + "order": [ + "/tests/diagnostics/split-cjs-require-early/main.js" + ], + "diags": [ + { + "code": "SC1013", + "message": "require() binding 'lib' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first) is not supported yet", + "loc": { + "file": "/tests/diagnostics/split-cjs-require-early/main.js", + "start": 396, + "end": 415 + }, + "milestone": "later" + }, + { + "code": "SC1013", + "message": "require() binding 'wrapped' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first) is not supported yet", + "loc": { + "file": "/tests/diagnostics/split-cjs-require-early/main.js", + "start": 530, + "end": 549 + }, + "milestone": "later" + } + ] +} diff --git a/tests/corpus/split-cjs-require-binding/chain-wrap.cjs b/tests/corpus/split-cjs-require-binding/chain-wrap.cjs new file mode 100644 index 000000000..435dcaeab --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/chain-wrap.cjs @@ -0,0 +1,3 @@ +var chained; +chained = require("./chain.cjs"); +module.exports = chained; diff --git a/tests/corpus/split-cjs-require-binding/chain.cjs b/tests/corpus/split-cjs-require-binding/chain.cjs new file mode 100644 index 000000000..a279f456e --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/chain.cjs @@ -0,0 +1 @@ +module.exports = require("./whole.cjs"); diff --git a/tests/corpus/split-cjs-require-binding/lib.cjs b/tests/corpus/split-cjs-require-binding/lib.cjs new file mode 100644 index 000000000..b318eb85c --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/lib.cjs @@ -0,0 +1,5 @@ +console.log("lib: init"); +exports.n = 7; +exports.f = function (x) { + return x * 2; +}; diff --git a/tests/corpus/split-cjs-require-binding/main.cjs b/tests/corpus/split-cjs-require-binding/main.cjs new file mode 100644 index 000000000..974421bd3 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/main.cjs @@ -0,0 +1,55 @@ +console.log("main: start"); +var lib, whole; +lib = require("./lib.cjs"); +whole = require("./whole.cjs"); +console.log("main: after", lib.n, lib.f(3), whole(4)); +exports.fromLib = lib.f; +exports.whole = whole; +console.log("main: exported", exports.fromLib(5), whole(6)); +console.log("main: identity", exports.fromLib === lib.f, exports.whole === whole); +const re = require("./reexport.cjs"); +console.log("main: reexport", re(7)); +let later; +later = require("./lib.cjs"); +console.log("main: let", later.n); +const rx = require("./reexport-extra.cjs"); +console.log("main: reexport extra", rx(8), rx.extra); +const splitObject = { whole }; +console.log("main: shorthand value", splitObject.whole(10)); +var libAgain; +libAgain = require("./lib.cjs"); +const { n: splitN, f: splitF } = libAgain; +console.log("main: destructured split", splitN, splitF(4)); +var chained; +chained = require("./chain-wrap.cjs"); +console.log("main: chained", chained(11)); + +// Any later write, including a destructuring target, keeps ordinary storage. +var shorthand; +shorthand = require("./whole.cjs"); +({ shorthand } = { shorthand: "shorthand" }); +console.log("main: shorthand write", shorthand); +var renamed; +renamed = require("./whole.cjs"); +({ value: renamed } = { value: "renamed" }); +console.log("main: renamed write", renamed); +var nested; +nested = require("./whole.cjs"); +({ a: { nested } } = { a: { nested: "nested" } }); +console.log("main: nested write", nested); +var rest; +rest = require("./whole.cjs"); +({ ...rest } = { a: 1, b: 2 }); +console.log("main: rest write", rest.a, rest.b); +var array; +array = require("./whole.cjs"); +[array] = ["array"]; +console.log("main: array write", array); +var arrayRest; +arrayRest = require("./whole.cjs"); +[...arrayRest] = [1, 2]; +console.log("main: array-rest write", arrayRest[0], arrayRest[1]); +var each; +each = require("./whole.cjs"); +for ({ each } of [{ each: "each" }]) {} +console.log("main: for-of write", each); diff --git a/tests/corpus/split-cjs-require-binding/package.json b/tests/corpus/split-cjs-require-binding/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/corpus/split-cjs-require-binding/reexport-extra.cjs b/tests/corpus/split-cjs-require-binding/reexport-extra.cjs new file mode 100644 index 000000000..584de98fe --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/reexport-extra.cjs @@ -0,0 +1,6 @@ +var whole; +whole = require("./whole.cjs"); +console.log("extra: before", whole.extra, whole(9)); +module.exports = whole; +module.exports.extra = 1; +console.log("extra: after", whole.extra, module.exports.extra); diff --git a/tests/corpus/split-cjs-require-binding/reexport.cjs b/tests/corpus/split-cjs-require-binding/reexport.cjs new file mode 100644 index 000000000..9a931bdef --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/reexport.cjs @@ -0,0 +1,4 @@ +console.log("reexport: init"); +var whole; +whole = require("./whole.cjs"); +module.exports = whole; diff --git a/tests/corpus/split-cjs-require-binding/whole.cjs b/tests/corpus/split-cjs-require-binding/whole.cjs new file mode 100644 index 000000000..31b476c4e --- /dev/null +++ b/tests/corpus/split-cjs-require-binding/whole.cjs @@ -0,0 +1,4 @@ +console.log("whole: init"); +module.exports = function whole(x) { + return x + 1; +}; diff --git a/tests/corpus/split-cjs-require-cycle/even.cjs b/tests/corpus/split-cjs-require-cycle/even.cjs new file mode 100644 index 000000000..94d74ea87 --- /dev/null +++ b/tests/corpus/split-cjs-require-cycle/even.cjs @@ -0,0 +1,10 @@ +var odd; +exports.name = "even"; +exports.isEven = function (n) { + return n === 0 ? true : odd.isOdd(n - 1); +}; +exports.describe = function (n) { + return n + " is " + (exports.isEven(n) ? "even" : "odd") + " (asked " + odd.name + ")"; +}; +odd = require("./odd.cjs"); +console.log("even: loaded"); diff --git a/tests/corpus/split-cjs-require-cycle/main.cjs b/tests/corpus/split-cjs-require-cycle/main.cjs new file mode 100644 index 000000000..9ec7997db --- /dev/null +++ b/tests/corpus/split-cjs-require-cycle/main.cjs @@ -0,0 +1,12 @@ +// Split require bindings (`var m; m = require("./x.cjs")`) that close a +// CommonJS cycle: each module publishes its exports before its require, and +// reads the other module's binding only in functions that run after both +// modules finish, the cycle shape the const form compiles too. One +// assignment parenthesizes its require. +console.log("main: start"); +var even, odd; +even = require("./even.cjs"); +odd = require("./odd.cjs"); +console.log(even.isEven(10), odd.isOdd(7), even.isEven(3)); +console.log(even.describe(4), "|", even.describe(9)); +console.log("odd calls", odd.calls(), even.name, odd.name); diff --git a/tests/corpus/split-cjs-require-cycle/odd.cjs b/tests/corpus/split-cjs-require-cycle/odd.cjs new file mode 100644 index 000000000..d449c28e8 --- /dev/null +++ b/tests/corpus/split-cjs-require-cycle/odd.cjs @@ -0,0 +1,12 @@ +var even; +let calls = 0; +exports.name = "odd"; +exports.isOdd = function (n) { + calls++; + return n === 0 ? false : even.isEven(n - 1); +}; +exports.calls = function () { + return calls; +}; +even = (require("./even.cjs")); +console.log("odd: loaded"); diff --git a/tests/corpus/split-cjs-require-cycle/package.json b/tests/corpus/split-cjs-require-cycle/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/corpus/split-cjs-require-cycle/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/diagnostics/split-cjs-require-cycle/b.js b/tests/diagnostics/split-cjs-require-cycle/b.js new file mode 100644 index 000000000..ad5a7441a --- /dev/null +++ b/tests/diagnostics/split-cjs-require-cycle/b.js @@ -0,0 +1,7 @@ +var a; +a = require('./main.js'); +exports.fromB = 'b-value'; +exports.readA = function () { + return a.early + '/' + a.late; +}; +console.log('b sees a.early:', a.early, 'a.late:', a.late); diff --git a/tests/diagnostics/split-cjs-require-cycle/main.js b/tests/diagnostics/split-cjs-require-cycle/main.js new file mode 100644 index 000000000..286de459a --- /dev/null +++ b/tests/diagnostics/split-cjs-require-cycle/main.js @@ -0,0 +1,11 @@ +// A split require binding (`var b; b = require("./b.js")`) whose require +// closes a CommonJS cycle that top-level code can observe: b.js reads a.js's +// partial exports while a.js is still evaluating, which the compiled export +// table does not model. Preflight refuses it with SC1016, as it refuses the +// `const b = require("./b.js")` form; tests/corpus/split-cjs-require-cycle +// compiles the cycle shape both forms admit. +exports.early = 'a-early'; +var b; +b = require('./b.js'); +exports.late = 'a-late'; +console.log('a sees', b.fromB, b.readA()); diff --git a/tests/diagnostics/split-cjs-require-cycle/package.json b/tests/diagnostics/split-cjs-require-cycle/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/diagnostics/split-cjs-require-cycle/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/diagnostics/split-cjs-require-early/lib.js b/tests/diagnostics/split-cjs-require-early/lib.js new file mode 100644 index 000000000..2a9669e12 --- /dev/null +++ b/tests/diagnostics/split-cjs-require-early/lib.js @@ -0,0 +1,8 @@ +'use strict'; + +/** @param {string} who */ +function greet(who) { + return `hello ${who}`; +} + +module.exports = { greet }; diff --git a/tests/diagnostics/split-cjs-require-early/main.js b/tests/diagnostics/split-cjs-require-early/main.js new file mode 100644 index 000000000..ae1d8d770 --- /dev/null +++ b/tests/diagnostics/split-cjs-require-early/main.js @@ -0,0 +1,21 @@ +// A split require binding (`let lib;` then `lib = require(...)`) that code +// above the assignment can reach: Node reads undefined there, a value the +// compiled module alias cannot hold, so the assignment keeps a named SC1013 +// fence. Assigned before any such code, the binding compiles +// (tests/corpus/split-cjs-require-binding). +'use strict'; + +let lib; +console.log('boot', helper()); +lib = require('./lib.js'); + +// The same with the require parenthesized. +let wrapped; +console.log('peek', wrapped === undefined); +wrapped = (require('./lib.js')); + +function helper() { + return lib === undefined ? 'not loaded' : lib.greet('late'); +} + +console.log(helper()); diff --git a/tests/diagnostics/split-cjs-require-early/package.json b/tests/diagnostics/split-cjs-require-early/package.json new file mode 100644 index 000000000..a3c15a7a6 --- /dev/null +++ b/tests/diagnostics/split-cjs-require-early/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/harness/__snapshots__/split-cjs-require-cycle/main.js.txt b/tests/harness/__snapshots__/split-cjs-require-cycle/main.js.txt new file mode 100644 index 000000000..6c39c35d6 --- /dev/null +++ b/tests/harness/__snapshots__/split-cjs-require-cycle/main.js.txt @@ -0,0 +1,7 @@ +split-cjs-require-cycle/main.js:1:1 - error SC1016: circular imports (split-cjs-require-cycle/main.js → split-cjs-require-cycle/b.js → split-cjs-require-cycle/main.js; the cycle closes through a require() edge: split-cjs-require-cycle/main.js uses its exports at split-cjs-require-cycle/main.js:10 outside the export statements that precede its first require, so a module of the cycle can observe them before they settle) are not supported yet + + 1 | // A split require binding (`var b; b = require("./b.js")`) whose require + | ^ + 2 | // closes a CommonJS cycle that top-level code can observe: b.js reads a.js's + + hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge \ No newline at end of file diff --git a/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt b/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt new file mode 100644 index 000000000..0496cd125 --- /dev/null +++ b/tests/harness/__snapshots__/split-cjs-require-early/main.js.txt @@ -0,0 +1,13 @@ +split-cjs-require-early/main.js:10:7 - error SC1013: require() binding 'lib' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first) is not supported yet + + 9 | console.log('boot', helper()); + 10 | lib = require('./lib.js'); + | ^~~~~~~~~~~~~~~~~~~ + 11 | + +split-cjs-require-early/main.js:15:12 - error SC1013: require() binding 'wrapped' assigned below code that can reach it (Node reads undefined before the assignment runs, which is not modeled — move the assignment above the code that can run first) is not supported yet + + 14 | console.log('peek', wrapped === undefined); + 15 | wrapped = (require('./lib.js')); + | ^~~~~~~~~~~~~~~~~~~ + 16 | \ No newline at end of file diff --git a/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json b/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json new file mode 100644 index 000000000..9d570f700 --- /dev/null +++ b/tests/harness/corpus-costs/split-cjs-require-binding/main.cjs.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 12000 } diff --git a/tests/harness/corpus-costs/split-cjs-require-cycle/main.cjs.json b/tests/harness/corpus-costs/split-cjs-require-cycle/main.cjs.json new file mode 100644 index 000000000..9619c105e --- /dev/null +++ b/tests/harness/corpus-costs/split-cjs-require-cycle/main.cjs.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3200 } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index f8068ac81..c8849a10f 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -325,6 +325,35 @@ console.log(cells.join(",")); ); }); + test("a split require closing a CommonJS cycle keeps the const form's SC1016", async () => { + // Node answers the closing require with the dependency's partial exports + // ({ fromA: 1 } at this point), which the static export model cannot + // represent. Preflight treats the split assignment like `const a = + // require("./a.cjs")`, so the cycle keeps that form's SC1016 instead of + // reading uninitialized export storage. A cycle whose reads are all deferred + // compiles (tests/corpus/split-cjs-require-cycle). + const r = compileAndRun( + "split-require-cycle", + `require("./a.cjs"); +`, + "cjs", + { + "a.cjs": `exports.fromA = 1; +const b = require("./b.cjs"); +exports.fromB = b.fromB; +`, + "b.cjs": `exports.fromB = 2; +var a; +a = require("./a.cjs"); +console.log(a.fromA, a.fromB); +`, + }, + ); + await expect(r).rejects.toThrow( + /SC1016: circular imports \(.*a\.cjs → .*b\.cjs → .*a\.cjs; the cycle closes through a require\(\) edge/, + ); + }); + test("an EFFECTFUL surplus argument is a named deferred fence (JS lane)", async () => { // Effect-free surplus args drop at compile time, JS-exact (corpus // 1760 — the test-string-decoder writeSequences shape). An effectful