diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9e2d3524e..f20c63b1d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5882,6 +5882,9 @@ export class LlEmitter { // for stdin. They remain JavaScript objects in boolean contexts. case "procStream": return "true"; + // A module namespace (a required builtin module object) is an object. + case "moduleNs": + return "true"; case "array": case "record": case "object": diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 36abb9130..4a6fb15a3 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -30,6 +30,7 @@ import { import { cjsExportMemberWriteGlobalOf, isCjsExportsIdentifier, + isCjsModuleExports, isFreshExportsReset, } from "./cjs-exports-object.js"; import { isDeadTscHelperDecl, tscInteropBindingOfDecl } from "./cjs-tsc-interop.js"; @@ -6627,6 +6628,25 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm "require() outside the module's top level (move it to the top of the file)", ); } + // Replacing the export object (or rebinding `exports`) anywhere but a + // top-level statement makes the module's exports depend on which path + // ran (`cond ? module.exports = a : module.exports = b`, an + // environment-dependent branch); the static export table has one shape. + if ( + !topLevelJs && + isJsSourceFile(expr.getSourceFile()) && + ts.isBinaryExpression(expr) && + expr.operatorToken.kind === ts.SyntaxKind.EqualsToken && + (isCjsModuleExports(lowerer, expr.left) || isCjsExportsIdentifier(lowerer, expr.left)) + ) { + lowerer.unsupported( + "SC1090", + expr.left, + isCjsModuleExports(lowerer, expr.left) + ? "replacing module.exports outside a top-level statement (a conditional or environment-dependent export object)" + : "rebinding 'exports' outside a top-level statement (a conditional or environment-dependent export object)", + ); + } if (topLevelJs && ts.isExpressionStatement(stmtNode)) { if (isFreshExportsReset(lowerer, stmtNode)) return { kind: "block", body: [], loc: locOf(expr) }; 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 f9463ea1e..1d8e39a73 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.test.ts @@ -274,3 +274,21 @@ export const g = import_g.g; degrade: expect.stringContaining("bundled CommonJS modules") as string, }); }); + +test("a chunk that only re-exports a builtin module becomes that module", () => { + const answer = expectSameBehavior(`${keyedHelper}var require_stream = __commonJS({ + "lib/stream.js"(exports2, module2) { + module2.exports = require("stream"); + } +}); +var require_user = __commonJS({ + "lib/user.js"(exports2) { + var Stream = require_stream(); + exports2.same = Stream === require("stream"); + } +}); +console.log(typeof require_stream().PassThrough, require_user().same); +`); + expect(answer.chunks.map((c) => basename(c.path))).toEqual(["index.js.require_user.cjs"]); + expect(answer.text).toContain('typeof require("stream").PassThrough'); +}); diff --git a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts index 2a3ad0b53..8a6f681a3 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-chunks.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-chunks.ts @@ -23,6 +23,7 @@ * beside it). Anything else answers a degrade reason. */ import * as ts from "./ts7/syntax.js"; import { helperTokens } from "./helper-tokens.js"; +import { canonicalBuiltinModule } from "./builtin-modules.js"; export interface BundledChunkSplit { /** The bundle with chunk declarations blanked and calls respelled. */ @@ -581,10 +582,11 @@ export function splitBundledChunks( return { degrade: `its bundled CommonJS module factory '${r.text}' is used other than as a direct call`, }; + const forwarded = forwarderSpec(chunk); callEdits.push({ start: call.getStart(sf), end: call.getEnd(), - text: `require(${JSON.stringify(chunk.spec)})`, + text: `require(${JSON.stringify(forwarded ?? chunk.spec)})`, }); } for (const name of chunks.keys()) { @@ -596,6 +598,11 @@ export function splitBundledChunks( const out: BundledChunkSplit["chunks"] = []; const bundleEdits: Edit[] = [...callEdits]; for (const chunk of chunks.values()) { + if (forwarderSpec(chunk) !== null) { + // A chunk that only re-exports a builtin module is that module. + bundleEdits.push({ start: chunk.stmt.getStart(sf), end: chunk.stmt.getEnd(), text: "" }); + continue; + } const result = chunkSource( sf, chunk, @@ -656,6 +663,41 @@ export function applyEdits(source: string, from: number, to: number, edits: Edit return text + source.slice(at, to); } +/** The builtin module a chunk forwards, or null: a body that is exactly + * `module.exports = require("")` (readable-stream's + * internal/streams/stream.js). Requiring the chunk is then requiring the + * builtin itself (same object, and loading a builtin runs no code). */ +function forwarderSpec(chunk: Chunk): string | null { + const moduleParam = chunk.params[1]; + if (moduleParam === undefined) return null; + const stmts = chunk.body.statements.nodes.filter( + (st) => !(ts.isExpressionStatement(st) && ts.isStringLiteral(st.expression)), + ); + const only = stmts[0]; + if (stmts.length !== 1 || only === undefined || !ts.isExpressionStatement(only)) return null; + const e = unparen(only.expression); + if ( + !ts.isBinaryExpression(e) || + e.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isPropertyAccessExpression(e.left) || + !ts.isIdentifier(e.left.expression) || + e.left.expression.text !== moduleParam || + e.left.name.text !== "exports" + ) + return null; + const call = unparen(e.right); + if ( + !ts.isCallExpression(call) || + !ts.isIdentifier(call.expression) || + call.expression.text !== "require" || + call.arguments.nodes.length !== 1 || + !ts.isStringLiteral(call.arguments.nodes[0]!) + ) + return null; + const spec = (call.arguments.nodes[0] as ts.StringLiteral).text; + return canonicalBuiltinModule(spec) !== null ? spec : null; +} + /** How code in a factory body (a chunk or a lazy ES-module initializer) * observes the factory's own frame, or null: its receiver, its arguments * object, `super`, a meta property, or (when `returns`) a top-level diff --git a/packages/compiler/src/frontend/npm-static-cjs-exports-alias.test.ts b/packages/compiler/src/frontend/npm-static-cjs-exports-alias.test.ts new file mode 100644 index 000000000..c3704ea29 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-cjs-exports-alias.test.ts @@ -0,0 +1,81 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function rewrite(source: string): string | null { + return nodeFrontendServices().cjsFunctionExports(source, "/pkg/index.js"); +} + +function run(file: string) { + const result = spawnSync(process.execPath, [file], { encoding: "utf8" }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +/** The rewritten module prints what the original prints. */ +function expectSameBehavior(source: string): string { + const result = rewrite(source); + expect(typeof result).toBe("string"); + const dir = mkdtempSync(join(tmpdir(), "scriptc-cjs-function-exports-")); + dirs.push(dir); + const original = join(dir, "original.cjs"); + const rewritten = join(dir, "rewritten.cjs"); + writeFileSync(original, source); + writeFileSync(rewritten, result as string); + const oracle = run(original); + expect(oracle.status).toBe(0); + expect(run(rewritten)).toEqual(oracle); + return result as string; +} + +const report = ` +console.log(typeof module.exports, module.exports === Version, Object.keys(Version).join(",")); +`; + +test("members of a function export object read and write the function", () => { + const rewritten = expectSameBehavior(`exports = module.exports = Version; +exports.parse = parse; +module.exports.major = (v) => parse(v)[0]; +function Version(v) { return { v }; } +function parse(v) { return v.split(".").map(Number); } +console.log(exports.major("2.1.0"), module.exports.parse("1.2").length); +${report}`); + expect(rewritten).toContain("Version.parse = parse"); + expect(rewritten).not.toContain("exports = module.exports"); +}); + +test.each([ + ["a parameter", "function inner(Version) { return exports.parse; }"], + ["a local", "function inner() { const Version = {}; return exports.parse; }"], + ["a nested function", "function inner() { function Version() {} return exports.parse; }"], + [ + "a catch binding", + "function inner() { try { throw 0; } catch (Version) { return exports.parse; } }", + ], +])("a nested binding of the function's name (%s) keeps the module as it is", (_, inner) => { + expect( + rewrite(`exports = module.exports = Version; +exports.parse = parse; +function Version() {} +function parse() {} +${inner} +`), + ).toBeNull(); +}); + +test("an `exports` shorthand property keeps its key", () => { + const rewritten = expectSameBehavior(`exports = module.exports = Version; +exports.tag = "v"; +function Version() {} +const holder = { exports }; +console.log(Object.keys(holder).join(","), holder.exports === Version, holder.exports.tag); +${report}`); + expect(rewritten).toContain("{ exports: Version }"); +}); diff --git a/packages/compiler/src/frontend/npm-static-cjs-exports-alias.ts b/packages/compiler/src/frontend/npm-static-cjs-exports-alias.ts new file mode 100644 index 000000000..dfe45b862 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-cjs-exports-alias.ts @@ -0,0 +1,191 @@ +/* CommonJS modules whose export object is one of their own functions: + * + * exports = module.exports = SemVer; // or module.exports = SemVer; + * exports.parse = parse; // or module.exports.parse = … + * function SemVer(version) { … } + * + * After that statement `module.exports` (and, after the rebinding form, + * `exports`) IS the function, so every member read or write through them + * is a member of the function itself. The pass respells those accesses + * through the function's name, the expando form both resolvers model + * (members on a function declaration), and drops the `exports =` + * rebinding the static export protocol has no storage for. + * + * Exact only while nothing can observe the module before the statement + * runs and nothing replaces the export object again: the statement is a + * top-level statement preceded only by inert declarations, it is the + * file's only write of `module.exports` and `exports`, the function is a + * top-level declaration nothing reassigns and the file's only binding of + * its name, and `exports`/`module` are the CommonJS globals (never + * declared in the file). Other shapes answer + * null and keep their existing treatment. */ +import * as ts from "./ts7/syntax.js"; +import { + applyEdits, + countDeclarations, + declaredNames, + isWritten, + references, + unparen, + type Edit, +} from "./npm-static-bundled-chunks.js"; + +function isModuleExports(e: ts.Expression): e is ts.PropertyAccessExpression { + const x = unparen(e); + return ( + ts.isPropertyAccessExpression(x) && + !x.questionDotToken && + ts.isIdentifier(x.expression) && + x.expression.text === "module" && + x.name.text === "exports" + ); +} + +/** True for a statement whose evaluation runs no code that could observe + * the export object: declarations and member writes (`F.prototype.m = + * function …`) without calls, and directives. */ +function inert(stmt: ts.Statement): boolean { + if (ts.isFunctionDeclaration(stmt) || ts.isEmptyStatement(stmt)) return true; + if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) return true; + if (ts.isExpressionStatement(stmt)) { + const e = unparen(stmt.expression); + if ( + !ts.isBinaryExpression(e) || + e.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !(ts.isPropertyAccessExpression(e.left) || ts.isElementAccessExpression(e.left)) + ) + return false; + let root: ts.Expression = e.left; + while (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) + root = root.expression; + if (!ts.isIdentifier(root) || root.text === "module" || root.text === "exports") return false; + } else if (!ts.isVariableStatement(stmt)) return false; + let calls = false; + ts.walkPreorder(stmt, (n) => { + if (calls) return "stop"; + if (ts.isArrowFunction(n) || ts.isFunctionExpression(n)) return "skip"; + if (ts.isCallExpression(n) || ts.isNewExpression(n) || ts.isTaggedTemplateExpression(n)) + calls = true; + return undefined; + }); + return !calls; +} + +export function normalizeCjsFunctionExports(sf: ts.SourceFile): string | null { + const source = sf.text; + if (!source.includes("module.exports")) return null; + if (!sf.fileName.endsWith(".cjs") && ts.isExternalModule(sf)) return null; + const declared = declaredNames(sf); + if (declared.has("exports") || declared.has("module")) return null; + + // The whole-export statement: `[exports =] module.exports = W;`. + let whole: { stmt: ts.ExpressionStatement; fn: string; rebinds: boolean } | null = null; + const stmts = sf.statements.nodes; + for (let i = 0; i < stmts.length; i++) { + const stmt = stmts[i]!; + if (!ts.isExpressionStatement(stmt)) continue; + const top = unparen(stmt.expression); + if (!ts.isBinaryExpression(top) || top.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + continue; + let e: ts.BinaryExpression = top; + let rebinds = false; + if (ts.isIdentifier(top.left) && top.left.text === "exports") { + const inner = unparen(top.right); + if (!ts.isBinaryExpression(inner) || inner.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + continue; + rebinds = true; + e = inner; + } + if (!isModuleExports(e.left)) continue; + const value = unparen(e.right); + if (!ts.isIdentifier(value)) return null; + if (whole !== null || !stmts.slice(0, i).every(inert)) return null; + whole = { stmt, fn: value.text, rebinds }; + } + if (whole === null) return null; + const fnName = whole.fn; + const fnDecls = stmts.filter( + (st) => ts.isFunctionDeclaration(st) && st.name?.text === fnName && st.body !== undefined, + ); + // The respelled reads name the function from wherever they sit, so no + // other binding of its name (a parameter, a local, a nested function) + // may exist anywhere in the file. + if (fnDecls.length !== 1 || countDeclarations(sf, fnName) !== 1) return null; + + const edits: Edit[] = []; + let unsafe = false; + const refs = references(sf); + for (const r of refs) { + if (r.text === fnName && isWritten(r)) unsafe = true; + if (r.text === "exports") { + if (isWritten(r)) { + // Only the whole statement's own rebinding. + const assign = r.parent; + if ( + !whole.rebinds || + assign === undefined || + !ts.isBinaryExpression(assign) || + assign.parent !== whole.stmt || + assign.left !== r + ) + unsafe = true; + continue; + } + // Without the rebinding, `exports` keeps the replaced object. + if (!whole.rebinds || r.getStart(sf) < whole.stmt.getEnd()) { + unsafe = true; + continue; + } + // `{ exports }` keeps its key: the shorthand spells out the value. + const shorthand = + r.parent !== undefined && ts.isShorthandPropertyAssignment(r.parent) && r.parent.name === r; + edits.push({ + start: r.getStart(sf), + end: r.getEnd(), + text: shorthand ? `exports: ${fnName}` : fnName, + }); + continue; + } + if (r.text === "module") { + const access = r.parent; + if (access === undefined || !isModuleExports(access as ts.Expression)) continue; + const outer = access.parent; + if (outer !== undefined && ts.isBinaryExpression(outer) && outer.left === access) { + if ( + outer.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + outer.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + !( + (outer.parent === whole.stmt && !whole.rebinds) || + (whole.rebinds && outer.parent?.parent === whole.stmt) + ) + ) + unsafe = true; + continue; + } + // A member access through module.exports: a member of the function. + if ( + outer !== undefined && + (ts.isPropertyAccessExpression(outer) || ts.isElementAccessExpression(outer)) && + outer.expression === access + ) { + if (access.getStart(sf) < whole.stmt.getEnd() && !insideFunction(access)) unsafe = true; + edits.push({ start: access.getStart(sf), end: access.getEnd(), text: fnName }); + } + } + } + if (unsafe) return null; + if (whole.rebinds) { + // `exports = module.exports = W` → `module.exports = W`. + const e = unparen(whole.stmt.expression) as ts.BinaryExpression; + edits.push({ start: e.getStart(sf), end: unparen(e.right).getStart(sf), text: "" }); + } + if (edits.length === 0) return null; + return applyEdits(source, 0, source.length, edits); +} + +function insideFunction(node: ts.Node): boolean { + for (let p = node.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) { + if (ts.isFunctionLike(p)) return true; + } + return false; +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index e00122238..182c5b62b 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -448,7 +448,11 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow } else if (esm !== null) { original = esm; } - const respelled = split !== null || esm !== null; + // A module exporting one of its functions reaches its members + // through that function. + const fnExports = original === null ? null : services.cjsFunctionExports(original, path); + if (fnExports !== null) original = fnExports; + const respelled = split !== null || esm !== null || fnExports !== null; if (original !== null) { const namepaths = services.jsDocNamepaths(path, original); const source = namepaths ?? original; diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index c5dcbf513..2e3d686ab 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -2,6 +2,7 @@ import { Ts7SourceParser, type Ts7SourceKind } from "./ts7/source-parser.js"; import { Ts7Host, type Ts7ApiFactory, type Ts7HostOptions } from "./ts7/program-host.js"; import { lowerEsmInitsWithParser, rewriteBundlerCjsWithParser } from "./npm-static-rewrite-host.js"; import { isBundlerCjsCandidate } from "./npm-static-rewrite-syntax.js"; +import { normalizeCjsFunctionExports } from "./npm-static-cjs-exports-alias.js"; import { mayBundleChunks, splitBundledChunks, @@ -91,6 +92,13 @@ export class FrontendServices { if (!source.includes("__esm")) return null; return lowerEsmInitsWithParser(this.sourceParser(), source, path); } + /** A CommonJS module whose export object is one of its functions, with + * its member accesses respelled through that function (null: none). */ + cjsFunctionExports(source: string, path: string): string | null { + this.ensureOpen(); + if (!source.includes("module.exports")) return null; + return normalizeCjsFunctionExports(this.parse(path, source, "js")); + } nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null { return syntax.applyNpmStaticNullableClassFields(this.parse(path, source, "js"), source); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/required-module-truthiness.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/required-module-truthiness.cjs.json new file mode 100644 index 000000000..101bb2d57 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/required-module-truthiness.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/required-module-truthiness.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts.json new file mode 100644 index 000000000..caf2144ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts.json new file mode 100644 index 000000000..e255c754a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/required-module-truthiness.cjs b/tests/corpus/required-module-truthiness.cjs new file mode 100644 index 000000000..626d1cd4d --- /dev/null +++ b/tests/corpus/required-module-truthiness.cjs @@ -0,0 +1,10 @@ +// A required builtin module object in a boolean context is truthy, as any +// object: readable-stream's `process.env.READABLE_STREAM === "disable" && +// Stream` and `Stream || exports` guards. +"use strict"; +var Stream = require("stream"); +var os = require("os"); +console.log(Stream ? "stream" : "none", !!os, !os); +console.log(process.env.SCRIPTC_UNSET_FLAG === "disable" && Stream ? "disabled" : "default"); +var fallback = Stream || null; +console.log(fallback === Stream); diff --git a/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts b/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts new file mode 100644 index 000000000..29e65cbfc --- /dev/null +++ b/tests/fixtures/npm/cases/2583-cjs-function-exports/main.ts @@ -0,0 +1,12 @@ +// CommonJS modules whose export object is one of their own functions: +// semver 5/6's `exports = module.exports = Version` with members attached +// through both `exports` and `module.exports` (and read back through +// `exports` inside the module), and a constructor function exported whole +// with function and value members attached afterwards. +import Version from "gtfnexports"; +import Point from "gtfnmembers"; + +const v = Version.parse("2.4.1"); +console.log(v === null ? "null" : v.major(), String(new Version("3.0.0"))); +console.log(Version.compare("5.0.0", "3.2.0"), Version.gt("1.0.0", "2.0.0"), Version.MAX_LENGTH); +console.log(Point.origin().sum(), new Point(2, 3).sum(), Point.dimensions); diff --git a/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts b/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts new file mode 100644 index 000000000..2c117e914 --- /dev/null +++ b/tests/fixtures/npm/cases/2584-cjs-conditional-exports/main.ts @@ -0,0 +1,7 @@ +// An environment-dependent export object (readable-stream 2.x's +// READABLE_STREAM=disable switch): module.exports is replaced inside an +// if/else, so the module's exports depend on which branch ran. scriptc +// names the construct; Node picks the branch at load. +import rs from "gtcondexports"; + +console.log(rs.Readable.name); diff --git a/tests/fixtures/npm/node_modules/gtcondexports/index.d.ts b/tests/fixtures/npm/node_modules/gtcondexports/index.d.ts new file mode 100644 index 000000000..ba94e150b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcondexports/index.d.ts @@ -0,0 +1,2 @@ +declare const exported: { Readable: Function; Stream: unknown; name: string }; +export = exported; diff --git a/tests/fixtures/npm/node_modules/gtcondexports/index.js b/tests/fixtures/npm/node_modules/gtcondexports/index.js new file mode 100644 index 000000000..b4017d055 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcondexports/index.js @@ -0,0 +1,14 @@ +var Stream = require("stream"); +function Local(name) { + this.name = name; +} +if (process.env.GT_STREAMS === "native" && Stream) { + module.exports = Stream; + exports = module.exports = Stream.Readable; + exports.Readable = Stream.Readable; + exports.Stream = Stream; +} else { + exports = module.exports = Local; + exports.Stream = Stream || exports; + exports.Readable = exports; +} diff --git a/tests/fixtures/npm/node_modules/gtcondexports/package.json b/tests/fixtures/npm/node_modules/gtcondexports/package.json new file mode 100644 index 000000000..eab4647ed --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtcondexports/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtcondexports", + "version": "1.0.0", + "description": "readable-stream 2.x's environment-dependent export object: module.exports is replaced (and exports rebound) inside an if/else on an environment variable", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtfnexports/index.d.ts b/tests/fixtures/npm/node_modules/gtfnexports/index.d.ts new file mode 100644 index 000000000..c2ee082d3 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtfnexports/index.d.ts @@ -0,0 +1,13 @@ +declare class Version { + constructor(raw: string); + raw: string; + major(): number; + toString(): string; +} +declare namespace Version { + const MAX_LENGTH: number; + function parse(raw: string): Version | null; + function compare(a: string, b: string): number; + function gt(a: string, b: string): boolean; +} +export = Version; diff --git a/tests/fixtures/npm/node_modules/gtfnexports/index.js b/tests/fixtures/npm/node_modules/gtfnexports/index.js new file mode 100644 index 000000000..d64b58f0c --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtfnexports/index.js @@ -0,0 +1,25 @@ +exports = module.exports = Version; + +var label = process.env.GT_LABEL ? "[" + process.env.GT_LABEL + "]" : ""; + +function Version(raw) { + this.raw = raw; + this.parts = raw.split("."); +} +Version.prototype.major = function () { + return Number(this.parts[0]); +}; +Version.prototype.toString = function () { + return label + this.raw; +}; + +exports.MAX_LENGTH = 256; +exports.parse = function parse(raw) { + return raw.length > exports.MAX_LENGTH ? null : new Version(raw); +}; +exports.compare = function compare(a, b) { + return exports.parse(a).major() - exports.parse(b).major(); +}; +module.exports.gt = function gt(a, b) { + return exports.compare(a, b) > 0; +}; diff --git a/tests/fixtures/npm/node_modules/gtfnexports/package.json b/tests/fixtures/npm/node_modules/gtfnexports/package.json new file mode 100644 index 000000000..4524cd1bc --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtfnexports/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtfnexports", + "version": "1.0.0", + "description": "semver 5/6's export idiom: exports = module.exports = Version, then members attached through exports and module.exports", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/fixtures/npm/node_modules/gtfnmembers/index.d.ts b/tests/fixtures/npm/node_modules/gtfnmembers/index.d.ts new file mode 100644 index 000000000..ea1888e0b --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtfnmembers/index.d.ts @@ -0,0 +1,11 @@ +declare class Point { + constructor(x: number, y: number); + x: number; + y: number; + sum(): number; +} +declare namespace Point { + function origin(): Point; + const dimensions: number; +} +export = Point; diff --git a/tests/fixtures/npm/node_modules/gtfnmembers/index.js b/tests/fixtures/npm/node_modules/gtfnmembers/index.js new file mode 100644 index 000000000..6eaf003f7 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtfnmembers/index.js @@ -0,0 +1,13 @@ +"use strict"; +function Point(x, y) { + this.x = x; + this.y = y; +} +Point.prototype.sum = function () { + return this.x + this.y; +}; +module.exports = Point; +module.exports.origin = function origin() { + return new Point(0, 0); +}; +module.exports.dimensions = 2; diff --git a/tests/fixtures/npm/node_modules/gtfnmembers/package.json b/tests/fixtures/npm/node_modules/gtfnmembers/package.json new file mode 100644 index 000000000..5179c6caf --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtfnmembers/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtfnmembers", + "version": "1.0.0", + "description": "a constructor function as module.exports with function and value members attached through module.exports afterwards", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index ef46a59de..01718abf9 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,13 +19,13 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469, 2556-2567, 2571 and 2580-2582 are the --npm-static bundler-emitted-CJS + // 2465-2469, 2556-2567, 2571 and 2580-2584 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-7]|2571|258[0-2])-[^/]+\/main\.ts$/.test(entry), + (entry) => !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-4])-[^/]+\/main\.ts$/.test(entry), ) .map((entry) => ({ name: entry.split("/").at(-2)!, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index d48f9aea2..c16567f47 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-2567, 2571, 2580-2582) + // the bundler-emitted-CJS mini packages (cases 2465-2469, 2556-2567, 2571, 2580-2584) ...globSync(join(fixturesRoot, "npm/node_modules/gt*/**/*.{js,cjs,json}")).sort(), ...globSync(join(fixturesRoot, "npm/store/gt*/**/*.{js,cjs,json}")).sort(), ]; @@ -1514,6 +1514,32 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(nativeRes.exitCode).toBe(nodeRes.exitCode); }, 180_000); + // 2583: a module whose export object is one of its functions (semver + // 5/6's `exports = module.exports = Version`), under both values of the + // environment variable the module reads. + test("function export objects 2583-cjs-function-exports compile statically and byte-match Node", async () => { + const entry = join(fixturesRoot, "npm/cases/2583-cjs-function-exports/main.ts"); + await expectNodeParity(entry, ["gtfnexports", "gtfnmembers"]); + const binary = await buildStatic(entry, ["gtfnexports", "gtfnmembers"]); + const env = { ...process.env, GT_LABEL: "x" }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry], env), + runBinary(binary, [], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toContain("[x]"); + }, 180_000); + + // 2584: an export object replaced inside an environment-dependent branch + // (readable-stream 2.x) is named precisely. + test("an environment-dependent export object is refused by name", () => { + const entry = join(fixturesRoot, "npm/cases/2584-cjs-conditional-exports/main.ts"); + const { coverage } = analyze(entry, { npmStatic: ["gtcondexports"] }); + expect(JSON.stringify(coverage)).toContain( + "replacing module.exports outside a top-level statement (a conditional or environment-dependent export object)", + ); + }, 120_000); + // 2567: an export that reads `this`, called through its namespace, sees // Node's module object; the lowered namespace would differ, so the // package stays refused.