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] }), + }), + ]), + }), + ); +});