diff --git a/src/application/JavaScriptArtifactPathResolution.ts b/src/application/JavaScriptArtifactPathResolution.ts index 7708366e11..f00e3795b4 100644 --- a/src/application/JavaScriptArtifactPathResolution.ts +++ b/src/application/JavaScriptArtifactPathResolution.ts @@ -448,7 +448,8 @@ const packageExport = ( return { status: "invalid" }; const flattened = exportTargets(root, packageExportConditions(moduleKind)); if (flattened.kind === "invalid") return { status: "invalid" }; - const first = flattened.values[0]; + const first = + flattened.kind === "unmatched" ? undefined : flattened.values[0]; return first === undefined ? { status: "unmatched", declared: Object.keys(root) } : { status: "value", value: first }; @@ -456,38 +457,45 @@ const packageExport = ( type ExportTargets = | { readonly kind: "targets"; readonly values: readonly string[] } + | { readonly kind: "unmatched" } | { readonly kind: "invalid" }; /** - * Flatten one exports target into the ordered strings Node would consider. - * A condition object stops at its first active key, so a null or unmatched - * nested target ends the search rather than falling through to a later key. An - * array instead skips the entries it cannot resolve and concatenates the rest. + * Resolve a target in Node's declared order. Explicit null blocks an active + * condition, whereas an unmatched nested object permits the next condition. + * An array skips unmatched and invalid entries, stops at its first string, and + * preserves the final invalid-versus-null refusal if no string is selected. */ const exportTargets = ( value: unknown, conditions: ReadonlySet, ): ExportTargets => { if (typeof value === "string") - return value.length > 0 + return value.startsWith("./") ? { kind: "targets", values: [value] } : { kind: "invalid" }; if (value === null) return { kind: "targets", values: [] }; if (Array.isArray(value)) { - const values: string[] = []; + let invalid = false; for (const entry of value) { const nested = exportTargets(entry, conditions); - if (nested.kind === "invalid") return nested; - values.push(...nested.values); + if (nested.kind === "invalid") { + invalid = true; + continue; + } + if (nested.kind === "unmatched") continue; + invalid = false; + if (nested.values.length > 0) return nested; } - return { kind: "targets", values }; + return invalid ? { kind: "invalid" } : { kind: "targets", values: [] }; } if (typeof value !== "object") return { kind: "invalid" }; for (const [condition, target] of Object.entries(value)) { if (!conditions.has(condition)) continue; - return exportTargets(target, conditions); + const nested = exportTargets(target, conditions); + if (nested.kind !== "unmatched") return nested; } - return { kind: "targets", values: [] }; + return { kind: "unmatched" }; }; /** diff --git a/tests/boundary/javascript/packageConditionalExports.test.ts b/tests/boundary/javascript/packageConditionalExports.test.ts index e11e865601..a85e3ba4c6 100644 --- a/tests/boundary/javascript/packageConditionalExports.test.ts +++ b/tests/boundary/javascript/packageConditionalExports.test.ts @@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { resolveArtifactPathByContext, type ArtifactPathResolution, @@ -18,29 +19,34 @@ const TARGETS = [ "array.cjs", "nested.cjs", ]; -const SCRIPT = - 'import { createRequire } from "node:module"; console.log((await import("example")).default ?? createRequire(import.meta.url)("example"))'; -/** Resolve the bare `example` specifier through the Node resolver that owns it. */ -const nodeTarget = async ( - cwd: string, - moduleKind: "import" | "require", -): Promise => { - try { - const { stdout } = await execute( - process.execPath, - [ - "--input-type=module", - "-e", - moduleKind === "import" - ? 'console.log((await import("example")).default)' - : 'import { createRequire } from "node:module"; console.log(createRequire(import.meta.url)("example"))', - ], - { cwd }, - ); - return stdout.trim(); - } catch { - return null; - } +const nodeExportOutcomeSchema = z.strictObject({ + target: z.string().nullable(), + error_code: z.string().nullable(), +}); + +/** Resolve through the real Node resolver, retaining its refusal reason. */ +const nodeTarget = async (cwd: string, moduleKind: "import" | "require") => { + const expression = + moduleKind === "import" + ? '(await import("example")).default' + : 'createRequire(import.meta.url)("example")'; + const { stdout } = await execute( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { createRequire } from "node:module"; + try { + console.log(JSON.stringify({target: ${expression}, error_code: null})); + } catch (error) { + console.log(JSON.stringify({target: null, error_code: error.code})); + } + `, + ], + { cwd }, + ); + return nodeExportOutcomeSchema.parse(JSON.parse(stdout)); }; /** * Install one real package on disk and mirror it into an artifact inventory, @@ -52,6 +58,7 @@ const compareWithNode = async ( present: readonly string[] = TARGETS, ): Promise<{ readonly node: string | null; + readonly node_error: string | null; readonly outcome: ArtifactPathResolution; }> => { const root = await createTestTempDirectory("rea-exports-"); @@ -83,8 +90,10 @@ const compareWithNode = async ( }, ]), ); + const node = await nodeTarget(root, moduleKind); return { - node: await nodeTarget(root, moduleKind), + node: node.target, + node_error: node.error_code, outcome: resolveArtifactPathByContext({ declaredPath: "example", sourcePath: "main.js", @@ -171,6 +180,13 @@ describe("nested and array package export targets", () => { "nested.cjs", ], [{ import: { default: "./default.cjs" } }, "default.cjs"], + [ + { import: { browser: "./specific.cjs" }, default: "./default.cjs" }, + "default.cjs", + ], + [{ import: [42, "./array.cjs"] }, "array.cjs"], + [{ import: ["./specific.cjs", 42] }, "specific.cjs"], + [{ import: ["../outside.cjs", "./array.cjs"] }, "array.cjs"], [{ import: { node: { default: "./default.cjs" } } }, "default.cjs"], [{ import: [{ node: "./nested.cjs" }, "./array.cjs"] }, "nested.cjs"], [{ import: [{ browser: "./x.cjs" }, "./array.cjs"] }, "array.cjs"], @@ -226,3 +242,29 @@ describe("unmatched package export conditions", () => { expect(outcome.limitations[0]).toContain("not valid package JSON"); }); }); + +describe("package exports fallback refusal reasons", () => { + it.each([ + [{ import: [42, 7] }, "ERR_INVALID_PACKAGE_TARGET", "unavailable"], + [{ import: [null, null] }, "ERR_PACKAGE_PATH_NOT_EXPORTED", "external"], + [{ import: [42, null] }, "ERR_PACKAGE_PATH_NOT_EXPORTED", "external"], + [{ import: [null, 42] }, "ERR_INVALID_PACKAGE_TARGET", "unavailable"], + [ + { import: { node: null }, default: "./default.cjs" }, + "ERR_PACKAGE_PATH_NOT_EXPORTED", + "external", + ], + ] as const)( + "matches Node refusal for %j", + async (exportsMap, code, status) => { + const { node, node_error, outcome } = await compareWithNode( + exportsMap, + "import", + ); + expect(node).toBeNull(); + expect(node_error).toBe(code); + expect(outcome.resolved_path).toBeNull(); + expect(outcome.resolution_status).toBe(status); + }, + ); +});