diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 47516e8f16..375b16c3ca 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -252,7 +252,8 @@ Selected bounded text is then parsed as inert data to recover: - Electron preload paths and renderer files visible in static syntax; - local HTML script entrypoints; - Webpack and Rspack chunk registrations and module factories represented as - AST literals; + AST literals, plus esbuild `__commonJS` module tables; recovered esbuild + CommonJS exports use its factory's `exports`-then-`module` parameter order; - static imports, dynamic imports, CommonJS `require` calls, workers, and service workers (only `new Worker(new URL(path, import.meta.url))` resolves relative to the calling module; string worker and service-worker paths use @@ -761,6 +762,14 @@ 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. +For a proven `createRequire(new URL(relativeFile, import.meta.url))` call, +relative `require()` edges use the selected filename's directory and retain the +source-relative `resolution_base_path` on the graph edge. This follows Node's +`createRequire(filename)` contract; the path is a loader-resolution fact and +does not change the source file or call-site Evidence identity. If the filename +cannot be proven, REA keeps the require target unresolved rather than silently +resolving it from the caller's directory. + 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. diff --git a/docs/web-source-location.md b/docs/web-source-location.md index bb6a87b6dc..743ad3a1f9 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -67,7 +67,14 @@ The adapter uses the locked upstream without modifying its code. It validates map/VLQ structure before allocation, normalizes nested indexed offsets before upstream flattening, checks section boundaries and indexes, and retains raw declaration identity. It supports regular -maps and inline indexed sections. External section URLs return an actionable +maps and inline indexed sections. Numeric `version: 3` is required on the root +and every embedded map, including indexed ancestors. Invalid embedded headers +report their section path and cannot produce descendant mapping facts. This +uses the rejection option for version errors allowed by +[ECMA-426](https://tc39.es/ecma426/#sec-decodeindexsourcemap). +Equal section starts are allowed when earlier sections contribute no mappings; +actual decoded mappings must stay before the next section boundary. +External section URLs return an actionable unsupported-profile error. This is a defined decoder profile, not a claim to implement every current source-map extension. diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index db67c40cbf..69adac5a1b 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { posix } from "node:path"; import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; @@ -306,6 +307,7 @@ const addReference = ( kind: input.value.kind, specifier: input.value.specifier, expression: input.value.expression, + resolution_base_path: input.value.resolution_base_path ?? null, resolution_mode: input.value.kind === "static-import" || input.value.kind === "dynamic-import" || @@ -634,6 +636,12 @@ const resolveReference = ( if (specifier === null) return null; const isWorker = reference.kind === "worker" || reference.kind === "service-worker"; + const resolutionSourcePath = + reference.kind === "require" && reference.resolution_base_path !== undefined + ? posix.normalize( + posix.join(posix.dirname(sourcePath), reference.resolution_base_path), + ) + : sourcePath; const chunk = isWorker ? null : resolveBundlerChunkReference(context, sourcePath, reference); @@ -668,7 +676,7 @@ const resolveReference = ( }) : resolveArtifactPathByContext({ declaredPath: specifier, - sourcePath, + sourcePath: resolutionSourcePath, context: "module-specifier", files: context.filesByPath, jsonDocuments: context.analysis.jsonDocuments, @@ -679,7 +687,7 @@ const resolveReference = ( "dynamic-import" === reference.kind ? { moduleKind: moduleResolutionModeForJavaScriptReference( - sourcePath, + resolutionSourcePath, reference.kind, ), } diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 89a781640f..7b3407d853 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -5,10 +5,7 @@ import * as t from "@babel/types"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -import { - runtimeFunctionParameters, - semanticStaticPropertyKey, -} from "./javascriptAstValues.js"; +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; import { resolveSemanticBindingState, @@ -36,15 +33,18 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => { export const collectJavaScriptExports = ( node: t.Node, bindings?: JavaScriptSemanticAnalysisState, + exportsParameter?: t.Identifier, ): StaticExports => { const exports = new Set(); - const parameter = t.isFunction(node) - ? runtimeFunctionParameters(node)[1] - : undefined; const factoryExports = - bindings !== undefined && t.isFunction(node) && t.isIdentifier(parameter) - ? resolveSemanticBindingState(bindings, parameter, parameter.name) - ?.bindingId + bindings !== undefined && + t.isFunction(node) && + exportsParameter !== undefined + ? resolveSemanticBindingState( + bindings, + exportsParameter, + exportsParameter.name, + )?.bindingId : undefined; const isExportsAlias = (reference: t.Node | null): boolean => { if (!t.isIdentifier(reference)) return false; diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index 11d127401d..7aae839daf 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -256,7 +256,13 @@ const recoverBundlerModule = ( if (key.startsWith("[computed@") || key.startsWith("[unknown@")) accumulator.unknownFindings += 1; const fingerprint = fingerprintJavaScriptAst(factory); - const exportsValue = collectJavaScriptExports(factory, bindings); + const exportsParameter = + runtimeFunctionParameters(factory)[runtime === "esbuild-commonjs" ? 0 : 1]; + const exportsValue = collectJavaScriptExports( + factory, + bindings, + t.isIdentifier(exportsParameter) ? exportsParameter : undefined, + ); const requireName = factoryRequireName(factory); const requireParameter = runtimeFunctionParameters(factory)[2]; const requireBindingId = t.isIdentifier(requireParameter) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 07bfd5a92c..5bfa12e247 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -91,6 +91,9 @@ export const inspectCall = ( node, kind: "require", specifier: requireCall.specifier, + ...(requireCall.resolutionBasePath === undefined + ? {} + : { resolutionBasePath: requireCall.resolutionBasePath }), }); const chunkLoad = chunkLoadSpecifier(node, bindings, context); if (chunkLoad !== null) @@ -265,7 +268,10 @@ const requireCallSpecifier = ( bindings: JavaScriptSemanticAnalysisState, context: FindingContext, resolveMemberChanged = false, -): { readonly specifier: string | undefined } | null => { +): { + readonly specifier: string | undefined; + readonly resolutionBasePath?: string; +} | null => { const call = semanticRequireCallAt( node, bindings, @@ -280,7 +286,12 @@ const requireCallSpecifier = ( context.accumulator.unknownFindings += 1; return { specifier: undefined }; } - return { specifier: argumentValue(call.arguments?.[0]) }; + return { + specifier: argumentValue(call.arguments?.[0]), + ...(call.sourceRelativePath === undefined + ? {} + : { resolutionBasePath: call.sourceRelativePath }), + }; }; /** Examine member-loader receivers only after shared alias mutation facts settle. */ @@ -311,6 +322,9 @@ export const finishModuleRequireCalls = ( node, kind: "require", specifier: call.specifier, + ...(call.resolutionBasePath === undefined + ? {} + : { resolutionBasePath: call.resolutionBasePath }), }); } context.accumulator.pendingModuleRequireCalls.length = 0; @@ -933,11 +947,14 @@ export const addReference = ( : { specifier, expression: null }; addLocatedFinding(context, { collection: context.accumulator.references, - key: `reference\0${input.kind}\0${reference.specifier ?? reference.expression}`, + key: `reference\0${input.kind}\0${reference.specifier ?? reference.expression}\0${input.resolutionBasePath ?? ""}`, node: input.node, value: { kind: input.kind, ...reference, + ...(input.resolutionBasePath === undefined + ? {} + : { resolution_base_path: input.resolutionBasePath }), module_key: null, location: range(input.node), }, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index f9943d66a6..dc42744ef0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -113,6 +113,7 @@ export interface JavaScriptReferenceInput { readonly node: t.Node; readonly kind: JavaScriptStaticReference["kind"]; readonly specifier: string | undefined; + readonly resolutionBasePath?: string; } /** Candidate route or network endpoint literal. */ diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 5dd08ebb2b..a2adfb811a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -31,6 +31,8 @@ interface JavaScriptStaticReferenceState { readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ readonly module_runtime?: string; + /** Source-relative filename selected by a proven createRequire factory. */ + readonly resolution_base_path?: string; readonly location: JavaScriptSourceRange; } diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts index 793b21c783..bacf3e048e 100644 --- a/src/domain/sourceMapEnvelope.ts +++ b/src/domain/sourceMapEnvelope.ts @@ -78,6 +78,11 @@ export const classifySourceMapSection = ( reason: "Indexed source-map section requires an embedded map or external URL.", }; + if (!isVersion3Map(section.map)) + return { + kind: "invalid", + reason: "Indexed source-map section map is not a version 3 map.", + }; return { kind: "embedded", map: section.map }; }; @@ -169,11 +174,6 @@ export const inspectSourceMapEnvelope = (root: unknown): SourceMapEnvelope => { externalSections.push({ path, url: classification.url }); else if (classification.kind === "invalid") invalidSections.push({ path, reason: classification.reason }); - else if (!isVersion3Map(classification.map)) - invalidSections.push({ - path, - reason: "Indexed source-map section map is not a version 3 map.", - }); else children.push({ map: classification.map, path }); } for (let index = children.length - 1; index >= 0; index -= 1) { diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index c772d615e6..2532c3c71d 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -296,6 +296,7 @@ const electron = z })); const referenceShape = { ...findingContext, + resolution_base_path: z.string().exactOptional(), kind: z.enum([ "static-import", "dynamic-import", diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index 64579c63b1..fc5e6eeb8b 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -147,10 +147,10 @@ const addSections = ({ for (const [index, section] of sections.entries()) { if ((index & 255) === 0) options.check?.(); const previous = sections[index - 1]; - if (previous !== undefined && compare(section.offset, previous.offset) <= 0) + if (previous !== undefined && compare(section.offset, previous.offset) < 0) throw new SourceMapFormatFailure( "format", - "Indexed section offsets must be strictly increasing.", + "Indexed section offsets must be in nondecreasing order.", ); const classification = classifySourceMapSection(section); if (classification.kind === "external") @@ -159,7 +159,10 @@ const addSections = ({ `External source-map section URL is not fetched: ${classification.url}`, ); if (classification.kind === "invalid") - throw new SourceMapFormatFailure("format", classification.reason); + throw new SourceMapFormatFailure( + "format", + `${classification.reason} Section path: ${JSON.stringify([...node.path, index])}.`, + ); embeddedMaps.push(classification.map); } for (let index = sections.length - 1; index >= 0; index -= 1) { @@ -181,11 +184,6 @@ const addSections = ({ ? nextOffset : node.stop; const offset = addOffset(node.offset, section.offset); - if (node.stop !== null && compare(offset, node.stop) >= 0) - throw new SourceMapFormatFailure( - "format", - "Nested source-map section overlaps its enclosing boundary.", - ); pending.push({ value: map, path: [...node.path, index], diff --git a/src/javascript/sourceMaps/TraceSourceMap.test.ts b/src/javascript/sourceMaps/TraceSourceMap.test.ts index 64fcde4ed4..03f1a8bb7c 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.test.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.test.ts @@ -63,7 +63,8 @@ it("uses the same conservative section classification for URL-plus-map and malfo expect(malformed).toThrow( expect.objectContaining({ reason: "format", - message: "External source-map section URL must be a string.", + message: + "External source-map section URL must be a string. Section path: [0].", }), ); }); diff --git a/tests/acceptance/applications/createRequireFilenameBase.test.ts b/tests/acceptance/applications/createRequireFilenameBase.test.ts new file mode 100644 index 0000000000..4958564ab4 --- /dev/null +++ b/tests/acceptance/applications/createRequireFilenameBase.test.ts @@ -0,0 +1,52 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + analyzeJavaScriptDirectory, + runNodeJsonControl, +} from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "resolves proven createRequire filenames from their own directory through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-create-require-base-"); + await writeFixtureFiles(root, { + "main.mjs": `import { createRequire } from "node:module"; +const nested = createRequire(new URL("./nested/anchor.cjs", import.meta.url)); +const current = createRequire(import.meta.url); +console.log(JSON.stringify([nested("./dep.cjs"), current("./dep.cjs")]));`, + "dep.cjs": 'module.exports = "root";', + "nested/anchor.cjs": "", + "nested/dep.cjs": 'module.exports = "nested";', + }); + + expect(await runNodeJsonControl(root, "main.mjs")).toEqual([ + "nested", + "root", + ]); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const dependencies = graph.edges + .filter( + ({ relation, properties }) => + relation === "imports" && + properties.specifier === "./dep.cjs" && + properties.kind === "require", + ) + .map(({ properties }) => ({ + resolution_base_path: properties.resolution_base_path, + resolved_path: properties.resolved_path, + })); + expect(dependencies).toHaveLength(2); + expect(dependencies).toEqual( + expect.arrayContaining([ + { + resolution_base_path: "./nested/anchor.cjs", + resolved_path: "nested/dep.cjs", + }, + { resolution_base_path: null, resolved_path: "dep.cjs" }, + ]), + ); + }, +); diff --git a/tests/acceptance/applications/esbuildCommonJsExports.test.ts b/tests/acceptance/applications/esbuildCommonJsExports.test.ts new file mode 100644 index 0000000000..a85fdf5a54 --- /dev/null +++ b/tests/acceptance/applications/esbuildCommonJsExports.test.ts @@ -0,0 +1,45 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + analyzeJavaScriptDirectory, + runNodeJsonControl, +} from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const esbuildBundle = `(() => { + var __getOwnPropNames = Object.getOwnPropertyNames; + var __commonJS = (cb, mod) => () => { + if (!mod) { + mod = { exports: {} }; + cb[__getOwnPropNames(cb)[0]](mod.exports, mod); + } + return mod.exports; + }; + var require_dep = __commonJS({ + "dep.cjs"(exports, module) { + exports.parse = function parse() { return module.id; }; + } + }); + console.log(JSON.stringify(Object.keys(require_dep()))); +})();`; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains exports from esbuild CommonJS factories that also accept module through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-esbuild-commonjs-exports-"); + await writeFixtureFiles(root, { "bundle.js": esbuildBundle }); + + expect(await runNodeJsonControl(root, "bundle.js")).toEqual(["parse"]); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const module = graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-module" && + observations[0]?.properties.module_key === "dep.cjs" && + observations[0]?.properties.bundler === "esbuild", + ); + expect(module?.observations[0]?.properties.exports).toEqual(["parse"]); + expect(graph.coverage.status).toBe("complete"); + }, +); diff --git a/tests/acceptance/webSourceLocationCli.test.ts b/tests/acceptance/webSourceLocationCli.test.ts index f933d6390a..313410c2ba 100644 --- a/tests/acceptance/webSourceLocationCli.test.ts +++ b/tests/acceptance/webSourceLocationCli.test.ts @@ -1,3 +1,5 @@ +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { expect, onTestFinished } from "vitest"; import { Client } from "@modelcontextprotocol/client"; import { @@ -112,3 +114,195 @@ cliTest( // Allow cold CLI and MCP ownership preparation across the full parity sequence. 180_000, ); + +cliTest( + "applies the shared version constraint to nested indexed maps through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-indexed-map-version-"); + const fixture = webSourceLocationFixture(); + await mkdir(join(root, "files", "modules"), { recursive: true }); + await writeFile(join(root, "files", "modules", "main.js"), fixture.source); + const manifest = join(root, "manifest.json").replaceAll("\\", "/"); + await writeFile(manifest, JSON.stringify(fixture.manifest)); + const { call } = await connectLocalToolsMcp({ + REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser", + }); + const leaf: unknown = JSON.parse(fixture.sourceMap.text); + for (const [index, version] of [3, 2, null, undefined, "3"].entries()) { + const text = JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version, + sections: [{ offset: { line: 0, column: 0 }, map: leaf }], + }, + }, + ], + }); + const path = join(root, `nested-${index}.map`); + await writeFile(path, text); + const result = await cli.run({ + arguments: [ + "trace-web-source-location", + manifest, + "0", + path, + fixture.sourceMap.url, + "1", + "0", + "--json", + ], + environment: { + REA_LOG_LEVEL: "silent", + REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser", + }, + timeoutMs: 45_000, + }); + const response = await call("trace_web_source_location", { + manifest_path: manifest, + script_index: 0, + source_map: { path, url: fixture.sourceMap.url }, + generated_position: { line: 1, column: 0 }, + }); + if (version === 3) { + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + matches: [ + { + state: "mapped", + section_path: [0, 0], + content: { text: "original" }, + }, + ], + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + } else { + const failure = { + code: "invalid_request", + details: { + issues: [ + { + path: ["source_map", "path"], + reason: "invalid_format", + message: expect.stringContaining( + "not a version 3 map. Section path: [0].", + ), + }, + ], + }, + }; + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(failure); + expect(result.json).not.toHaveProperty("normalized_result"); + expect(parseMcpToolError(response).error).toMatchObject(failure); + expect(response.structuredContent).toBeUndefined(); + } + } + }, + 180_000, +); + +cliTest( + "traces empty equal-offset sections while rejecting decreasing offsets and represented overlaps", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-indexed-map-"); + const fixture = webSourceLocationFixture(); + await mkdir(join(root, "files", "modules"), { recursive: true }); + await writeFile(join(root, "files", "modules", "main.js"), fixture.source); + const manifest = join(root, "manifest.json").replaceAll("\\", "/"); + await writeFile(manifest, JSON.stringify(fixture.manifest)); + const { call } = await connectLocalToolsMcp(); + const leaf: unknown = JSON.parse(fixture.sourceMap.text); + const empty = { version: 3, sources: [], names: [], mappings: "" }; + const nestedEmpty = { + version: 3, + sections: [{ offset: { line: 0, column: 0 }, map: empty }], + }; + for (const [index, sample] of [ + { first: empty, firstColumn: 0, nextColumn: 0, succeeds: true }, + { first: nestedEmpty, firstColumn: 0, nextColumn: 0, succeeds: true }, + { first: empty, firstColumn: 0, nextColumn: 1, succeeds: true }, + { first: leaf, firstColumn: 0, nextColumn: 0, succeeds: false }, + { first: empty, firstColumn: 1, nextColumn: 0, succeeds: false }, + ].entries()) { + const path = join(root, `empty-${index}.map`); + await writeFile( + path, + JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: sample.firstColumn }, + map: sample.first, + }, + { offset: { line: 0, column: sample.nextColumn }, map: leaf }, + ], + }), + ); + const result = await cli.run({ + arguments: [ + "trace-web-source-location", + manifest, + "0", + path, + fixture.sourceMap.url, + "1", + String(sample.nextColumn), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + timeoutMs: 45_000, + }); + const response = await call("trace_web_source_location", { + manifest_path: manifest, + script_index: 0, + source_map: { path, url: fixture.sourceMap.url }, + generated_position: { line: 1, column: sample.nextColumn }, + }); + if (sample.succeeds) { + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + lookup: { + matched_generated_position: { + line: 1, + column: sample.nextColumn, + }, + }, + matches: [ + { + state: "mapped", + section_path: [1], + content: { text: "original" }, + }, + ], + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + } else { + const failure = { + code: "invalid_request", + details: { + issues: [ + { + path: ["source_map", "path"], + reason: "invalid_format", + message: expect.stringMatching(/overlap|nondecreasing/u), + }, + ], + }, + }; + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(failure); + expect(parseMcpToolError(response).error).toMatchObject(failure); + } + } + }, + 180_000, +);