diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts index 82926d2b8..f9463ea1e 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -111,6 +111,98 @@ console.log(require_named().name); expect(chunk).toContain("var __defProp = Object.defineProperty;"); }); +test("a helper travels with a chunk whose nested scopes declare its name", () => { + const answer = expectSameBehavior(`${keyedHelper}function label(n) { + return "label " + n; +} +function wrap(n) { + function inner() { + var label = "inner"; + return label; + } + return label(n) + " " + inner(); +} +var require_e = __commonJS({ + "e.js"(exports2, module2) { + function nested() { + var label = "nested"; + return label; + } + var seen = []; + for (let label of ["loop"]) seen.push(label); + try { + throw "caught"; + } catch (label) { + seen.push(label); + } + { + const label = "block"; + seen.push(label); + } + module2.exports = { outer: label(1), indirect: (0, label)(3), wrapped: wrap(2), nested: nested(), seen }; + } +}); +var e = require_e(); +console.log(e.outer, e.indirect, e.wrapped, e.nested, e.seen.join(",")); +`); + const chunk = answer.chunks[0]!.source; + expect(chunk).toContain("function label(n)"); + expect(chunk).toContain("function wrap(n)"); +}); + +test("a named function expression that only calls itself travels with a chunk", () => { + const answer = expectSameBehavior(`${keyedHelper}var fact = function self(n) { + return n <= 1 ? 1 : n * self(n - 1); +}; +var kind = function self(self) { + return typeof self; +}; +var outer = function self() { + var self = 2; + return self; +}; +var require_g = __commonJS({ + "g.js"(exports2, module2) { + module2.exports = [fact(5), kind(1), outer()]; + } +}); +console.log(require_g().join(",")); +`); + const chunk = answer.chunks[0]!.source; + expect(chunk).toContain("var fact = function self(n)"); +}); + +test.each([ + ["exports it", `function tally() {}`, `module2.exports = { tally };`], + ["reads its properties", `function tally() {}`, `exports2.calls = tally.calls;`], + ["constructs it", `function tally() {}`, `exports2.made = new tally();`], + ["stores an arrow helper", `var tally = () => 1;`, `exports2.fn = tally;`], + [ + "calls a function that reads its own name as a value", + `var tally = function self() { self.n = (self.n || 0) + 1; return self.n; };`, + `exports2.count = tally();`, + ], + [ + "calls a function whose parameter default reads its own name", + `var tally = function self(box = self) { box.n = (box.n || 0) + 1; return box.n; };`, + `exports2.count = tally();`, + ], +])("a chunk that %s keeps the package refused", (_, declaration, statement) => { + const result = split(`${keyedHelper}${declaration} +var require_f = __commonJS({ + "f.js"(exports2, module2) { + ${statement} + } +}); +require_f(); +`); + expect(result).toEqual({ + degrade: expect.stringContaining( + "uses the bundle's top-level function 'tally' as a value", + ) as string, + }); +}); + test.each([ ["reads its factory's receiver", `console.log(this);`], ["reads its factory's arguments object", `console.log(arguments.length);`], diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index 28fe4a1b9..2a3ad0b53 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -105,6 +105,128 @@ export function declaredNames(root: ts.Node): Set { return names; } +/** Adds the identifiers a binding name (an identifier or a destructuring + * pattern) declares. */ +function addBindingNames(name: ts.BindingName, out: Set): void { + if (ts.isIdentifier(name)) { + out.add(name.text); + return; + } + for (const element of name.elements.nodes) { + if (!ts.isOmittedExpression(element) && element.name !== undefined) + addBindingNames(element.name, out); + } +} + +function isFunctionScope(n: ts.Node): n is ts.SignatureDeclaration { + return ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isConstructorDeclaration(n) + ); +} + +/** The `var` names a function body declares, without entering nested + * functions. Function declarations in nested blocks count as block-scoped + * here; treating one as free only adds an unused helper copy. */ +function varScopedNames(body: ts.Node): Set { + const out = new Set(); + ts.walkPreorder(body, (n) => { + if (n !== body && (isFunctionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n))) + return "skip"; + if (ts.isVariableDeclarationList(n) && (n.flags & ts.NodeFlags.BlockScoped) === 0) { + for (const d of n.declarations.nodes) addBindingNames(d.name, out); + } + return undefined; + }); + return out; +} + +/** The names one scope node binds for the references inside it. */ +function scopeNames(scope: ts.Node): Set { + const out = new Set(); + if (isFunctionScope(scope)) { + for (const param of scope.parameters.nodes) addBindingNames(param.name, out); + if (ts.isFunctionExpression(scope) && scope.name !== undefined) out.add(scope.name.text); + if (scope.body !== undefined) for (const name of varScopedNames(scope.body)) out.add(name); + } else if (ts.isBlock(scope) || ts.isSourceFile(scope) || ts.isCaseBlock(scope)) { + const statements = ts.isCaseBlock(scope) + ? scope.clauses.nodes.flatMap((clause) => clause.statements.nodes) + : scope.statements.nodes; + for (const stmt of statements) { + if ((ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) && stmt.name) + out.add(stmt.name.text); + else if ( + ts.isVariableStatement(stmt) && + (stmt.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const d of stmt.declarationList.declarations.nodes) addBindingNames(d.name, out); + } + } + } else if (ts.isCatchClause(scope) && scope.variableDeclaration !== undefined) { + addBindingNames(scope.variableDeclaration.name, out); + } else if ( + (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) && + scope.initializer !== undefined && + ts.isVariableDeclarationList(scope.initializer) && + (scope.initializer.flags & ts.NodeFlags.BlockScoped) !== 0 + ) { + for (const d of scope.initializer.declarations.nodes) addBindingNames(d.name, out); + } else if ( + (ts.isClassDeclaration(scope) || ts.isClassExpression(scope)) && + scope.name !== undefined + ) { + out.add(scope.name.text); + } + return out; +} + +/** Resolves references against one bundle's scopes, keeping each scope's + * names for the bundle's split. */ +class BundleScopes { + private readonly names = new Map>(); + + private of(scope: ts.Node): Set { + let names = this.names.get(scope); + if (names === undefined) { + names = scopeNames(scope); + this.names.set(scope, names); + } + return names; + } + + /** True when a scope between the reference and `root` (inclusive) + * binds its name. */ + boundWithin(id: ts.Identifier, root: ts.Node): boolean { + for (let n = id.parent; n !== undefined; n = n.parent) { + if (this.of(n).has(id.text)) return true; + if (n === root) return false; + } + return false; + } + + /** True when a scope inside `root` (exclusive) binds the reference's + * name. */ + boundInside(id: ts.Identifier, root: ts.Node): boolean { + for (let n = id.parent; n !== undefined && n !== root; n = n.parent) { + if (this.of(n).has(id.text)) return true; + } + return false; + } + + /** True when the reference resolves to a top-level binding of `sf`. */ + topLevel(id: ts.Identifier, sf: ts.SourceFile): boolean { + for (let n = id.parent; n !== undefined && n !== sf; n = n.parent) { + if (this.of(n).has(id.text)) return false; + } + return true; + } +} + /** Every binding reference inside a subtree. */ export function references(root: ts.Node): ts.Identifier[] { const out: ts.Identifier[] = []; @@ -230,6 +352,52 @@ function copyableInitializer(init: ts.Expression | undefined): boolean { return depth > 0 && ts.isIdentifier(walk) && walk.text === "Object"; } +/** A top-level declaration whose value is a function. */ +function isFunctionValued(node: ts.VariableDeclaration | ts.FunctionDeclaration): boolean { + if (ts.isFunctionDeclaration(node)) return true; + const init = node.initializer && unparen(node.initializer); + return init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)); +} + +/** True when a named function expression initializer (`var f = function + * self() {}`) uses its own name only as a callee: `self` is the copied + * function, so it is as observable as the top-level binding. */ +function selfCalledOnly( + node: ts.VariableDeclaration | ts.FunctionDeclaration, + scopes: BundleScopes, +): boolean { + if (!ts.isVariableDeclaration(node) || node.initializer === undefined) return true; + const fn = unparen(node.initializer); + if (!ts.isFunctionExpression(fn) || fn.name === undefined || fn.body === undefined) return true; + const self = fn.name.text; + // A parameter or `var` of the same name hides the function's own name. + const own = varScopedNames(fn.body); + for (const param of fn.parameters.nodes) addBindingNames(param.name, own); + if (own.has(self)) return true; + // Its parameter initializers see the name too. + return references(fn).every((r) => r.text !== self || scopes.boundInside(r, fn) || isCallee(r)); +} + +/** True when a reference is the function a call invokes: `f(...)`, or the + * `(0, f)(...)` form that drops the receiver. */ +function isCallee(id: ts.Identifier): boolean { + let callee: ts.Node = id; + const p = id.parent; + if ( + p !== undefined && + ts.isBinaryExpression(p) && + p.operatorToken.kind === ts.SyntaxKind.CommaToken && + p.right === id && + p.parent !== undefined && + ts.isParenthesizedExpression(p.parent) + ) + callee = p.parent; + while (callee.parent !== undefined && ts.isParenthesizedExpression(callee.parent)) + callee = callee.parent; + const call = callee.parent; + return call !== undefined && ts.isCallExpression(call) && call.expression === callee; +} + /** One recognized chunk declaration. */ interface Chunk { name: string; @@ -302,6 +470,12 @@ export function splitBundledChunks( if (declaredAnywhere.has("Object") || written.has("Object")) return { degrade: CHUNK_SHAPE_DEGRADE }; const stable = (name: string): boolean => !duplicate.has(name) && !written.has(name); + // Names whose every reference in the bundle is a callee: `f(...)` or the + // `(0, f)(...)` form. + const scopes = new BundleScopes(); + const valueUsed = new Set(); + for (const r of allRefs) if (!isCallee(r) && scopes.topLevel(r, sf)) valueUsed.add(r.text); + const calledOnly = (name: string): boolean => !valueUsed.has(name); const ownNames = (name: string): boolean => { const t = top.get(name); if (t === undefined || !stable(name) || !ts.isVariableDeclaration(t.node)) return false; @@ -422,7 +596,17 @@ export function splitBundledChunks( const out: BundledChunkSplit["chunks"] = []; const bundleEdits: Edit[] = [...callEdits]; for (const chunk of chunks.values()) { - const result = chunkSource(sf, chunk, chunks, top, stable, callEdits, bundleStrict); + const result = chunkSource( + sf, + chunk, + chunks, + top, + stable, + calledOnly, + scopes, + callEdits, + bundleStrict, + ); if (typeof result !== "string") return result; out.push({ path: chunk.path, source: result }); bundleEdits.push({ start: chunk.stmt.getStart(sf), end: chunk.stmt.getEnd(), text: "" }); @@ -524,6 +708,8 @@ function chunkSource( chunks: ReadonlyMap, top: ReadonlyMap, stable: (name: string) => boolean, + calledOnly: (name: string) => boolean, + scopes: BundleScopes, callEdits: readonly Edit[], bundleStrict: boolean, ): string | { degrade: string } { @@ -570,7 +756,8 @@ function chunkSource( } continue; } - if (local.has(name)) continue; + // The factory function itself binds the parameters and `var`s. + if (scopes.boundWithin(r, body.parent ?? body)) continue; if (name === "exports" || name === "module") return why(`reads the bundle's own '${name}' binding`); if (name === "__filename") return why("reads __filename, which names the bundle file"); @@ -594,9 +781,14 @@ function chunkSource( (ts.isVariableDeclaration(node) && copyableInitializer(node.initializer))); if (!pure || chunks.has(name)) return why(`reads the bundle's top-level binding '${name}', which it cannot carry along`); - const own = declaredNames(node); + // Each chunk gets its own copy, so a function's identity and its + // properties must stay unobservable: the bundle only ever calls it. + if (isFunctionValued(node) && (!calledOnly(name) || !selfCalledOnly(node, scopes))) + return why( + `uses the bundle's top-level function '${name}' as a value, which its chunks cannot share`, + ); for (const r of references(node)) { - if (own.has(r.text) || !top.has(r.text) || copied.has(r.text)) continue; + if (scopes.boundWithin(r, node) || !top.has(r.text) || copied.has(r.text)) continue; copied.add(r.text); copyQueue.push(r.text); } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 908a587ea..79978abee 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3199,29 +3199,105 @@ function cjsCycleRefusal7( return null; } -/** True for a plain read `binding.name` (not a call, not a write) of the - * cycle member `dep` whose export `name` is a top-level function - * declaration nothing reassigns. Every member settles its exports before - * entering the cycle, so Node reads that same function whenever the read - * runs, and the compiled read resolves to the declaration itself. */ +/** True when `use` initializes a top-level const whose every other + * reference is a value of an export statement (`module.exports = { … }` + * or `exports.n = …`). */ +function publishedAlias7(use: ts.Node, decl: ts.Node | undefined): boolean { + if ( + decl === undefined || + !ts.isVariableDeclaration(decl) || + decl.initializer !== use || + !ts.isIdentifier(decl.name) + ) + return false; + const list = decl.parent; + const stmt = list?.parent; + const sf = stmt?.parent; + if ( + list === undefined || + (list.flags & ts.NodeFlags.Const) === 0 || + stmt === undefined || + !ts.isVariableStatement(stmt) || + sf === undefined || + !ts.isSourceFile(sf) + ) + return false; + const alias = decl.name; + const published = new Set(); + for (const st of sf.statements.nodes) { + const cjs = cjsExportAssignmentOf(st); + if (cjs === null) continue; + if (cjs.kind === "member") published.add(cjs.value); + else if (cjs.obj !== null) { + for (const p of cjs.obj.properties.nodes) { + if (ts.isPropertyAssignment(p) && !ts.isComputedPropertyName(p.name)) + published.add(p.initializer); + else if (ts.isShorthandPropertyAssignment(p)) published.add(p.name); + } + } + } + let kept = false; + ts.walkPreorder(sf, (n) => { + if (kept) return "stop"; + if (!ts.isIdentifier(n) || n === alias || n.text !== alias.text) return undefined; + const p = n.parent; + if ( + p !== undefined && + ((ts.isPropertyAccessExpression(p) && p.name === n) || + (ts.isPropertyAssignment(p) && p.name === n)) + ) + return undefined; + if (!published.has(n)) kept = true; + return undefined; + }); + return !kept; +} + +/** True for a plain read `binding.name` (not a call, not a write, not a + * value that is stored or passed on) of the cycle member `dep` whose + * export `name` is a top-level function declaration nothing reassigns. + * Every member settles its exports before entering the cycle, so Node + * reads that same function whenever the read runs, and the compiled read + * resolves to the declaration itself. */ function functionExportRead7(id: ts.Identifier, dep: ts.SourceFile): boolean { const access = id.parent; if (!ts.isPropertyAccessExpression(access) || access.expression !== id) return false; + // The value the read yields: through parentheses and the right operand + // of a comma, so `(0, binding.name)(...)` is the call it spells. let use: ts.Node = access; - while (ts.isParenthesizedExpression(use.parent)) use = use.parent; + for (;;) { + const p = use.parent; + if (ts.isParenthesizedExpression(p)) use = p; + else if ( + ts.isBinaryExpression(p) && + p.operatorToken.kind === ts.SyntaxKind.CommaToken && + p.right === use + ) + use = p; + else break; + } const parent = use.parent; - if ( - (ts.isCallExpression(parent) && parent.expression === use) || - ts.isDeleteExpression(parent) || - ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && - (parent.operator === ts.SyntaxKind.PlusPlusToken || - parent.operator === ts.SyntaxKind.MinusMinusToken)) || + // Only a use that neither runs the function nor keeps it is a read: a + // stored alias (`const f = binding.name`, `{ f: binding.name }`) or an + // argument can be called while the cycle initializes. A top-level const + // that only publishes it (`module.exports = { name: f }`, the static + // rewrite's re-export spelling) runs nothing: a module that reads the + // export back checks that read itself. + const observes = + publishedAlias7(use, parent) || + ts.isExpressionStatement(parent) || + ts.isTypeOfExpression(parent) || + ts.isVoidExpression(parent) || + (ts.isPrefixUnaryExpression(parent) && parent.operator === ts.SyntaxKind.ExclamationToken) || (ts.isBinaryExpression(parent) && - parent.left === use && - parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) - ) - return false; + (parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken)) || + ((ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) && + parent.expression === use) || + (ts.isConditionalExpression(parent) && parent.condition === use); + if (!observes) return false; const name = access.name.text; // The exported value's identifier: a table entry or a member export. const values: ts.Expression[] = []; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json new file mode 100644 index 000000000..90e304cbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json new file mode 100644 index 000000000..6443d170b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json new file mode 100644 index 000000000..f51a29951 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json new file mode 100644 index 000000000..c3e0442f9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts b/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts new file mode 100644 index 000000000..d52b3731d --- /dev/null +++ b/tests/fixtures/npm/cases/2564-chunk-helper-shadowing/main.ts @@ -0,0 +1,7 @@ +// A bundled CommonJS chunk that calls the bundle's top-level `label` while +// a nested function of the chunk declares its own `label`: the chunk +// carries the top-level function along, and each reference keeps its own +// binding. +import { inner, outer } from "gtchunkshadow"; + +console.log(outer(), inner()); diff --git a/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts b/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts new file mode 100644 index 000000000..21ca81b40 --- /dev/null +++ b/tests/fixtures/npm/cases/2565-chunk-shared-function/main.ts @@ -0,0 +1,7 @@ +// Two bundled CommonJS chunks export the bundle's top-level `tally` and +// count calls on a property of it. Node prints "1 2 true": both chunks +// share one function. A per-chunk copy would split its identity and its +// properties, so the package stays refused. +import { counts, same } from "gtchunkshared"; + +console.log(counts(), same()); diff --git a/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts b/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts new file mode 100644 index 000000000..3cca145e0 --- /dev/null +++ b/tests/fixtures/npm/cases/2566-cjs-cycle-indirect-call/main.ts @@ -0,0 +1,7 @@ +// A require cycle whose partner calls an export of index.js with esbuild's +// `(0, import_index.peek)()` spelling while index is still initializing: +// peek reads a const in its temporal dead zone, so Node throws a +// ReferenceError, and scriptc refuses the cycle. +import { show } from "gtcyclecall"; + +console.log(show()); diff --git a/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts b/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts new file mode 100644 index 000000000..ae2be341a --- /dev/null +++ b/tests/fixtures/npm/cases/2571-cjs-cycle-stored-function/main.ts @@ -0,0 +1,8 @@ +// Require cycles whose partner stores an export of index.js (in a const, +// or in an object literal) and calls it while index is still +// initializing: the export reads a const in its temporal dead zone, so +// Node throws a ReferenceError, and scriptc refuses both cycles. +import { show } from "gtcyclealias"; +import { show as showBox } from "gtcyclebox"; + +console.log(show(), showBox()); diff --git a/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts new file mode 100644 index 000000000..238cf1744 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare function outer(): string; +export declare function inner(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js new file mode 100644 index 000000000..0f89c7ec5 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshadow/dist/index.js @@ -0,0 +1,52 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +function label(n) { + return "label " + n; +} + +// chunk/vendor/shadow.js +var require_shadow = __commonJS({ + "chunk/vendor/shadow.js"(exports2, module2) { + function nested() { + var label = "nested"; + return label; + } + module2.exports = { outer: label(1), inner: nested() }; + } +}); + +// chunk/src/index.ts +var index_exports = {}; +__export(index_exports, { + inner: () => inner, + outer: () => outer +}); +module.exports = __toCommonJS(index_exports); +function outer() { + return require_shadow().outer; +} +function inner() { + return require_shadow().inner; +} +0 && (module.exports = { + inner, + outer +}); diff --git a/tests/fixtures/npm/node_modules/gtchunkshadow/package.json b/tests/fixtures/npm/node_modules/gtchunkshadow/package.json new file mode 100644 index 000000000..6830fbd2c --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshadow/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtchunkshadow", + "version": "1.0.0", + "description": "esbuild's CommonJS bundle shape: a chunk calls a top-level function whose name a nested scope of the chunk also declares", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts new file mode 100644 index 000000000..3a47a0495 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare function same(): boolean; +export declare function counts(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js new file mode 100644 index 000000000..0d3aacec8 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshared/dist/index.js @@ -0,0 +1,56 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +function tally() { + tally.calls = (tally.calls || 0) + 1; + return tally.calls; +} + +// chunk/vendor/a.js +var require_a = __commonJS({ + "chunk/vendor/a.js"(exports2, module2) { + module2.exports = { tally, first: tally() }; + } +}); + +// chunk/vendor/b.js +var require_b = __commonJS({ + "chunk/vendor/b.js"(exports2, module2) { + module2.exports = { tally, second: tally() }; + } +}); + +// chunk/src/index.ts +var index_exports = {}; +__export(index_exports, { + counts: () => counts, + same: () => same +}); +module.exports = __toCommonJS(index_exports); +function same() { + return require_a().tally === require_b().tally; +} +function counts() { + return require_a().first + " " + require_b().second; +} +0 && (module.exports = { + counts, + same +}); diff --git a/tests/fixtures/npm/node_modules/gtchunkshared/package.json b/tests/fixtures/npm/node_modules/gtchunkshared/package.json new file mode 100644 index 000000000..bfc90c5d2 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkshared/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtchunkshared", + "version": "1.0.0", + "description": "esbuild's CommonJS bundle shape: two chunks export one top-level function and count calls on a property of it", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts new file mode 100644 index 000000000..5f4373e2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.d.ts @@ -0,0 +1,3 @@ +export declare const LIMIT = 3; +export declare function show(): string; +export declare function peek(): number; diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js new file mode 100644 index 000000000..77aca3c9b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/dist/index.js @@ -0,0 +1,37 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + peek: () => peek, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function peek() { + return LIMIT; +} +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js new file mode 100644 index 000000000..943e61a0a --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/dist/reader.js @@ -0,0 +1,34 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const read = import_index.peek; +const captured = read(); +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclealias/package.json b/tests/fixtures/npm/node_modules/gtcyclealias/package.json new file mode 100644 index 000000000..9ee7441e0 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclealias/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcyclealias", + "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output shape of a cycle whose partner stores an exported function in a const and calls it while that function's const binding is uninitialized", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts new file mode 100644 index 000000000..5f4373e2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.d.ts @@ -0,0 +1,3 @@ +export declare const LIMIT = 3; +export declare function show(): string; +export declare function peek(): number; diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js new file mode 100644 index 000000000..77aca3c9b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/dist/index.js @@ -0,0 +1,37 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + peek: () => peek, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function peek() { + return LIMIT; +} +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js new file mode 100644 index 000000000..b8f8a927e --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/dist/reader.js @@ -0,0 +1,34 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const box = { read: import_index.peek }; +const captured = box.read(); +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclebox/package.json b/tests/fixtures/npm/node_modules/gtcyclebox/package.json new file mode 100644 index 000000000..67f943195 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclebox/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcyclebox", + "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output shape of a cycle whose partner stores an exported function in an object literal and calls it while that function's const binding is uninitialized", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts new file mode 100644 index 000000000..5f4373e2f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.d.ts @@ -0,0 +1,3 @@ +export declare const LIMIT = 3; +export declare function show(): string; +export declare function peek(): number; diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js new file mode 100644 index 000000000..77aca3c9b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/dist/index.js @@ -0,0 +1,37 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var index_exports = {}; +__export(index_exports, { + LIMIT: () => LIMIT, + peek: () => peek, + show: () => show +}); +module.exports = __toCommonJS(index_exports); +var import_reader = require("./reader"); +const LIMIT = 3; +function peek() { + return LIMIT; +} +function show() { + return (0, import_reader.describe)(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + LIMIT, + show +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js b/tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js new file mode 100644 index 000000000..c98d1ae46 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/dist/reader.js @@ -0,0 +1,33 @@ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var reader_exports = {}; +__export(reader_exports, { + captured: () => captured, + describe: () => describe +}); +module.exports = __toCommonJS(reader_exports); +var import_index = require("./index"); +const captured = (0, import_index.peek)(); +function describe() { + return "limit " + captured; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + captured, + describe +}); diff --git a/tests/fixtures/npm/node_modules/gtcyclecall/package.json b/tests/fixtures/npm/node_modules/gtcyclecall/package.json new file mode 100644 index 000000000..cc1069a6f --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcyclecall/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcyclecall", + "version": "1.0.0", + "description": "esbuild 0.25.10 per-file CommonJS output shape of a cycle whose partner calls an exported function through (0, binding.fn)() while that function's const binding is uninitialized", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 35a9df4a6..9ca3c0df0 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,12 +19,12 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469, 2556-2563 and 2567 are the --npm-static bundler-emitted-CJS + // 2465-2469, 2556-2567 and 2571 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. - .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-37])-[^/]+\/main\.ts$/.test(entry)) + .filter((entry) => !/\/(246[5-9]|255[6-9]|256[0-7]|2571)-[^/]+\/main\.ts$/.test(entry)) .map((entry) => ({ name: entry.split("/").at(-2)!, entry, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 443c0c72d..32639e0d1 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -76,7 +76,7 @@ async function buildStatic(entry: string, npmStatic: string[] | "auto"): Promise ...globSync( join(fixturesRoot, "npm/node_modules/cryptozoo/**/*.{js,mjs,cjs,json,d.ts}"), ).sort(), - // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2563, 2567) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2567, 2571) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ]; for (const f of inputs) hash.update(f).update(readFileSync(f)); @@ -1502,6 +1502,61 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 180_000); + // 2566: the same early read through esbuild's `(0, binding.fn)()` call + // spelling also keeps SC1016. + test("a CommonJS cycle that calls an export during initialization stays refused", () => { + const entry = join(fixturesRoot, "npm/cases/2566-cjs-cycle-indirect-call/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtcyclecall"] }); + expect(coverage.npmStatic).toEqual([ + { + package: "gtcyclecall", + status: "refused", + detail: expect.stringMatching( + /circular imports .*can use its binding 'import_index' of a module of the cycle/, + ) as string, + }, + ]); + }, 120_000); + + // 2571: the partner stores the export (in a const, or in an object + // literal) and calls it while the cycle initializes; both keep SC1016. + test("a CommonJS cycle that stores an export and calls it stays refused", () => { + const entry = join(fixturesRoot, "npm/cases/2571-cjs-cycle-stored-function/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtcyclealias", "gtcyclebox"] }); + for (const pkg of ["gtcyclealias", "gtcyclebox"]) + expect(coverage.npmStatic).toContainEqual({ + package: pkg, + status: "refused", + detail: expect.stringMatching( + /circular imports .*can use its binding 'import_index' of a module of the cycle/, + ) as string, + }); + }, 120_000); + + // 2564: a chunk reads a top-level function whose name a nested scope of + // the chunk also declares; the function still travels with the chunk. + test("a chunk carries a top-level function its nested scopes shadow", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2564-chunk-helper-shadowing/main.ts"), [ + "gtchunkshadow", + ]); + }, 180_000); + + // 2565: Node shares one function between the chunks that export it; a + // per-chunk copy would not, so the package stays refused. + test("chunks that export a shared top-level function stay refused", () => { + const entry = join(fixturesRoot, "npm/cases/2565-chunk-shared-function/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtchunkshared"] }); + expect(coverage.npmStatic).toEqual([ + { + package: "gtchunkshared", + status: "refused", + detail: expect.stringMatching( + /uses the bundle's top-level function 'tally' as a value, which its chunks cannot share/, + ) as string, + }, + ]); + }, 120_000); + test("published type-only declarations coexist with a static runtime bundle", async () => { const entry = join(fixturesRoot, "npm/cases/2469-bundle-offender/main.ts"); const { coverage } = analyze(entry, { npmStatic: ["gtghost"] });