mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
fix(javascript): preserve package and source-map boundary identity (#1939)
* fix(javascript): preserve bare package specifier suffixes * fix(javascript): resolve ESM package self references * fix(javascript): recover retained prefixed source maps * test(javascript): cover suffixed ESM self references * fix(javascript): retain legacy ESM package suffix behavior * fix(javascript): reject legacy ESM directory and extension fallback * refactor(javascript): consolidate package resolution and workflow coverage * fix(javascript): report exact ESM package subpath misses * fix(javascript): compare parsed names for ESM self references * refactor(javascript): parse bare package requests once * fix(javascript): derive ESM dependency lookup from package URLs --------- Co-authored-by: hhang <[email protected]>
This commit is contained in:
@@ -508,6 +508,12 @@ 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.
|
||||
|
||||
Package self-references use the importer's enclosing package scope before
|
||||
installed dependencies. ESM requires the parsed package name to equal the
|
||||
metadata name; a scope-only name such as `@scope` cannot capture `@scope/pkg`.
|
||||
CommonJS retains Node's raw-request name/prefix matching. Both paths preserve
|
||||
the original specifier and select exports under their own loader conditions.
|
||||
|
||||
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
|
||||
@@ -523,8 +529,15 @@ 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
|
||||
ESM relative module paths, legacy package subpaths without exports, and selected
|
||||
package exports targets use URL suffix handling and one percent-decoding pass.
|
||||
Bare specifiers retain their complete query/fragment text for exports key matching.
|
||||
For dependency lookup, ESM constructs a package metadata URL instead of treating
|
||||
the parsed package name as a literal directory. A root request such as `legacy?x`
|
||||
does not load either `node_modules/legacy?x` or `node_modules/legacy`; an exact
|
||||
self-reference with that metadata name can still select its exports first.
|
||||
Without an exports map, ESM subpaths select exact files; directory imports and
|
||||
extension fallback are refused. CommonJS relative paths and legacy package main
|
||||
fields retain literal filename punctuation. Exports targets must start with
|
||||
`./` and exclude `.`, `..`, and `node_modules` path segments, including their
|
||||
encoded forms, before URL normalization. Arrays skip invalid exports targets
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSem
|
||||
import { projectJavaScriptModuleSemantics } from "../../domain/javascript/javascriptModuleSemanticIr.js";
|
||||
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
|
||||
import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js";
|
||||
import { flattenSourceMapLeaves } from "../../domain/sourceMapEnvelope.js";
|
||||
import {
|
||||
flattenSourceMapLeaves,
|
||||
sourceMapJsonText,
|
||||
} from "../../domain/sourceMapEnvelope.js";
|
||||
import type {
|
||||
JavaScriptSourceRange,
|
||||
JavaScriptSourcePoint,
|
||||
@@ -521,7 +524,7 @@ const parseSourceMap = (
|
||||
if (!file.text.included) return unavailableSourceMap(file);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(file.text.value);
|
||||
value = JSON.parse(sourceMapJsonText(file.text.value));
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return invalidSourceMap(file, "Source map is not valid JSON.");
|
||||
|
||||
@@ -176,9 +176,7 @@ const contextualCandidate = (
|
||||
): string | CandidateResolution => {
|
||||
const { context } = input;
|
||||
let declared =
|
||||
context === "html-reference" ||
|
||||
context === "url-reference" ||
|
||||
(context === "module-specifier" && input.moduleKind !== "require")
|
||||
context === "html-reference" || context === "url-reference"
|
||||
? stripQueryAndFragment(input.declaredPath)
|
||||
: input.declaredPath;
|
||||
if (context === "html-reference") return htmlCandidate(input);
|
||||
@@ -190,9 +188,9 @@ const contextualCandidate = (
|
||||
"URL and Node builtin schemes are outside static artifact module resolution.",
|
||||
]);
|
||||
if (!declared.startsWith(".") && !declared.startsWith("/"))
|
||||
return bareModuleCandidate(input, declared);
|
||||
return bareModuleCandidate(input);
|
||||
if (input.moduleKind !== "require") {
|
||||
const decoded = decodeModuleUrlPath(declared);
|
||||
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
|
||||
if (typeof decoded !== "string") return decoded;
|
||||
declared = decoded;
|
||||
}
|
||||
@@ -237,34 +235,51 @@ const relativeCandidate = (sourcePath: string, declared: string): string =>
|
||||
|
||||
const bareModuleCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
declared: string,
|
||||
): CandidateResolution => {
|
||||
const packageName = barePackageName(declared);
|
||||
if (packageName === null || isBuiltin(declared))
|
||||
const request = parseBarePackageRequest(input.declaredPath, input.moduleKind);
|
||||
if (request === null || isBuiltin(input.declaredPath))
|
||||
return unresolvedCandidate("external", [
|
||||
"The bare specifier is a Node builtin or invalid package name.",
|
||||
]);
|
||||
if (input.moduleKind === "import" && packageName.includes("%"))
|
||||
if (input.moduleKind === "import" && request.packageName.includes("%"))
|
||||
return unresolvedCandidate("rejected", [
|
||||
"Percent signs are not admitted in ES module package names.",
|
||||
]);
|
||||
const self = commonJsSelfCandidate(input, declared);
|
||||
const self = packageSelfCandidate(input, request);
|
||||
if (self !== null) return self;
|
||||
const subpath =
|
||||
declared === packageName ? "." : `.${declared.slice(packageName.length)}`;
|
||||
// ESM constructs a package.json URL; its parsed identity is not a literal
|
||||
// dependency directory. Self resolution above still uses the exact name.
|
||||
let dependencyDirectory = posix.join("node_modules", request.packageName);
|
||||
if (input.moduleKind === "import") {
|
||||
const metadataUrl = new URL(
|
||||
`./node_modules/${request.packageName}/package.json`,
|
||||
PACKAGE_ARTIFACT_ROOT_URL,
|
||||
);
|
||||
if (!metadataUrl.pathname.endsWith("/package.json"))
|
||||
return unresolvedCandidate("external", [
|
||||
`ESM package name ${JSON.stringify(request.packageName)} produces a dependency URL whose pathname does not name package.json; no literal or suffix-stripped package directory was searched.`,
|
||||
]);
|
||||
const decoded = decodeModuleUrlPath(
|
||||
metadataUrl.pathname.slice(PACKAGE_ARTIFACT_ROOT_URL.pathname.length),
|
||||
);
|
||||
if (typeof decoded !== "string") return decoded;
|
||||
const confined = confineCandidate(decoded);
|
||||
if (typeof confined !== "string") return confined;
|
||||
dependencyDirectory = posix.dirname(confined);
|
||||
}
|
||||
const source = input.files.get(input.sourcePath);
|
||||
let directory = posix.dirname(input.sourcePath);
|
||||
while (true) {
|
||||
const candidate = posix.join(directory, "node_modules", packageName);
|
||||
const candidate = posix.join(directory, dependencyDirectory);
|
||||
const metadata = readPackageMetadata(input, candidate);
|
||||
if (packageExportsEligible(input) && metadata.kind !== "absent")
|
||||
return resolvePackageSpecifier(input, candidate, subpath, metadata);
|
||||
if (subpath === ".") {
|
||||
if (request.dependencyExportsEligible && metadata.kind !== "absent")
|
||||
return resolvePackageDependency(input, candidate, request, metadata);
|
||||
if (request.packageSubpath === ".") {
|
||||
const direct = resolveFileCandidates(input, fileCandidates(candidate));
|
||||
if (direct !== null) return direct;
|
||||
}
|
||||
if (hasContainerDirectory(input.files, candidate, source?.container_sha256))
|
||||
return resolvePackageSpecifier(input, candidate, subpath, metadata);
|
||||
return resolvePackageDependency(input, candidate, request, metadata);
|
||||
if (directory === "." || directory === "") break;
|
||||
directory = posix.dirname(directory);
|
||||
}
|
||||
@@ -330,24 +345,25 @@ const resolvePackageImportAlias = (
|
||||
return unresolvedCandidate("external", [
|
||||
`Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`,
|
||||
]);
|
||||
const selected = selectPackageImportTarget(
|
||||
const selected = selectExportTarget(
|
||||
lookup.target,
|
||||
input.moduleKind,
|
||||
metadata.path,
|
||||
packageExportConditions(input.moduleKind),
|
||||
packageMetadataUrl(metadata.path),
|
||||
"imports",
|
||||
);
|
||||
if (selected.status === "rejected")
|
||||
if (selected.kind === "invalid" || selected.kind === "invalid-config")
|
||||
return unresolvedCandidate("rejected", [
|
||||
`Package metadata ${metadata.path}: ${selected.limitation}`,
|
||||
]);
|
||||
if (selected.status === "unmatched")
|
||||
if (selected.kind === "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")
|
||||
if (selected.kind === "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") {
|
||||
if (selected.kind === "non-relative") {
|
||||
const target = classifyPackageImportTargetString(selected.value);
|
||||
return unresolvedCandidate("external", [
|
||||
target.kind === "recursive"
|
||||
@@ -358,77 +374,44 @@ const resolvePackageImportAlias = (
|
||||
return resolveExportTarget(input, metadata.path, selected, "imports");
|
||||
};
|
||||
|
||||
const selectPackageImportTarget = (
|
||||
value: unknown,
|
||||
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
||||
packagePath: string,
|
||||
):
|
||||
| ({ readonly status: "value" } & PackageFileTarget)
|
||||
| { 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),
|
||||
packageMetadataUrl(packagePath),
|
||||
"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,
|
||||
url: selected.url,
|
||||
};
|
||||
};
|
||||
|
||||
/** Self references use the nearest package scope before dependency lookup. */
|
||||
const commonJsSelfCandidate = (
|
||||
const packageSelfCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
declared: string,
|
||||
request: BarePackageRequest,
|
||||
): CandidateResolution | null => {
|
||||
if (input.moduleKind !== "require" || !input.files.has(input.sourcePath))
|
||||
if (input.moduleKind === undefined || !input.files.has(input.sourcePath))
|
||||
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}/`))
|
||||
)
|
||||
if (metadata.name === undefined || metadata.exports === undefined)
|
||||
return null;
|
||||
const subpath =
|
||||
declared === metadata.name
|
||||
? "."
|
||||
: `.${declared.slice(metadata.name.length)}`;
|
||||
let subpath: string;
|
||||
if (input.moduleKind === "import") {
|
||||
if (metadata.name !== request.packageName) return null;
|
||||
subpath = request.packageSubpath;
|
||||
} else if (input.declaredPath === metadata.name) {
|
||||
subpath = ".";
|
||||
} else if (input.declaredPath.startsWith(`${metadata.name}/`)) {
|
||||
subpath = `.${input.declaredPath.slice(metadata.name.length)}`;
|
||||
} else return null;
|
||||
// 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. */
|
||||
const resolvePackageSpecifier = (
|
||||
/** Resolve an installed dependency using one parsed request and its metadata. */
|
||||
const resolvePackageDependency = (
|
||||
input: ResolveArtifactPathInput,
|
||||
directory: string,
|
||||
subpath: string,
|
||||
request: BarePackageRequest,
|
||||
metadata: PackageMetadata,
|
||||
): CandidateResolution => {
|
||||
const exportsEligible = packageExportsEligible(input);
|
||||
if (exportsEligible && metadata.kind === "unavailable")
|
||||
const subpath = request.packageSubpath;
|
||||
if (request.dependencyExportsEligible && metadata.kind === "unavailable")
|
||||
return unresolvedCandidate("unavailable", [metadata.limitation]);
|
||||
const rawExports =
|
||||
exportsEligible && metadata.kind === "available"
|
||||
request.dependencyExportsEligible && metadata.kind === "available"
|
||||
? metadata.exports
|
||||
: undefined;
|
||||
if (rawExports === undefined) {
|
||||
@@ -437,8 +420,17 @@ const resolvePackageSpecifier = (
|
||||
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)}`);
|
||||
// Resolve legacy ESM subpaths as URLs: suffixes identify the loaded module
|
||||
// but do not belong to its filesystem path. Keep the declared specifier on
|
||||
// the relationship; only derive the inventory lookup path here.
|
||||
const fileSubpath =
|
||||
input.moduleKind === "import"
|
||||
? decodeModuleUrlPath(stripQueryAndFragment(subpath.slice(2)))
|
||||
: subpath.slice(2);
|
||||
if (typeof fileSubpath !== "string") return fileSubpath;
|
||||
const candidate = confineCandidate(`${directory}/${fileSubpath}`);
|
||||
if (input.moduleKind === "import" && typeof candidate === "string")
|
||||
return resolveLegacyEsmSubpath(input, candidate);
|
||||
return typeof candidate === "string"
|
||||
? resolveCandidate(input, candidate, new Set())
|
||||
: candidate;
|
||||
@@ -451,6 +443,21 @@ const resolvePackageSpecifier = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Legacy ESM package subpaths are exact URLs, never extension or directory lookups. */
|
||||
const resolveLegacyEsmSubpath = (
|
||||
input: ResolveArtifactPathInput,
|
||||
candidate: string,
|
||||
): CandidateResolution => {
|
||||
const file = resolveFileCandidates(input, [candidate]);
|
||||
if (file !== null) return file;
|
||||
const source = input.files.get(input.sourcePath);
|
||||
if (hasContainerDirectory(input.files, candidate, source?.container_sha256))
|
||||
return unresolvedCandidate("rejected", [
|
||||
`ESM package subpath ${candidate} names a directory; directory imports are unsupported and package main or index lookup was not attempted.`,
|
||||
]);
|
||||
return exactPackageTargetNotFound(candidate, "ESM subpath");
|
||||
};
|
||||
|
||||
const resolvePackageExports = (
|
||||
input: ResolveArtifactPathInput,
|
||||
packagePath: string,
|
||||
@@ -517,23 +524,36 @@ const resolveExportTarget = (
|
||||
);
|
||||
};
|
||||
|
||||
const barePackageName = (specifier: string): string | null => {
|
||||
const segments = specifier.split("/");
|
||||
if (specifier.startsWith("@"))
|
||||
return segments.length >= 2 && segments[0] !== "" && segments[1] !== ""
|
||||
? `${segments[0]}/${segments[1]}`
|
||||
: null;
|
||||
return segments[0] === "" ? null : (segments[0] ?? null);
|
||||
};
|
||||
/** Derived package identity and dependency policy; input retains the raw request. */
|
||||
interface BarePackageRequest {
|
||||
readonly packageName: string;
|
||||
readonly packageSubpath: string;
|
||||
readonly dependencyExportsEligible: boolean;
|
||||
}
|
||||
|
||||
const packageExportsEligible = (input: ResolveArtifactPathInput): boolean => {
|
||||
if (input.moduleKind !== "require") return true;
|
||||
const parseBarePackageRequest = (
|
||||
specifier: string,
|
||||
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
||||
): BarePackageRequest | null => {
|
||||
const segments = specifier.split("/");
|
||||
let packageName = segments[0] ?? "";
|
||||
if (packageName === "") return null;
|
||||
if (specifier.startsWith("@")) {
|
||||
const packageSegment = segments[1];
|
||||
if (packageSegment === undefined || packageSegment === "") return null;
|
||||
packageName = `${packageName}/${packageSegment}`;
|
||||
}
|
||||
// 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 dependencyExportsEligible =
|
||||
moduleKind !== "require" ||
|
||||
/^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/.exec(specifier)?.[1] ===
|
||||
packageName;
|
||||
return {
|
||||
packageName,
|
||||
packageSubpath: `.${specifier.slice(packageName.length)}`,
|
||||
dependencyExportsEligible,
|
||||
};
|
||||
};
|
||||
|
||||
const hasContainerDirectory = (
|
||||
@@ -1061,12 +1081,12 @@ const notFoundCandidate = (): CandidateResolution => ({
|
||||
|
||||
const exactPackageTargetNotFound = (
|
||||
path: string,
|
||||
mapKind: "exports" | "imports",
|
||||
targetKind: "exports" | "imports" | "ESM subpath",
|
||||
): CandidateResolution => ({
|
||||
resolvedPath: null,
|
||||
status: "not-found",
|
||||
limitations: [
|
||||
`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.`,
|
||||
`The selected package ${targetKind === "imports" ? "import" : targetKind} target ${path} does not exist in the inventoried artifact container; ${targetKind === "imports" ? "import" : targetKind} targets are exact files, so no extension or directory index was tried.`,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ import type {
|
||||
import { webSourceMapsSchema } from "../domain/webBundleAnalysis.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { sourceMapJsonText } from "../domain/sourceMapEnvelope.js";
|
||||
import { jsonParts } from "../domain/jsonSerialization.js";
|
||||
import {
|
||||
inspectSourceMapValue,
|
||||
sourceMapJsonText,
|
||||
SourceMapFormatFailure,
|
||||
type SourceMapLeaf,
|
||||
} from "../javascript/sourceMaps/SourceMapFormat.js";
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
*/
|
||||
type SourceMapRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Return the JSON parsing view of retained source-map text. ECMA-426 permits
|
||||
* HTTP responses to start with `)]}'`; the first line is transport framing.
|
||||
* Keep the original text for artifact identity and Evidence provenance.
|
||||
* https://tc39.es/ecma426/#sec-fetchsourcemap
|
||||
*/
|
||||
export const sourceMapJsonText = (text: string): string => {
|
||||
const withoutBom = text.replace(/^\uFEFF/u, "");
|
||||
if (!withoutBom.startsWith(")]}'")) return withoutBom;
|
||||
const newline = withoutBom.search(/[\r\n]/u);
|
||||
return newline === -1 ? "" : withoutBom.slice(newline);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is SourceMapRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
|
||||
import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js";
|
||||
import { sourceMapJsonText } from "../../domain/sourceMapEnvelope.js";
|
||||
|
||||
const leafSchema = z.object({
|
||||
version: z.literal(3),
|
||||
@@ -279,20 +280,6 @@ const inspectLeaf = (
|
||||
return leaf;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the JSON view of source-map text without changing the source evidence.
|
||||
* ECMA-426 §11.2.1 permits HTTP(S) responses to start with `)]}'`; the line
|
||||
* through the first HTTP newline is transport framing, not map content. Local
|
||||
* point tracing shares this parser and has historically accepted the same
|
||||
* framing. https://tc39.es/ecma426/#sec-fetchsourcemap
|
||||
*/
|
||||
export const sourceMapJsonText = (text: string): string => {
|
||||
const withoutBom = text.replace(/^\uFEFF/u, "");
|
||||
if (!withoutBom.startsWith(")]}'")) return withoutBom;
|
||||
const newline = withoutBom.search(/[\r\n]/u);
|
||||
return newline === -1 ? "" : withoutBom.slice(newline);
|
||||
};
|
||||
|
||||
/** Validate structure before allocation, retaining raw declarations and section boundaries. */
|
||||
export const inspectSourceMap = (
|
||||
text: string,
|
||||
|
||||
@@ -4,12 +4,10 @@ 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";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
|
||||
const literalAliases = ["#/x", "#x%2Fy", "#x%2Ey", "#x\\y", "#x\0y"];
|
||||
const fallbackAliases = {
|
||||
@@ -158,26 +156,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
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;
|
||||
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
for (const specifier of resolvedAliases)
|
||||
for (const [moduleKind, sourcePath, source] of [
|
||||
["require", "main.cjs", mainSource],
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
|
||||
const requests = [
|
||||
{ specifier: "legacy?x", plainName: "legacy", exports: false },
|
||||
{ specifier: "exported#entry", plainName: "exported", exports: true },
|
||||
{ specifier: "@scope/legacy?x", plainName: "@scope/legacy", exports: false },
|
||||
{ specifier: "@scope#entry/pkg", plainName: "@scope/pkg", exports: true },
|
||||
] as const;
|
||||
|
||||
// Windows cannot materialize the question-mark directories in this fixture.
|
||||
cliTest.skipIf(process.platform === "win32").for(["CLI", "stdio MCP"] as const)(
|
||||
"keeps ESM root URL suffixes out of dependency filesystem lookup through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-package-root-suffix-");
|
||||
const sources = {
|
||||
"main.mjs": requests
|
||||
.map(({ specifier }) => `import ${JSON.stringify(specifier)};`)
|
||||
.join("\n"),
|
||||
"main.cjs": requests
|
||||
.map(({ specifier }) => `require(${JSON.stringify(specifier)});`)
|
||||
.join("\n"),
|
||||
};
|
||||
const files: Record<string, string> = { ...sources };
|
||||
for (const item of requests)
|
||||
for (const name of [item.specifier, item.plainName]) {
|
||||
const directory = `node_modules/${name}`;
|
||||
files[`${directory}/package.json`] = JSON.stringify(
|
||||
item.exports ? { exports: "./entry.cjs" } : { main: "./entry.cjs" },
|
||||
);
|
||||
files[`${directory}/entry.cjs`] =
|
||||
`module.exports = ${JSON.stringify(name)};`;
|
||||
}
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([path, text]) => {
|
||||
const absolute = join(root, path);
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, text);
|
||||
}),
|
||||
);
|
||||
if (surface === "CLI") {
|
||||
const native = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`import { createRequire } from "node:module";
|
||||
const require = createRequire(new URL("./main.cjs", import.meta.url));
|
||||
const requests = ${JSON.stringify(requests.map(({ specifier }) => specifier))};
|
||||
const results = await Promise.all(requests.map(async (specifier) => {
|
||||
let esm;
|
||||
try { esm = { value: (await import(specifier)).default, error: null }; }
|
||||
catch (error) { esm = { value: null, error: error.code }; }
|
||||
return { esm, require: require(specifier) };
|
||||
}));
|
||||
console.log(JSON.stringify(results));`,
|
||||
],
|
||||
{ cwd: root, timeout: 5_000 },
|
||||
);
|
||||
expect(JSON.parse(native.stdout)).toEqual(
|
||||
requests.map(({ specifier }) => ({
|
||||
esm: { value: null, error: "ERR_MODULE_NOT_FOUND" },
|
||||
require: specifier,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
for (const { specifier } of requests)
|
||||
for (const [moduleKind, sourcePath] of [
|
||||
["import", "main.mjs"],
|
||||
["require", "main.cjs"],
|
||||
] as const)
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "imports",
|
||||
properties: expect.objectContaining({
|
||||
module_link_kind: moduleKind,
|
||||
specifier,
|
||||
resolved_path:
|
||||
moduleKind === "import"
|
||||
? null
|
||||
: `node_modules/${specifier}/entry.cjs`,
|
||||
resolution_status:
|
||||
moduleKind === "import" ? "external" : "resolved",
|
||||
}),
|
||||
evidence: expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
sha256: createHash("sha256")
|
||||
.update(sources[sourcePath])
|
||||
.digest("hex"),
|
||||
}),
|
||||
location: expect.objectContaining({
|
||||
value: expect.objectContaining({ source: sourcePath }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,207 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
|
||||
type SelfNameCase = {
|
||||
readonly directory: string;
|
||||
readonly packageName: string;
|
||||
readonly exportKey: string;
|
||||
readonly specifier: string;
|
||||
readonly selfValue: string;
|
||||
readonly dependency?: {
|
||||
readonly packagePath: string;
|
||||
readonly exportKey: string;
|
||||
readonly value: string;
|
||||
};
|
||||
readonly esmTarget: string;
|
||||
readonly requireTarget: string;
|
||||
readonly esmValue: string;
|
||||
readonly requireValue: string;
|
||||
};
|
||||
|
||||
const cases = [
|
||||
{
|
||||
directory: "scope-prefix",
|
||||
packageName: "@scope",
|
||||
exportKey: "./pkg",
|
||||
specifier: "@scope/pkg",
|
||||
selfValue: "scope self",
|
||||
dependency: {
|
||||
packagePath: "node_modules/@scope/pkg",
|
||||
exportKey: ".",
|
||||
value: "scope dependency",
|
||||
},
|
||||
esmTarget: "scope-prefix/node_modules/@scope/pkg/dependency.cjs",
|
||||
requireTarget: "scope-prefix/self.cjs",
|
||||
esmValue: "scope dependency",
|
||||
requireValue: "scope self",
|
||||
},
|
||||
{
|
||||
directory: "slash-name",
|
||||
packageName: "fixture/sub",
|
||||
exportKey: "./feature",
|
||||
specifier: "fixture/sub/feature",
|
||||
selfValue: "slash self",
|
||||
dependency: {
|
||||
packagePath: "node_modules/fixture",
|
||||
exportKey: "./sub/feature",
|
||||
value: "slash dependency",
|
||||
},
|
||||
esmTarget: "slash-name/node_modules/fixture/dependency.cjs",
|
||||
requireTarget: "slash-name/self.cjs",
|
||||
esmValue: "slash dependency",
|
||||
requireValue: "slash self",
|
||||
},
|
||||
{
|
||||
directory: "slash-exact-name",
|
||||
packageName: "fixture/sub",
|
||||
exportKey: ".",
|
||||
specifier: "fixture/sub",
|
||||
selfValue: "slash exact self",
|
||||
dependency: {
|
||||
packagePath: "node_modules/fixture",
|
||||
exportKey: "./sub",
|
||||
value: "slash exact dependency",
|
||||
},
|
||||
esmTarget: "slash-exact-name/node_modules/fixture/dependency.cjs",
|
||||
requireTarget: "slash-exact-name/self.cjs",
|
||||
esmValue: "slash exact dependency",
|
||||
requireValue: "slash exact self",
|
||||
},
|
||||
{
|
||||
directory: "valid-scoped",
|
||||
packageName: "@scope/pkg",
|
||||
exportKey: "./feature",
|
||||
specifier: "@scope/pkg/feature",
|
||||
selfValue: "valid scoped self",
|
||||
esmTarget: "valid-scoped/self.cjs",
|
||||
requireTarget: "valid-scoped/self.cjs",
|
||||
esmValue: "valid scoped self",
|
||||
requireValue: "valid scoped self",
|
||||
},
|
||||
{
|
||||
directory: "query-self-name",
|
||||
packageName: "self?x",
|
||||
exportKey: ".",
|
||||
specifier: "self?x",
|
||||
selfValue: "query self",
|
||||
esmTarget: "query-self-name/self.cjs",
|
||||
requireTarget: "query-self-name/self.cjs",
|
||||
esmValue: "query self",
|
||||
requireValue: "query self",
|
||||
},
|
||||
{
|
||||
directory: "fragment-self-name",
|
||||
packageName: "@scope/self#entry",
|
||||
exportKey: ".",
|
||||
specifier: "@scope/self#entry",
|
||||
selfValue: "fragment self",
|
||||
esmTarget: "fragment-self-name/self.cjs",
|
||||
requireTarget: "fragment-self-name/self.cjs",
|
||||
esmValue: "fragment self",
|
||||
requireValue: "fragment self",
|
||||
},
|
||||
] satisfies readonly SelfNameCase[];
|
||||
|
||||
const sourceFor = (specifier: string, moduleKind: "import" | "require") =>
|
||||
moduleKind === "import"
|
||||
? `import value from ${JSON.stringify(specifier)};\nexport default value;`
|
||||
: `const value = require(${JSON.stringify(specifier)});\nmodule.exports = value;`;
|
||||
|
||||
const createSelfNameFixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-package-self-name-boundary-");
|
||||
const files: Record<string, string> = {};
|
||||
for (const item of cases) {
|
||||
const prefix = item.directory;
|
||||
files[`${prefix}/package.json`] = JSON.stringify({
|
||||
name: item.packageName,
|
||||
exports: { [item.exportKey]: "./self.cjs" },
|
||||
});
|
||||
files[`${prefix}/main.mjs`] = sourceFor(item.specifier, "import");
|
||||
files[`${prefix}/main.cjs`] = sourceFor(item.specifier, "require");
|
||||
files[`${prefix}/self.cjs`] =
|
||||
`module.exports = ${JSON.stringify(item.selfValue)};`;
|
||||
if (item.dependency !== undefined) {
|
||||
const dependencyPrefix = `${prefix}/${item.dependency.packagePath}`;
|
||||
files[`${dependencyPrefix}/package.json`] = JSON.stringify({
|
||||
exports: { [item.dependency.exportKey]: "./dependency.cjs" },
|
||||
});
|
||||
files[`${dependencyPrefix}/dependency.cjs`] =
|
||||
`module.exports = ${JSON.stringify(item.dependency.value)};`;
|
||||
}
|
||||
}
|
||||
files["node-control.mjs"] = `import { createRequire } from "node:module";
|
||||
const cases = ${JSON.stringify(cases.map(({ directory, specifier }) => ({ directory, specifier })))};
|
||||
const esm = await Promise.all(
|
||||
cases.map(async ({ directory }) =>
|
||||
(await import(new URL(directory + "/main.mjs", import.meta.url))).default,
|
||||
),
|
||||
);
|
||||
const require = cases.map(({ directory, specifier }) =>
|
||||
createRequire(new URL(directory + "/main.cjs", import.meta.url))(specifier),
|
||||
);
|
||||
console.log(JSON.stringify({ esm, require }));`;
|
||||
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;
|
||||
};
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"matches self references by parsed ESM name and preserves CommonJS prefix matching through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createSelfNameFixture();
|
||||
if (surface === "CLI") {
|
||||
const native = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[join(root, "node-control.mjs")],
|
||||
{ cwd: root, timeout: 5_000 },
|
||||
);
|
||||
expect(JSON.parse(native.stdout)).toEqual({
|
||||
esm: cases.map(({ esmValue }) => esmValue),
|
||||
require: cases.map(({ requireValue }) => requireValue),
|
||||
});
|
||||
}
|
||||
|
||||
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
for (const item of cases)
|
||||
for (const [moduleKind, target] of [
|
||||
["import", item.esmTarget],
|
||||
["require", item.requireTarget],
|
||||
] as const) {
|
||||
const sourcePath = `${item.directory}/main.${moduleKind === "import" ? "mjs" : "cjs"}`;
|
||||
const source = sourceFor(item.specifier, moduleKind);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "imports",
|
||||
properties: expect.objectContaining({
|
||||
module_link_kind: moduleKind,
|
||||
specifier: item.specifier,
|
||||
resolved_path: target,
|
||||
resolution_status: "resolved",
|
||||
}),
|
||||
evidence: expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
sha256: createHash("sha256").update(source).digest("hex"),
|
||||
}),
|
||||
location: expect.objectContaining({
|
||||
value: expect.objectContaining({ source: sourcePath }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,278 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
|
||||
type PackageSpecifierCase = {
|
||||
readonly specifier: string;
|
||||
readonly target: string | null;
|
||||
readonly status: "resolved" | "external" | "rejected" | "not-found";
|
||||
readonly value: string | number | null;
|
||||
readonly error: string | null;
|
||||
};
|
||||
|
||||
const requests = [
|
||||
{
|
||||
specifier: "rea-self-fixture",
|
||||
target: "root.cjs",
|
||||
status: "resolved",
|
||||
value: 11,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "rea-self-fixture/feature",
|
||||
target: "feature.cjs",
|
||||
status: "resolved",
|
||||
value: 22,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "rea-self-fixture/feature?cache=1",
|
||||
target: "punctuated.cjs",
|
||||
status: "resolved",
|
||||
value: 33,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "rea-self-fixture/feature?cache=2",
|
||||
target: null,
|
||||
status: "external",
|
||||
value: null,
|
||||
error: "ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
},
|
||||
{
|
||||
specifier: "rea-self-fixture/feature#entry",
|
||||
target: null,
|
||||
status: "external",
|
||||
value: null,
|
||||
error: "ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
},
|
||||
{
|
||||
specifier: "fixture/feature?cache=1",
|
||||
target: null,
|
||||
status: "external",
|
||||
value: null,
|
||||
error: "ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
},
|
||||
{
|
||||
specifier: "fixture/feature#entry",
|
||||
target: null,
|
||||
status: "external",
|
||||
value: null,
|
||||
error: "ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
},
|
||||
{
|
||||
specifier: "fixture/literal?cache=1",
|
||||
target: "node_modules/fixture/literal.cjs",
|
||||
status: "resolved",
|
||||
value: "literal",
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "legacy/?cache=1",
|
||||
target: null,
|
||||
status: "rejected",
|
||||
value: null,
|
||||
error: "ERR_UNSUPPORTED_DIR_IMPORT",
|
||||
},
|
||||
{
|
||||
specifier: "legacy/#entry",
|
||||
target: null,
|
||||
status: "rejected",
|
||||
value: null,
|
||||
error: "ERR_UNSUPPORTED_DIR_IMPORT",
|
||||
},
|
||||
{
|
||||
specifier: "legacy/nested/?cache=1",
|
||||
target: null,
|
||||
status: "rejected",
|
||||
value: null,
|
||||
error: "ERR_UNSUPPORTED_DIR_IMPORT",
|
||||
},
|
||||
{
|
||||
specifier: "legacy/nested/#entry",
|
||||
target: null,
|
||||
status: "rejected",
|
||||
value: null,
|
||||
error: "ERR_UNSUPPORTED_DIR_IMPORT",
|
||||
},
|
||||
{
|
||||
specifier: "legacy/feature.js",
|
||||
target: "node_modules/legacy/feature.js",
|
||||
status: "resolved",
|
||||
value: "legacy feature",
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "legacy/feature.js?cache=1",
|
||||
target: "node_modules/legacy/feature.js",
|
||||
status: "resolved",
|
||||
value: "legacy feature",
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "legacy/feature.js#entry",
|
||||
target: "node_modules/legacy/feature.js",
|
||||
status: "resolved",
|
||||
value: "legacy feature",
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "legacy/with%20space.js?cache=1",
|
||||
target: "node_modules/legacy/with space.js",
|
||||
status: "resolved",
|
||||
value: "legacy spaced feature",
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
specifier: "legacy/feature",
|
||||
target: null,
|
||||
status: "not-found",
|
||||
value: null,
|
||||
error: "ERR_MODULE_NOT_FOUND",
|
||||
},
|
||||
] satisfies readonly PackageSpecifierCase[];
|
||||
|
||||
const createPackageSpecifierFixture = async (): Promise<{
|
||||
readonly root: string;
|
||||
readonly source: string;
|
||||
}> => {
|
||||
const root = await createTestTempDirectory("rea-package-specifier-suffix-");
|
||||
const packagePath = join(root, "node_modules", "fixture");
|
||||
const legacyPackagePath = join(root, "node_modules", "legacy");
|
||||
await Promise.all([
|
||||
mkdir(packagePath, { recursive: true }),
|
||||
mkdir(join(legacyPackagePath, "nested"), { recursive: true }),
|
||||
]);
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "rea-self-fixture",
|
||||
exports: {
|
||||
".": { import: "./root.cjs", require: "./require.cjs" },
|
||||
"./feature": { import: "./feature.cjs", require: "./require.cjs" },
|
||||
"./feature?cache=1": {
|
||||
import: "./punctuated.cjs",
|
||||
require: "./require.cjs",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const source = requests
|
||||
.map(
|
||||
({ specifier }, index) =>
|
||||
`import value${index} from ${JSON.stringify(specifier)};`,
|
||||
)
|
||||
.join("\n");
|
||||
await Promise.all([
|
||||
writeFile(join(root, "main.mjs"), source),
|
||||
writeFile(join(root, "root.cjs"), "module.exports = 11;"),
|
||||
writeFile(join(root, "feature.cjs"), "module.exports = 22;"),
|
||||
writeFile(join(root, "punctuated.cjs"), "module.exports = 33;"),
|
||||
writeFile(join(root, "require.cjs"), "module.exports = -1;"),
|
||||
]);
|
||||
await writeFile(
|
||||
join(packagePath, "package.json"),
|
||||
JSON.stringify({
|
||||
exports: {
|
||||
"./feature": "./feature.cjs",
|
||||
"./literal?cache=1": "./literal.cjs",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await Promise.all([
|
||||
writeFile(join(packagePath, "feature.cjs"), 'module.exports = "feature";'),
|
||||
writeFile(join(packagePath, "literal.cjs"), 'module.exports = "literal";'),
|
||||
writeFile(
|
||||
join(legacyPackagePath, "package.json"),
|
||||
JSON.stringify({ name: "legacy", main: "./main.cjs" }),
|
||||
),
|
||||
writeFile(
|
||||
join(legacyPackagePath, "main.cjs"),
|
||||
'module.exports = "legacy root";',
|
||||
),
|
||||
writeFile(
|
||||
join(legacyPackagePath, "feature.js"),
|
||||
'module.exports = "legacy feature";',
|
||||
),
|
||||
writeFile(
|
||||
join(legacyPackagePath, "with space.js"),
|
||||
'module.exports = "legacy spaced feature";',
|
||||
),
|
||||
writeFile(
|
||||
join(legacyPackagePath, "nested", "package.json"),
|
||||
JSON.stringify({ main: "./main.cjs" }),
|
||||
),
|
||||
writeFile(
|
||||
join(legacyPackagePath, "nested", "main.cjs"),
|
||||
'module.exports = "legacy nested";',
|
||||
),
|
||||
]);
|
||||
return { root, source };
|
||||
};
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"resolves package self references and suffix keys through %s",
|
||||
async (surface, { cli }) => {
|
||||
const { root, source } = await createPackageSpecifierFixture();
|
||||
if (surface === "CLI") {
|
||||
const native = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`const requests = ${JSON.stringify(requests.map(({ specifier }) => specifier))};
|
||||
const results = await Promise.all(requests.map(async (specifier) => {
|
||||
try {
|
||||
const result = await import(specifier);
|
||||
return { value: result.default, error: null };
|
||||
} catch (error) {
|
||||
return { value: null, error: error.code };
|
||||
}
|
||||
}));
|
||||
console.log(JSON.stringify(results));`,
|
||||
],
|
||||
{ cwd: root, timeout: 5_000 },
|
||||
);
|
||||
expect(JSON.parse(native.stdout)).toEqual(
|
||||
requests.map(({ value, error }) => ({ value, error })),
|
||||
);
|
||||
}
|
||||
|
||||
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
for (const { specifier, target, status } of requests)
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "imports",
|
||||
properties: expect.objectContaining({
|
||||
module_link_kind: "import",
|
||||
specifier,
|
||||
resolved_path: target,
|
||||
resolution_status: status,
|
||||
}),
|
||||
evidence: expect.objectContaining({
|
||||
...(specifier === "legacy/feature"
|
||||
? {
|
||||
limitations: expect.arrayContaining([
|
||||
"The selected package ESM subpath target node_modules/legacy/feature does not exist in the inventoried artifact container; ESM subpath targets are exact files, so no extension or directory index was tried.",
|
||||
]),
|
||||
}
|
||||
: {}),
|
||||
artifact: expect.objectContaining({
|
||||
sha256: createHash("sha256").update(source).digest("hex"),
|
||||
}),
|
||||
location: expect.objectContaining({
|
||||
value: expect.objectContaining({ source: "main.mjs" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -7,9 +7,9 @@ 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";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
|
||||
const createPackageSubpathFixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-package-subpaths-");
|
||||
@@ -124,26 +124,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
).replaceAll("\\", "/"),
|
||||
).toBe("node_modules/fi%xture/actual.cjs");
|
||||
|
||||
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;
|
||||
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
const imports = graph.edges.filter(
|
||||
({ properties }) => properties.module_link_kind === "require",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } 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";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"recovers retained prefixed source-map originals and byte identity through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-retained-map-prefix-");
|
||||
const original = "export const answer: number = 42;";
|
||||
const mapText = `\uFEFF)]}'retained response\r\n${JSON.stringify({
|
||||
version: 3,
|
||||
sources: ["original.ts"],
|
||||
sourcesContent: [original],
|
||||
names: [],
|
||||
mappings: "AAAA",
|
||||
})}`;
|
||||
const mapPath = join(root, "bundle.js.map");
|
||||
await writeFile(
|
||||
join(root, "bundle.js"),
|
||||
"export const answer = 42;\n//# sourceMappingURL=bundle.js.map\n",
|
||||
);
|
||||
await writeFile(mapPath, mapText);
|
||||
|
||||
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;
|
||||
const mapDigest = createHash("sha256").update(mapText).digest("hex");
|
||||
expect(graph.nodes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "source-module",
|
||||
identity: expect.objectContaining({
|
||||
strategy: "source-map-original",
|
||||
source_map_sha256: mapDigest,
|
||||
original_source: "original.ts",
|
||||
source_sha256: createHash("sha256").update(original).digest("hex"),
|
||||
}),
|
||||
observations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source_map_reference: expect.objectContaining({
|
||||
source_name: "original.ts",
|
||||
map_path: "bundle.js.map",
|
||||
}),
|
||||
properties: { content_available: true },
|
||||
evidence: expect.objectContaining({
|
||||
artifact: expect.objectContaining({ sha256: mapDigest }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(graph.coverage.status).toBe("complete");
|
||||
expect(await readFile(mapPath, "utf8")).toBe(mapText);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import type { TestCli } from "./cliFixture.js";
|
||||
|
||||
/** Analyze a directory through a public adapter and validate its result contract. */
|
||||
export const analyzeJavaScriptDirectory = async (
|
||||
surface: "CLI" | "stdio MCP",
|
||||
cli: TestCli,
|
||||
root: string,
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
return z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(document).normalized_result;
|
||||
};
|
||||
Reference in New Issue
Block a user