From a4dbb36d8ea83e9f94dd289ac1a2d220d2c72c9b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:54:33 +0800 Subject: [PATCH] fix(javascript): retain uncertainty for missing source map mappings Share the required mappings field contract and retain readable declarations across malformed indexed leaves while marking graph coverage partial. Refs #5469. --- .../javascript/JavaScriptArtifactAnalysis.ts | 8 +- .../JavaScriptArtifactAnalysisTypes.ts | 2 +- src/domain/sourceMapEnvelope.ts | 5 + src/javascript/sourceMaps/SourceMapFormat.ts | 7 +- .../applications/sourceMapPrefixCli.test.ts | 92 ++++++++ ...scriptApplicationSourceMapMappings.test.ts | 202 ++++++++++++++++++ 6 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index b4187a52f8..468196acfb 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -13,6 +13,7 @@ import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js"; import { flattenSourceMapLeaves, sourceMapJsonText, + sourceMapMappingsSchema, } from "../../domain/sourceMapEnvelope.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import { failedJavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; @@ -603,6 +604,7 @@ const collectSourceMapOriginals = ( maps: readonly Readonly>[], ): JavaScriptSourceMapObservation => { const sources: JavaScriptSourceMapOriginal[] = []; + let limitation: string | null = null; let declarationIndex = 0; for (const map of maps) { const names = map.sources; @@ -645,7 +647,10 @@ const collectSourceMapOriginals = ( content_sha256: content === null ? null : sha256(content), }); } + if (!sourceMapMappingsSchema.safeParse(map.mappings).success) + limitation ??= "Source map has no mappings string."; } + if (limitation !== null) return invalidSourceMap(file, limitation, sources); return { path: file.path, sha256: file.sha256, @@ -689,11 +694,12 @@ const unavailableSourceMap = ( const invalidSourceMap = ( file: JavaScriptArtifactFile, limitation: string, + sources: readonly JavaScriptSourceMapOriginal[] = [], ): JavaScriptSourceMapObservation => ({ path: file.path, sha256: file.sha256, status: "invalid", - sources: [], + sources, limitation, }); diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index f3387b9eb0..bef1063742 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -84,7 +84,7 @@ export type JavaScriptSourceMapObservation = JavaScriptStructuredObservation & } | { readonly status: "invalid"; - readonly sources: readonly []; + readonly sources: readonly JavaScriptSourceMapOriginal[]; readonly limitation: string; } ); diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts index b1cc1ff49c..be3cbfb809 100644 --- a/src/domain/sourceMapEnvelope.ts +++ b/src/domain/sourceMapEnvelope.ts @@ -1,6 +1,11 @@ +import { z } from "zod"; + /** Artifact source declarations do not require generated-position decoding. */ type SourceMapRecord = Readonly>; +/** ECMA-426 requires a string mapping payload on every regular map leaf. */ +export const sourceMapMappingsSchema = z.string(); + /** * Return the JSON parsing view of retained source-map text. ECMA-426 permits * HTTP responses to start with `)]}'`; the first line is transport framing. diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index a2edcab528..b21fef2c87 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -1,6 +1,9 @@ import { z } from "zod"; import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js"; -import { sourceMapJsonText } from "../../domain/sourceMapEnvelope.js"; +import { + sourceMapJsonText, + sourceMapMappingsSchema, +} from "../../domain/sourceMapEnvelope.js"; import type { EncodedSourceMapXInput } from "@jridgewell/trace-mapping"; const leafSchema = z.object({ @@ -10,7 +13,7 @@ const leafSchema = z.object({ sources: z.array(z.string().nullable()), sourcesContent: z.array(z.string().nullable()).nullable().optional(), names: z.array(z.string()).default([]), - mappings: z.string().regex(/^[A-Za-z0-9+/,;]*$/u), + mappings: sourceMapMappingsSchema.regex(/^[A-Za-z0-9+/,;]*$/u), ignoreList: z.array(z.number().int().min(0)).optional(), x_google_ignoreList: z.array(z.number().int().min(0)).optional(), }); diff --git a/tests/acceptance/applications/sourceMapPrefixCli.test.ts b/tests/acceptance/applications/sourceMapPrefixCli.test.ts index af5f8eaec3..3dfdd6738b 100644 --- a/tests/acceptance/applications/sourceMapPrefixCli.test.ts +++ b/tests/acceptance/applications/sourceMapPrefixCli.test.ts @@ -9,6 +9,98 @@ import { cliTest } from "../../support/cli/cliFixture.js"; import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps missing source-map mappings partial while retaining explicit declarations through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-source-map-mappings-"); + const original = "export const answer: number = 42;"; + const cases = [ + { + directory: "missing", + map: { + version: 3, + sources: ["original.ts"], + sourcesContent: [original], + names: [], + }, + expectedCoverage: "partial", + limitation: "Source map has no mappings string.", + }, + { + directory: "empty", + map: { + version: 3, + sources: ["original.ts"], + sourcesContent: [original], + names: [], + mappings: "", + }, + expectedCoverage: "complete", + limitation: null, + }, + { + directory: "mapped", + map: { + version: 3, + sources: ["original.ts"], + sourcesContent: [original], + names: [], + mappings: "AAAA", + }, + expectedCoverage: "complete", + limitation: null, + }, + ] as const; + for (const { directory, map, expectedCoverage, limitation } of cases) { + const app = join(root, directory); + const mapText = JSON.stringify(map); + await writeFixtureFiles(app, { "app.js.map": mapText }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, app); + const mapDigest = createHash("sha256").update(mapText).digest("hex"); + const source = graph.nodes.find( + (node) => + node.kind === "source-module" && + node.identity.strategy === "source-map-original" && + node.identity.source_map_sha256 === mapDigest, + ); + + expect(graph.coverage.status).toBe(expectedCoverage); + expect(source).toMatchObject({ + identity: { + strategy: "source-map-original", + original_source: "original.ts", + source_sha256: createHash("sha256").update(original).digest("hex"), + }, + observations: [ + expect.objectContaining({ + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: mapDigest }), + limitations: limitation === null ? [] : [limitation], + }), + }), + ], + }); + if (limitation !== null) + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + operation: "parse-local-source-map", + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: mapDigest }), + limitations: [limitation], + }), + }), + ]), + }), + ); + } + }, +); + cliTest.for(["CLI", "stdio MCP"] as const)( "recovers retained prefixed source-map originals and byte identity through %s", async (surface, { cli }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts new file mode 100644 index 0000000000..85a6e1f63f --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts @@ -0,0 +1,202 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; + +const analyzeMaps = async (maps: Readonly>) => { + const root = await createTestTempDirectory("rea-app-source-map-mappings-"); + await Promise.all( + Object.entries(maps).map(([path, text]) => + writeFile(join(root, path), text), + ), + ); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const analysis = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + return analysis.graph; +}; + +it("marks missing regular-map mappings unavailable and accepts empty or populated strings", async () => { + const original = "export const answer: number = 42;"; + const maps = { + "missing.js.map": JSON.stringify({ + version: 3, + sources: ["original.ts"], + sourcesContent: [original], + names: [], + }), + "empty.js.map": JSON.stringify({ + version: 3, + sources: ["original.ts"], + sourcesContent: [original], + names: [], + mappings: "", + }), + "mapped.js.map": JSON.stringify({ + version: 3, + sources: ["original.ts"], + sourcesContent: [original], + names: [], + mappings: "AAAA", + }), + }; + const graph = await analyzeMaps(maps); + const missingDigest = createHash("sha256") + .update(maps["missing.js.map"]) + .digest("hex"); + expect(graph.coverage.status).toBe("partial"); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + operation: "parse-local-source-map", + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: missingDigest }), + limitations: ["Source map has no mappings string."], + }), + }), + ]), + }), + ); + const originals = graph.nodes.filter( + (node) => node.identity.strategy === "source-map-original", + ); + expect(originals).toHaveLength(3); + expect(originals).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + source_map_sha256: missingDigest, + original_source: "original.ts", + source_sha256: createHash("sha256").update(original).digest("hex"), + }), + observations: expect.arrayContaining([ + expect.objectContaining({ + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: missingDigest }), + limitations: ["Source map has no mappings string."], + }), + }), + ]), + }), + ); + for (const mapPath of ["empty.js.map", "mapped.js.map"] as const) { + const digest = createHash("sha256").update(maps[mapPath]).digest("hex"); + expect(originals).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ source_map_sha256: digest }), + observations: expect.arrayContaining([ + expect.objectContaining({ + evidence: expect.objectContaining({ limitations: [] }), + }), + ]), + }), + ); + } +}); + +it("retains declarations before and after an invalid indexed-map leaf", async () => { + const mapText = JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ["before.ts"], + sourcesContent: ["export const before = 1;"], + names: [], + mappings: "AAAA", + }, + }, + { + offset: { line: 1, column: 0 }, + map: { + version: 3, + sources: ["middle.ts"], + sourcesContent: ["export const middle = 2;"], + names: [], + }, + }, + { + offset: { line: 2, column: 0 }, + map: { + version: 3, + sources: ["after.ts"], + sourcesContent: ["export const after = 3;"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + const graph = await analyzeMaps({ "indexed.js.map": mapText }); + const digest = createHash("sha256").update(mapText).digest("hex"); + expect(graph.coverage.status).toBe("partial"); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + path: "indexed.js.map", + operation: "parse-local-source-map", + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: digest }), + limitations: ["Source map has no mappings string."], + }), + }), + ]), + }), + ); + const originals = graph.nodes.filter( + (node) => + node.identity.strategy === "source-map-original" && + node.identity.source_map_sha256 === digest, + ); + expect(originals).toHaveLength(3); + for (const [index, sourceName, content] of [ + [0, "before.ts", "export const before = 1;"], + [1, "middle.ts", "export const middle = 2;"], + [2, "after.ts", "export const after = 3;"], + ] as const) { + expect(originals).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + source_map_sha256: digest, + original_source: sourceName, + source_sha256: createHash("sha256").update(content).digest("hex"), + }), + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + declaration_index: index, + source_name_available: true, + content_available: true, + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: digest }), + limitations: ["Source map has no mappings string."], + }), + }), + ]), + }), + ); + } +});