diff --git a/src/application/JavaScriptArtifactPathResolution.test.ts b/src/application/JavaScriptArtifactPathResolution.test.ts index 63d083fe6..d65b325de 100644 --- a/src/application/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/JavaScriptArtifactPathResolution.test.ts @@ -326,6 +326,149 @@ describe("contextual JavaScript package path resolution", () => { }); }); +describe("directory package entrypoint precedence", () => { + it.each([ + ["require", '{"main":"actual.cjs"}', "actual.cjs"], + ["require", '{"exports":{"require":"./actual.cjs"}}', "actual.cjs"], + ["import", '{"exports":{"import":"./actual.mjs"}}', "actual.mjs"], + ] as const)( + "prefers declared %s entrypoints over index files", + (moduleKind, metadata, entry) => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/node_modules/fixture/package.json", "root", metadata), + file(`app/node_modules/fixture/${entry}`, "root"), + file("app/node_modules/fixture/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind, + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: `app/node_modules/fixture/${entry}`, + }); + }, + ); + + it.each(["{}", '{"main":"."}'])( + "preserves index fallback for package metadata %s", + (metadata) => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/entry/package.json", "root", metadata), + file("app/entry/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./entry", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/entry/index.js", + }); + }, + ); + + it.each([null, "{"])( + "keeps unreadable or invalid metadata explicit even when an index exists", + (metadata) => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/entry/package.json", "root", metadata), + file("app/entry/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./entry", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + }, + ); + + it("does not replace a missing exports target with an index", () => { + const files = fileMap([ + file("app/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + '{"exports":"./missing.js"}', + ), + file("app/node_modules/fixture/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + }); + }); + + it("keeps direct files ahead of a relative directory package", () => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/entry.js", "root"), + file("app/entry/package.json", "root", '{"main":"actual.js"}'), + file("app/entry/actual.js", "root"), + file("app/entry/index.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./entry", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/entry.js", + }); + }); + + it("retains cycle diagnostics when directory packages refer to each other", () => { + const files = fileMap([ + file("app/consumer.js", "root"), + file("app/a/package.json", "root", '{"main":"../b"}'), + file("app/b/package.json", "root", '{"main":"../a"}'), + ]); + expect( + resolve({ + declaredPath: "./a", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: [expect.stringContaining("cycle")], + }); + }); +}); + describe("contextual JavaScript module identity", () => { it("retains CommonJS and ESM file-base identity during inert extraction", () => { const analysis = analyzeJavaScriptStaticSource( diff --git a/src/application/JavaScriptArtifactPathResolution.ts b/src/application/JavaScriptArtifactPathResolution.ts index e981df814..c30c04fe7 100644 --- a/src/application/JavaScriptArtifactPathResolution.ts +++ b/src/application/JavaScriptArtifactPathResolution.ts @@ -78,6 +78,7 @@ export const resolveArtifactPathByContext = ( const resolvePackagePath = ( input: ResolveArtifactPathInput, packageChain: ReadonlySet, + mode: "full" | "files-and-index" = "full", ): ArtifactPathResolution => { const rejected = rejectDeclaration(input); if (rejected !== null) return rejected; @@ -85,7 +86,13 @@ const resolvePackagePath = ( if (typeof candidate !== "string") return candidate; const confined = confineCandidate(input, candidate); if (typeof confined !== "string") return confined; - const resolved = resolveCandidate(input, confined, packageChain); + const resolved = + mode === "files-and-index" + ? (resolveFileCandidates(input, [ + ...fileCandidates(confined), + ...indexCandidates(confined), + ]) ?? notFoundCandidate()) + : resolveCandidate(input, confined, packageChain); return outcome(input, resolved); }; @@ -182,16 +189,18 @@ const hasContainerCandidate = ( candidate: string, containerSha256: string | undefined, ): boolean => - [...directCandidates(candidate), posix.join(candidate, "package.json")].some( - (path) => { - const file = files.get(path); - return ( - file !== undefined && - (containerSha256 === undefined || - file.container_sha256 === containerSha256) - ); - }, - ); + [ + ...fileCandidates(candidate), + ...indexCandidates(candidate), + posix.join(candidate, "package.json"), + ].some((path) => { + const file = files.get(path); + return ( + file !== undefined && + (containerSha256 === undefined || + file.container_sha256 === containerSha256) + ); + }); const htmlCandidate = ( input: ResolveArtifactPathInput, @@ -237,16 +246,9 @@ const resolveCandidate = ( candidate: string, packageChain: ReadonlySet, ): CandidateResolution => { + const direct = resolveFileCandidates(input, fileCandidates(candidate)); + if (direct !== null) return direct; const source = input.files.get(input.sourcePath); - for (const path of directCandidates(candidate)) { - const target = input.files.get(path); - if ( - target !== undefined && - (source === undefined || - target.container_sha256 === source.container_sha256) - ) - return { resolvedPath: path, status: "resolved", limitations: [] }; - } const packagePath = posix.join(candidate, "package.json"); const packageFile = input.files.get(packagePath); if ( @@ -254,7 +256,10 @@ const resolveCandidate = ( (source !== undefined && packageFile.container_sha256 !== source.container_sha256) ) - return notFoundCandidate(); + return ( + resolveFileCandidates(input, indexCandidates(candidate)) ?? + notFoundCandidate() + ); if (!packageFile.text.included) return { resolvedPath: null, @@ -263,15 +268,14 @@ const resolveCandidate = ( `Directory package metadata ${packagePath} was inventoried but its text is unavailable: ${packageFile.text.reason}.`, ], }; - if (packageChain.has(packagePath)) - return { - resolvedPath: null, - status: "unavailable", - limitations: [ - `Directory package entrypoint cycle includes ${packagePath}.`, - ], - }; - const main = packageEntry(packageFile.text.value, input.moduleKind); + const main = packageEntry( + packageFile.text.value, + input.moduleKind, + input.context === "module-specifier" && + !input.declaredPath.startsWith(".") && + !input.declaredPath.startsWith("/") && + !hasScheme(input.declaredPath), + ); if (main.status === "invalid") return { resolvedPath: null, @@ -280,17 +284,41 @@ const resolveCandidate = ( `Directory package metadata ${packagePath} is not valid package JSON.`, ], }; - if (main.status === "missing") return notFoundCandidate(); - const nested = resolvePackagePath( - { - declaredPath: main.value, - sourcePath: packagePath, - context: "package-entrypoint", - files: input.files, - }, - new Set([...packageChain, packagePath]), - ); - return nested.resolution_status === "resolved" + if (packageChain.has(packagePath)) { + return { + resolvedPath: null, + status: "unavailable", + limitations: [ + `Directory package entrypoint cycle includes ${packagePath}.`, + ], + }; + } + if (main.status === "missing") + return ( + resolveFileCandidates(input, indexCandidates(candidate)) ?? + notFoundCandidate() + ); + const entryInput: ResolveArtifactPathInput = { + declaredPath: main.value, + sourcePath: packagePath, + context: "package-entrypoint", + files: input.files, + }; + const chain = new Set([...packageChain, packagePath]); + if (main.source === "legacy") { + const entry = resolvePackagePath(entryInput, chain, "files-and-index"); + if (entry.resolution_status !== "not-found") return candidateOutcome(entry); + const index = resolveFileCandidates(input, indexCandidates(candidate)); + if (index !== null) return index; + } + // Preserve recursive artifact package lookup only after legacy file/index fallbacks. + return candidateOutcome(resolvePackagePath(entryInput, chain)); +}; + +const candidateOutcome = ( + nested: ArtifactPathResolution, +): CandidateResolution => + nested.resolution_status === "resolved" ? { resolvedPath: nested.resolved_path, status: nested.resolution_status, @@ -301,19 +329,42 @@ const resolveCandidate = ( status: nested.resolution_status, limitations: nested.limitations, }; + +const resolveFileCandidates = ( + input: ResolveArtifactPathInput, + candidates: readonly string[], +): CandidateResolution | null => { + const source = input.files.get(input.sourcePath); + for (const path of candidates) { + const target = input.files.get(path); + if ( + target !== undefined && + (source === undefined || + target.container_sha256 === source.container_sha256) + ) + return { resolvedPath: path, status: "resolved", limitations: [] }; + } + return null; }; -const directCandidates = (candidate: string): readonly string[] => [ +const fileCandidates = (candidate: string): readonly string[] => [ candidate, ...EXTENSIONS.map((extension) => `${candidate}${extension}`), - ...EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`)), ]; +const indexCandidates = (candidate: string): readonly string[] => + EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`)); + const packageEntry = ( text: string, moduleKind: ResolveArtifactPathInput["moduleKind"], + useExports: boolean, ): - | { readonly status: "value"; readonly value: string } + | { + readonly status: "value"; + readonly value: string; + readonly source: "legacy" | "exports"; + } | { readonly status: "missing" } | { readonly status: "invalid" } => { try { @@ -321,14 +372,20 @@ const packageEntry = ( if (typeof value !== "object" || value === null) return { status: "invalid" }; const rawExports = Reflect.get(value, "exports"); - if (rawExports !== undefined) return packageExport(rawExports, moduleKind); + if (useExports && rawExports !== undefined && rawExports !== null) { + const exported = packageExport(rawExports, moduleKind); + return exported.status === "value" + ? { ...exported, source: "exports" } + : exported; + } const preferred = moduleKind === "import" ? [Reflect.get(value, "module"), Reflect.get(value, "main")] : [Reflect.get(value, "main"), Reflect.get(value, "module")]; const entry = preferred.find((candidate) => candidate !== undefined); if (entry === undefined) return { status: "missing" }; - return packagePathValue(entry); + const legacy = packagePathValue(entry); + return legacy.status === "value" ? { ...legacy, source: "legacy" } : legacy; } catch { return { status: "invalid" }; } diff --git a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts new file mode 100644 index 000000000..59f3f4620 --- /dev/null +++ b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts @@ -0,0 +1,142 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, relative, sep } from "node:path"; + +import { expect, it } from "vitest"; + +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const cases: readonly { + name: string; + metadata: Readonly>; + index: boolean; + specifier?: string; + extraFiles?: Readonly>; +}[] = [ + { name: "main before index", metadata: { main: "actual.cjs" }, index: true }, + { + name: "exports before index", + metadata: { exports: { require: "./actual.cjs" } }, + index: true, + }, + { + name: "main without an index", + metadata: { main: "actual.cjs" }, + index: false, + }, + { name: "index fallback", metadata: {}, index: true }, + { + name: "missing legacy main fallback", + metadata: { main: "missing.cjs" }, + index: true, + }, + { name: "self-directory main", metadata: { main: "." }, index: true }, + { + name: "bare exports with legacy main", + metadata: { main: "legacy.cjs", exports: "./actual.cjs" }, + index: true, + extraFiles: { + "node_modules/fixture/legacy.cjs": "module.exports = 'legacy';", + }, + }, + { + name: "relative directory ignores exports", + metadata: { main: "legacy.cjs", exports: "./actual.cjs" }, + index: true, + specifier: "./node_modules/fixture", + extraFiles: { + "node_modules/fixture/legacy.cjs": "module.exports = 'legacy';", + }, + }, + { + name: "relative exports-only directory uses index", + metadata: { exports: "./actual.cjs" }, + index: true, + specifier: "./node_modules/fixture", + }, + { + name: "legacy main directory uses its index", + metadata: { main: "./lib" }, + index: true, + extraFiles: { + "node_modules/fixture/lib/package.json": '{"main":"actual.cjs"}', + "node_modules/fixture/lib/actual.cjs": "module.exports = 'nested';", + "node_modules/fixture/lib/index.js": "module.exports = 'nested index';", + }, + }, + { + name: "legacy main directory falls back to root index", + metadata: { main: "./lib" }, + index: true, + extraFiles: { + "node_modules/fixture/lib/package.json": '{"main":"actual.cjs"}', + "node_modules/fixture/lib/actual.cjs": "module.exports = 'nested';", + }, + }, + { + name: "legacy main cycle falls back to root index", + metadata: { main: "./lib" }, + index: true, + extraFiles: { "node_modules/fixture/lib/package.json": '{"main":".."}' }, + }, + { + name: "null exports preserves legacy main", + metadata: { exports: null, main: "actual.cjs" }, + index: true, + }, +]; + +it.each(cases)( + "matches Node package resolution for $name", + async ({ metadata, index, specifier = "fixture", extraFiles = {} }) => { + const root = await createTestTempDirectory("rea-package-precedence-"); + const files = { + ...extraFiles, + "package.json": JSON.stringify({ + name: "app", + type: "commonjs", + main: "main.cjs", + }), + "main.cjs": `const value = require(${JSON.stringify(specifier)}); module.exports = value;`, + "node_modules/fixture/package.json": JSON.stringify({ + name: "fixture", + ...metadata, + }), + "node_modules/fixture/actual.cjs": "module.exports = 'actual entry';", + ...(index + ? { + "node_modules/fixture/index.js": + "module.exports = 'fallback entry';", + } + : {}), + }; + 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 expected = relative( + root, + createRequire(join(root, "main.cjs")).resolve(specifier), + ) + .split(sep) + .join("/"); + const result = await reconstructJavaScriptArtifact({ + input_path: root, + format: "directory", + }); + const edge = result.graph.edges.find( + ({ properties }) => + properties.module_link_kind === "require" && + properties.specifier === specifier, + ); + + expect(edge?.properties).toMatchObject({ + resolution_status: "resolved", + resolved_path: expected, + }); + expect(result.graph.coverage.status).toBe("complete"); + }, +);