From 2b2e362a00fc5ba2f93fa6c62e2d8780072d1b4b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:18:23 +0800 Subject: [PATCH] fix(javascript): retain partial indexed source map facts Share section classification across declaration analysis and mapping parsing. Preserve external URLs, malformed section reasons, section paths and safe sibling declarations without fetching nested maps. Refs #5466. All 50 focused tests passed, including real CLI and stdio MCP regressions; typecheck and lint passed. --- .../javascript/JavaScriptArtifactAnalysis.ts | 104 ++++-- .../JavaScriptArtifactAnalysisTypes.ts | 8 + .../JavaScriptArtifactGraphStructure.ts | 27 ++ src/domain/sourceMapEnvelope.test.ts | 151 +++++++- src/domain/sourceMapEnvelope.ts | 208 ++++++++++-- src/javascript/sourceMaps/SourceMapFormat.ts | 77 +++-- .../sourceMaps/TraceSourceMap.test.ts | 71 ++++ .../applications/sourceMapPrefixCli.test.ts | 110 +++++- ...scriptApplicationSourceMapMappings.test.ts | 321 +++++++++++++++++- 9 files changed, 972 insertions(+), 105 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 468196acfb..919f7b044f 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -11,10 +11,11 @@ import { projectJavaScriptModuleSemantics } from "../../domain/javascript/javasc import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js"; import { - flattenSourceMapLeaves, + inspectSourceMapEnvelope, sourceMapJsonText, sourceMapMappingsSchema, } from "../../domain/sourceMapEnvelope.js"; +import type { SourceMapLeaf } from "../../domain/sourceMapEnvelope.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import { failedJavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { @@ -593,64 +594,109 @@ const parseSourceMap = ( void cause; return invalidSourceMap(file, "Source map is not valid JSON."); } - const maps = flattenSourceMapLeaves(value); - if (maps === undefined) - return invalidSourceMap(file, "Source map is not a version 3 map."); - return collectSourceMapOriginals(file, maps); + const envelope = inspectSourceMapEnvelope(value); + if (envelope.status === "invalid") + return invalidSourceMap(file, envelope.reason); + const observation = collectSourceMapOriginals(file, envelope.leaves); + if (envelope.status === "included") return observation; + const envelopeLimitations = [ + ...envelope.invalid_sections.map( + ({ section_path, reason }) => + `Indexed source-map section at [${section_path.join(",")}]: ${reason}`, + ), + ...envelope.external_sections.map( + ({ url }) => `External source-map section URL is not fetched: ${url}`, + ), + ]; + const limitation = [ + ...(observation.status === "invalid" ? [observation.limitation] : []), + ...envelopeLimitations, + ].join("; "); + return { + ...observation, + status: "invalid", + limitation, + ...(envelope.external_sections.length === 0 + ? {} + : { external_sections: envelope.external_sections }), + ...(envelope.invalid_sections.length === 0 + ? {} + : { invalid_sections: envelope.invalid_sections }), + }; }; const collectSourceMapOriginals = ( file: JavaScriptArtifactFile, - maps: readonly Readonly>[], + leaves: readonly SourceMapLeaf[], ): JavaScriptSourceMapObservation => { const sources: JavaScriptSourceMapOriginal[] = []; - let limitation: string | null = null; + const limitations: string[] = []; let declarationIndex = 0; - for (const map of maps) { + for (const { map, section_path } of leaves) { + const location = + section_path.length === 0 + ? "" + : ` in section [${section_path.join(",")}]`; const names = map.sources; - if (!Array.isArray(names)) - return invalidSourceMap(file, "Source map has no sources array."); + if (!Array.isArray(names)) { + limitations.push(`Source map has no sources array${location}.`); + continue; + } const contents = map.sourcesContent; if (!hasValidSourceMapContents(contents)) - return invalidSourceMap( - file, - "Source map sourcesContent must be an array of strings or null entries.", + limitations.push( + `Source map sourcesContent must be an array of strings or null entries${location}.`, ); - if (map.sourceRoot != null && typeof map.sourceRoot !== "string") - return invalidSourceMap( - file, - "Source map sourceRoot must be a string or null.", + if (map.sourceRoot != null && typeof map.sourceRoot !== "string") { + limitations.push( + `Source map sourceRoot must be a string or null${location}.`, ); + continue; + } const root = typeof map.sourceRoot === "string" ? map.sourceRoot : null; - const sourceNames: (string | null)[] = []; - for (const raw of names) { - if (raw !== null && typeof raw !== "string") - return invalidSourceMap( - file, - "Source map contains a non-string source name.", + const sourceDeclarations: { + readonly index: number; + readonly name: string | null; + }[] = []; + for (const [index, raw] of names.entries()) { + if (raw !== null && typeof raw !== "string") { + limitations.push( + `Source map contains a non-string source name at index ${String(index)}${location}.`, ); - sourceNames.push(raw); + continue; + } + sourceDeclarations.push({ index, name: raw }); } const references = resolveJavaScriptSourceMapReferences( - sourceNames, + sourceDeclarations.map(({ name }) => name), root, file.path, ); for (const [index, reference] of references.entries()) { - const rawContent = Array.isArray(contents) ? contents[index] : undefined; + const declaration = sourceDeclarations[index]; + if (declaration === undefined) continue; + const rawContent = Array.isArray(contents) + ? contents[declaration.index] + : undefined; const content = typeof rawContent === "string" ? rawContent : null; sources.push({ - source_index: index, + source_index: declaration.index, declaration_index: declarationIndex++, reference, content, content_sha256: content === null ? null : sha256(content), + section_path, }); } if (!sourceMapMappingsSchema.safeParse(map.mappings).success) - limitation ??= "Source map has no mappings string."; + limitations.push(`Source map has no mappings string${location}.`); } - if (limitation !== null) return invalidSourceMap(file, limitation, sources); + if (limitations.length > 0) + return invalidSourceMap( + file, + [...new Set(limitations)].join("; "), + sources, + ); return { path: file.path, sha256: file.sha256, diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index bef1063742..d4d22b6dc0 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -5,6 +5,10 @@ import type { } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js"; +import type { + ExternalSourceMapSection, + InvalidSourceMapSection, +} from "../../domain/sourceMapEnvelope.js"; import type { JavaScriptArtifactFile, JavaScriptArtifactFileKind, @@ -65,6 +69,8 @@ export interface JavaScriptHtmlInlineScriptsObservation { /** One original source declared by a local source map. */ export interface JavaScriptSourceMapOriginal { + /** Indexed-map section containing this declaration; empty for regular maps. */ + readonly section_path: readonly number[]; /** Index within the leaf map's sources and sourcesContent arrays. */ readonly source_index: number; /** Index across the flattened map's leaf declarations. */ @@ -86,6 +92,8 @@ export type JavaScriptSourceMapObservation = JavaScriptStructuredObservation & readonly status: "invalid"; readonly sources: readonly JavaScriptSourceMapOriginal[]; readonly limitation: string; + readonly external_sections?: readonly ExternalSourceMapSection[]; + readonly invalid_sections?: readonly InvalidSourceMapSection[]; } ); diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 4594f1589b..559f9a7c73 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -293,6 +293,33 @@ export const addJavaScriptArtifactFiles = ( asset: target, ...javascriptArtifactAnalysisScopes["source-map"], limitation: sourceMap.limitation, + ...(sourceMap.external_sections === undefined && + sourceMap.invalid_sections === undefined + ? {} + : { + details: { + ...(sourceMap.external_sections === undefined + ? {} + : { + external_sections: sourceMap.external_sections.map( + ({ section_path, url }) => ({ + section_path: [...section_path], + url, + }), + ), + }), + ...(sourceMap.invalid_sections === undefined + ? {} + : { + invalid_sections: sourceMap.invalid_sections.map( + ({ section_path, reason }) => ({ + section_path: [...section_path], + reason, + }), + ), + }), + }, + }), }); const inlineScripts = inlineScriptsByPath.get(file.path); if (inlineScripts !== undefined) diff --git a/src/domain/sourceMapEnvelope.test.ts b/src/domain/sourceMapEnvelope.test.ts index 4f8ea5c0eb..eca6a3aeff 100644 --- a/src/domain/sourceMapEnvelope.test.ts +++ b/src/domain/sourceMapEnvelope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { flattenSourceMapLeaves } from "./sourceMapEnvelope.js"; +import { inspectSourceMapEnvelope } from "./sourceMapEnvelope.js"; const leaf = { version: 3, @@ -36,32 +36,155 @@ describe("source-map envelope walk", () => { }, ], }; - expect(flattenSourceMapLeaves(root)?.map(({ sources }) => sources)).toEqual( - [["b.ts"], ["c.ts"], ["d.ts"]], - ); + const result = inspectSourceMapEnvelope(root); + expect(result.status).toBe("included"); + if (result.status !== "included") throw new Error("Expected embedded maps"); + expect(result.leaves.map(({ map }) => map.sources)).toEqual([ + ["b.ts"], + ["c.ts"], + ["d.ts"], + ]); }); - it("rejects sections without embedded maps", () => { + it("retains external sections in canonical path order and embedded leaves", () => { + const url = "https://app.test/maps/part.map"; + const result = inspectSourceMapEnvelope({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sections: [ + { offset: { line: 0, column: 0 }, url: `${url}/nested` }, + { offset: { line: 1, column: 0 }, map: leaf }, + ], + }, + }, + { offset: { line: 1, column: 0 }, url }, + { + offset: { line: 2, column: 0 }, + map: { ...leaf, sources: ["after.ts"] }, + }, + ], + }); + expect(result).toEqual({ + status: "unsupported", + leaves: [ + { section_path: [0, 1], map: leaf }, + { section_path: [2], map: { ...leaf, sources: ["after.ts"] } }, + ], + external_sections: [ + { section_path: [0, 0], url: `${url}/nested` }, + { section_path: [1], url }, + ], + invalid_sections: [], + }); + }); + + it("retains valid leaves and URLs when a sibling section is malformed", () => { + const url = "https://app.test/maps/part.map"; + const result = inspectSourceMapEnvelope({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { ...leaf, sources: ["before.ts"] }, + }, + { offset: { line: 1, column: 0 }, url }, + { offset: { line: 2, column: 0 }, url: 12, map: leaf }, + { + offset: { line: 3, column: 0 }, + map: { ...leaf, sources: ["after.ts"] }, + }, + ], + }); + expect(result).toEqual({ + status: "malformed", + leaves: [ + { section_path: [0], map: { ...leaf, sources: ["before.ts"] } }, + { section_path: [3], map: { ...leaf, sources: ["after.ts"] } }, + ], + external_sections: [{ section_path: [1], url }], + invalid_sections: [ + { + section_path: [2], + reason: "External source-map section URL must be a string.", + }, + ], + }); + }); + + it("rejects sections without embedded maps or an external URL", () => { const missing = { version: 3, sections: [{ offset: { line: 0, column: 0 } }], }; - expect(flattenSourceMapLeaves(missing)).toBeUndefined(); + expect(inspectSourceMapEnvelope(missing)).toMatchObject({ + status: "malformed", + invalid_sections: [ + { + section_path: [0], + reason: + "Indexed source-map section requires an embedded map or external URL.", + }, + ], + }); + }); +}); + +describe("malformed source-map envelope facts", () => { + it("marks a present non-array sections member malformed at the map path", () => { + expect( + inspectSourceMapEnvelope({ + version: 3, + sections: null, + sources: ["must-not-be-read.ts"], + mappings: "AAAA", + }), + ).toEqual({ + status: "malformed", + leaves: [], + external_sections: [], + invalid_sections: [ + { + section_path: [], + reason: "Indexed source-map sections must be an array.", + }, + ], + }); }); it("rejects non-version-3 roots and nested maps", () => { - expect(flattenSourceMapLeaves({ version: 3 })).toHaveLength(1); - expect(flattenSourceMapLeaves(null)).toBeUndefined(); - expect(flattenSourceMapLeaves([{ version: 3 }])).toBeUndefined(); + expect(inspectSourceMapEnvelope({ version: 3 })).toMatchObject({ + status: "included", + leaves: [{ section_path: [], map: {} }], + }); + expect(inspectSourceMapEnvelope(null)).toMatchObject({ status: "invalid" }); + expect(inspectSourceMapEnvelope([{ version: 3 }])).toMatchObject({ + status: "invalid", + }); expect( - flattenSourceMapLeaves({ version: 2, mappings: "AAAA" }), - ).toBeUndefined(); - expect(flattenSourceMapLeaves({ mappings: "AAAA" })).toBeUndefined(); + inspectSourceMapEnvelope({ version: 2, mappings: "AAAA" }), + ).toMatchObject({ + status: "invalid", + }); + expect(inspectSourceMapEnvelope({ mappings: "AAAA" })).toMatchObject({ + status: "invalid", + }); expect( - flattenSourceMapLeaves({ + inspectSourceMapEnvelope({ version: 3, sections: [{ offset: { line: 0, column: 0 }, map: { version: 2 } }], }), - ).toBeUndefined(); + ).toMatchObject({ + status: "malformed", + invalid_sections: [ + { + section_path: [0], + reason: "Indexed source-map section map is not a version 3 map.", + }, + ], + }); }); }); diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts index be3cbfb809..793b21c783 100644 --- a/src/domain/sourceMapEnvelope.ts +++ b/src/domain/sourceMapEnvelope.ts @@ -3,6 +3,40 @@ import { z } from "zod"; /** Artifact source declarations do not require generated-position decoding. */ type SourceMapRecord = Readonly>; +export interface ExternalSourceMapSection { + readonly section_path: readonly number[]; + readonly url: string; +} + +export interface InvalidSourceMapSection { + readonly section_path: readonly number[]; + readonly reason: string; +} + +export interface SourceMapLeaf { + readonly section_path: readonly number[]; + readonly map: Readonly>; +} + +export type SourceMapSection = + | { readonly kind: "embedded"; readonly map: SourceMapRecord } + | { readonly kind: "external"; readonly url: string } + | { readonly kind: "invalid"; readonly reason: string }; + +export type SourceMapSectionList = + | { readonly kind: "regular" } + | { readonly kind: "indexed"; readonly sections: readonly unknown[] } + | { readonly kind: "invalid"; readonly reason: string }; + +export type SourceMapEnvelope = + | { readonly status: "invalid"; readonly reason: string } + | { + readonly status: "included" | "unsupported" | "malformed"; + readonly leaves: readonly SourceMapLeaf[]; + readonly external_sections: readonly ExternalSourceMapSection[]; + readonly invalid_sections: readonly InvalidSourceMapSection[]; + }; + /** ECMA-426 requires a string mapping payload on every regular map leaf. */ export const sourceMapMappingsSchema = z.string(); @@ -22,32 +56,162 @@ export const sourceMapJsonText = (text: string): string => { const isRecord = (value: unknown): value is SourceMapRecord => typeof value === "object" && value !== null && !Array.isArray(value); +/** Classify section ownership once; an external URL takes precedence over map. */ +export const classifySourceMapSection = ( + section: unknown, +): SourceMapSection => { + if (!isRecord(section)) + return { + kind: "invalid", + reason: "Indexed source-map section must be an object.", + }; + if (Object.prototype.hasOwnProperty.call(section, "url")) + return typeof section.url === "string" + ? { kind: "external", url: section.url } + : { + kind: "invalid", + reason: "External source-map section URL must be a string.", + }; + if (!isRecord(section.map)) + return { + kind: "invalid", + reason: + "Indexed source-map section requires an embedded map or external URL.", + }; + return { kind: "embedded", map: section.map }; +}; + +/** A present `sections` member must be an array in every source-map consumer. */ +export const classifySourceMapSections = ( + map: unknown, +): SourceMapSectionList => { + if (!isRecord(map)) + return { kind: "invalid", reason: "Source map must be an object." }; + if (!Object.prototype.hasOwnProperty.call(map, "sections")) + return { kind: "regular" }; + return Array.isArray(map.sections) + ? { kind: "indexed", sections: map.sections } + : { + kind: "invalid", + reason: "Indexed source-map sections must be an array.", + }; +}; + /** Whether a parsed value has the version-3 map shape. */ const isVersion3Map = (value: unknown): value is SourceMapRecord => isRecord(value) && value.version === 3; /** - * Collect leaf maps from an indexed source-map envelope, or undefined when - * the envelope is not a version-3 map. A map carrying neither `mappings` - * nor `sections` yields itself as the single leaf; callers decide whether - * that leaf is valid content. + * Collect valid embedded leaves and retain external or malformed section + * facts. A map with neither `mappings` nor `sections` is a leaf; callers + * decide whether that leaf has valid declarations and mappings. */ -export const flattenSourceMapLeaves = ( - root: unknown, -): readonly SourceMapRecord[] | undefined => { - if (!isVersion3Map(root)) return undefined; - const leaves: SourceMapRecord[] = []; - const pending: unknown[] = [root]; - while (pending.length > 0) { - const map = pending.pop(); - if (!isVersion3Map(map)) return undefined; - if (Array.isArray(map.sections)) { - for (let index = map.sections.length - 1; index >= 0; index -= 1) { - const section = map.sections[index]; - if (!isRecord(section) || !isRecord(section.map)) return undefined; - pending.push(section.map); - } - } else leaves.push(map); - } - return leaves; +interface SectionPath { + readonly parent: SectionPath | null; + readonly index: number; +} + +const materializeSectionPath = (path: SectionPath | null): number[] => { + const result: number[] = []; + for (let current = path; current !== null; current = current.parent) + result.push(current.index); + return result.reverse(); +}; + +const compareSectionPaths = ( + left: readonly number[], + right: readonly number[], +): number => { + for (let index = 0; index < Math.min(left.length, right.length); index += 1) { + const leftIndex = left[index] ?? 0; + const rightIndex = right[index] ?? 0; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + return left.length - right.length; +}; + +/** Walk embedded maps while retaining unsupported and malformed section facts. */ +export const inspectSourceMapEnvelope = (root: unknown): SourceMapEnvelope => { + if (!isVersion3Map(root)) + return { status: "invalid", reason: "Source map is not a version 3 map." }; + const leaves: SourceMapLeaf[] = []; + const externalSections: { + readonly path: SectionPath; + readonly url: string; + }[] = []; + const invalidSections: { + readonly path: SectionPath | null; + readonly reason: string; + }[] = []; + const pending: { + readonly map: SourceMapRecord; + readonly path: SectionPath | null; + }[] = [{ map: root, path: null }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + const map = current.map; + const sectionList = classifySourceMapSections(map); + if (sectionList.kind === "invalid") { + invalidSections.push({ path: current.path, reason: sectionList.reason }); + continue; + } + if (sectionList.kind === "indexed") { + const children: { + readonly map: SourceMapRecord; + readonly path: SectionPath; + }[] = []; + for (let index = 0; index < sectionList.sections.length; index += 1) { + const section = sectionList.sections[index]; + const path = { parent: current.path, index }; + const classification = classifySourceMapSection(section); + if (classification.kind === "external") + 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) { + const child = children[index]; + if (child !== undefined) pending.push(child); + } + } else + leaves.push({ + section_path: materializeSectionPath(current.path), + map, + }); + } + const external = externalSections + .map(({ path, url }) => ({ + section_path: materializeSectionPath(path), + url, + })) + .sort((left, right) => + compareSectionPaths(left.section_path, right.section_path), + ); + const invalid = invalidSections + .map(({ path, reason }) => ({ + section_path: materializeSectionPath(path), + reason, + })) + .sort((left, right) => + compareSectionPaths(left.section_path, right.section_path), + ); + const status = + invalid.length > 0 + ? "malformed" + : external.length > 0 + ? "unsupported" + : "included"; + return { + status, + leaves, + external_sections: external, + invalid_sections: invalid, + }; }; diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index b21fef2c87..daacf47e05 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -1,9 +1,12 @@ import { z } from "zod"; import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js"; import { + classifySourceMapSection, + classifySourceMapSections, sourceMapJsonText, sourceMapMappingsSchema, } from "../../domain/sourceMapEnvelope.js"; +import type { SourceMapSection } from "../../domain/sourceMapEnvelope.js"; import type { EncodedSourceMapXInput } from "@jridgewell/trace-mapping"; const leafSchema = z.object({ @@ -28,13 +31,8 @@ const sectionSchema = z.object({ column: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), }), map: z.unknown().optional(), - url: z.string().optional(), + url: z.unknown().optional(), }); -const indexedSchema = z.object({ - version: z.literal(3), - sections: z.array(sectionSchema), -}); - type Offset = { readonly line: number; readonly column: number }; interface PendingMap { readonly value: unknown; @@ -126,21 +124,26 @@ const consumeRecords = (budget: { records: number }, count: number): void => { budget.records += count; }; -const addSections = ( - node: PendingMap, - pending: PendingMap[], - budget: { records: number }, - options: SourceMapStructureOptions, -): void => { +const addSections = ({ + node, + pending, + rawSections, + budget, + options, +}: { + readonly node: PendingMap; + readonly pending: PendingMap[]; + readonly rawSections: readonly unknown[]; + readonly budget: { records: number }; + readonly options: SourceMapStructureOptions; +}): void => { options.check?.(); - const header = mapHeaderSchema.parse(node.value); - if (!Array.isArray(header.sections)) - throw new SourceMapFormatFailure( - "format", - "Indexed source-map sections must be an array.", - ); - consumeRecords(budget, header.sections.length); - const sections = indexedSchema.parse(node.value).sections; + consumeRecords(budget, rawSections.length); + const sections = z.array(sectionSchema).parse(rawSections); + const embeddedMaps: Extract< + SourceMapSection, + { readonly kind: "embedded" } + >["map"][] = []; for (const [index, section] of sections.entries()) { if ((index & 255) === 0) options.check?.(); const previous = sections[index - 1]; @@ -149,20 +152,25 @@ const addSections = ( "format", "Indexed section offsets must be strictly increasing.", ); - if (section.url !== undefined) + const classification = classifySourceMapSection(section); + if (classification.kind === "external") throw new SourceMapFormatFailure( "unsupported", - `External source-map section URL is not fetched: ${section.url}`, - ); - if (section.map === undefined) - throw new SourceMapFormatFailure( - "format", - "Indexed section requires an embedded map.", + `External source-map section URL is not fetched: ${classification.url}`, ); + if (classification.kind === "invalid") + throw new SourceMapFormatFailure("format", classification.reason); + embeddedMaps.push(classification.map); } for (let index = sections.length - 1; index >= 0; index -= 1) { const section = sections[index]; if (section === undefined) continue; + const map = embeddedMaps[index]; + if (map === undefined) + throw new SourceMapFormatFailure( + "format", + "Validated indexed section map is unavailable.", + ); const next = sections[index + 1]; const nextOffset = next === undefined ? null : addOffset(node.offset, next.offset); @@ -179,7 +187,7 @@ const addSections = ( "Nested source-map section overlaps its enclosing boundary.", ); pending.push({ - value: section.map, + value: map, path: [...node.path, index], offset, stop, @@ -319,8 +327,17 @@ export const inspectSourceMapValue = ( "limit", "Indexed source-map nesting exceeds the 64-level decoder stack budget.", ); - if (mapHeaderSchema.parse(node.value).sections !== undefined) { - addSections(node, pending, budget, options); + const sections = classifySourceMapSections(node.value); + if (sections.kind === "invalid") + throw new SourceMapFormatFailure("format", sections.reason); + if (sections.kind === "indexed") { + addSections({ + node, + pending, + rawSections: sections.sections, + budget, + options, + }); continue; } const leaf = inspectLeaf(node, budget, options); diff --git a/src/javascript/sourceMaps/TraceSourceMap.test.ts b/src/javascript/sourceMaps/TraceSourceMap.test.ts index ddb70b9fa4..d2ca915fd5 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.test.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { traceSourceMap } from "./TraceSourceMap.js"; +import { inspectSourceMap, SourceMapFormatFailure } from "./SourceMapFormat.js"; const url = "https://app.test/maps/app.js.map?v=2#map"; const point = { line: 1, column: 0 }; @@ -15,6 +16,76 @@ const leaf = { const trace = (map: unknown, position = point) => traceSourceMap(JSON.stringify(map), url, position); +it("classifies external indexed sections as unsupported with their exact URL", () => { + const externalUrl = "https://app.test/maps/part.map?revision=7"; + try { + inspectSourceMap( + JSON.stringify({ + version: 3, + sections: [{ offset: { line: 0, column: 0 }, url: externalUrl }], + }), + ); + throw new Error("Expected external section rejection"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(SourceMapFormatFailure); + expect(error).toMatchObject({ + reason: "unsupported", + message: `External source-map section URL is not fetched: ${externalUrl}`, + }); + } +}); + +it("uses the same conservative section classification for URL-plus-map and malformed URLs", () => { + const externalUrl = "https://app.test/maps/part.map"; + const external = () => + inspectSourceMap( + JSON.stringify({ + version: 3, + sections: [ + { offset: { line: 0, column: 0 }, url: externalUrl, map: leaf }, + ], + }), + ); + expect(external).toThrow( + expect.objectContaining({ + reason: "unsupported", + message: `External source-map section URL is not fetched: ${externalUrl}`, + }), + ); + + const malformed = () => + inspectSourceMap( + JSON.stringify({ + version: 3, + sections: [{ offset: { line: 0, column: 0 }, url: 12, map: leaf }], + }), + ); + expect(malformed).toThrow( + expect.objectContaining({ + reason: "format", + message: "External source-map section URL must be a string.", + }), + ); +}); + +it("rejects a present non-array sections member through the shared section-list rule", () => { + const malformed = () => + inspectSourceMap( + JSON.stringify({ + version: 3, + sections: null, + sources: ["ignored.ts"], + mappings: "AAAA", + }), + ); + expect(malformed).toThrow( + expect.objectContaining({ + reason: "format", + message: "Indexed source-map sections must be an array.", + }), + ); +}); + it("traces BOM-prefixed maps with a source-map transport prefix ending in CR", () => { const text = `\uFEFF)]}'transport prefix\r${JSON.stringify(leaf)}`; const report = traceSourceMap(text, url, point); diff --git a/tests/acceptance/applications/sourceMapPrefixCli.test.ts b/tests/acceptance/applications/sourceMapPrefixCli.test.ts index 3dfdd6738b..f1d8fa4b6e 100644 --- a/tests/acceptance/applications/sourceMapPrefixCli.test.ts +++ b/tests/acceptance/applications/sourceMapPrefixCli.test.ts @@ -282,7 +282,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ); cliTest.for(["CLI", "stdio MCP"] as const)( - "rejects non-string source-map names without inventing originals through %s", + "retains valid declarations beside non-string source-map names through %s", async (surface, { cli }) => { const root = await createTestTempDirectory("rea-invalid-map-source-"); const mapText = JSON.stringify({ @@ -295,11 +295,29 @@ cliTest.for(["CLI", "stdio MCP"] as const)( await writeFixtureFiles(root, { "invalid.js.map": mapText }); const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); expect(graph.coverage.status).toBe("partial"); - expect( - graph.nodes.some( - (node) => node.identity.strategy === "source-map-original", - ), - ).toBe(false); + const mapDigest = createHash("sha256").update(mapText).digest("hex"); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + strategy: "source-map-original", + source_map_sha256: mapDigest, + original_source: "discarded.ts", + source_sha256: createHash("sha256") + .update("export const answer: number = 42;") + .digest("hex"), + }), + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + section_path: [], + source_index: 1, + declaration_index: 0, + }), + }), + ]), + }), + ); expect(graph.nodes).toContainEqual( expect.objectContaining({ kind: "unknown", @@ -310,10 +328,10 @@ cliTest.for(["CLI", "stdio MCP"] as const)( }), evidence: expect.objectContaining({ artifact: expect.objectContaining({ - sha256: createHash("sha256").update(mapText).digest("hex"), + sha256: mapDigest, }), limitations: expect.arrayContaining([ - "Source map contains a non-string source name.", + "Source map contains a non-string source name at index 0.", ]), }), }), @@ -323,3 +341,79 @@ cliTest.for(["CLI", "stdio MCP"] as const)( expect(await readFile(join(root, "invalid.js.map"), "utf8")).toBe(mapText); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps external indexed sections unknown while retaining embedded siblings through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-external-indexed-map-"); + const url = "https://app.test/maps/part.map?revision=7"; + 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 }, url }, + { + offset: { line: 2, column: 0 }, + map: { + version: 3, + sources: ["after.ts"], + sourcesContent: ["export const after = 2;"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + await writeFixtureFiles(root, { "indexed.js.map": mapText }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const mapDigest = 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", + limitation: `External source-map section URL is not fetched: ${url}`, + external_sections: [{ section_path: [1], url }], + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: mapDigest }), + limitations: [ + `External source-map section URL is not fetched: ${url}`, + ], + }), + }), + ]), + }), + ); + for (const [source, content] of [ + ["before.ts", "export const before = 1;"], + ["after.ts", "export const after = 2;"], + ] as const) + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + strategy: "source-map-original", + source_map_sha256: mapDigest, + original_source: source, + source_sha256: createHash("sha256").update(content).digest("hex"), + }), + }), + ); + expect(await readFile(join(root, "indexed.js.map"), "utf8")).toBe(mapText); + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts index 85a6e1f63f..dea95dc837 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationSourceMapMappings.test.ts @@ -158,7 +158,7 @@ it("retains declarations before and after an invalid indexed-map leaf", async () }), evidence: expect.objectContaining({ artifact: expect.objectContaining({ sha256: digest }), - limitations: ["Source map has no mappings string."], + limitations: ["Source map has no mappings string in section [1]."], }), }), ]), @@ -192,7 +192,9 @@ it("retains declarations before and after an invalid indexed-map leaf", async () }), evidence: expect.objectContaining({ artifact: expect.objectContaining({ sha256: digest }), - limitations: ["Source map has no mappings string."], + limitations: [ + "Source map has no mappings string in section [1].", + ], }), }), ]), @@ -200,3 +202,318 @@ it("retains declarations before and after an invalid indexed-map leaf", async () ); } }); + +it("keeps external indexed sections unsupported while retaining local siblings", async () => { + const url = "https://app.test/maps/part.map"; + 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 }, url }, + { + offset: { line: 2, column: 0 }, + url: 42, + map: { + version: 3, + sources: ["ignored.ts"], + sourcesContent: ["export const ignored = 0;"], + names: [], + mappings: "AAAA", + }, + }, + { + offset: { line: 3, column: 0 }, + map: { + version: 3, + sources: ["after.ts"], + sourcesContent: ["export const after = 2;"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + const graph = await analyzeMaps({ "indexed.js.map": mapText }); + const digest = createHash("sha256").update(mapText).digest("hex"); + const limitation = + `Indexed source-map section at [2]: External source-map section URL must be a string.; ` + + `External source-map section URL is not fetched: ${url}`; + 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", + external_sections: [{ section_path: [1], url }], + invalid_sections: [ + { + section_path: [2], + reason: "External source-map section URL must be a string.", + }, + ], + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: digest }), + limitations: [limitation], + }), + }), + ]), + }), + ); + for (const [source, content] of [ + ["before.ts", "export const before = 1;"], + ["after.ts", "export const after = 2;"], + ] as const) { + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + source_map_sha256: digest, + original_source: source, + source_sha256: createHash("sha256").update(content).digest("hex"), + }), + }), + ); + } +}); + +it("marks non-array sections malformed even when the object has regular-map fields", async () => { + const mapText = JSON.stringify({ + version: 3, + sections: null, + sources: ["must-not-be-read.ts"], + sourcesContent: ["export const value = 1;"], + names: [], + mappings: "AAAA", + }); + const graph = await analyzeMaps({ "malformed.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: "malformed.js.map", + operation: "parse-local-source-map", + invalid_sections: [ + { + section_path: [], + reason: "Indexed source-map sections must be an array.", + }, + ], + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: digest }), + limitations: [ + "Indexed source-map section at []: Indexed source-map sections must be an array.", + ], + }), + }), + ]), + }), + ); + expect( + graph.nodes.some( + (node) => + node.identity.strategy === "source-map-original" && + node.identity.source_map_sha256 === digest, + ), + ).toBe(false); +}); + +it("retains declarations around a semantically invalid indexed 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, sourcesContent: [], names: [], mappings: "AAAA" }, + }, + { + offset: { line: 2, column: 0 }, + map: { + version: 3, + sources: ["after.ts"], + sourcesContent: ["export const after = 2;"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + const graph = await analyzeMaps({ "indexed-invalid-leaf.js.map": mapText }); + const digest = createHash("sha256").update(mapText).digest("hex"); + const limitation = "Source map has no sources array in section [1]."; + + expect(graph.coverage.status).toBe("partial"); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + path: "indexed-invalid-leaf.js.map", + operation: "parse-local-source-map", + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: digest }), + limitations: [limitation], + }), + }), + ]), + }), + ); + for (const [source, section_path, content] of [ + ["before.ts", [0], "export const before = 1;"], + ["after.ts", [2], "export const after = 2;"], + ] as const) + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + source_map_sha256: digest, + original_source: source, + source_sha256: createHash("sha256").update(content).digest("hex"), + }), + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ section_path }), + evidence: expect.objectContaining({ limitations: [limitation] }), + }), + ]), + }), + ); +}); + +it("keeps safe declarations across malformed roots, contents, and source entries", 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, + sourceRoot: 42, + sources: ["unknown-root.ts"], + sourcesContent: ["export const unknownRoot = 2;"], + names: [], + mappings: "AAAA", + }, + }, + { + offset: { line: 2, column: 0 }, + map: { + version: 3, + sources: ["content.ts"], + sourcesContent: [42], + names: [], + mappings: "AAAA", + }, + }, + { + offset: { line: 3, column: 0 }, + map: { + version: 3, + sources: [42, "valid-entry.ts"], + sourcesContent: [null, "export const validEntry = 3;"], + names: [], + mappings: "AAAA", + }, + }, + { + offset: { line: 4, column: 0 }, + map: { + version: 3, + sources: ["after.ts"], + sourcesContent: ["export const after = 4;"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + const graph = await analyzeMaps({ + "indexed-malformed-values.js.map": mapText, + }); + const digest = createHash("sha256").update(mapText).digest("hex"); + const limitation = [ + "Source map sourceRoot must be a string or null in section [1].", + "Source map sourcesContent must be an array of strings or null entries in section [2].", + "Source map contains a non-string source name at index 0 in section [3].", + ].join("; "); + + expect(graph.coverage.status).toBe("partial"); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: digest }), + limitations: [limitation], + }), + }), + ]), + }), + ); + for (const [source, section_path, content, source_index] of [ + ["before.ts", [0], "export const before = 1;", 0], + ["content.ts", [2], null, 0], + ["valid-entry.ts", [3], "export const validEntry = 3;", 1], + ["after.ts", [4], "export const after = 4;", 0], + ] as const) + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + source_map_sha256: digest, + original_source: source, + source_sha256: + content === null + ? null + : createHash("sha256").update(content).digest("hex"), + }), + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ section_path, source_index }), + evidence: expect.objectContaining({ limitations: [limitation] }), + }), + ]), + }), + ); +});