From 2a620c06cdcaa32844c4a80aeb3df1793f1ce09e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:49:32 -0700 Subject: [PATCH] fix(javascript): resolve package exports targets only to the exact file (#1181) A selected package exports target names one file, and Node tries no extension or directory index for it. REA still resolved it like a relative specifier, so `exports: "./file"` with only `file.js` present, or `"./folder"` with only `folder/index.js`, reported a resolved import that Node rejects under both import and require. Resolve an exports target only to its exact file and otherwise report not-found with a limitation naming the target. Legacy main entries keep their extension and index fallbacks. Fixes #1174 Co-authored-by: Claude Opus 5.5 --- .../JavaScriptArtifactPathResolution.test.ts | 62 +++++++++++++++++++ .../JavaScriptArtifactPathResolution.ts | 15 ++++- .../javascriptPackageExportTargets.test.ts | 13 ++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index f798ebfc3..1b5bd33c4 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -391,6 +391,68 @@ describe("generic package entry heuristics", () => { ); }); +describe("package exports exact targets", () => { + it.each([ + ["./file", "app/node_modules/fixture/file.js"], + ["./folder", "app/node_modules/fixture/folder/index.js"], + ])( + "resolves exports target %s only to that exact file", + (target, nearMiss) => { + const files = fileMap([ + file("app/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ exports: target }), + ), + file(nearMiss, "root"), + ]); + for (const moduleKind of ["import", "require"] as const) { + const resolution = resolve({ + declaredPath: "fixture", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind, + files, + }); + expect(resolution).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + }); + expect(resolution.limitations.join(" ")).toContain( + "exports targets are exact files", + ); + } + }, + ); + + it("keeps extension and index fallbacks for a legacy main entry", () => { + for (const [main, target] of [ + ["./file", "app/node_modules/fixture/file.js"], + ["./folder", "app/node_modules/fixture/folder/index.js"], + ] as const) { + const files = fileMap([ + file("app/consumer.js", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ main }), + ), + file(target, "root"), + ]); + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/consumer.js", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ resolution_status: "resolved", resolved_path: target }); + } + }); +}); + 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 dca7ac8e7..900f1ba49 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -105,8 +105,11 @@ const resolvePackagePath = ( ], }, ); - const resolved = - mode === "files-and-index" + // An exports target names one exact file; Node tries no extension or index. + const resolved = exportsTargetUrl + ? (resolveFileCandidates(input, [confined]) ?? + exactExportsTargetNotFound(confined)) + : mode === "files-and-index" ? (resolveFileCandidates(input, [ ...fileCandidates(confined), ...indexCandidates(confined), @@ -619,6 +622,14 @@ const notFoundCandidate = (): CandidateResolution => ({ ], }); +const exactExportsTargetNotFound = (path: string): CandidateResolution => ({ + resolvedPath: null, + status: "not-found", + limitations: [ + `The selected package exports target ${path} does not exist in the inventoried artifact container; exports targets are exact files, so no extension or directory index was tried.`, + ], +}); + const fileUrlPath = (value: string): string | undefined => { if (!value.startsWith("file://")) return undefined; try { diff --git a/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts b/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts index dafd2cba2..4d22c8a7a 100644 --- a/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts +++ b/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts @@ -51,6 +51,18 @@ const cases: readonly { nodeError: "ERR_INVALID_PACKAGE_TARGET", rejectedTarget: "./../outside.cjs", }, + { + name: "extensionless target is not completed with an extension", + exports: "./actual", + expected: null, + nodeError: "MODULE_NOT_FOUND", + }, + { + // Node reports ERR_UNSUPPORTED_DIR_IMPORT for import, MODULE_NOT_FOUND for require. + name: "directory target does not load its index", + exports: "./folder", + expected: null, + }, { name: "top-level array skips an invalid target", exports: ["./../outside.cjs", "./actual.cjs"], @@ -166,6 +178,7 @@ it.each( "node_modules/fixture/node_modules/actual.cjs", "node_modules/fixture/node_modules.cjs", "node_modules/fixture/literal%2e.cjs", + "node_modules/fixture/folder/index.js", ].map((path) => [path, `module.exports = ${JSON.stringify(path)};`]), ), };