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