diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index c647dfe48..f798ebfc3 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -362,6 +362,35 @@ describe("contextual JavaScript package path resolution", () => { }); }); +describe("generic package entry heuristics", () => { + it.each([ + ['{"main":"actual.cjs","module":"bundler.mjs"}', "actual.cjs"], + ['{"module":"bundler.mjs"}', "bundler.mjs"], + ])( + "preserves generic entry heuristics without a module kind for %s", + (metadata, entry) => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/node_modules/fixture/package.json", "root", metadata), + file("app/node_modules/fixture/actual.cjs", "root"), + file("app/node_modules/fixture/bundler.mjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/consumer.js", + context: "module-specifier", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: `app/node_modules/fixture/${entry}`, + }); + }, + ); +}); + describe("directory package entrypoint precedence", () => { it.each([ ["require", '{"main":"actual.cjs"}', "actual.cjs"], diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 143d4503a..dca7ac8e7 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -461,10 +461,12 @@ const packageEntry = ( ? { ...exported, source: "exports" } : exported; } - const preferred = - moduleKind === "import" - ? [Reflect.get(value, "module"), Reflect.get(value, "main")] - : [Reflect.get(value, "main"), Reflect.get(value, "module")]; + // Node imports and requires ignore the bundler-only module field. Keep + // that fallback for callers without an explicit module-loading kind. + const preferred = [ + Reflect.get(value, "main"), + ...(moduleKind === undefined ? [Reflect.get(value, "module")] : []), + ]; const entry = preferred.find((candidate) => candidate !== undefined); if (entry === undefined) return { status: "missing" }; const legacy = packagePathValue(entry); diff --git a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts index 14c056166..4c730fb73 100644 --- a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts +++ b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts @@ -1,7 +1,11 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + import { expect, it } from "vitest"; import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; @@ -160,3 +164,108 @@ it.each(cases)( expect(result.graph.coverage.status).toBe("complete"); }, ); + +const runNode = promisify(execFile); + +it.each([ + { + name: "import uses main before bundler module", + kind: "import", + metadata: { main: "actual.cjs", module: "bundler.mjs" }, + }, + { + name: "import ignores a module-only entry", + kind: "import", + metadata: { module: "bundler.mjs" }, + }, + { + name: "require ignores a module-only entry", + kind: "require", + metadata: { module: "bundler.mjs" }, + }, + { + name: "import preserves a main-only entry", + kind: "import", + metadata: { main: "actual.cjs" }, + }, + { + name: "require preserves main before bundler module", + kind: "require", + metadata: { main: "actual.cjs", module: "bundler.mjs" }, + }, + { + name: "import preserves declared exports before legacy entries", + kind: "import", + metadata: { + main: "actual.cjs", + module: "bundler.mjs", + exports: { import: "./exported.mjs", require: "./actual.cjs" }, + }, + }, +] as const)( + "matches the native Node loader when $name", + async ({ kind, metadata }) => { + const root = await createTestTempDirectory("rea-node-package-fields-"); + const entry = kind === "import" ? "main.mjs" : "main.cjs"; + const source = + kind === "import" + ? `import value from "fixture"; console.log(JSON.stringify({value, resolved: import.meta.resolve("fixture")}));` + : `const value = require("fixture"); console.log(JSON.stringify({value, resolved: require.resolve("fixture")}));`; + const files = { + "package.json": JSON.stringify({ + name: "app", + main: entry, + type: kind === "import" ? "module" : "commonjs", + }), + [entry]: source, + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + ...metadata, + }), + "node_modules/fixture/actual.cjs": "module.exports = 'actual.cjs';", + "node_modules/fixture/index.js": "module.exports = 'index.js';", + "node_modules/fixture/bundler.mjs": "export default 'bundler.mjs';", + "node_modules/fixture/exported.mjs": "export default 'exported.mjs';", + }; + await Promise.all( + Object.entries(files).map(async ([path, text]) => { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), text); + }), + ); + const { stdout } = await runNode(process.execPath, [join(root, entry)], { + cwd: root, + }); + const oracle: unknown = JSON.parse(stdout); + expect(oracle).toMatchObject({ + value: expect.any(String), + resolved: expect.any(String), + }); + if (typeof oracle !== "object" || oracle === null) + throw new Error("Node returned no package identity"); + const resolved: unknown = Reflect.get(oracle, "resolved"); + if (typeof resolved !== "string") + throw new Error("Node returned no resolved path"); + const expected = relative( + await realpath(root), + kind === "import" ? fileURLToPath(resolved) : resolved, + ) + .split(sep) + .join("/"); + expect(Reflect.get(oracle, "value")).toBe(expected.split("/").at(-1)); + const result = await reconstructJavaScriptArtifact({ + input_path: root, + format: "directory", + }); + const edge = result.graph.edges.find( + ({ properties }) => + properties.module_link_kind === kind && + properties.specifier === "fixture", + ); + expect(edge?.properties).toMatchObject({ + resolution_status: "resolved", + resolved_path: expected, + }); + expect(result.graph.coverage.status).toBe("complete"); + }, +);