From ccba355fa9dbddf7718d7255e87b39c747446768 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:00:14 +0800 Subject: [PATCH] fix(javascript): preserve map boundaries and loader provenance Share embedded-map version admission and derive indexed overlap from represented mappings. Carry createRequire filename bases through worker facts and graph identity. Select factory export bindings at each bundler owner. Validated accepted and malformed source maps through real CLI and stdio MCP codec workflows; verified loader and esbuild graph output against Node runtime controls. Rebased consumer checks passed. Fixes #6803 Fixes #6614 Fixes #6798 Fixes #6797 --- docs/javascript-artifact-reconstruction.md | 11 +- docs/web-source-location.md | 9 +- .../JavaScriptArtifactGraphFindings.ts | 12 +- .../javascript/javascriptAstFingerprint.ts | 20 +- .../javascriptStaticAnalysisBundler.ts | 8 +- .../javascriptStaticAnalysisCalls.ts | 23 ++- .../javascriptStaticAnalysisState.ts | 1 + .../javascriptStaticAnalysisTypes.ts | 2 + src/domain/sourceMapEnvelope.ts | 10 +- .../JavaScriptAnalysisWorkerSchemas.ts | 1 + src/javascript/sourceMaps/SourceMapFormat.ts | 14 +- .../sourceMaps/TraceSourceMap.test.ts | 3 +- .../createRequireFilenameBase.test.ts | 52 +++++ .../esbuildCommonJsExports.test.ts | 45 ++++ tests/acceptance/webSourceLocationCli.test.ts | 194 ++++++++++++++++++ 15 files changed, 373 insertions(+), 32 deletions(-) create mode 100644 tests/acceptance/applications/createRequireFilenameBase.test.ts create mode 100644 tests/acceptance/applications/esbuildCommonJsExports.test.ts 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, +);