diff --git a/docs/web-source-location.md b/docs/web-source-location.md index 20de709568..a9e6a985a2 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -31,10 +31,11 @@ zero-based UTF-16 column. The MCP arguments are: The URL supplies the context for resolving `sources` and `sourceRoot` using the ECMA-426 URL parsing rule; it is not an instruction to fetch. Repeated path -separators remain part of the resolved URL identity. A non-null `sourceRoot` +separators remain part of the resolved URL identity. A non-empty `sourceRoot` prefixes each source, adding `/` only when the root does not already end with -one. An empty root therefore prefixes `/`; a null or omitted root adds no -prefix. Absolute virtual or file contexts can be selected as well. The map/script association is caller-selected: a declaration or local map +one. An empty, null or omitted root adds no prefix. webpack 5 and tsc maps +(`"sourceRoot": ""`) therefore resolve against the map URL, as Chrome, Firefox +and the pinned codec do. Absolute virtual or file contexts can be selected as well. The map/script association is caller-selected: a declaration or local map does not prove deployment authenticity or execution. Use [import tracing](website-module-trace.md) to inspect outgoing native module edges. diff --git a/src/browser/WebSourceMapFetcher.test.ts b/src/browser/WebSourceMapFetcher.test.ts index 6628c6e714..0bc5cc1c26 100644 --- a/src/browser/WebSourceMapFetcher.test.ts +++ b/src/browser/WebSourceMapFetcher.test.ts @@ -131,7 +131,7 @@ it.each([ }, { sourceRoot: "webpack://", expected: "webpack://a.js" }, { sourceRoot: "webpack:///", expected: "webpack:///a.js" }, - { sourceRoot: "", expected: "https://app.example.test/a.js" }, + { sourceRoot: "", expected: "https://app.example.test/maps/a.js" }, { sourceRoot: null, expected: "https://app.example.test/maps/a.js" }, ])( "retains the admitted ECMA-426 source identity for root $sourceRoot", diff --git a/src/domain/javascript/javascriptSourceMapPaths.ts b/src/domain/javascript/javascriptSourceMapPaths.ts index 083d63ead8..7402dd4f54 100644 --- a/src/domain/javascript/javascriptSourceMapPaths.ts +++ b/src/domain/javascript/javascriptSourceMapPaths.ts @@ -57,11 +57,19 @@ export const resolveJavaScriptSourceMapUrl = ( ); }; +/** + * Prefix a source with its declared root before URL resolution. An empty root + * adds no prefix: webpack 5 and tsc emit `"sourceRoot": ""` for map-relative + * sources. Chrome, Firefox and trace-mapping all skip it, and ECMA-426's + * DecodeSourceMapSources steps were written to match them (tc39/ecma426#183). + * Only a non-empty root gains a `/` separator, so repeated slashes stay + * significant. + */ const sourceMapReference = ( source: string, sourceRoot: string | null | undefined, ): string => { - if (sourceRoot == null) return source; + if (sourceRoot == null || sourceRoot === "") return source; return `${sourceRoot}${sourceRoot.endsWith("/") ? "" : "/"}${source}`; }; diff --git a/src/domain/javascript/sourceToBundleComparison.test.ts b/src/domain/javascript/sourceToBundleComparison.test.ts index 483380b533..a52604541f 100644 --- a/src/domain/javascript/sourceToBundleComparison.test.ts +++ b/src/domain/javascript/sourceToBundleComparison.test.ts @@ -466,8 +466,8 @@ describe("source-map declaration resolution", () => { }, { sourceRoot: "", - expectedSource: "file:///a.js", - expectedResolution: { kind: "suffix", path: "a.js" }, + expectedSource: "file:///artifact/dist/a.js", + expectedResolution: { kind: "artifact-relative", path: "dist/a.js" }, }, { sourceRoot: null, diff --git a/tests/acceptance/webSourceLocationCli.test.ts b/tests/acceptance/webSourceLocationCli.test.ts index f5bee3d4f4..ccdcdb158e 100644 --- a/tests/acceptance/webSourceLocationCli.test.ts +++ b/tests/acceptance/webSourceLocationCli.test.ts @@ -380,3 +380,78 @@ cliTest( }, 180_000, ); + +cliTest( + "resolves empty-sourceRoot sources against the map URL through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-source-root-"); + // webpack 5 and tsc emit `"sourceRoot": ""` for map-relative sources. + const text = JSON.stringify({ + version: 3, + sourceRoot: "", + sources: ["../src/a.ts", "webpack://app/./src/b.ts", "dir//c.ts"], + sourcesContent: ["a", "b", "c"], + names: [], + mappings: "AAAA,ACAA,ACAA", + }); + const fixture = webSourceLocationFixture(text); + 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 map = join(root, "app.js.map"); + await writeFile(map, text); + const url = "https://cdn.test/npm/app@1/dist/app.js.map"; + const result = await cli.run({ + arguments: [ + "trace-web-source-location", + manifest, + "0", + map, + url, + "1", + "0", + "--json", + ], + environment: { + REA_LOG_LEVEL: "silent", + REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser", + }, + timeoutMs: 45_000, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + matches: [ + { + source_index: 0, + reported_source_root: "", + resolved_url: "https://cdn.test/npm/app@1/src/a.ts", + }, + { + source_index: 1, + reported_source_root: "", + resolved_url: "webpack://app/src/b.ts", + }, + { + source_index: 2, + reported_source_root: "", + resolved_url: "https://cdn.test/npm/app@1/dist/dir//c.ts", + }, + ], + }, + }); + const { call } = await connectLocalToolsMcp({ + REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser", + }); + const response = await call("trace_web_source_location", { + manifest_path: manifest, + script_index: 0, + source_map: { path: map, url }, + generated_position: { line: 1, column: 0 }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(result.json); + }, + 180_000, +); diff --git a/tests/boundary/filesystem/sourceToBundlePaths.test.ts b/tests/boundary/filesystem/sourceToBundlePaths.test.ts index cd79ce5012..0e70e41404 100644 --- a/tests/boundary/filesystem/sourceToBundlePaths.test.ts +++ b/tests/boundary/filesystem/sourceToBundlePaths.test.ts @@ -171,8 +171,16 @@ it("matches source-map originals named relative to the map", async () => { it.each([ { root: "../src/", expected: "https://example.test/src/index.js" }, - { root: "", expected: "https://example.test/index.js" }, + // webpack 5 and tsc emit an empty root for map-relative sources. + { root: "", expected: "https://example.test/maps/index.js" }, { root: null, expected: "https://example.test/maps/index.js" }, + { root: "/", expected: "https://example.test/index.js" }, + { root: "./", expected: "https://example.test/maps/index.js" }, + { root: " ", expected: "https://example.test/index.js" }, + { + root: "https://cdn.example/src", + expected: "https://cdn.example/src/index.js", + }, ])("resolves sourceRoot $root according to ECMA-426", ({ root, expected }) => { expect( resolveJavaScriptSourceMapUrl( @@ -194,7 +202,14 @@ it.each([ mapPath: "maps/app.js.map", sourceRoot: "", source: "index.js", - expected: "index.js", + expected: "maps/index.js", + decoy: "index.js", + }, + { + mapPath: "pkg/dist/cjs/main.js.map", + sourceRoot: "", + source: "../../src/a.js", + expected: "pkg/src/a.js", }, { mapPath: "maps/app.js.map",