From 3f80b75708f88e51c64eb0122c178b96789c53b3 Mon Sep 17 00:00:00 2001 From: Riley Gramlich <101842921+rileygramlich@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:52:47 -0600 Subject: [PATCH] fix(source-maps): keep sources when sourcesContent length differs (#2873) ECMA-426 DecodeSourceMapSources reads a content slot only while its index is within sourcesContent, so a shorter array leaves trailing sources without content and a longer array adds no sources. Local application analysis and browser collection rejected any length mismatch and dropped every original from the map. The content helper now checks only entry types. Browser collection drops its separate check because the leaf schema already validates every supplied entry, and the raw records are still budgeted before parsing. Fixes #2821 --- .../javascript/JavaScriptArtifactAnalysis.ts | 4 +- src/domain/sourceMapContents.test.ts | 20 +++++--- src/domain/sourceMapContents.ts | 15 +++--- src/javascript/sourceMaps/SourceMapFormat.ts | 13 ------ .../browser/webSourceMapFetcher.test.ts | 45 +++++++++++++++++- .../javascriptArtifactReconstruction.test.ts | 46 ++++++++++++++++++- 6 files changed, 114 insertions(+), 29 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index e61d3ae94f..6b48b448c4 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -553,10 +553,10 @@ const collectSourceMapOriginals = ( if (!Array.isArray(names)) return invalidSourceMap(file, "Source map has no sources array."); const contents = map.sourcesContent; - if (!hasValidSourceMapContents(names.length, contents)) + if (!hasValidSourceMapContents(contents)) return invalidSourceMap( file, - "Source map sourcesContent must contain one string or null entry per source.", + "Source map sourcesContent must be an array of strings or null entries.", ); if (map.sourceRoot != null && typeof map.sourceRoot !== "string") return invalidSourceMap( diff --git a/src/domain/sourceMapContents.test.ts b/src/domain/sourceMapContents.test.ts index 06f07197b0..dd80f39fb0 100644 --- a/src/domain/sourceMapContents.test.ts +++ b/src/domain/sourceMapContents.test.ts @@ -2,10 +2,18 @@ import { expect, it } from "vitest"; import { hasValidSourceMapContents } from "./sourceMapContents.js"; -it("accepts absent map-level source contents and aligned nullable slots", () => { - expect(hasValidSourceMapContents(2, undefined)).toBe(true); - expect(hasValidSourceMapContents(2, null)).toBe(true); - expect(hasValidSourceMapContents(2, [null, "retained source"])).toBe(true); - expect(hasValidSourceMapContents(2, ["retained source"])).toBe(false); - expect(hasValidSourceMapContents(2, [null, 42])).toBe(false); +it("accepts absent map-level source contents and nullable string slots", () => { + expect(hasValidSourceMapContents(undefined)).toBe(true); + expect(hasValidSourceMapContents(null)).toBe(true); + expect(hasValidSourceMapContents([null, "retained source"])).toBe(true); + expect(hasValidSourceMapContents([null, 42])).toBe(false); + expect(hasValidSourceMapContents("retained source")).toBe(false); +}); + +it("leaves sourcesContent length to source decoding (ECMA-426 DecodeSourceMapSources)", () => { + expect(hasValidSourceMapContents([])).toBe(true); + expect(hasValidSourceMapContents(["retained source"])).toBe(true); + expect( + hasValidSourceMapContents(["a", null, "unused trailing content"]), + ).toBe(true); }); diff --git a/src/domain/sourceMapContents.ts b/src/domain/sourceMapContents.ts index 5d55810a9c..c6a621f1aa 100644 --- a/src/domain/sourceMapContents.ts +++ b/src/domain/sourceMapContents.ts @@ -1,12 +1,15 @@ -/** Validate optional source-map content against its source inventory. */ -export const hasValidSourceMapContents = ( - sourceCount: number, - contents: unknown, -): boolean => +/** + * Validate optional source-map content against its source inventory. + * + * ECMA-426 DecodeSourceMapSources reads a content slot only while its index is + * within `sourcesContent`, so a shorter array leaves trailing sources without + * content and a longer array contributes no extra sources. Only the entry types + * are checked here. + */ +export const hasValidSourceMapContents = (contents: unknown): boolean => contents === undefined || contents === null || (Array.isArray(contents) && - contents.length === sourceCount && contents.every( (content: unknown) => content === null || typeof content === "string", )); diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index f8975c9b64..a2edcab528 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -1,6 +1,5 @@ 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"; import type { EncodedSourceMapXInput } from "@jridgewell/trace-mapping"; @@ -267,18 +266,6 @@ const inspectLeaf = ( consumeRecords(budget, Array.isArray(rawNames) ? rawNames.length : 0); consumeRecords(budget, Array.isArray(rawContents) ? rawContents.length : 0); options.check?.(); - if ( - options.profile === "browser-collection" && - !hasValidSourceMapContents( - Array.isArray(rawSources) ? rawSources.length : 0, - rawContents, - ) - ) - throw new SourceMapFormatFailure( - "format", - "Source-map sourcesContent must align with its source declarations.", - ); - options.check?.(); const leaf = leafSchema.parse(node.value); const layout = inspectMappingLayout(leaf.mappings, options.check); consumeRecords(budget, layout.segments + layout.lines); diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 6dcabb75b0..5fab66f588 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -810,7 +810,6 @@ describe("web source-map sourcesContent validation", () => { it.each([ ["a scalar sourcesContent value", { sourcesContent: "source" }], ["a non-string, non-null source entry", { sourcesContent: [42] }], - ["a sourcesContent array with the wrong length", { sourcesContent: [] }], ])("reports %s as invalid", async (_description, content) => { const result = await fetchWebSourceMaps([request], input(), undefined, { fetch: () => @@ -841,6 +840,50 @@ describe("web source-map sourcesContent validation", () => { }); }); + it.each([ + ["shorter", ["import './a.ts';"]], + ["longer", ["import './a.ts';", null, "unused trailing content"]], + ])( + "keeps every source declaration when sourcesContent is %s than sources", + async (_length, sourcesContent) => { + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => + Promise.resolve( + new Response( + JSON.stringify({ + version: 3, + sources: ["a.ts", "b.ts"], + sourcesContent, + names: [], + mappings: "", + }), + { status: 200 }, + ), + ), + }); + + expect(result.items[0]).toMatchObject({ + status: "included", + limitation: null, + original_sources: [ + { + source_index: 0, + declaration_index: 0, + source: `${origin}/assets/a.ts`, + artifact: { text: "import './a.ts';" }, + }, + { + source_index: 1, + declaration_index: 1, + source: `${origin}/assets/b.ts`, + artifact: null, + }, + ], + }); + expect(result.items[0]?.original_sources).toHaveLength(2); + }, + ); + it("accepts null source-map source contents", async () => { const result = await fetchWebSourceMaps([request], input(), undefined, { fetch: () => diff --git a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts index aabfdd19b8..1254d11dd5 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts @@ -135,7 +135,6 @@ it("retains every source declared by every included local source map", async () it.each([ ["a non-array sourcesContent value", { sourcesContent: "source" }], ["a non-string, non-null source entry", { sourcesContent: [42] }], - ["a source-content array with the wrong length", { sourcesContent: [] }], ])( "reports %s as an invalid local source map", async (_description, content) => { @@ -210,6 +209,51 @@ it("accepts omitted and explicitly null source-map source contents", async () => expect(result.graph.coverage.status).toBe("complete"); }); +it.each([ + ["shorter", ["export const answer = 42;"]], + ["longer", ["export const answer = 42;", null, "unused trailing content"]], +])( + "keeps every source declaration when sourcesContent is %s than sources", + async (length, sourcesContent) => { + const root = await createTestTempDirectory( + `rea-javascript-${length}-source-content-`, + ); + await writeFile( + join(root, "bundle.js.map"), + JSON.stringify({ + version: 3, + sources: ["a.ts", "b.ts"], + sourcesContent, + names: [], + mappings: "AAAA;ACAA", + }), + ); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const originals = result.graph.nodes.filter( + ({ identity }) => identity.strategy === "source-map-original", + ); + + expect( + originals + .map(({ identity, observations }) => ({ + original_source: + "original_source" in identity ? identity.original_source : null, + source_index: observations[0]?.properties.source_index, + content_available: observations[0]?.properties.content_available, + })) + .sort( + (left, right) => + Number(left.source_index) - Number(right.source_index), + ), + ).toEqual([ + { original_source: "a.ts", source_index: 0, content_available: true }, + { original_source: "b.ts", source_index: 1, content_available: false }, + ]); + expect(result.graph.coverage.status).toBe("complete"); + }, +); + it("retains unnamed source declarations without shifting later source evidence", async () => { const root = await createTestTempDirectory("rea-javascript-unnamed-source-"); const text = JSON.stringify({