fix(javascript): resolve module URLs and reject invalid exports targets (#1031)

Resolve module URL paths once and reject invalid root package exports targets before normalization. Preserve actionable graph diagnostics and array selection behavior.

Fixes #1040.

Co-authored-by: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com>
Co-authored-by: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-08 02:00:34 -04:00 committed by GitHub
parent 1d13cef5f4
commit 8d5c88f17c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 591 additions and 25 deletions

View File

@ -231,6 +231,15 @@ Package `exports` fallback arrays are supported both at the top level and under
the root `"."` entry. The resolver selects targets in declared order using the
same conditional and invalid-entry handling as nested exports arrays.
ESM relative module paths and selected package exports targets use URL suffix handling
and one percent-decoding pass. CommonJS relative paths and legacy package main
fields retain literal filename punctuation. Exports targets must start with
`./` and exclude `.`, `..`, and `node_modules` path segments, including their
encoded forms, before URL normalization. Arrays skip invalid exports targets
in declared order; URL decoding and file lookup failures for a selected target
do not select a later entry. Rejected references retain the package metadata
path, original target, failed constraint, and importing source location.
HTML script references resolve to exact inventoried files after applying the
document base and query/fragment rules. CommonJS module lookups retain extension
and directory resolution.

View File

@ -86,10 +86,11 @@ const resolvePackagePath = (
input: ResolveArtifactPathInput,
packageChain: ReadonlySet<string>,
mode: "full" | "files-and-index" = "full",
exportsTargetUrl = false,
): ArtifactPathResolution => {
const rejected = rejectDeclaration(input);
if (rejected !== null) return rejected;
const candidate = contextualCandidate(input);
const candidate = contextualCandidate(input, exportsTargetUrl);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(input, candidate);
if (typeof confined !== "string") return confined;
@ -135,11 +136,13 @@ const rejectDeclaration = (
const contextualCandidate = (
input: ResolveArtifactPathInput,
exportsTargetUrl: boolean,
): string | ArtifactPathResolution => {
const { context } = input;
const declared =
let declared =
context === "html-reference" ||
context === "url-reference" ||
exportsTargetUrl ||
(context === "module-specifier" && input.moduleKind !== "require")
? stripQueryAndFragment(input.declaredPath)
: input.declaredPath;
@ -157,6 +160,24 @@ const contextualCandidate = (
return unresolvedOutcome(input, "external", [
"URL schemes and protocol-relative URLs are outside this local artifact path context.",
]);
// Exports targets use URL paths for both loaders; legacy main fields remain literal.
if (
exportsTargetUrl ||
(context === "module-specifier" && input.moduleKind !== "require")
) {
try {
declared = decodeURIComponent(declared);
} catch (cause: unknown) {
void cause;
return unresolvedOutcome(input, "rejected", [
"The module URL path contains malformed percent encoding.",
]);
}
if (declared.includes("\0"))
return unresolvedOutcome(input, "rejected", [
"The decoded module URL path contains NUL.",
]);
}
const relative = declared.startsWith("/") ? declared.slice(1) : declared;
return declared.startsWith("/")
? relative
@ -328,6 +349,12 @@ const resolveCandidate = (
`Directory package metadata ${packagePath} is not valid package JSON.`,
],
};
if (main.status === "rejected")
return {
resolvedPath: null,
status: "rejected",
limitations: [`Package metadata ${packagePath}: ${main.limitation}`],
};
if (main.status === "unmatched")
return {
resolvedPath: null,
@ -364,7 +391,9 @@ const resolveCandidate = (
if (index !== null) return index;
}
// Preserve recursive artifact package lookup only after legacy file/index fallbacks.
return candidateOutcome(resolvePackagePath(entryInput, chain));
return candidateOutcome(
resolvePackagePath(entryInput, chain, "full", main.source === "exports"),
);
};
const candidateOutcome = (
@ -419,6 +448,7 @@ const packageEntry = (
}
| { readonly status: "missing" }
| { readonly status: "invalid" }
| { readonly status: "rejected"; readonly limitation: string }
| { readonly status: "unmatched"; readonly declared: readonly string[] } => {
try {
const value: unknown = JSON.parse(text);
@ -450,24 +480,53 @@ const packageExport = (
value: unknown,
moduleKind: ResolveArtifactPathInput["moduleKind"],
): PackageExportOutcome => {
if (typeof value === "string") return packagePathValue(value);
if (typeof value !== "object" || value === null) return { status: "invalid" };
const root = Reflect.get(value, ".") ?? value;
if (typeof root === "string") return packagePathValue(root);
if (typeof root !== "object" || root === null) return { status: "invalid" };
const root =
typeof value === "object" && value !== null && Object.hasOwn(value, ".")
? Reflect.get(value, ".")
: value;
const flattened = exportTargets(root, packageExportConditions(moduleKind));
if (flattened.kind === "invalid") return { status: "invalid" };
if (flattened.kind === "invalid")
return { status: "rejected", limitation: flattened.limitation };
const first =
flattened.kind === "unmatched" ? undefined : flattened.values[0];
return first === undefined
? { status: "unmatched", declared: Object.keys(root) }
? {
status: "unmatched",
declared:
typeof root === "object" && root !== null ? Object.keys(root) : [],
}
: { status: "value", value: first };
};
type ExportTargets =
| { readonly kind: "targets"; readonly values: readonly string[] }
| { readonly kind: "unmatched" }
| { readonly kind: "invalid" };
| { readonly kind: "invalid"; readonly limitation: string };
const invalidExportTarget = (
value: unknown,
reason: string,
): ExportTargets => ({
kind: "invalid",
limitation: `The package exports target ${JSON.stringify(value) ?? String(value)} ${reason}.`,
});
// Node checks raw target segments before URL normalization. Compare encoded
// segment names here; full URL decoding follows array target selection.
const forbiddenExportSegment = (value: string): string | undefined =>
value
.slice(2)
.split(/[\\/]/u)
.find((raw) => {
const segment = raw.replace(/%[0-9a-f]{2}/giu, (encoded) =>
String.fromCharCode(Number.parseInt(encoded.slice(1), 16)),
);
return (
segment === "." ||
segment === ".." ||
segment.toLowerCase() === "node_modules"
);
});
/**
* Resolve a target in Node's declared order. Explicit null blocks an active
@ -479,26 +538,39 @@ const exportTargets = (
value: unknown,
conditions: ReadonlySet<string>,
): ExportTargets => {
if (typeof value === "string")
return value.startsWith("./")
if (typeof value === "string") {
if (!value.startsWith("./"))
return invalidExportTarget(value, 'must start with "./"');
const forbidden = forbiddenExportSegment(value);
return forbidden === undefined
? { kind: "targets", values: [value] }
: { kind: "invalid" };
: invalidExportTarget(
value,
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
);
}
if (value === null) return { kind: "targets", values: [] };
if (Array.isArray(value)) {
let invalid = false;
let invalid: string | null = null;
for (const entry of value) {
const nested = exportTargets(entry, conditions);
if (nested.kind === "invalid") {
invalid = true;
invalid = nested.limitation;
continue;
}
if (nested.kind === "unmatched") continue;
invalid = false;
invalid = null;
if (nested.values.length > 0) return nested;
}
return invalid ? { kind: "invalid" } : { kind: "targets", values: [] };
return invalid === null
? { kind: "targets", values: [] }
: { kind: "invalid", limitation: invalid };
}
if (typeof value !== "object") return { kind: "invalid" };
if (typeof value !== "object")
return invalidExportTarget(
value,
"must be a relative string, object, array, or null",
);
for (const [condition, target] of Object.entries(value)) {
if (!conditions.has(condition)) continue;
const nested = exportTargets(target, conditions);
@ -525,7 +597,7 @@ const packageExportConditions = (
type PackageExportOutcome =
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" }
| { readonly status: "rejected"; readonly limitation: string }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
const packagePathValue = (

View File

@ -1,7 +1,14 @@
import { describe, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
const filesFor = (paths: readonly string[]) =>
new Map<string, JavaScriptArtifactFile>(
@ -84,3 +91,241 @@ describe("module specifier punctuation", () => {
},
);
});
describe("ESM module URL decoding", () => {
it.each([
["./plain.mjs", "plain.mjs"],
["./space%20name.mjs", "space name.mjs"],
["./pr%C3%A9load.mjs", "préload.mjs"],
["./name%3F%23.mjs", "name?#.mjs"],
["./percent%2520.mjs?cache=1#v2", "percent%20.mjs"],
])(
"points %s imports to the file loaded by Node",
async (specifier, target) => {
const root = await createTestTempDirectory("rea-module-url-");
const main = join(root, "main.mjs");
await writeFile(
main,
`import value from ${JSON.stringify(specifier)}; console.log(value);`,
);
await writeFile(
join(root, target),
`export default ${JSON.stringify(target)};`,
);
const encoded = specifier.slice(2).split("?", 1)[0]?.split("#", 1)[0];
if (encoded !== undefined && encoded !== target)
await writeFile(join(root, encoded), 'export default "encoded decoy";');
const native = await promisify(execFile)(process.execPath, [main], {
timeout: 5_000,
});
expect(native.stdout.trim()).toBe(target);
const result = await analyzeJavaScriptApplication({
input_path: root,
format: "directory",
});
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
result.value.normalized_result,
);
const imports = graph.edges.filter(
(edge) =>
edge.relation === "imports" &&
edge.properties.specifier === specifier,
);
expect(imports.length).toBeGreaterThan(0);
for (const edge of imports)
expect(edge.properties.resolved_path).toBe(target);
},
);
it("preserves CommonJS literal percent names", () => {
expect(
resolveArtifactPathByContext({
declaredPath: "./space%20name.cjs",
sourcePath: "app/main.js",
context: "module-specifier",
moduleKind: "require",
files: filesFor(["app/space%20name.cjs", "app/space name.cjs"]),
}),
).toMatchObject({
resolved_path: "app/space%20name.cjs",
resolution_status: "resolved",
});
});
it.each(["./invalid%.mjs", "./invalid%C3.mjs", "./nul%00.mjs"])(
"rejects malformed or NUL URL %s",
(declaredPath) => {
expect(
resolveArtifactPathByContext({
declaredPath,
sourcePath: "app/main.js",
context: "module-specifier",
moduleKind: "import",
files: filesFor([`app/${declaredPath.slice(2)}`]),
}),
).toMatchObject({ resolved_path: null, resolution_status: "rejected" });
},
);
});
const packageEntryUrlCases = [
["exports", "./actual%20file.cjs", "actual file.cjs"],
["exports", "./actual.cjs?variant", "actual.cjs"],
["exports", "./actual.cjs#variant", "actual.cjs"],
["exports", "./actual%23file.cjs", "actual#file.cjs"],
["exports", "./actual%3Ffile.cjs", "actual?file.cjs"],
["exports", "./actual%252Ffile.cjs", "actual%2Ffile.cjs"],
["exports", "./actual.cjs", "actual.cjs"],
["main", "./actual%20file.cjs", "actual%20file.cjs"],
["main", "./actual?file.cjs", "actual?file.cjs"],
["main", "./actual#file.cjs", "actual#file.cjs"],
] as const;
describe("package exports URL paths", () => {
it.each(
(["import", "require"] as const).flatMap((moduleKind) =>
packageEntryUrlCases.map(([field, declaredPath, target]) => ({
moduleKind,
field,
declaredPath,
target,
})),
),
)(
"matches Node $moduleKind for $field $declaredPath",
async ({ moduleKind, field, declaredPath, target }) => {
const root = await createTestTempDirectory("rea-package-export-url-");
const packageRoot = join(root, "node_modules", "fixture");
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(packageRoot, "package.json"),
JSON.stringify({
name: "fixture",
[field]: declaredPath,
}),
);
await writeFile(
join(packageRoot, target),
`module.exports=${JSON.stringify(target)};`,
);
const literal = declaredPath.slice(2).split("?", 1)[0]?.split("#", 1)[0];
if (literal !== undefined && literal !== target)
await writeFile(
join(packageRoot, literal),
'module.exports="literal decoy";',
);
const main = join(
root,
moduleKind === "import" ? "main.mjs" : "main.cjs",
);
await writeFile(
main,
moduleKind === "import"
? 'import value from "fixture"; console.log(value);'
: 'console.log(require("fixture"));',
);
const native = await promisify(execFile)(process.execPath, [main], {
timeout: 5_000,
});
expect(native.stdout.trim()).toBe(target);
const result = await analyzeJavaScriptApplication({
input_path: root,
format: "directory",
});
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
result.value.normalized_result,
);
const imports = graph.edges.filter(
(edge) =>
edge.relation === "imports" &&
edge.properties.specifier === "fixture",
);
expect(imports.length).toBeGreaterThan(0);
for (const edge of imports)
expect(edge.properties.resolved_path).toBe(
`node_modules/fixture/${target}`,
);
},
);
it.each(
(["import", "require"] as const).flatMap((moduleKind) =>
[
"./actual%file.cjs",
"./actual%E0%A4file.cjs",
"./actual%00file.cjs",
"./actual%2Ffile.cjs",
"./actual%5Cfile.cjs",
"./actual%2Efile.cjs",
"./../../../../escape.cjs",
].map((declaredPath) => ({ moduleKind, declaredPath })),
),
)(
"rejects $moduleKind URL $declaredPath without borrowing a literal decoy",
({ moduleKind, declaredPath }) => {
const packagePath = "app/node_modules/fixture/package.json";
const files = filesFor([
packagePath,
`app/node_modules/fixture/${declaredPath.slice(2)}`,
]);
const metadata = files.get(packagePath);
if (metadata === undefined) throw new Error("Missing fixture metadata");
files.set(packagePath, {
...metadata,
kind: "package-json",
text: {
included: true,
value: JSON.stringify({ exports: declaredPath }),
},
});
expect(
resolveArtifactPathByContext({
declaredPath: "fixture",
sourcePath: "app/main.js",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({ resolved_path: null, resolution_status: "rejected" });
},
);
it.each(["import", "require"] as const)(
"keeps %s exports inside their inventoried container",
(moduleKind) => {
const packagePath = "app/node_modules/fixture/package.json";
const target = "app/node_modules/fixture/actual file.cjs";
const files = filesFor([packagePath, target]);
const metadata = files.get(packagePath);
const crossContainer = files.get(target);
if (metadata === undefined || crossContainer === undefined)
throw new Error("Missing fixture files");
files.set(packagePath, {
...metadata,
kind: "package-json",
text: {
included: true,
value: '{"exports":"./actual%20file.cjs"}',
},
});
files.set(target, {
...crossContainer,
container_sha256: "c".repeat(64),
});
expect(
resolveArtifactPathByContext({
declaredPath: "fixture",
sourcePath: "app/main.js",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({ resolved_path: null, resolution_status: "not-found" });
},
);
});

View File

@ -0,0 +1,231 @@
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { z } from "zod";
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const cases: readonly {
name: string;
exports: unknown;
expected: string | null;
nodeError?: string;
rejectedTarget?: string;
}[] = [
...[
"./../outside.cjs",
"./lib/../actual.cjs",
"././actual.cjs",
"./node_modules/actual.cjs",
"./NODE_MODULES/actual.cjs",
"./%2e%2e/outside.cjs",
"./n%6fde_modules/actual.cjs",
"./lib\\..\\actual.cjs",
"../outside.cjs",
"actual.cjs",
"/actual.cjs",
"file:./actual.cjs",
"",
].map((target) => ({
name: `invalid target ${JSON.stringify(target)}`,
exports: target,
expected: null,
nodeError: "ERR_INVALID_PACKAGE_TARGET",
rejectedTarget: target,
})),
{
name: "root subpath rejects a parent segment",
exports: { ".": "./../outside.cjs" },
expected: null,
nodeError: "ERR_INVALID_PACKAGE_TARGET",
rejectedTarget: "./../outside.cjs",
},
{
name: "active condition rejects instead of selecting default",
exports: { node: "./../outside.cjs", default: "./actual.cjs" },
expected: null,
nodeError: "ERR_INVALID_PACKAGE_TARGET",
rejectedTarget: "./../outside.cjs",
},
{
name: "top-level array skips an invalid target",
exports: ["./../outside.cjs", "./actual.cjs"],
expected: "node_modules/fixture/actual.cjs",
},
{
name: "root array skips encoded invalid segments",
exports: { ".": ["./%2e%2e/outside.cjs", "./actual.cjs"] },
expected: "node_modules/fixture/actual.cjs",
},
{
name: "nested arrays skip invalid package-local segments",
exports: { node: [["./n%6fde_modules/actual.cjs", "./actual.cjs"]] },
expected: "node_modules/fixture/actual.cjs",
},
{
name: "last invalid target retains its reason",
exports: ["./../outside.cjs", "././actual.cjs"],
expected: null,
nodeError: "ERR_INVALID_PACKAGE_TARGET",
rejectedTarget: "././actual.cjs",
},
{
name: "null following an invalid target blocks exports",
exports: ["./../outside.cjs", null],
expected: null,
nodeError: "ERR_PACKAGE_PATH_NOT_EXPORTED",
},
{
name: "an invalid target following null remains invalid",
exports: [null, "./../outside.cjs"],
expected: null,
nodeError: "ERR_INVALID_PACKAGE_TARGET",
rejectedTarget: "./../outside.cjs",
},
{
name: "inactive invalid condition does not block default",
exports: { browser: "./../outside.cjs", default: "./actual.cjs" },
expected: "node_modules/fixture/actual.cjs",
},
...["./bad%.cjs", "./bad%C3.cjs"].map((target) => ({
name: `malformed URL ${target} is selected before URL decoding`,
exports: [target, "./actual.cjs"],
expected: null,
nodeError: "URIError",
})),
{
name: "encoded separator is selected before final path refusal",
exports: ["./lib%2Factual.cjs", "./actual.cjs"],
expected: null,
nodeError: "ERR_INVALID_MODULE_SPECIFIER",
},
{
name: "missing first target does not select a second file",
exports: ["./missing.cjs", "./actual.cjs"],
expected: null,
nodeError: "MODULE_NOT_FOUND",
},
{
name: "nested directory is valid",
exports: "./lib/actual.cjs",
expected: "node_modules/fixture/lib/actual.cjs",
},
{
name: "node_modules substring is not a forbidden segment",
exports: "./node_modules.cjs",
expected: "node_modules/fixture/node_modules.cjs",
},
{
name: "encoded underscore and letters do not borrow a forbidden directory",
exports: ["./%6eode%5fmodules/actual.cjs", "./actual.cjs"],
expected: "node_modules/fixture/actual.cjs",
},
{
name: "percent encoding is applied once",
exports: "./literal%252e.cjs",
expected: "node_modules/fixture/literal%2e.cjs",
},
];
const nativeResultSchema = z.object({
value: z.string().nullable(),
code: z.string().nullable(),
});
it.each(
(["import", "require"] as const).flatMap((moduleKind) =>
cases.map((testCase) => ({ ...testCase, moduleKind })),
),
)(
"matches Node $moduleKind target selection for $name",
async ({
moduleKind,
name,
exports,
expected,
nodeError,
rejectedTarget,
}) => {
const root = await createTestTempDirectory("rea-package-target-");
const mainPath = moduleKind === "import" ? "main.mjs" : "main.cjs";
const files = {
[mainPath]:
moduleKind === "import"
? 'import value from "fixture"; export default value;'
: 'module.exports = require("fixture");',
"node_modules/fixture/package.json": JSON.stringify({ exports }),
...Object.fromEntries(
[
"node_modules/fixture/actual.cjs",
"node_modules/outside.cjs",
"node_modules/fixture/lib/actual.cjs",
"node_modules/fixture/node_modules/actual.cjs",
"node_modules/fixture/node_modules.cjs",
"node_modules/fixture/literal%2e.cjs",
].map((path) => [path, `module.exports = ${JSON.stringify(path)};`]),
),
};
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 runner =
moduleKind === "import"
? 'const load = async () => (await import("fixture")).default;'
: 'const load = async () => require("fixture");';
const native = await promisify(execFile)(
process.execPath,
[
"--input-type",
moduleKind === "import" ? "module" : "commonjs",
"--eval",
`${runner} load().then(value => console.log(JSON.stringify({value,code:null})), error => console.log(JSON.stringify({value:null,code:error.code ?? error.name})));`,
],
{ cwd: root, timeout: 5_000 },
);
const oracle = nativeResultSchema.parse(JSON.parse(native.stdout));
expect(oracle.value, name).toBe(expected);
if (nodeError !== undefined)
expect(oracle.code).toBe(
moduleKind === "import" && nodeError === "MODULE_NOT_FOUND"
? "ERR_MODULE_NOT_FOUND"
: nodeError,
);
const result = await reconstructJavaScriptArtifact({
input_path: root,
format: "directory",
});
const edge = result.graph.edges.find(
({ properties }) =>
properties.specifier === "fixture" &&
properties.resolution_status !== undefined &&
properties.resolution_status !== null,
);
expect(edge).toBeDefined();
expect(edge?.properties.resolved_path).toBe(expected);
if (expected !== null) {
expect(edge?.properties.resolution_status).toBe("resolved");
} else {
expect(edge?.properties.resolution_status).not.toBe("resolved");
}
if (rejectedTarget !== undefined) {
expect(edge?.properties.resolution_status).toBe("rejected");
expect(edge?.evidence.limitations.join(" ")).toContain(
JSON.stringify(rejectedTarget),
);
expect(edge?.evidence.limitations.join(" ")).toContain("exports target");
expect(edge?.evidence.limitations.join(" ")).not.toContain(
"not valid package JSON",
);
expect(edge?.evidence.location).toMatchObject({
value: { kind: "source-range", source: mainPath },
});
}
},
);

View File

@ -237,18 +237,23 @@ describe("unmatched package export conditions", () => {
},
);
it("does not report valid metadata as malformed", async () => {
const { outcome } = await compareWithNode({ default: 7 }, "import");
expect(outcome.resolution_status).toBe("unavailable");
expect(outcome.limitations[0]).toContain("not valid package JSON");
const { node_error, outcome } = await compareWithNode(
{ default: 7 },
"import",
);
expect(node_error).toBe("ERR_INVALID_PACKAGE_TARGET");
expect(outcome.resolution_status).toBe("rejected");
expect(outcome.limitations[0]).toContain("exports target 7");
expect(outcome.limitations[0]).not.toContain("not valid package JSON");
});
});
describe("package exports fallback refusal reasons", () => {
it.each([
[{ import: [42, 7] }, "ERR_INVALID_PACKAGE_TARGET", "unavailable"],
[{ import: [42, 7] }, "ERR_INVALID_PACKAGE_TARGET", "rejected"],
[{ 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: [null, 42] }, "ERR_INVALID_PACKAGE_TARGET", "rejected"],
[
{ import: { node: null }, default: "./default.cjs" },
"ERR_PACKAGE_PATH_NOT_EXPORTED",
@ -265,6 +270,10 @@ describe("package exports fallback refusal reasons", () => {
expect(node_error).toBe(code);
expect(outcome.resolved_path).toBeNull();
expect(outcome.resolution_status).toBe(status);
if (code === "ERR_INVALID_PACKAGE_TARGET") {
expect(outcome.limitations[0]).toContain("exports target");
expect(outcome.limitations[0]).not.toContain("not valid package JSON");
}
},
);
});