From 3ad5782dfdc8864879a887dc63fef682ffeb5839 Mon Sep 17 00:00:00 2001 From: Yann Roberto <165009858+shoyann@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:57:58 +0800 Subject: [PATCH] fix(javascript): honor package exports before sibling files Resolve package exports before sibling-file fallback, preserve explicit export blocking, and retain precise CommonJS package self-reference behavior and caller provenance. Fixes #1761. Co-authored-by: Yann Roberto <165009858+shoyann@users.noreply.github.com> --- .../JavaScriptArtifactPathResolution.ts | 128 ++++- .../applications/packageSubpathsCli.test.ts | 219 +++++--- .../packageConditionalExports.test.ts | 509 +++++++++++++++++- 3 files changed, 755 insertions(+), 101 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 690192a79f..73d82e91b3 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -206,18 +206,27 @@ const bareModuleCandidate = ( return unresolvedCandidate("external", [ "The bare specifier is a Node builtin, package import map, or invalid package name.", ]); + if (input.moduleKind === "import" && packageName.includes("%")) + return unresolvedCandidate("rejected", [ + "Percent signs are not admitted in ES module package names.", + ]); + const self = commonJsSelfCandidate(input, declared); + if (self !== null) return self; const subpath = declared === packageName ? "." : `.${declared.slice(packageName.length)}`; const source = input.files.get(input.sourcePath); let directory = posix.dirname(input.sourcePath); while (true) { const candidate = posix.join(directory, "node_modules", packageName); + const metadata = readPackageMetadata(input, candidate); + if (packageExportsEligible(input) && metadata.kind !== "absent") + return resolvePackageSpecifier(input, candidate, subpath, metadata); if (subpath === ".") { const direct = resolveFileCandidates(input, fileCandidates(candidate)); if (direct !== null) return direct; } if (hasContainerDirectory(input.files, candidate, source?.container_sha256)) - return resolvePackageSpecifier(input, candidate, subpath); + return resolvePackageSpecifier(input, candidate, subpath, metadata); if (directory === "." || directory === "") break; directory = posix.dirname(directory); } @@ -226,30 +235,85 @@ const bareModuleCandidate = ( ]); }; +/** Self references use the nearest package scope before dependency lookup. */ +const commonJsSelfCandidate = ( + input: ResolveArtifactPathInput, + declared: string, +): CandidateResolution | null => { + if (input.moduleKind !== "require" || !input.files.has(input.sourcePath)) + return null; + let directory = posix.dirname(input.sourcePath); + while (posix.basename(directory) !== "node_modules") { + const metadata = readPackageMetadata(input, directory); + if (metadata.kind === "unavailable") + return unresolvedCandidate("unavailable", [metadata.limitation]); + if (metadata.kind === "available") { + if ( + metadata.name === undefined || + metadata.exports === undefined || + (declared !== metadata.name && + !declared.startsWith(`${metadata.name}/`)) + ) + return null; + const subpath = + declared === metadata.name + ? "." + : `.${declared.slice(metadata.name.length)}`; + // Self resolution does not apply the CommonJS dependency-name matcher. + return resolvePackageExports( + input, + metadata.path, + subpath, + metadata.exports, + ); + } + if (directory === "." || directory === "") break; + directory = posix.dirname(directory); + } + return null; +}; + /** Package roots and exact subpaths share metadata and exports selection. */ const resolvePackageSpecifier = ( input: ResolveArtifactPathInput, directory: string, subpath: string, + metadata: PackageMetadata, ): CandidateResolution => { - const metadata = readPackageMetadata(input, directory); - if (metadata.kind === "unavailable") + const exportsEligible = packageExportsEligible(input); + if (exportsEligible && metadata.kind === "unavailable") return unresolvedCandidate("unavailable", [metadata.limitation]); const rawExports = - metadata.kind === "available" - ? Reflect.get(metadata.value, "exports") + exportsEligible && metadata.kind === "available" + ? metadata.exports : undefined; - if (rawExports === undefined || rawExports === null) { + if (rawExports === undefined) { if (subpath === ".") - return resolveDirectory(input, directory, metadata, new Set()); + return ( + resolveFileCandidates(input, fileCandidates(directory)) ?? + resolveDirectory(input, directory, metadata, new Set()) + ); // Preserve the trailing slash for a package directory request. const candidate = confineCandidate(`${directory}/${subpath.slice(2)}`); return typeof candidate === "string" ? resolveCandidate(input, candidate, new Set()) : candidate; } + return resolvePackageExports( + input, + posix.join(directory, "package.json"), + subpath, + rawExports, + ); +}; + +const resolvePackageExports = ( + input: ResolveArtifactPathInput, + packagePath: string, + subpath: string, + rawExports: string | object, +): CandidateResolution => { const exported = selectPackageExport(rawExports, subpath, input.moduleKind); - const packagePath = posix.join(directory, "package.json"); if (exported.status === "rejected") return unresolvedCandidate("rejected", [ `Package metadata ${packagePath}: ${exported.limitation}`, @@ -292,6 +356,16 @@ const barePackageName = (specifier: string): string | null => { return segments[0] === "" ? null : (segments[0] ?? null); }; +const packageExportsEligible = (input: ResolveArtifactPathInput): boolean => { + if (input.moduleKind !== "require") return true; + // Node's CommonJS exports matcher excludes percent-containing package names. + // Compare the captured name so a scoped name cannot fall back to its scope. + const match = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/.exec( + input.declaredPath, + ); + return match?.[1] === barePackageName(input.declaredPath); +}; + const hasContainerDirectory = ( files: ReadonlyMap, candidate: string, @@ -416,7 +490,10 @@ type PackageMetadata = | { readonly kind: "available"; readonly path: string; - readonly value: object; + readonly name: string | undefined; + readonly exports: string | object | undefined; + readonly main: unknown; + readonly module: unknown; } | { readonly kind: "absent" } | { readonly kind: "unavailable"; readonly limitation: string }; @@ -448,12 +525,27 @@ const readPackageMetadata = ( limitation: `Directory package metadata ${path} is not valid package JSON.`, }; } - return typeof value === "object" && value !== null - ? { kind: "available", path, value } - : { - kind: "unavailable", - limitation: `Directory package metadata ${path} is not valid package JSON.`, - }; + if (typeof value !== "object" || value === null) + return { + kind: "unavailable", + limitation: `Directory package metadata ${path} is not valid package JSON.`, + }; + const name = Reflect.get(value, "name"); + const exports = Reflect.get(value, "exports"); + return { + kind: "available", + path, + name: typeof name === "string" ? name : undefined, + // Node's package reader ignores scalar root exports other than strings. + // Nested targets retain their raw values for exports validation. + exports: + typeof exports === "string" || + (typeof exports === "object" && exports !== null) + ? exports + : undefined, + main: Reflect.get(value, "main"), + module: Reflect.get(value, "module"), + }; }; /** File and directory lookup never interprets a nested directory's exports. */ @@ -488,10 +580,8 @@ const resolveDirectory = ( `Directory package entrypoint cycle includes ${metadata.path}.`, ]); const preferred = [ - Reflect.get(metadata.value, "main"), - ...(input.moduleKind === undefined - ? [Reflect.get(metadata.value, "module")] - : []), + metadata.main, + ...(input.moduleKind === undefined ? [metadata.module] : []), ]; const entry = preferred.find((value) => value !== undefined); if (entry === undefined) diff --git a/tests/acceptance/applications/packageSubpathsCli.test.ts b/tests/acceptance/applications/packageSubpathsCli.test.ts index 185580c182..7b192fd66b 100644 --- a/tests/acceptance/applications/packageSubpathsCli.test.ts +++ b/tests/acceptance/applications/packageSubpathsCli.test.ts @@ -11,55 +11,88 @@ import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +const createPackageSubpathFixture = async () => { + const root = await createTestTempDirectory("rea-package-subpaths-"); + const source = [ + 'const nearest = require("fixture/parser");', + 'const nested = require("nested/parser");', + 'const parser = require("exported/parser");', + 'const directory = require("exported/parser/");', + 'const hidden = require("exported/hidden");', + 'const missing = require("exported/missing");', + 'new BrowserWindow({webPreferences:{preload:"exported/parser"}});', + 'const file = require("fixture");', + 'const packageDirectory = require("fixture/");', + 'const selected = require("@scope/selected");', + 'const blocked = require("blocked");', + 'const literal = require("fi%xture");', + 'const scalar = require("scalar");', + 'const self = require("../node_modules/fi%xture/run.cjs");', + ].join("\n"); + const selfSource = 'const self = require("fi%xture"); module.exports = self;'; + const importSource = 'import literal from "fi%xture";'; + const files = { + "src/main.cjs": source, + "src/imports.mjs": importSource, + "src/node_modules/fixture/parser.js": "module.exports = 1;", + "src/node_modules/fixture.js": "module.exports = 'file';", + "src/node_modules/fixture/index.js": "module.exports = 'directory';", + "node_modules/fixture/package.json": "{}", + "node_modules/fixture/parser.js": "module.exports = 2;", + "node_modules/nested/package.json": "{}", + "node_modules/nested/parser/package.json": JSON.stringify({ + main: "./entry.js", + exports: "./decoy.js", + }), + "node_modules/nested/parser/entry.js": "module.exports = 3;", + "node_modules/nested/parser/index.js": "module.exports = 4;", + "node_modules/nested/parser/decoy.js": "module.exports = 5;", + "node_modules/exported/package.json": JSON.stringify({ + exports: { + "./parser": "./parser.cjs", + "./parser/": "./parser.cjs", + "./missing": "./missing.cjs", + }, + }), + "node_modules/exported/parser.cjs": "module.exports = 6;", + "node_modules/exported/hidden.js": "module.exports = 7;", + "node_modules/exported/missing/index.js": "module.exports = 8;", + "node_modules/@scope/selected.js": "module.exports = 'sibling';", + "node_modules/@scope/selected/package.json": JSON.stringify({ + exports: { require: "./actual.cjs" }, + }), + "node_modules/@scope/selected/actual.cjs": "module.exports = 'export';", + "node_modules/blocked.js": "module.exports = 'sibling';", + "node_modules/blocked/package.json": JSON.stringify({ + exports: { ".": null }, + }), + "node_modules/fi%xture.js": "module.exports = 'literal sibling';", + "node_modules/fi%xture/package.json": JSON.stringify({ + name: "fi%xture", + exports: "./actual.cjs", + main: "./legacy.cjs", + }), + "node_modules/fi%xture/actual.cjs": "module.exports = 'export';", + "node_modules/fi%xture/legacy.cjs": "module.exports = 'legacy';", + "node_modules/fi%xture/run.cjs": selfSource, + "node_modules/scalar.js": "module.exports = 'scalar sibling';", + "node_modules/scalar/package.json": JSON.stringify({ exports: false }), + }; + await Promise.all( + Object.entries(files).map(async ([path, content]) => { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content); + }), + ); + return { root, source, selfSource, importSource }; +}; + cliTest.for(["CLI", "stdio MCP"] as const)( "resolves package subpaths with source provenance through %s", async (surface, { cli }) => { - const root = await createTestTempDirectory("rea-package-subpaths-"); - const source = [ - 'const nearest = require("fixture/parser");', - 'const nested = require("nested/parser");', - 'const parser = require("exported/parser");', - 'const directory = require("exported/parser/");', - 'const hidden = require("exported/hidden");', - 'const missing = require("exported/missing");', - 'new BrowserWindow({webPreferences:{preload:"exported/parser"}});', - 'const file = require("fixture");', - 'const packageDirectory = require("fixture/");', - ].join("\n"); - const files = { - "src/main.cjs": source, - "src/node_modules/fixture/parser.js": "module.exports = 1;", - "src/node_modules/fixture.js": "module.exports = 'file';", - "src/node_modules/fixture/index.js": "module.exports = 'directory';", - "node_modules/fixture/package.json": "{}", - "node_modules/fixture/parser.js": "module.exports = 2;", - "node_modules/nested/package.json": "{}", - "node_modules/nested/parser/package.json": JSON.stringify({ - main: "./entry.js", - exports: "./decoy.js", - }), - "node_modules/nested/parser/entry.js": "module.exports = 3;", - "node_modules/nested/parser/index.js": "module.exports = 4;", - "node_modules/nested/parser/decoy.js": "module.exports = 5;", - "node_modules/exported/package.json": JSON.stringify({ - exports: { - "./parser": "./parser.cjs", - "./parser/": "./parser.cjs", - "./missing": "./missing.cjs", - }, - }), - "node_modules/exported/parser.cjs": "module.exports = 6;", - "node_modules/exported/hidden.js": "module.exports = 7;", - "node_modules/exported/missing/index.js": "module.exports = 8;", - }; - await Promise.all( - Object.entries(files).map(async ([path, content]) => { - const absolute = join(root, path); - await mkdir(dirname(absolute), { recursive: true }); - await writeFile(absolute, content); - }), - ); - + const { root, source, selfSource, importSource } = + await createPackageSubpathFixture(); const nativeRequire = createRequire(join(root, "src/main.cjs")); const expectedTargets = { "fixture/parser": "src/node_modules/fixture/parser.js", @@ -67,18 +100,29 @@ cliTest.for(["CLI", "stdio MCP"] as const)( "fixture/": "src/node_modules/fixture/index.js", "nested/parser": "node_modules/nested/parser/entry.js", "exported/parser": "node_modules/exported/parser.cjs", + "@scope/selected": "node_modules/@scope/selected/actual.cjs", + "fi%xture": "node_modules/fi%xture.js", + scalar: "node_modules/scalar.js", + "../node_modules/fi%xture/run.cjs": "node_modules/fi%xture/run.cjs", }; for (const [specifier, target] of Object.entries(expectedTargets)) expect( relative(root, nativeRequire.resolve(specifier)).replaceAll("\\", "/"), ).toBe(target); - for (const specifier of ["exported/parser/", "exported/hidden"]) + for (const specifier of ["exported/parser/", "exported/hidden", "blocked"]) expect(() => nativeRequire.resolve(specifier)).toThrow( expect.objectContaining({ code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }), ); expect(() => nativeRequire.resolve("exported/missing")).toThrow( expect.objectContaining({ code: "MODULE_NOT_FOUND" }), ); + const selfPath = "node_modules/fi%xture/run.cjs"; + expect( + relative( + root, + createRequire(join(root, selfPath)).resolve("fi%xture"), + ).replaceAll("\\", "/"), + ).toBe("node_modules/fi%xture/actual.cjs"); let document: unknown; if (surface === "CLI") { @@ -103,34 +147,31 @@ cliTest.for(["CLI", "stdio MCP"] as const)( const imports = graph.edges.filter( ({ properties }) => properties.module_link_kind === "require", ); - const sourceDigest = createHash("sha256").update(source).digest("hex"); - for (const [specifier, target] of Object.entries(expectedTargets)) - expect(imports).toContainEqual( - expect.objectContaining({ - properties: expect.objectContaining({ - specifier, - resolved_path: target, - resolution_status: "resolved", - }), - evidence: expect.objectContaining({ - artifact: expect.objectContaining({ - available: true, - sha256: sourceDigest, - }), - location: expect.objectContaining({ - available: true, - value: expect.objectContaining({ - kind: "source-range", - source: "src/main.cjs", - }), - }), - }), + for (const observation of [ + { sourcePath: "src/main.cjs", source, targets: expectedTargets }, + { + sourcePath: selfPath, + source: selfSource, + targets: { "fi%xture": "node_modules/fi%xture/actual.cjs" }, + }, + ]) + expectResolvedImports(imports, observation); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + module_link_kind: "import", + specifier: "fi%xture", + resolved_path: null, + resolution_status: "rejected", }), - ); + evidence: sourceEvidence("src/imports.mjs", importSource), + }), + ); for (const [specifier, status] of [ ["exported/parser/", "external"], ["exported/hidden", "external"], ["exported/missing", "not-found"], + ["blocked", "external"], ]) expect(imports).toContainEqual( expect.objectContaining({ @@ -164,3 +205,41 @@ cliTest.for(["CLI", "stdio MCP"] as const)( }); }, ); + +const expectResolvedImports = ( + imports: z.infer< + typeof javascriptApplicationAnalysisResultSchema + >["graph"]["edges"], + observation: { + readonly sourcePath: string; + readonly source: string; + readonly targets: Readonly>; + }, +): void => { + for (const [specifier, target] of Object.entries(observation.targets)) + expect.soft(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier, + resolved_path: target, + resolution_status: "resolved", + }), + evidence: sourceEvidence(observation.sourcePath, observation.source), + }), + ); +}; + +const sourceEvidence = (sourcePath: string, source: string): unknown => + expect.objectContaining({ + artifact: expect.objectContaining({ + available: true, + sha256: createHash("sha256").update(source).digest("hex"), + }), + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + kind: "source-range", + source: sourcePath, + }), + }), + }); diff --git a/tests/boundary/javascript/packageConditionalExports.test.ts b/tests/boundary/javascript/packageConditionalExports.test.ts index 190fa0b4ef..1eb9927605 100644 --- a/tests/boundary/javascript/packageConditionalExports.test.ts +++ b/tests/boundary/javascript/packageConditionalExports.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -25,11 +25,15 @@ const nodeExportOutcomeSchema = z.strictObject({ }); /** Resolve through the real Node resolver, retaining its refusal reason. */ -const nodeTarget = async (cwd: string, moduleKind: "import" | "require") => { +const nodeTarget = async ( + cwd: string, + moduleKind: "import" | "require", + specifier: string, +) => { const expression = moduleKind === "import" - ? '(await import("example")).default' - : 'createRequire(import.meta.url)("example")'; + ? `(await import(${JSON.stringify(specifier)})).default` + : `createRequire(import.meta.url)(${JSON.stringify(specifier)})`; const { stdout } = await execute( process.execPath, [ @@ -56,24 +60,58 @@ const compareWithNode = async ( exportsMap: unknown, moduleKind: "import" | "require", present: readonly string[] = TARGETS, + options: { + readonly siblingFile?: boolean; + readonly packageName?: string; + readonly specifier?: string; + readonly main?: string; + readonly metadataText?: string; + readonly sourceWithinPackage?: string; + readonly packageFiles?: Readonly>; + } = {}, ): Promise<{ readonly node: string | null; readonly node_error: string | null; readonly outcome: ArtifactPathResolution; + readonly files: ReadonlyMap; }> => { const root = await createTestTempDirectory("rea-exports-"); - const packageDirectory = join(root, "node_modules", "example"); + const packageName = options.packageName ?? "example"; + const specifier = options.specifier ?? packageName; + const sourcePath = + options.sourceWithinPackage === undefined + ? "main.js" + : `node_modules/${packageName}/${options.sourceWithinPackage}`; + const packageDirectory = join(root, "node_modules", packageName); await mkdir(packageDirectory, { recursive: true }); - const metadata = JSON.stringify({ name: "example", exports: exportsMap }); + const metadata = + options.metadataText ?? + JSON.stringify({ + name: packageName, + exports: exportsMap, + main: options.main, + }); await writeFile(join(packageDirectory, "package.json"), metadata); const contents = new Map([ - ["main.js", ""], - ["node_modules/example/package.json", metadata], + [sourcePath, ""], + [`node_modules/${packageName}/package.json`, metadata], ]); + if (options.siblingFile === true) { + const filename = `${packageName}.js`; + const source = `module.exports = ${JSON.stringify(filename)};`; + await writeFile(join(root, "node_modules", filename), source); + contents.set(`node_modules/${filename}`, source); + } for (const target of present) { const source = `module.exports = ${JSON.stringify(target)};`; await writeFile(join(packageDirectory, target), source); - contents.set(`node_modules/example/${target}`, source); + contents.set(`node_modules/${packageName}/${target}`, source); + } + for (const [path, source] of Object.entries(options.packageFiles ?? {})) { + const absolute = join(packageDirectory, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, source); + contents.set(`node_modules/${packageName}/${path}`, source); } const files = new Map( [...contents].map(([path, value]) => [ @@ -90,13 +128,18 @@ const compareWithNode = async ( }, ]), ); - const node = await nodeTarget(root, moduleKind); + const node = await nodeTarget( + dirname(join(root, sourcePath)), + moduleKind, + specifier, + ); return { node: node.target, node_error: node.error_code, + files, outcome: resolveArtifactPathByContext({ - declaredPath: "example", - sourcePath: "main.js", + declaredPath: specifier, + sourcePath, context: "module-specifier", moduleKind, files, @@ -209,6 +252,448 @@ describe("ordered package exports conditions", () => { }, ); }); + +describe("package exports before sibling files", () => { + it.each(["import", "require"] as const)( + "selects the active %s export before a same-named sibling file", + async (moduleKind) => { + const { node, node_error, outcome } = await compareWithNode( + { import: "./specific.cjs", require: "./require.cjs" }, + moduleKind, + TARGETS, + { siblingFile: true }, + ); + const target = moduleKind === "import" ? "specific.cjs" : "require.cjs"; + expect(node).toBe(target); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: `node_modules/example/${target}`, + resolution_status: "resolved", + }); + }, + ); + + it.each([ + [{ ".": null }, "ERR_PACKAGE_PATH_NOT_EXPORTED", "external"], + ["./missing.cjs", "MODULE_NOT_FOUND", "not-found"], + ["../outside.cjs", "ERR_INVALID_PACKAGE_TARGET", "rejected"], + ] as const)( + "preserves the refusal for %j despite a same-named sibling file", + async (exportsMap, error, status) => { + for (const moduleKind of ["import", "require"] as const) { + const { node, node_error, outcome } = await compareWithNode( + exportsMap, + moduleKind, + TARGETS, + { siblingFile: true }, + ); + expect(node).toBeNull(); + expect(node_error).toBe( + error === "MODULE_NOT_FOUND" && moduleKind === "import" + ? "ERR_MODULE_NOT_FOUND" + : error, + ); + expect(outcome).toMatchObject({ + resolved_path: null, + resolution_status: status, + }); + expect(outcome.limitations).not.toHaveLength(0); + } + }, + ); + + it.each([undefined, null])( + "preserves CommonJS sibling-file priority when exports is %s", + async (exportsMap) => { + const { node, node_error, outcome } = await compareWithNode( + exportsMap, + "require", + [...TARGETS, "index.js"], + { siblingFile: true }, + ); + expect(node).toBe("example.js"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example.js", + resolution_status: "resolved", + }); + }, + ); + + it("does not let another container's exports override a local sibling file", async () => { + const { files } = await compareWithNode( + "./default.cjs", + "require", + TARGETS, + { siblingFile: true }, + ); + const outcome = resolveArtifactPathByContext({ + declaredPath: "example", + sourcePath: "main.js", + context: "module-specifier", + moduleKind: "require", + files: new Map( + [...files].map(([path, file]) => [ + path, + path.startsWith("node_modules/example/") + ? { ...file, container_sha256: "c".repeat(64) } + : file, + ]), + ), + }); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example.js", + resolution_status: "resolved", + }); + }); +}); +describe("scalar package exports metadata", () => { + it.each([false, true, 0, 42])( + "preserves CommonJS sibling-file priority for root exports %s", + async (exportsMap) => { + const { node, node_error, outcome } = await compareWithNode( + exportsMap, + "require", + TARGETS, + { siblingFile: true, main: "./default.cjs" }, + ); + expect(node).toBe("example.js"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example.js", + resolution_status: "resolved", + }); + }, + ); + + it.each(["import", "require"] as const)( + "uses the legacy main for %s with numeric root exports and no sibling", + async (moduleKind) => { + const { node, node_error, outcome } = await compareWithNode( + 42, + moduleKind, + TARGETS, + { main: "./default.cjs" }, + ); + expect(node).toBe("default.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example/default.cjs", + resolution_status: "resolved", + }); + }, + ); + + it.each([false, 42])( + "still rejects nested scalar target %s without a sibling fallback", + async (target) => { + const { node, node_error, outcome } = await compareWithNode( + { ".": target }, + "require", + TARGETS, + { siblingFile: true }, + ); + expect(node).toBeNull(); + expect(node_error).toBe("ERR_INVALID_PACKAGE_TARGET"); + expect(outcome).toMatchObject({ + resolved_path: null, + resolution_status: "rejected", + }); + }, + ); +}); + +describe("package self-reference", () => { + it.each(["fi%xture", "@scope/fi%xture"])( + "applies self exports before the CommonJS sibling for %s", + async (packageName) => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { packageName, sourceWithinPackage: "main.js", siblingFile: true }, + ); + expect(node).toBe("specific.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: `node_modules/${packageName}/specific.cjs`, + resolution_status: "resolved", + }); + }, + ); + + it("resolves an exported self subpath for a percent-named package", async () => { + const { node, node_error, outcome } = await compareWithNode( + { "./part": "./specific.cjs" }, + "require", + TARGETS, + { + packageName: "fi%xture", + specifier: "fi%xture/part", + sourceWithinPackage: "main.js", + }, + ); + expect(node).toBe("specific.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/fi%xture/specific.cjs", + resolution_status: "resolved", + }); + }); + + it("does not fall back to main or a sibling when self exports block the root", async () => { + const { node, node_error, outcome } = await compareWithNode( + { ".": null }, + "require", + TARGETS, + { + packageName: "fi%xture", + sourceWithinPackage: "main.js", + siblingFile: true, + main: "./default.cjs", + }, + ); + expect(node).toBeNull(); + expect(node_error).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED"); + expect(outcome).toMatchObject({ + resolved_path: null, + resolution_status: "external", + }); + }); + + it("falls through to dependency lookup when the self package has no exports", async () => { + const { node, node_error, outcome } = await compareWithNode( + undefined, + "require", + TARGETS, + { sourceWithinPackage: "main.js", siblingFile: true }, + ); + expect(node).toBe("example.js"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example.js", + resolution_status: "resolved", + }); + }); + + it("selects self exports before a same-named nested dependency", async () => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { + sourceWithinPackage: "main.js", + packageFiles: { + "node_modules/example/index.js": 'module.exports = "dependency.js";', + }, + }, + ); + expect(node).toBe("specific.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example/specific.cjs", + resolution_status: "resolved", + }); + }); +}); + +describe("package self-reference scope boundaries", () => { + it.each([{ name: "other", exports: "./missing.cjs" }, { name: "example" }])( + "stops self lookup at the nearest package metadata %j", + async (metadata) => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { + sourceWithinPackage: "child/main.js", + packageFiles: { + "child/package.json": JSON.stringify(metadata), + "child/node_modules/example/index.js": + 'module.exports = "dependency.js";', + }, + }, + ); + expect(node).toBe("dependency.js"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: + "node_modules/example/child/node_modules/example/index.js", + resolution_status: "resolved", + }); + }, + ); + + it("stops self lookup at node_modules when the nearer package has no metadata", async () => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { + sourceWithinPackage: "node_modules/dependency/main.js", + packageFiles: { + "node_modules/dependency/node_modules/example/index.js": + 'module.exports = "dependency.js";', + }, + }, + ); + expect(node).toBe("dependency.js"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: + "node_modules/example/node_modules/dependency/node_modules/example/index.js", + resolution_status: "resolved", + }); + }); + + it("reports malformed nearest metadata without resolving an ancestor self export", async () => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { + sourceWithinPackage: "child/main.js", + packageFiles: { "child/package.json": "{" }, + }, + ); + expect(node).toBeNull(); + expect(node_error).toBe("ERR_INVALID_PACKAGE_CONFIG"); + expect(outcome).toMatchObject({ + resolved_path: null, + resolution_status: "unavailable", + }); + }); + + it("does not apply self exports from another container", async () => { + const { files } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { sourceWithinPackage: "main.js", siblingFile: true }, + ); + const outcome = resolveArtifactPathByContext({ + declaredPath: "example", + sourcePath: "node_modules/example/main.js", + context: "module-specifier", + moduleKind: "require", + files: new Map( + [...files].map(([path, file]) => [ + path, + path === "node_modules/example/package.json" + ? { ...file, container_sha256: "c".repeat(64) } + : file, + ]), + ), + }); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example.js", + resolution_status: "resolved", + }); + }); +}); + +describe("package exports name eligibility", () => { + it.each([ + ["fi%xture", "./specific.cjs"], + ["@scope/fi%xture", { ".": null }], + ["@sc%ope/fixture", "./specific.cjs"], + ] as const)( + "uses the literal sibling file for %s without applying exports", + async (packageName, exportsMap) => { + const { node, node_error, outcome } = await compareWithNode( + exportsMap, + "require", + TARGETS, + { packageName, siblingFile: true }, + ); + expect(node).toBe(`${packageName}.js`); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: `node_modules/${packageName}.js`, + resolution_status: "resolved", + }); + }, + ); + + it.each([ + { packageName: "fi%xture" }, + { packageName: "@scope/fi%xture" }, + { packageName: "@sc%ope/fixture", metadataText: "{" }, + ])( + "rejects the ESM package name $packageName before metadata or sibling lookup", + async (options) => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "import", + TARGETS, + { ...options, siblingFile: true }, + ); + expect(node).toBeNull(); + expect(node_error).toBe("ERR_INVALID_MODULE_SPECIFIER"); + expect(outcome).toMatchObject({ + resolved_path: null, + resolution_status: "rejected", + }); + }, + ); + + it("uses the legacy main of a percent-named package without a sibling file", async () => { + const { node, node_error, outcome } = await compareWithNode( + "./specific.cjs", + "require", + TARGETS, + { packageName: "fi%xture", main: "./default.cjs" }, + ); + expect(node).toBe("default.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/fi%xture/default.cjs", + resolution_status: "resolved", + }); + }); + + it.each([ + ["require", "example"], + ["import", "example"], + ["import", "@scope/example"], + ] as const)( + "still applies exports to %s percent subpaths of %s", + async (moduleKind, packageName) => { + const { node, node_error, outcome } = await compareWithNode( + { "./fi%xture": "./specific.cjs" }, + moduleKind, + TARGETS, + { packageName, specifier: `${packageName}/fi%xture` }, + ); + expect(node).toBe("specific.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: `node_modules/${packageName}/specific.cjs`, + resolution_status: "resolved", + }); + }, + ); + + it("resolves a percent-package subpath despite malformed parent metadata", async () => { + const { node, node_error, outcome } = await compareWithNode( + undefined, + "require", + TARGETS, + { + packageName: "fi%xture", + specifier: "fi%xture/specific.cjs", + metadataText: "{", + }, + ); + expect(node).toBe("specific.cjs"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/fi%xture/specific.cjs", + resolution_status: "resolved", + }); + }); +}); + describe("nested and array package export targets", () => { it.each([ [