mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
feat(javascript): resolve exact package import aliases (#1862)
* feat(javascript): resolve exact package import aliases Resolve exact #alias specifiers against the importer's enclosing package.json imports map using the shared condition, array, and null target selection. Nested packages keep their own maps; missing keys, explicit nulls, and out-of-scope aliases report a precise reason. Wildcard, recursive, and external-package import targets remain unresolved. * fix(javascript): keep nested import aliases in CLI graphs Bound nested #alias requires produce application-graph require edges from the enclosing package. Node reports MODULE_NOT_FOUND when a nested package.json has no imports field, without inheriting the ancestor map. * fix(javascript): resolve literal package import keys before path admission * fix(javascript): preserve import keys and invalid-target fallback * fix(javascript): preserve unmatched package target arrays * fix(javascript): retain package target refusal evidence * test(browser): match real verifier startup budget --------- Co-authored-by: morluto <[email protected]>
This commit is contained in:
@@ -9,7 +9,10 @@ provenance; inputs and recovered code are never executed. Recovery requires a
|
||||
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. When size is unknown or large and the connected schema advertises
|
||||
extracted tree. Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
packages use their own map. Wildcard, recursive, and external-package import
|
||||
targets stay unresolved with that limitation. When size is unknown or large and the connected schema advertises
|
||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||
Evidence record with a distinct ID and a `normalized_result.parent_evidence_id`
|
||||
for the complete retained application analysis. Source-map contents are part of
|
||||
|
||||
@@ -452,9 +452,29 @@ such as `./web3:app.js` still names a local artifact.
|
||||
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.
|
||||
Nonempty arrays containing only inactive conditions remain unmatched, allowing
|
||||
the enclosing condition object to continue to a later active condition.
|
||||
Empty arrays and explicit null targets keep their blocking outcome.
|
||||
Refusal evidence distinguishes those source values and retains condition names
|
||||
from unmatched nested targets rather than reporting array indices.
|
||||
For imports and requires, the active Node conditions include `module-sync`;
|
||||
earlier active conditions retain precedence over later ones.
|
||||
|
||||
Exact package-private `#alias` specifiers resolve against the importer's
|
||||
enclosing `package.json` `imports` map — the nearest same-container package
|
||||
scope, stopping at a `node_modules` directory so a nested package cannot inherit
|
||||
an ancestor's aliases. Selected targets are package-local `./` files with no
|
||||
extension or index fallback, using the same condition, array, and null selection
|
||||
as exports subpaths. A missing key, explicit null, or out-of-scope alias keeps
|
||||
an unresolved edge with that reason. Wildcard keys (`#internal/*`), recursive
|
||||
`#` targets, and external-package import targets remain unresolved.
|
||||
|
||||
Exact keys such as `#/x` are literal lookup identities. `#` and names ending
|
||||
in `/` are invalid. Imports arrays skip invalid parent-relative, absolute, and
|
||||
URL targets before selecting a later package-local file; an unsupported bare
|
||||
package or recursive alias keeps its unresolved outcome rather than selecting
|
||||
a later fallback.
|
||||
|
||||
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
|
||||
|
||||
@@ -868,6 +868,365 @@ describe("package exports exact targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias resolution", () => {
|
||||
it.each(["import", "require"] as const)(
|
||||
"resolves an exact #%s alias from the enclosing package imports map",
|
||||
(moduleKind) => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind,
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
declared_path: "#config",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves an alias from a nested file through the enclosing package root", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"src/main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "src/main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a nested package imports map rather than the application root", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"root.cjs": "",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
imports: { "#config": "./local.cjs" },
|
||||
}),
|
||||
"node_modules/fixture/index.js": "",
|
||||
"node_modules/fixture/local.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "node_modules/fixture/index.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "node_modules/fixture/local.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit an ancestor imports map across a nested package.json", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"root.cjs": "",
|
||||
"node_modules/fixture/package.json": "{}",
|
||||
"node_modules/fixture/index.js": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "node_modules/fixture/index.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain(
|
||||
"node_modules/fixture/package.json",
|
||||
);
|
||||
expect(outcome.limitations.join(" ")).not.toContain("root.cjs");
|
||||
});
|
||||
|
||||
it("does not apply a nested package map to the application root importer", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"root.cjs": "",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
imports: { "#config": "./local.cjs" },
|
||||
}),
|
||||
"node_modules/fixture/local.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "root.cjs",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias target selection", () => {
|
||||
it("selects import and require conditions from an imports target", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: {
|
||||
"#config": {
|
||||
import: "./config.mjs",
|
||||
require: "./config.cjs",
|
||||
default: "./fallback.js",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.mjs": "",
|
||||
"config.cjs": "",
|
||||
"fallback.js": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}).resolved_path,
|
||||
).toBe("config.mjs");
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}).resolved_path,
|
||||
).toBe("config.cjs");
|
||||
});
|
||||
|
||||
it("resolves array import targets at their first string entry", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": [{ import: "./missing.mjs" }, "./config.cjs"] },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add an extension or index to a selected import target", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
"config/index.js": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "not-found",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain(
|
||||
"import targets are exact files",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias refusals", () => {
|
||||
it("reports a missing exact alias without looking in another package", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#other",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain("#other");
|
||||
expect(outcome.limitations.join(" ")).toContain("#config");
|
||||
});
|
||||
|
||||
it("reports an explicit null import target as blocked", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": null },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain("explicit null");
|
||||
});
|
||||
|
||||
it("leaves wildcard import aliases unresolved", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#internal/*": "./src/*.js" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"src/util.js": "",
|
||||
});
|
||||
const outcome = resolve({
|
||||
declaredPath: "#internal/util",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toMatch(/wildcard/i);
|
||||
});
|
||||
|
||||
it("leaves recursive and external-package import targets unresolved", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#loop": "#config", "#dep": "fixture" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"node_modules/fixture/index.js": "",
|
||||
});
|
||||
const recursive = resolve({
|
||||
declaredPath: "#loop",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
const external = resolve({
|
||||
declaredPath: "#dep",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(recursive).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(recursive.limitations.join(" ")).toMatch(/recursive/i);
|
||||
expect(external).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(external.limitations.join(" ")).toMatch(/external-package/i);
|
||||
});
|
||||
|
||||
it("rejects reserved hash specifiers", () => {
|
||||
const files = aliasFiles({
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.js": "",
|
||||
"config.cjs": "",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "#",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
|
||||
});
|
||||
|
||||
it("does not use a foreign-container enclosing package.json", () => {
|
||||
const files = fileMap([
|
||||
file(
|
||||
"package.json",
|
||||
"other",
|
||||
JSON.stringify({ imports: { "#config": "./config.cjs" } }),
|
||||
),
|
||||
file("main.js", "root"),
|
||||
file("config.cjs", "root"),
|
||||
]);
|
||||
const outcome = resolve({
|
||||
declaredPath: "#config",
|
||||
sourcePath: "main.js",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain("package scopes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory package entrypoint precedence", () => {
|
||||
it.each([
|
||||
["require", '{"main":"actual.cjs"}', "actual.cjs"],
|
||||
@@ -1211,6 +1570,15 @@ const file = (
|
||||
: { included: true, value: text },
|
||||
});
|
||||
|
||||
const aliasFiles = (
|
||||
entries: Readonly<Record<string, string | null>>,
|
||||
): ReturnType<typeof fileMap> =>
|
||||
fileMap(
|
||||
Object.entries(entries).map(([path, text]) =>
|
||||
file(path, "root", text ?? ""),
|
||||
),
|
||||
);
|
||||
|
||||
describe("exact HTML resource resolution", () => {
|
||||
it.each([
|
||||
["extension", ["app.js"]],
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
percentDecodeUrlPath,
|
||||
stripQueryAndFragment,
|
||||
} from "../../domain/artifactPathSyntax.js";
|
||||
import {
|
||||
classifyPackageImportSpecifier,
|
||||
classifyPackageImportTargetString,
|
||||
lookupExactPackageImport,
|
||||
} from "../../domain/javascript/javascriptPackageImportAliases.js";
|
||||
|
||||
type ArtifactPathResolutionContext =
|
||||
| "package-entrypoint"
|
||||
@@ -103,6 +108,13 @@ export const resolveArtifactPathByContext = (
|
||||
};
|
||||
|
||||
const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
|
||||
// Package import keys are literal lookup identities, not artifact paths.
|
||||
// The selected target is admitted separately by resolveExportTarget.
|
||||
if (
|
||||
input.context === "module-specifier" &&
|
||||
input.declaredPath.startsWith("#")
|
||||
)
|
||||
return resolvePackageImportAlias(input, input.declaredPath);
|
||||
const rejected = rejectDeclaration(input.declaredPath);
|
||||
if (rejected !== null) return rejected;
|
||||
const candidate = contextualCandidate(input);
|
||||
@@ -202,9 +214,9 @@ const bareModuleCandidate = (
|
||||
declared: string,
|
||||
): CandidateResolution => {
|
||||
const packageName = barePackageName(declared);
|
||||
if (packageName === null || isBuiltin(declared) || declared.startsWith("#"))
|
||||
if (packageName === null || isBuiltin(declared))
|
||||
return unresolvedCandidate("external", [
|
||||
"The bare specifier is a Node builtin, package import map, or invalid package name.",
|
||||
"The bare specifier is a Node builtin or invalid package name.",
|
||||
]);
|
||||
if (input.moduleKind === "import" && packageName.includes("%"))
|
||||
return unresolvedCandidate("rejected", [
|
||||
@@ -235,6 +247,115 @@ const bareModuleCandidate = (
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Nearest same-container package.json, stopping at a `node_modules` directory
|
||||
* so a nested package cannot inherit an ancestor's map.
|
||||
*/
|
||||
const enclosingPackageMetadata = (
|
||||
input: ResolveArtifactPathInput,
|
||||
):
|
||||
| Extract<PackageMetadata, { readonly kind: "available" | "unavailable" }>
|
||||
| { readonly kind: "none" } => {
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
while (posix.basename(directory) !== "node_modules") {
|
||||
const metadata = readPackageMetadata(input, directory);
|
||||
if (metadata.kind !== "absent") return metadata;
|
||||
if (directory === "." || directory === "") break;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
return { kind: "none" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an exact `#alias` against the importer's enclosing `imports` map.
|
||||
* Selected relative targets are exact files; patterns, recursive aliases, and
|
||||
* external-package targets stay unresolved without falling through.
|
||||
*/
|
||||
const resolvePackageImportAlias = (
|
||||
input: ResolveArtifactPathInput,
|
||||
specifier: string,
|
||||
): CandidateResolution => {
|
||||
const classified = classifyPackageImportSpecifier(specifier);
|
||||
if (classified.kind === "invalid")
|
||||
return unresolvedCandidate("rejected", [
|
||||
`The package import specifier ${JSON.stringify(specifier)} is invalid; "#" and specifiers that end with "/" are not valid import aliases.`,
|
||||
]);
|
||||
if (classified.kind === "pattern")
|
||||
return unresolvedCandidate("external", [
|
||||
`Wildcard and directory package import aliases remain unresolved; ${JSON.stringify(specifier)} is not an exact imports key.`,
|
||||
]);
|
||||
const metadata = enclosingPackageMetadata(input);
|
||||
if (metadata.kind === "none")
|
||||
return unresolvedCandidate("external", [
|
||||
`No enclosing package.json defines package import alias ${specifier}; package import maps do not cross package scopes.`,
|
||||
]);
|
||||
if (metadata.kind === "unavailable")
|
||||
return unresolvedCandidate("unavailable", [metadata.limitation]);
|
||||
const lookup = lookupExactPackageImport(metadata.imports, specifier);
|
||||
if (lookup.kind === "undefined-map")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} does not define package import alias ${specifier}.`,
|
||||
]);
|
||||
if (lookup.kind === "missing-key")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} does not define package import alias ${specifier}; it declares ${lookup.declared.join(", ") || "no imports keys"}.`,
|
||||
]);
|
||||
if (lookup.kind === "pattern-unresolved")
|
||||
return unresolvedCandidate("external", [
|
||||
`Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`,
|
||||
]);
|
||||
const selected = selectPackageImportTarget(lookup.target, input.moduleKind);
|
||||
if (selected.status === "rejected")
|
||||
return unresolvedCandidate("rejected", [
|
||||
`Package metadata ${metadata.path}: ${selected.limitation}`,
|
||||
]);
|
||||
if (selected.status === "unmatched")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} declares no imports target for alias ${specifier} under the active conditions; it declares ${selected.declared.join(", ") || "no conditions"}.`,
|
||||
]);
|
||||
if (selected.status === "blocked")
|
||||
return unresolvedCandidate("external", [
|
||||
`Package metadata ${metadata.path} blocks import alias ${specifier} with ${selected.cause === "null" ? "an explicit null imports target" : "an empty imports target array"}.`,
|
||||
]);
|
||||
if (selected.status === "non-relative") {
|
||||
const target = classifyPackageImportTargetString(selected.value);
|
||||
return unresolvedCandidate("external", [
|
||||
target.kind === "recursive"
|
||||
? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`
|
||||
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
|
||||
]);
|
||||
}
|
||||
return resolveExportTarget(input, metadata.path, selected.value, "imports");
|
||||
};
|
||||
|
||||
const selectPackageImportTarget = (
|
||||
value: unknown,
|
||||
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
||||
):
|
||||
| { readonly status: "value"; readonly value: string }
|
||||
| { readonly status: "non-relative"; readonly value: string }
|
||||
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
|
||||
| { readonly status: "unmatched"; readonly declared: readonly string[] }
|
||||
| { readonly status: "rejected"; readonly limitation: string } => {
|
||||
const selected = selectExportTarget(
|
||||
value,
|
||||
packageExportConditions(moduleKind),
|
||||
"imports",
|
||||
);
|
||||
if (selected.kind === "invalid" || selected.kind === "invalid-config")
|
||||
return { status: "rejected", limitation: selected.limitation };
|
||||
if (selected.kind === "unmatched")
|
||||
return {
|
||||
status: "unmatched",
|
||||
declared: selected.declared,
|
||||
};
|
||||
if (selected.kind === "blocked")
|
||||
return { status: "blocked", cause: selected.cause };
|
||||
if (selected.kind === "non-relative")
|
||||
return { status: "non-relative", value: selected.value };
|
||||
return { status: "value", value: selected.value };
|
||||
};
|
||||
|
||||
/** Self references use the nearest package scope before dependency lookup. */
|
||||
const commonJsSelfCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
@@ -242,35 +363,22 @@ const commonJsSelfCandidate = (
|
||||
): 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;
|
||||
const metadata = enclosingPackageMetadata(input);
|
||||
if (metadata.kind === "none") return null;
|
||||
if (metadata.kind === "unavailable")
|
||||
return unresolvedCandidate("unavailable", [metadata.limitation]);
|
||||
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);
|
||||
};
|
||||
|
||||
/** Package roots and exact subpaths share metadata and exports selection. */
|
||||
@@ -324,16 +432,17 @@ const resolvePackageExports = (
|
||||
]);
|
||||
if (exported.status === "blocked")
|
||||
return unresolvedCandidate("external", [
|
||||
`Directory package metadata ${packagePath} blocks subpath ${subpath} with an explicit null exports target.`,
|
||||
`Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`,
|
||||
]);
|
||||
return resolveExportTarget(input, packagePath, exported.value);
|
||||
};
|
||||
|
||||
/** An exports target is a URL naming one exact file for both Node loaders. */
|
||||
/** An exports or import target is a URL naming one exact file for both Node loaders. */
|
||||
const resolveExportTarget = (
|
||||
input: ResolveArtifactPathInput,
|
||||
packagePath: string,
|
||||
target: string,
|
||||
mapKind: "exports" | "imports" = "exports",
|
||||
): CandidateResolution => {
|
||||
const rejected = rejectDeclaration(target);
|
||||
if (rejected !== null) return rejected;
|
||||
@@ -343,7 +452,7 @@ const resolveExportTarget = (
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
return (
|
||||
resolveFileCandidates(input, [candidate]) ??
|
||||
exactExportsTargetNotFound(candidate)
|
||||
exactPackageTargetNotFound(candidate, mapKind)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -492,6 +601,7 @@ type PackageMetadata =
|
||||
readonly path: string;
|
||||
readonly name: string | undefined;
|
||||
readonly exports: string | object | undefined;
|
||||
readonly imports: object | undefined;
|
||||
readonly main: unknown;
|
||||
readonly module: unknown;
|
||||
}
|
||||
@@ -532,6 +642,7 @@ const readPackageMetadata = (
|
||||
};
|
||||
const name = Reflect.get(value, "name");
|
||||
const exports = Reflect.get(value, "exports");
|
||||
const imports = Reflect.get(value, "imports");
|
||||
return {
|
||||
kind: "available",
|
||||
path,
|
||||
@@ -543,6 +654,11 @@ const readPackageMetadata = (
|
||||
(typeof exports === "object" && exports !== null)
|
||||
? exports
|
||||
: undefined,
|
||||
// Import maps must be non-array objects; other shapes are not a map.
|
||||
imports:
|
||||
typeof imports === "object" && imports !== null && !Array.isArray(imports)
|
||||
? imports
|
||||
: undefined,
|
||||
main: Reflect.get(value, "main"),
|
||||
module: Reflect.get(value, "module"),
|
||||
};
|
||||
@@ -644,10 +760,12 @@ const fileCandidates = (candidate: string): readonly string[] => [
|
||||
const indexCandidates = (candidate: string): readonly string[] =>
|
||||
EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`));
|
||||
|
||||
type PackageTargetBlockCause = "null" | "empty-array";
|
||||
|
||||
type PackageExportOutcome =
|
||||
| { readonly status: "value"; readonly value: string }
|
||||
| { readonly status: "rejected"; readonly limitation: string }
|
||||
| { readonly status: "blocked" }
|
||||
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
|
||||
| { readonly status: "unmatched"; readonly declared: readonly string[] };
|
||||
|
||||
/** Only exact non-directory keys participate; wildcard expansion is unsupported. */
|
||||
@@ -687,29 +805,33 @@ const selectPackageExport = (
|
||||
if (selected.kind === "unmatched")
|
||||
return {
|
||||
status: "unmatched",
|
||||
declared:
|
||||
typeof target === "object" && target !== null
|
||||
? Object.keys(target)
|
||||
: declared,
|
||||
declared: selected.declared,
|
||||
};
|
||||
return selected.kind === "blocked"
|
||||
? { status: "blocked" }
|
||||
: { status: "value", value: selected.value };
|
||||
if (selected.kind === "blocked")
|
||||
return { status: "blocked", cause: selected.cause };
|
||||
if (selected.kind === "non-relative")
|
||||
return {
|
||||
status: "rejected",
|
||||
limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`,
|
||||
};
|
||||
return { status: "value", value: selected.value };
|
||||
};
|
||||
|
||||
type ExportTargetOutcome =
|
||||
| { readonly kind: "value"; readonly value: string }
|
||||
| { readonly kind: "blocked" }
|
||||
| { readonly kind: "unmatched" }
|
||||
| { readonly kind: "non-relative"; readonly value: string }
|
||||
| { readonly kind: "blocked"; readonly cause: PackageTargetBlockCause }
|
||||
| { readonly kind: "unmatched"; readonly declared: readonly string[] }
|
||||
| { readonly kind: "invalid"; readonly limitation: string }
|
||||
| { readonly kind: "invalid-config"; readonly limitation: string };
|
||||
|
||||
const invalidExportTarget = (
|
||||
value: unknown,
|
||||
reason: string,
|
||||
mapKind: "exports" | "imports" = "exports",
|
||||
): ExportTargetOutcome => ({
|
||||
kind: "invalid",
|
||||
limitation: `The package exports target ${JSON.stringify(value) ?? String(value)} ${reason}.`,
|
||||
limitation: `The package ${mapKind === "imports" ? "import" : "exports"} target ${JSON.stringify(value) ?? String(value)} ${reason}.`,
|
||||
});
|
||||
|
||||
// Node checks raw target segments before URL normalization. Compare encoded
|
||||
@@ -729,50 +851,80 @@ const forbiddenExportSegment = (value: string): string | undefined =>
|
||||
);
|
||||
});
|
||||
|
||||
const selectStringPackageTarget = (
|
||||
value: string,
|
||||
mapKind: "exports" | "imports",
|
||||
): ExportTargetOutcome => {
|
||||
if (mapKind === "imports") {
|
||||
const target = classifyPackageImportTargetString(value);
|
||||
if (target.kind === "invalid")
|
||||
return invalidExportTarget(
|
||||
value,
|
||||
"cannot be parent-relative, absolute, or a URL",
|
||||
mapKind,
|
||||
);
|
||||
if (target.kind !== "relative") return { kind: "non-relative", value };
|
||||
} else if (!value.startsWith("./"))
|
||||
return invalidExportTarget(value, 'must start with "./"', mapKind);
|
||||
const forbidden = forbiddenExportSegment(value);
|
||||
return forbidden === undefined
|
||||
? { kind: "value", value }
|
||||
: invalidExportTarget(
|
||||
value,
|
||||
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
|
||||
mapKind,
|
||||
);
|
||||
};
|
||||
|
||||
const selectArrayPackageTarget = (
|
||||
value: readonly unknown[],
|
||||
conditions: ReadonlySet<string>,
|
||||
mapKind: "exports" | "imports",
|
||||
): ExportTargetOutcome => {
|
||||
if (value.length === 0) return { kind: "blocked", cause: "empty-array" };
|
||||
let lastRefusal:
|
||||
| Extract<ExportTargetOutcome, { readonly kind: "invalid" | "blocked" }>
|
||||
| undefined;
|
||||
const declared = new Set<string>();
|
||||
for (const entry of value) {
|
||||
const nested = selectExportTarget(entry, conditions, mapKind);
|
||||
// Node's array fallback catches invalid targets, not invalid configuration.
|
||||
if (nested.kind === "invalid-config") return nested;
|
||||
if (nested.kind === "unmatched") {
|
||||
for (const condition of nested.declared) declared.add(condition);
|
||||
continue;
|
||||
}
|
||||
if (nested.kind === "invalid" || nested.kind === "blocked") {
|
||||
lastRefusal = nested;
|
||||
continue;
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
return lastRefusal ?? { kind: "unmatched", declared: [...declared] };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* preserves the last invalid/null outcome if no string is selected. A nonempty
|
||||
* array of unmatched conditions stays unmatched; an empty array blocks.
|
||||
*/
|
||||
const selectExportTarget = (
|
||||
value: unknown,
|
||||
conditions: ReadonlySet<string>,
|
||||
mapKind: "exports" | "imports" = "exports",
|
||||
): ExportTargetOutcome => {
|
||||
if (typeof value === "string") {
|
||||
if (!value.startsWith("./"))
|
||||
return invalidExportTarget(value, 'must start with "./"');
|
||||
const forbidden = forbiddenExportSegment(value);
|
||||
return forbidden === undefined
|
||||
? { kind: "value", value }
|
||||
: invalidExportTarget(
|
||||
value,
|
||||
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
|
||||
);
|
||||
}
|
||||
if (value === null) return { kind: "blocked" };
|
||||
if (Array.isArray(value)) {
|
||||
let invalid: string | null = null;
|
||||
for (const entry of value) {
|
||||
const nested = selectExportTarget(entry, conditions);
|
||||
// Node's array fallback catches invalid targets, not invalid configuration.
|
||||
if (nested.kind === "invalid-config") return nested;
|
||||
if (nested.kind === "invalid") {
|
||||
invalid = nested.limitation;
|
||||
continue;
|
||||
}
|
||||
if (nested.kind === "unmatched") continue;
|
||||
invalid = null;
|
||||
if (nested.kind === "value") return nested;
|
||||
}
|
||||
return invalid === null
|
||||
? { kind: "blocked" }
|
||||
: { kind: "invalid", limitation: invalid };
|
||||
}
|
||||
if (typeof value === "string")
|
||||
return selectStringPackageTarget(value, mapKind);
|
||||
if (value === null) return { kind: "blocked", cause: "null" };
|
||||
if (Array.isArray(value))
|
||||
return selectArrayPackageTarget(value, conditions, mapKind);
|
||||
if (typeof value !== "object")
|
||||
return invalidExportTarget(
|
||||
value,
|
||||
"must be a relative string, object, array, or null",
|
||||
mapKind,
|
||||
);
|
||||
for (const key of Object.keys(value)) {
|
||||
const numeric = Number(key);
|
||||
@@ -782,12 +934,15 @@ const selectExportTarget = (
|
||||
limitation: `The package exports configuration contains numeric condition key ${JSON.stringify(key)}.`,
|
||||
};
|
||||
}
|
||||
const declared = new Set<string>();
|
||||
for (const [condition, target] of Object.entries(value)) {
|
||||
declared.add(condition);
|
||||
if (!conditions.has(condition)) continue;
|
||||
const nested = selectExportTarget(target, conditions);
|
||||
const nested = selectExportTarget(target, conditions, mapKind);
|
||||
if (nested.kind !== "unmatched") return nested;
|
||||
for (const name of nested.declared) declared.add(name);
|
||||
}
|
||||
return { kind: "unmatched" };
|
||||
return { kind: "unmatched", declared: [...declared] };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -816,11 +971,14 @@ const notFoundCandidate = (): CandidateResolution => ({
|
||||
],
|
||||
});
|
||||
|
||||
const exactExportsTargetNotFound = (path: string): CandidateResolution => ({
|
||||
const exactPackageTargetNotFound = (
|
||||
path: string,
|
||||
mapKind: "exports" | "imports",
|
||||
): 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.`,
|
||||
`The selected package ${mapKind === "imports" ? "import" : "exports"} target ${path} does not exist in the inventoried artifact container; ${mapKind === "imports" ? "import" : "exports"} targets are exact files, so no extension or directory index was tried.`,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
classifyPackageImportSpecifier,
|
||||
classifyPackageImportTargetString,
|
||||
lookupExactPackageImport,
|
||||
} from "./javascriptPackageImportAliases.js";
|
||||
|
||||
describe("package import specifier classification", () => {
|
||||
it("treats an exact hash alias as an exact key", () => {
|
||||
expect(classifyPackageImportSpecifier("#config")).toEqual({
|
||||
kind: "exact",
|
||||
key: "#config",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["#internal/env", "#/hidden"])(
|
||||
"treats %j as an exact key",
|
||||
(specifier) => {
|
||||
expect(classifyPackageImportSpecifier(specifier)).toEqual({
|
||||
kind: "exact",
|
||||
key: specifier,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["#", "#/", "#utils/"])(
|
||||
"rejects reserved specifier %j",
|
||||
(specifier) => {
|
||||
expect(classifyPackageImportSpecifier(specifier)).toEqual({
|
||||
kind: "invalid",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["#internal/*", "#*"])(
|
||||
"leaves pattern specifier %j unresolved",
|
||||
(specifier) => {
|
||||
expect(classifyPackageImportSpecifier(specifier)).toEqual({
|
||||
kind: "pattern",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("exact package import lookup", () => {
|
||||
it("returns the exact target without falling through to another key", () => {
|
||||
expect(
|
||||
lookupExactPackageImport(
|
||||
{ "#config": "./config.cjs", "#other": "./other.cjs" },
|
||||
"#config",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "found",
|
||||
target: "./config.cjs",
|
||||
declared: ["#config", "#other"],
|
||||
});
|
||||
});
|
||||
|
||||
it("names declared exact keys when the requested alias is absent", () => {
|
||||
expect(
|
||||
lookupExactPackageImport({ "#config": "./config.cjs" }, "#missing"),
|
||||
).toEqual({
|
||||
kind: "missing-key",
|
||||
declared: ["#config"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not claim a miss when the map only has pattern keys", () => {
|
||||
expect(
|
||||
lookupExactPackageImport({ "#internal/*": "./src/*.js" }, "#internal/a"),
|
||||
).toEqual({
|
||||
kind: "pattern-unresolved",
|
||||
declared: ["#internal/*"],
|
||||
});
|
||||
});
|
||||
|
||||
it("still selects an exact key when the same map also has patterns", () => {
|
||||
expect(
|
||||
lookupExactPackageImport(
|
||||
{ "#config": "./config.cjs", "#internal/*": "./src/*.js" },
|
||||
"#config",
|
||||
),
|
||||
).toEqual({
|
||||
kind: "found",
|
||||
target: "./config.cjs",
|
||||
declared: ["#config", "#internal/*"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, null, "./config.cjs", ["#config"], 7] as const)(
|
||||
"treats non-object imports value %j as an undefined map",
|
||||
(imports) => {
|
||||
expect(lookupExactPackageImport(imports, "#config")).toEqual({
|
||||
kind: "undefined-map",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat a pattern key as an exact match for itself", () => {
|
||||
expect(
|
||||
lookupExactPackageImport({ "#internal/*": "./src/*.js" }, "#internal/*"),
|
||||
).toEqual({
|
||||
kind: "pattern-unresolved",
|
||||
declared: ["#internal/*"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import target string classification", () => {
|
||||
it("keeps package-local relative targets", () => {
|
||||
expect(classifyPackageImportTargetString("./config.cjs")).toEqual({
|
||||
kind: "relative",
|
||||
target: "./config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks recursive hash aliases as recursive", () => {
|
||||
expect(classifyPackageImportTargetString("#other")).toEqual({
|
||||
kind: "recursive",
|
||||
target: "#other",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["lodash", "pkg/subpath"])(
|
||||
"marks %j as an external-package target",
|
||||
(target) => {
|
||||
expect(classifyPackageImportTargetString(target)).toEqual({
|
||||
kind: "external-package",
|
||||
target,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"../outside.js",
|
||||
"/outside.js",
|
||||
"https://example.invalid/mod.js",
|
||||
"file:///outside.js",
|
||||
"node:fs",
|
||||
])("marks %j as an invalid import target", (target) => {
|
||||
expect(classifyPackageImportTargetString(target)).toEqual({
|
||||
kind: "invalid",
|
||||
target,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Exact package-private import-alias lookup. Node resolves `#` specifiers
|
||||
* against the importer's enclosing package `imports` map; this module owns the
|
||||
* specifier and exact-key rules. Condition, array, and null target selection
|
||||
* stay with the shared package-target machinery. Wildcard keys, recursive
|
||||
* `#` targets, and external-package targets remain explicit unknowns.
|
||||
*/
|
||||
|
||||
/** How one `#` specifier participates in exact import-alias lookup. */
|
||||
export type PackageImportSpecifierClassification =
|
||||
| { readonly kind: "invalid" }
|
||||
| { readonly kind: "pattern" }
|
||||
| { readonly kind: "exact"; readonly key: string };
|
||||
|
||||
/** Outcome of looking up one exact `#` key in a package `imports` object. */
|
||||
export type PackageImportExactLookup =
|
||||
| { readonly kind: "undefined-map" }
|
||||
| {
|
||||
readonly kind: "missing-key";
|
||||
readonly declared: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "pattern-unresolved";
|
||||
readonly declared: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "found";
|
||||
readonly target: unknown;
|
||||
readonly declared: readonly string[];
|
||||
};
|
||||
|
||||
/** How a selected string target is interpreted for package-private imports. */
|
||||
export type PackageImportTargetClassification =
|
||||
| { readonly kind: "relative"; readonly target: string }
|
||||
| { readonly kind: "invalid"; readonly target: string }
|
||||
| { readonly kind: "recursive"; readonly target: string }
|
||||
| { readonly kind: "external-package"; readonly target: string };
|
||||
|
||||
/**
|
||||
* Classify a `#` module specifier before any package-scope walk.
|
||||
* `#` and names ending in `/` are invalid. Other literal keys, including
|
||||
* `#/…`, participate in exact lookup; wildcard expansion remains unsupported.
|
||||
*/
|
||||
export const classifyPackageImportSpecifier = (
|
||||
specifier: string,
|
||||
): PackageImportSpecifierClassification => {
|
||||
if (specifier === "#" || specifier.endsWith("/")) return { kind: "invalid" };
|
||||
if (specifier.includes("*")) return { kind: "pattern" };
|
||||
return { kind: "exact", key: specifier };
|
||||
};
|
||||
|
||||
/** True when an `imports` key is a pattern rather than an exact alias. */
|
||||
export const isPackageImportPatternKey = (key: string): boolean =>
|
||||
key.includes("*") || key.endsWith("/");
|
||||
|
||||
/**
|
||||
* Look up one exact `#` key in a package `imports` value.
|
||||
* A missing exact key is reported as missing when every declared key is also
|
||||
* exact; if the map contains pattern keys, the miss stays pattern-unresolved
|
||||
* rather than claiming the specifier is undefined.
|
||||
*/
|
||||
export const lookupExactPackageImport = (
|
||||
imports: unknown,
|
||||
key: string,
|
||||
): PackageImportExactLookup => {
|
||||
if (typeof imports !== "object" || imports === null || Array.isArray(imports))
|
||||
return { kind: "undefined-map" };
|
||||
const declared = Object.keys(imports);
|
||||
if (Object.hasOwn(imports, key) && !isPackageImportPatternKey(key))
|
||||
return {
|
||||
kind: "found",
|
||||
target: Reflect.get(imports, key),
|
||||
declared,
|
||||
};
|
||||
return declared.some(isPackageImportPatternKey)
|
||||
? { kind: "pattern-unresolved", declared }
|
||||
: { kind: "missing-key", declared };
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a selected string import target. Only package-local `./` paths are
|
||||
* resolved as files; recursive `#` aliases and bare packages stay unknown.
|
||||
* Parent-relative, absolute, and URL targets are invalid, allowing array
|
||||
* selection to skip them before choosing a package-local fallback.
|
||||
*/
|
||||
export const classifyPackageImportTargetString = (
|
||||
target: string,
|
||||
): PackageImportTargetClassification => {
|
||||
if (target.startsWith("./")) return { kind: "relative", target };
|
||||
if (
|
||||
target.startsWith("../") ||
|
||||
target.startsWith("/") ||
|
||||
URL.canParse(target)
|
||||
)
|
||||
return { kind: "invalid", target };
|
||||
if (target.startsWith("#")) return { kind: "recursive", target };
|
||||
return { kind: "external-package", target };
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const literalAliases = ["#/x", "#x%2Fy", "#x%2Ey", "#x\\y", "#x\0y"];
|
||||
const fallbackAliases = {
|
||||
"#inactive-array": {
|
||||
node: [{ browser: "./browser.cjs" }],
|
||||
default: "./config.cjs",
|
||||
},
|
||||
"#nested-inactive-array": {
|
||||
node: [[{ browser: "./browser.cjs" }]],
|
||||
default: "./config.cjs",
|
||||
},
|
||||
"#parent": ["../outside.cjs", "./config.cjs"],
|
||||
"#absolute": { node: [["/outside.cjs"], "./config.cjs"] },
|
||||
"#url": {
|
||||
import: ["https://example.invalid/outside.cjs", "./config.cjs"],
|
||||
require: ["file:///outside.cjs", "./config.cjs"],
|
||||
},
|
||||
};
|
||||
const resolvedAliases = [...literalAliases, ...Object.keys(fallbackAliases)];
|
||||
const refusedAliases = {
|
||||
"#empty": { target: [], limitation: "an empty imports target array" },
|
||||
"#empty-active": {
|
||||
target: { node: [] },
|
||||
limitation: "an empty imports target array",
|
||||
},
|
||||
"#null-last": {
|
||||
target: [[], null],
|
||||
limitation: "an explicit null imports target",
|
||||
},
|
||||
"#empty-last": {
|
||||
target: [null, []],
|
||||
limitation: "an empty imports target array",
|
||||
},
|
||||
"#inactive": {
|
||||
target: [{ browser: "./browser.cjs" }],
|
||||
limitation: "it declares browser.",
|
||||
},
|
||||
"#nested-inactive": {
|
||||
target: [
|
||||
[{ browser: "./browser.cjs" }, { "rea-inactive": "./inactive.cjs" }],
|
||||
],
|
||||
limitation: "it declares browser, rea-inactive.",
|
||||
},
|
||||
};
|
||||
const allAliases = [...resolvedAliases, ...Object.keys(refusedAliases)];
|
||||
|
||||
const createPackageImportAliasFixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-package-import-aliases-");
|
||||
const mainSource = [
|
||||
'const config = require("#config");',
|
||||
'const nested = require("./node_modules/fixture/index.cjs");',
|
||||
'const missing = require("#missing");',
|
||||
...allAliases.map((specifier) => `require(${JSON.stringify(specifier)});`),
|
||||
].join("\n");
|
||||
const moduleSource = allAliases
|
||||
.map((specifier) => `import ${JSON.stringify(specifier)};`)
|
||||
.join("\n");
|
||||
const nestedSource = [
|
||||
'const config = require("#config");',
|
||||
"module.exports = config;",
|
||||
].join("\n");
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "app",
|
||||
imports: {
|
||||
"#config": "./config.cjs",
|
||||
...Object.fromEntries(
|
||||
literalAliases.map((specifier) => [specifier, "./config.cjs"]),
|
||||
),
|
||||
...fallbackAliases,
|
||||
...Object.fromEntries(
|
||||
Object.entries(refusedAliases).map(([specifier, { target }]) => [
|
||||
specifier,
|
||||
target,
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
"main.cjs": mainSource,
|
||||
"main.mjs": moduleSource,
|
||||
"config.cjs": "module.exports = 'app-config';",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
name: "fixture",
|
||||
imports: { "#config": "./local.cjs" },
|
||||
}),
|
||||
"node_modules/fixture/index.cjs": nestedSource,
|
||||
"node_modules/fixture/local.cjs": "module.exports = 'fixture-config';",
|
||||
};
|
||||
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, mainSource, moduleSource, nestedSource };
|
||||
};
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"resolves exact package import aliases with package scope through %s",
|
||||
async (surface, { cli }) => {
|
||||
const { root, mainSource, moduleSource, nestedSource } =
|
||||
await createPackageImportAliasFixture();
|
||||
const nativeRequire = createRequire(join(root, "main.cjs"));
|
||||
expect(
|
||||
relative(root, nativeRequire.resolve("#config")).replaceAll("\\", "/"),
|
||||
).toBe("config.cjs");
|
||||
expect(
|
||||
relative(
|
||||
root,
|
||||
createRequire(join(root, "node_modules/fixture/index.cjs")).resolve(
|
||||
"#config",
|
||||
),
|
||||
).replaceAll("\\", "/"),
|
||||
).toBe("node_modules/fixture/local.cjs");
|
||||
expect(() => nativeRequire.resolve("#missing")).toThrow(
|
||||
expect.objectContaining({ code: "ERR_PACKAGE_IMPORT_NOT_DEFINED" }),
|
||||
);
|
||||
|
||||
let document: unknown;
|
||||
if (surface === "CLI") {
|
||||
const response = await cli.run({
|
||||
arguments: ["analyze-javascript-application", root, "--format", "json"],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
document = response.json;
|
||||
} else {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const response = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
document = response.structuredContent;
|
||||
}
|
||||
const { graph } = z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(document).normalized_result;
|
||||
for (const specifier of resolvedAliases)
|
||||
for (const [moduleKind, sourcePath, source] of [
|
||||
["require", "main.cjs", mainSource],
|
||||
["import", "main.mjs", moduleSource],
|
||||
] as const)
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
module_link_kind: moduleKind,
|
||||
specifier,
|
||||
resolved_path: "config.cjs",
|
||||
resolution_status: "resolved",
|
||||
}),
|
||||
evidence: sourceEvidence(sourcePath, source),
|
||||
}),
|
||||
);
|
||||
const imports = graph.edges.filter(
|
||||
({ properties }) => properties.module_link_kind === "require",
|
||||
);
|
||||
for (const [specifier, { limitation }] of Object.entries(refusedAliases))
|
||||
for (const [moduleKind, sourcePath, source] of [
|
||||
["require", "main.cjs", mainSource],
|
||||
["import", "main.mjs", moduleSource],
|
||||
] as const)
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
module_link_kind: moduleKind,
|
||||
specifier,
|
||||
resolved_path: null,
|
||||
resolution_status: "external",
|
||||
}),
|
||||
evidence: sourceEvidence(sourcePath, source, limitation),
|
||||
}),
|
||||
);
|
||||
expect(imports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
specifier: "#config",
|
||||
resolved_path: "config.cjs",
|
||||
resolution_status: "resolved",
|
||||
}),
|
||||
evidence: sourceEvidence("main.cjs", mainSource),
|
||||
}),
|
||||
);
|
||||
expect(imports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
specifier: "#config",
|
||||
resolved_path: "node_modules/fixture/local.cjs",
|
||||
resolution_status: "resolved",
|
||||
}),
|
||||
evidence: sourceEvidence(
|
||||
"node_modules/fixture/index.cjs",
|
||||
nestedSource,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(imports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
specifier: "#missing",
|
||||
resolved_path: null,
|
||||
resolution_status: "external",
|
||||
}),
|
||||
evidence: expect.objectContaining({
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("does not define package import alias"),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const sourceEvidence = (
|
||||
sourcePath: string,
|
||||
source: string,
|
||||
limitation?: 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,
|
||||
}),
|
||||
}),
|
||||
...(limitation === undefined
|
||||
? {}
|
||||
: {
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining(limitation),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
@@ -29,6 +29,11 @@ const chromeExecutable = [
|
||||
existsSync(candidate),
|
||||
);
|
||||
|
||||
// Match the real-browser verifier: a cold Chrome launch on a loaded coverage
|
||||
// runner can exceed 20 seconds before publishing DevToolsActivePort.
|
||||
const CHROME_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const CHROME_TEST_TIMEOUT_MS = CHROME_STARTUP_TIMEOUT_MS + 30_000;
|
||||
|
||||
// Issue 1586's page, plus the HTML, image, use, and foreign-origin cases the
|
||||
// real snapshot must distinguish. The XHTML page is the prefixed-svg gap
|
||||
// reported on PR 1602. The tests are skipped when no Chrome-family executable
|
||||
@@ -90,7 +95,7 @@ it.skipIf(chromeExecutable === undefined)(
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: 20_000,
|
||||
timeoutMs: CHROME_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${String(port)}`;
|
||||
const provider = new CdpBrowserProvider();
|
||||
@@ -158,6 +163,7 @@ it.skipIf(chromeExecutable === undefined)(
|
||||
});
|
||||
}
|
||||
},
|
||||
CHROME_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const xhtmlPage = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -261,7 +267,7 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: 20_000,
|
||||
timeoutMs: CHROME_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${String(port)}`;
|
||||
const provider = new CdpBrowserProvider();
|
||||
@@ -312,6 +318,7 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [
|
||||
});
|
||||
}
|
||||
},
|
||||
CHROME_TEST_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -742,6 +742,30 @@ describe("nested and array package export targets", () => {
|
||||
);
|
||||
});
|
||||
describe("unmatched package export conditions", () => {
|
||||
it.each([
|
||||
[[{ browser: "./default.cjs" }], "browser"],
|
||||
[
|
||||
[[{ browser: "./default.cjs" }, { "rea-inactive": "./node.cjs" }]],
|
||||
"browser, rea-inactive",
|
||||
],
|
||||
[{ node: [{ browser: "./default.cjs" }] }, "node, browser"],
|
||||
] as const)(
|
||||
"preserves condition names in unmatched target %j",
|
||||
async (target, declared) => {
|
||||
for (const moduleKind of ["import", "require"] as const) {
|
||||
const { node_error, outcome } = await compareWithNode(
|
||||
target,
|
||||
moduleKind,
|
||||
);
|
||||
expect(node_error).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED");
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations[0]).toContain(`It declares ${declared}.`);
|
||||
}
|
||||
},
|
||||
);
|
||||
it.each(["import", "require"] as const)(
|
||||
"names the declared conditions for %s",
|
||||
async (moduleKind) => {
|
||||
@@ -769,6 +793,31 @@ describe("unmatched package export conditions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocked package export provenance", () => {
|
||||
it.each([
|
||||
[[], "an empty exports target array"],
|
||||
[{ node: [] }, "an empty exports target array"],
|
||||
[[null, []], "an empty exports target array"],
|
||||
[[[], null], "an explicit null exports target"],
|
||||
] as const)(
|
||||
"preserves the blocking source value in %j",
|
||||
async (target, limitation) => {
|
||||
for (const moduleKind of ["import", "require"] as const) {
|
||||
const { node_error, outcome } = await compareWithNode(
|
||||
target,
|
||||
moduleKind,
|
||||
);
|
||||
expect(node_error).toBe("ERR_PACKAGE_PATH_NOT_EXPORTED");
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations[0]).toContain(limitation);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("package exports fallback refusal reasons", () => {
|
||||
it.each([
|
||||
[{ import: [42, 7] }, "ERR_INVALID_PACKAGE_TARGET", "rejected"],
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
resolveArtifactPathByContext,
|
||||
type ArtifactPathResolution,
|
||||
} from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js";
|
||||
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const nodeOutcomeSchema = z.strictObject({
|
||||
target: z.string().nullable(),
|
||||
error_code: z.string().nullable(),
|
||||
});
|
||||
|
||||
const posixRelative = (root: string, absolute: string): string =>
|
||||
relative(root, absolute).replaceAll("\\", "/");
|
||||
|
||||
const inventory = (
|
||||
contents: ReadonlyMap<string, string>,
|
||||
): Map<string, JavaScriptArtifactFile> =>
|
||||
new Map(
|
||||
[...contents].map(([path, value]) => [
|
||||
path,
|
||||
{
|
||||
path,
|
||||
container_sha256: "a".repeat(64),
|
||||
sha256: "b".repeat(64),
|
||||
bytes: Buffer.byteLength(value),
|
||||
inventory_artifact_id: path,
|
||||
kind: path.endsWith("package.json") ? "package-json" : "javascript",
|
||||
unpacked: false,
|
||||
text: { included: true, value },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const writeTree = async (
|
||||
root: string,
|
||||
files: Readonly<Record<string, string>>,
|
||||
): Promise<Map<string, string>> => {
|
||||
const contents = new Map<string, string>();
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([path, value]) => {
|
||||
const absolute = join(root, path);
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, value);
|
||||
contents.set(path, value);
|
||||
}),
|
||||
);
|
||||
return contents;
|
||||
};
|
||||
|
||||
const nodeRequireTarget = async (
|
||||
importer: string,
|
||||
specifier: string,
|
||||
): Promise<z.infer<typeof nodeOutcomeSchema>> => {
|
||||
try {
|
||||
return {
|
||||
target: createRequire(importer).resolve(specifier),
|
||||
error_code: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof Error && "code" in error
|
||||
? z.string().parse(Reflect.get(error, "code"))
|
||||
: null;
|
||||
return { target: null, error_code: code };
|
||||
}
|
||||
};
|
||||
|
||||
const nodeImportTarget = async (
|
||||
cwd: string,
|
||||
specifier: string,
|
||||
): Promise<z.infer<typeof nodeOutcomeSchema>> => {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
try {
|
||||
const url = await import.meta.resolve(${JSON.stringify(specifier)});
|
||||
console.log(JSON.stringify({ target: url, error_code: null }));
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({
|
||||
target: null,
|
||||
error_code: error.code ?? null,
|
||||
}));
|
||||
}
|
||||
`,
|
||||
],
|
||||
{ cwd },
|
||||
);
|
||||
return nodeOutcomeSchema.parse(JSON.parse(stdout));
|
||||
};
|
||||
|
||||
const resolveAlias = (
|
||||
files: Map<string, JavaScriptArtifactFile>,
|
||||
declaredPath: string,
|
||||
sourcePath: string,
|
||||
moduleKind: "import" | "require",
|
||||
): ArtifactPathResolution =>
|
||||
resolveArtifactPathByContext({
|
||||
declaredPath,
|
||||
sourcePath,
|
||||
context: "module-specifier",
|
||||
moduleKind,
|
||||
files,
|
||||
});
|
||||
|
||||
describe("literal package import keys compared with Node", () => {
|
||||
it("matches literal imports keys containing path-like bytes in both Node loaders", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-literal-");
|
||||
const specifiers = ["#/x", "#x%2Fy", "#x%2Ey", "#x\\y", "#x\0y"];
|
||||
const contents = await writeTree(root, {
|
||||
"package.json": JSON.stringify({
|
||||
imports: Object.fromEntries(
|
||||
specifiers.map((specifier) => [specifier, "./target.cjs"]),
|
||||
),
|
||||
}),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"target.cjs": "module.exports = 1;",
|
||||
});
|
||||
const inventoried = inventory(contents);
|
||||
for (const specifier of specifiers) {
|
||||
const required = await nodeRequireTarget(
|
||||
join(root, "main.cjs"),
|
||||
specifier,
|
||||
);
|
||||
expect(required.error_code).toBeNull();
|
||||
expect(posixRelative(root, required.target ?? "")).toBe("target.cjs");
|
||||
const imported = await nodeImportTarget(root, specifier);
|
||||
expect(imported.error_code).toBeNull();
|
||||
expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe(
|
||||
"target.cjs",
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const)
|
||||
expect(
|
||||
resolveAlias(inventoried, specifier, "main.cjs", moduleKind),
|
||||
JSON.stringify({ specifier, moduleKind }),
|
||||
).toMatchObject({
|
||||
declared_path: specifier,
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "target.cjs",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("still rejects unsafe selected targets for literal imports keys", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-target-");
|
||||
const contents = await writeTree(root, {
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#x%2Fy": "./nested%2Ftarget.cjs" },
|
||||
}),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"nested/target.cjs": "module.exports = 1;",
|
||||
});
|
||||
const inventoried = inventory(contents);
|
||||
const required = await nodeRequireTarget(join(root, "main.cjs"), "#x%2Fy");
|
||||
expect(required.error_code).toBe("ERR_INVALID_MODULE_SPECIFIER");
|
||||
const imported = await nodeImportTarget(root, "#x%2Fy");
|
||||
expect(imported.error_code).toBe("ERR_INVALID_MODULE_SPECIFIER");
|
||||
for (const moduleKind of ["require", "import"] as const)
|
||||
expect(
|
||||
resolveAlias(inventoried, "#x%2Fy", "main.cjs", moduleKind),
|
||||
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("inactive package target arrays compared with Node", () => {
|
||||
it("continues past inactive arrays in enclosing imports and exports conditions", async () => {
|
||||
const root = await createTestTempDirectory("rea-inactive-package-array-");
|
||||
const conditional = {
|
||||
node: [{ browser: "./browser.cjs" }],
|
||||
default: "./fallback.cjs",
|
||||
};
|
||||
const nested = {
|
||||
node: [
|
||||
[{ browser: "./browser.cjs" }],
|
||||
{ "rea-inactive": "./inactive.cjs" },
|
||||
],
|
||||
default: "./fallback.cjs",
|
||||
};
|
||||
const contents = await writeTree(root, {
|
||||
"package.json": JSON.stringify({
|
||||
imports: { "#conditional": conditional, "#nested": nested },
|
||||
}),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"fallback.cjs": "module.exports = 1;",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
name: "fixture",
|
||||
exports: { ".": conditional, "./nested": nested },
|
||||
}),
|
||||
"node_modules/fixture/fallback.cjs": "module.exports = 2;",
|
||||
});
|
||||
const inventoried = inventory(contents);
|
||||
for (const [specifier, expected] of [
|
||||
["#conditional", "fallback.cjs"],
|
||||
["#nested", "fallback.cjs"],
|
||||
["fixture", "node_modules/fixture/fallback.cjs"],
|
||||
["fixture/nested", "node_modules/fixture/fallback.cjs"],
|
||||
] as const) {
|
||||
const required = await nodeRequireTarget(
|
||||
join(root, "main.cjs"),
|
||||
specifier,
|
||||
);
|
||||
expect(required.error_code, specifier).toBeNull();
|
||||
expect(posixRelative(root, required.target ?? "")).toBe(expected);
|
||||
const imported = await nodeImportTarget(root, specifier);
|
||||
expect(imported.error_code).toBeNull();
|
||||
expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe(
|
||||
expected,
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const)
|
||||
expect(
|
||||
resolveAlias(inventoried, specifier, "main.cjs", moduleKind),
|
||||
).toMatchObject({
|
||||
declared_path: specifier,
|
||||
resolution_status: "resolved",
|
||||
resolved_path: expected,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("package target refusal evidence compared with Node", () => {
|
||||
it("keeps empty, null, invalid, and unmatched array outcomes distinct", async () => {
|
||||
const root = await createTestTempDirectory("rea-inactive-package-refusal-");
|
||||
const inactive = { browser: "./browser.cjs" };
|
||||
const contents = await writeTree(root, {
|
||||
"package.json": JSON.stringify({
|
||||
imports: {
|
||||
"#empty-direct": [],
|
||||
"#empty": { node: [], default: "./fallback.cjs" },
|
||||
"#null": { node: [null, inactive], default: "./fallback.cjs" },
|
||||
"#null-last": [[], null],
|
||||
"#empty-last": [null, []],
|
||||
"#invalid": {
|
||||
node: ["../outside.cjs", inactive],
|
||||
default: "./fallback.cjs",
|
||||
},
|
||||
"#unmatched": [inactive],
|
||||
"#unmatched-nested": [
|
||||
[inactive, { "rea-inactive": "./inactive.cjs" }],
|
||||
],
|
||||
"#unmatched-active": { node: [inactive] },
|
||||
},
|
||||
}),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"fallback.cjs": "module.exports = 1;",
|
||||
});
|
||||
const inventoried = inventory(contents);
|
||||
for (const [specifier, errorCode, status, limitation] of [
|
||||
[
|
||||
"#empty-direct",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"an empty imports target array",
|
||||
],
|
||||
[
|
||||
"#empty",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"an empty imports target array",
|
||||
],
|
||||
[
|
||||
"#null",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"an explicit null imports target",
|
||||
],
|
||||
[
|
||||
"#null-last",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"an explicit null imports target",
|
||||
],
|
||||
[
|
||||
"#empty-last",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"an empty imports target array",
|
||||
],
|
||||
["#invalid", "ERR_INVALID_PACKAGE_TARGET", "rejected", "../outside.cjs"],
|
||||
[
|
||||
"#unmatched",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"it declares browser.",
|
||||
],
|
||||
[
|
||||
"#unmatched-nested",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"it declares browser, rea-inactive.",
|
||||
],
|
||||
[
|
||||
"#unmatched-active",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"it declares node, browser.",
|
||||
],
|
||||
] as const) {
|
||||
expect(
|
||||
(await nodeRequireTarget(join(root, "main.cjs"), specifier)).error_code,
|
||||
).toBe(errorCode);
|
||||
expect((await nodeImportTarget(root, specifier)).error_code).toBe(
|
||||
errorCode,
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const) {
|
||||
const result = resolveAlias(
|
||||
inventoried,
|
||||
specifier,
|
||||
"main.cjs",
|
||||
moduleKind,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
resolution_status: status,
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(result.limitations.join(" "), specifier).toContain(limitation);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import target arrays compared with Node", () => {
|
||||
it("skips invalid parent, absolute, and URL targets before an exact fallback", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-fallback-");
|
||||
const imports = {
|
||||
"#parent": ["../outside.cjs", "./fallback.cjs"],
|
||||
"#absolute": { node: [["/outside.cjs"], "./fallback.cjs"] },
|
||||
"#url": {
|
||||
import: ["https://example.invalid/outside.cjs", "./fallback.cjs"],
|
||||
require: ["file:///outside.cjs", "./fallback.cjs"],
|
||||
},
|
||||
};
|
||||
const contents = await writeTree(root, {
|
||||
"package.json": JSON.stringify({ imports }),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"fallback.cjs": "module.exports = 1;",
|
||||
});
|
||||
const inventoried = inventory(contents);
|
||||
for (const specifier of Object.keys(imports)) {
|
||||
const required = await nodeRequireTarget(
|
||||
join(root, "main.cjs"),
|
||||
specifier,
|
||||
);
|
||||
expect(required.error_code).toBeNull();
|
||||
expect(posixRelative(root, required.target ?? "")).toBe("fallback.cjs");
|
||||
const imported = await nodeImportTarget(root, specifier);
|
||||
expect(imported.error_code).toBeNull();
|
||||
expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe(
|
||||
"fallback.cjs",
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const)
|
||||
expect(
|
||||
resolveAlias(inventoried, specifier, "main.cjs", moduleKind),
|
||||
).toMatchObject({
|
||||
declared_path: specifier,
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "fallback.cjs",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves invalid, null, and unsupported-package outcomes in arrays", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-import-alias-fallback-refuse-",
|
||||
);
|
||||
const contents = await writeTree(root, {
|
||||
"package.json": JSON.stringify({
|
||||
imports: {
|
||||
"#invalid": [null, "../outside.cjs"],
|
||||
"#blocked": ["../outside.cjs", null],
|
||||
"#package": ["fixture", "./fallback.cjs"],
|
||||
},
|
||||
}),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"fallback.cjs": "module.exports = 1;",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
name: "fixture",
|
||||
main: "index.cjs",
|
||||
}),
|
||||
"node_modules/fixture/index.cjs": "module.exports = 2;",
|
||||
});
|
||||
const inventoried = inventory(contents);
|
||||
for (const [specifier, errorCode, resolutionStatus, reason] of [
|
||||
["#invalid", "ERR_INVALID_PACKAGE_TARGET", "rejected", "../outside.cjs"],
|
||||
[
|
||||
"#blocked",
|
||||
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
|
||||
"external",
|
||||
"explicit null",
|
||||
],
|
||||
] as const) {
|
||||
const required = await nodeRequireTarget(
|
||||
join(root, "main.cjs"),
|
||||
specifier,
|
||||
);
|
||||
expect(required.error_code).toBe(errorCode);
|
||||
expect((await nodeImportTarget(root, specifier)).error_code).toBe(
|
||||
errorCode,
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const) {
|
||||
const result = resolveAlias(
|
||||
inventoried,
|
||||
specifier,
|
||||
"main.cjs",
|
||||
moduleKind,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
resolution_status: resolutionStatus,
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(result.limitations.join(" ")).toContain(reason);
|
||||
}
|
||||
}
|
||||
const required = await nodeRequireTarget(
|
||||
join(root, "main.cjs"),
|
||||
"#package",
|
||||
);
|
||||
expect(posixRelative(root, required.target ?? "")).toBe(
|
||||
"node_modules/fixture/index.cjs",
|
||||
);
|
||||
const imported = await nodeImportTarget(root, "#package");
|
||||
expect(imported.error_code).toBeNull();
|
||||
expect(posixRelative(root, fileURLToPath(imported.target ?? ""))).toBe(
|
||||
"node_modules/fixture/index.cjs",
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const) {
|
||||
const result = resolveAlias(
|
||||
inventoried,
|
||||
"#package",
|
||||
"main.cjs",
|
||||
moduleKind,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(result.limitations.join(" ")).toContain(
|
||||
"External-package import targets remain unresolved",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import aliases compared with Node", () => {
|
||||
it("matches Node require and import for an exact root alias", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-root-");
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "app",
|
||||
imports: { "#config": "./config.cjs" },
|
||||
}),
|
||||
"main.cjs": 'module.exports = require("#config");',
|
||||
"config.cjs": "module.exports = 'config.cjs';",
|
||||
};
|
||||
const contents = await writeTree(root, files);
|
||||
const inventoried = inventory(contents);
|
||||
const required = await nodeRequireTarget(join(root, "main.cjs"), "#config");
|
||||
expect(posixRelative(root, required.target ?? "")).toBe("config.cjs");
|
||||
expect(
|
||||
resolveAlias(inventoried, "#config", "main.cjs", "require"),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
const imported = await nodeImportTarget(root, "#config");
|
||||
expect(imported.error_code).toBeNull();
|
||||
expect(
|
||||
resolveAlias(inventoried, "#config", "main.cjs", "import"),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches Node when a nested package has its own imports map", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-nested-");
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "app",
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"root.cjs": "module.exports = 'root';",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
name: "fixture",
|
||||
imports: { "#config": "./local.cjs" },
|
||||
}),
|
||||
"node_modules/fixture/index.cjs": 'module.exports = require("#config");',
|
||||
"node_modules/fixture/local.cjs": "module.exports = 'local';",
|
||||
};
|
||||
const contents = await writeTree(root, files);
|
||||
const inventoried = inventory(contents);
|
||||
const nested = await nodeRequireTarget(
|
||||
join(root, "node_modules/fixture/index.cjs"),
|
||||
"#config",
|
||||
);
|
||||
expect(posixRelative(root, nested.target ?? "")).toBe(
|
||||
"node_modules/fixture/local.cjs",
|
||||
);
|
||||
expect(
|
||||
resolveAlias(
|
||||
inventoried,
|
||||
"#config",
|
||||
"node_modules/fixture/index.cjs",
|
||||
"require",
|
||||
),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "node_modules/fixture/local.cjs",
|
||||
});
|
||||
const fromRoot = await nodeRequireTarget(join(root, "root.cjs"), "#config");
|
||||
expect(posixRelative(root, fromRoot.target ?? "")).toBe("root.cjs");
|
||||
expect(
|
||||
resolveAlias(inventoried, "#config", "root.cjs", "require"),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "root.cjs",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches Node when a nested package does not define the alias", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-scope-");
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "app",
|
||||
imports: { "#config": "./root.cjs" },
|
||||
}),
|
||||
"root.cjs": "module.exports = 'root';",
|
||||
"node_modules/fixture/package.json": JSON.stringify({
|
||||
name: "fixture",
|
||||
}),
|
||||
"node_modules/fixture/index.cjs": 'module.exports = require("#config");',
|
||||
};
|
||||
await writeTree(root, files);
|
||||
const nested = await nodeRequireTarget(
|
||||
join(root, "node_modules/fixture/index.cjs"),
|
||||
"#config",
|
||||
);
|
||||
expect(nested.target).toBeNull();
|
||||
// CJS reports MODULE_NOT_FOUND when the nested package.json has no
|
||||
// `imports` field at all; it still does not inherit the ancestor map.
|
||||
expect(nested.error_code).toBe("MODULE_NOT_FOUND");
|
||||
const outcome = resolveAlias(
|
||||
inventory(new Map(Object.entries(files))),
|
||||
"#config",
|
||||
"node_modules/fixture/index.cjs",
|
||||
"require",
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
resolution_status: "external",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(outcome.limitations.join(" ")).toContain(
|
||||
"node_modules/fixture/package.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("package import alias refusals compared with Node", () => {
|
||||
it("matches Node refusal for a missing key, explicit null, and reserved specifier", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-refuse-");
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "app",
|
||||
imports: {
|
||||
"#config": "./config.cjs",
|
||||
"#blocked": null,
|
||||
"#": "./config.cjs",
|
||||
"#/": "./config.cjs",
|
||||
"#config/": "./config.cjs",
|
||||
},
|
||||
}),
|
||||
"main.cjs": "module.exports = 1;",
|
||||
"config.cjs": "module.exports = 1;",
|
||||
};
|
||||
await writeTree(root, files);
|
||||
const inventoried = inventory(new Map(Object.entries(files)));
|
||||
const missing = await nodeRequireTarget(join(root, "main.cjs"), "#missing");
|
||||
expect(missing.error_code).toBe("ERR_PACKAGE_IMPORT_NOT_DEFINED");
|
||||
expect(
|
||||
resolveAlias(inventoried, "#missing", "main.cjs", "require"),
|
||||
).toMatchObject({ resolution_status: "external", resolved_path: null });
|
||||
const blocked = await nodeRequireTarget(join(root, "main.cjs"), "#blocked");
|
||||
expect(blocked.error_code).toBe("ERR_PACKAGE_IMPORT_NOT_DEFINED");
|
||||
expect(
|
||||
resolveAlias(inventoried, "#blocked", "main.cjs", "require"),
|
||||
).toMatchObject({ resolution_status: "external", resolved_path: null });
|
||||
for (const specifier of ["#", "#/", "#config/"]) {
|
||||
const reserved = await nodeRequireTarget(
|
||||
join(root, "main.cjs"),
|
||||
specifier,
|
||||
);
|
||||
expect(reserved.error_code).toBe("ERR_INVALID_MODULE_SPECIFIER");
|
||||
expect((await nodeImportTarget(root, specifier)).error_code).toBe(
|
||||
"ERR_INVALID_MODULE_SPECIFIER",
|
||||
);
|
||||
for (const moduleKind of ["require", "import"] as const)
|
||||
expect(
|
||||
resolveAlias(inventoried, specifier, "main.cjs", moduleKind),
|
||||
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches Node when an import condition selects a different exact file", async () => {
|
||||
const root = await createTestTempDirectory("rea-import-alias-cond-");
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "app",
|
||||
type: "module",
|
||||
imports: {
|
||||
"#config": {
|
||||
import: "./config.mjs",
|
||||
require: "./config.cjs",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"main.cjs": 'module.exports = require("#config");',
|
||||
"config.cjs": "export default 'cjs'; module.exports = 'cjs';",
|
||||
"config.mjs": "export default 'mjs';",
|
||||
};
|
||||
await writeTree(root, files);
|
||||
const inventoried = inventory(new Map(Object.entries(files)));
|
||||
const required = await nodeRequireTarget(join(root, "main.cjs"), "#config");
|
||||
expect(posixRelative(root, required.target ?? "")).toBe("config.cjs");
|
||||
expect(
|
||||
resolveAlias(inventoried, "#config", "main.cjs", "require"),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.cjs",
|
||||
});
|
||||
const imported = await nodeImportTarget(root, "#config");
|
||||
expect(imported.error_code).toBeNull();
|
||||
expect(imported.target ?? "").toContain("config.mjs");
|
||||
expect(
|
||||
resolveAlias(inventoried, "#config", "main.cjs", "import"),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "config.mjs",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user