From e80fb638be5fcff7194455feb358dc193cbc81d7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:35:04 +0800 Subject: [PATCH] fix(web): retain source-map declaration provenance --- docs/web-source-location.md | 5 + src/browser/WebSourceMapFetcher.ts | 5 +- src/domain/webBundleAnalysis.ts | 2 + .../browser/webSourceMapFetcher.test.ts | 114 ++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/docs/web-source-location.md b/docs/web-source-location.md index 41b793ddf6..8e24bf533a 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -121,6 +121,11 @@ preceding browser mappings when a nested section starts with a gap or has no child leaves. Raw map text, original source identities and the dedicated decoder profile are preserved. +Browser `original_module_edges` carry a `declaration_index` joining each edge to +its `original_sources` declaration, including repeated source URLs across indexed +sections. Imports are deduplicated within each declaration. Older observations +may omit the index; their edge declaration identity remains unknown. + Browser collection accepts up to 64 MiB of map responses per inspection and preflights indexed sections, sources, names, generated rows and mapping segments against a 262144-record decode budget before upstream flattening. Indexed maps diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index ebd70a4b29..b5264265a2 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -868,7 +868,6 @@ const originalModuleEdges = ( ) => void, ): OriginalModuleEdges => { const edges: ParsedSourceMapItem["original_module_edges"] = []; - const seen = new Set(); const incomplete: string[] = []; for (const source of sources) { check(); @@ -892,6 +891,7 @@ const originalModuleEdges = ( // A recovered program still yields the imports it did parse, but nodes // after an unrecoverable point are missing, so the edges are a subset. if (parsed.errors.length > 0) incomplete.push(sourceName); + const seen = new Set(); let unboundRequires: ReadonlySet | undefined; traverseJavaScriptAst(parsed, { enter: (node) => { @@ -920,10 +920,11 @@ const originalModuleEdges = ( ) return; } - const key = `${sourceName}\0${kind}\0${specifier}`; + const key = `${kind}\0${specifier}`; if (seen.has(key)) return; seen.add(key); const edge = { + declaration_index: source.declaration_index, from_source: sourceName, kind, specifier, diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index 9a76a38fe5..1b370857ad 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -254,6 +254,8 @@ const originalSourceSchema = z.object({ artifact: webTextArtifactSchema.nullable(), }); const originalModuleEdgeSchema = z.object({ + // Historical observations omit this link to original_sources declarations. + declaration_index: z.int().min(0).optional(), from_source: z.string(), kind: z.enum(["static_import", "dynamic_import", "require"]), specifier: z.string(), diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 33f5b83e39..e933933711 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -31,6 +31,120 @@ const expectInvalidIncludedSourceMaps = (result: WebSourceMaps): void => { expectInvalidSourceMaps({ ...result, status: "unavailable" }); }; +it.each(["regular", "indexed"])( + "retains declaration identity for duplicate source URLs in HTTP %s maps", + async (format) => { + const contents = [ + null, + 'import "./shared.js"; import "./shared.js"; import "./first.js";', + 'import "./shared.js"; import "./second.js";', + ]; + const leaf = (sourcesContent: (string | null)[]) => ({ + version: 3, + sources: sourcesContent.map(() => "same.ts"), + sourcesContent, + names: [], + mappings: "", + }); + const map = + format === "regular" + ? leaf(contents) + : { + version: 3, + sections: contents.map((content, line) => ({ + offset: { line, column: 0 }, + map: leaf([content]), + })), + }; + const server = createServer((_incoming, response) => { + response.setHeader("content-type", "application/source-map+json"); + response.end(JSON.stringify(map)); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new TypeError("Expected a TCP listener address"); + const localOrigin = `http://127.0.0.1:${String(address.port)}`; + const url = `${localOrigin}/app.js.map`; + const result = await fetchWebSourceMapsOk( + [{ ...request, fetchUrl: url, declaredUrl: url }], + input({ allowed_origins: [localOrigin] }), + ); + const item = result.items[0]; + if (item?.status !== "included") throw new Error("map not included"); + expect(item.limitation).toBeNull(); + expect( + item.original_sources.map((source) => ({ + declaration_index: source.declaration_index, + source_index: source.source_index, + source: source.source, + text: source.artifact?.text ?? null, + })), + ).toEqual( + contents.map((text, declaration_index) => ({ + declaration_index, + source_index: format === "regular" ? declaration_index : 0, + source: `${localOrigin}/same.ts`, + text, + })), + ); + expect(item.original_module_edges).toEqual( + [ + [1, "./shared.js"], + [1, "./first.js"], + [2, "./shared.js"], + [2, "./second.js"], + ].map(([declaration_index, specifier]) => ({ + declaration_index, + from_source: `${localOrigin}/same.ts`, + kind: "static_import", + specifier, + resolved_source: new URL(String(specifier), url).href, + })), + ); + expect(webSourceMapsSchema.parse(result)).toEqual(result); + const historical = { + ...result, + items: [ + { + ...item, + original_module_edges: item.original_module_edges.map( + ({ declaration_index: _index, ...edge }) => edge, + ), + }, + ], + }; + expect(webSourceMapsSchema.parse(historical)).toEqual(historical); + expectInvalidSourceMaps({ + ...result, + items: [ + { + ...item, + original_module_edges: [ + { + ...item.original_module_edges[0], + declaration_index: -1, + }, + ], + }, + ], + }); + } finally { + server.closeAllConnections(); + if (server.listening) + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + } + }, +); + it.each(["regular", "indexed"])( "collects an HTTP %s source map with omitted names without accepting invalid names", async (format) => {