From 14935e4d470be318fdee442d279d6acb7d441119 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:09:17 +0800 Subject: [PATCH] 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 <169027046+xu91102@users.noreply.github.com> --- docs/javascript-artifact-reconstruction.md | 17 +- .../javascript/JavaScriptArtifactAnalysis.ts | 7 +- .../JavaScriptArtifactPathResolution.ts | 208 +++++++------ src/browser/WebSourceMapFetcher.ts | 2 +- src/domain/sourceMapEnvelope.ts | 13 + src/javascript/sourceMaps/SourceMapFormat.ts | 15 +- .../packageImportAliasesCli.test.ts | 25 +- .../applications/packageRootSuffixCli.test.ts | 109 +++++++ .../packageSelfNameBoundaryCli.test.ts | 207 +++++++++++++ .../packageSpecifierSuffixCli.test.ts | 278 ++++++++++++++++++ .../applications/packageSubpathsCli.test.ts | 23 +- .../applications/sourceMapPrefixCli.test.ts | 79 +++++ tests/support/cli/javascriptApplication.ts | 34 +++ 13 files changed, 860 insertions(+), 157 deletions(-) create mode 100644 tests/acceptance/applications/packageRootSuffixCli.test.ts create mode 100644 tests/acceptance/applications/packageSelfNameBoundaryCli.test.ts create mode 100644 tests/acceptance/applications/packageSpecifierSuffixCli.test.ts create mode 100644 tests/acceptance/applications/sourceMapPrefixCli.test.ts create mode 100644 tests/support/cli/javascriptApplication.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 4f7338366..eb37c6a2b 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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 diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index d28e5a6cc..1e41529b0 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -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."); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index bd3bcdaaf..035014370 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -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.`, ], }); diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 6c74cff6c..3032f20ed 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -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"; diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts index b5c78f1d4..65ac9f9c6 100644 --- a/src/domain/sourceMapEnvelope.ts +++ b/src/domain/sourceMapEnvelope.ts @@ -8,6 +8,19 @@ */ type SourceMapRecord = Readonly>; +/** + * 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); diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index 838c1082d..d59c8f3e5 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -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, diff --git a/tests/acceptance/applications/packageImportAliasesCli.test.ts b/tests/acceptance/applications/packageImportAliasesCli.test.ts index 7d3187a7f..fd52be267 100644 --- a/tests/acceptance/applications/packageImportAliasesCli.test.ts +++ b/tests/acceptance/applications/packageImportAliasesCli.test.ts @@ -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], diff --git a/tests/acceptance/applications/packageRootSuffixCli.test.ts b/tests/acceptance/applications/packageRootSuffixCli.test.ts new file mode 100644 index 000000000..ea8c81bbf --- /dev/null +++ b/tests/acceptance/applications/packageRootSuffixCli.test.ts @@ -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 = { ...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 }), + }), + }), + }), + ); + }, +); diff --git a/tests/acceptance/applications/packageSelfNameBoundaryCli.test.ts b/tests/acceptance/applications/packageSelfNameBoundaryCli.test.ts new file mode 100644 index 000000000..a46b07d51 --- /dev/null +++ b/tests/acceptance/applications/packageSelfNameBoundaryCli.test.ts @@ -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 = {}; + 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 }), + }), + }), + }), + ); + } + }, +); diff --git a/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts b/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts new file mode 100644 index 000000000..ad175006e --- /dev/null +++ b/tests/acceptance/applications/packageSpecifierSuffixCli.test.ts @@ -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" }), + }), + }), + }), + ); + }, +); diff --git a/tests/acceptance/applications/packageSubpathsCli.test.ts b/tests/acceptance/applications/packageSubpathsCli.test.ts index 7b192fd66..ab202f03d 100644 --- a/tests/acceptance/applications/packageSubpathsCli.test.ts +++ b/tests/acceptance/applications/packageSubpathsCli.test.ts @@ -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", ); diff --git a/tests/acceptance/applications/sourceMapPrefixCli.test.ts b/tests/acceptance/applications/sourceMapPrefixCli.test.ts new file mode 100644 index 000000000..4865b0289 --- /dev/null +++ b/tests/acceptance/applications/sourceMapPrefixCli.test.ts @@ -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); + }, +); diff --git a/tests/support/cli/javascriptApplication.ts b/tests/support/cli/javascriptApplication.ts new file mode 100644 index 000000000..085792100 --- /dev/null +++ b/tests/support/cli/javascriptApplication.ts @@ -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; +};