From ee50ed121e38cc6e1a2ff0dc8b789f15f71c5402 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:10:49 +0800 Subject: [PATCH] fix(sourcemaps)!: preserve nullable source declarations Accept absent map-level sourcesContent and preserve null source-name slots alongside known originals and aligned content. Represent unnamed local declarations explicitly, retain leaf-local and flattened indices, and preserve declared indexed-section order across consumers. Account for added indices before browser output allocation. Accept legacy retained rows without indices. Consolidate public CLI/MCP source-map fixtures and update the stale named-source-only assertion. BREAKING CHANGE: Browser source-map original and mapping rows expose null for unavailable source names instead of inventing an unknown-source string. New index fields are optional when reading older retained evidence. Refs #2023, #2024 --- .../javascript/JavaScriptArtifactAnalysis.ts | 10 +- .../JavaScriptArtifactAnalysisTypes.ts | 6 +- .../JavaScriptArtifactGraphDocuments.ts | 41 ++++-- src/browser/WebSourceMapFetcher.ts | 65 ++++++--- src/domain/sourceMapContents.test.ts | 11 ++ src/domain/sourceMapContents.ts | 1 + src/domain/sourceMapEnvelope.test.ts | 26 +++- src/domain/sourceMapEnvelope.ts | 3 +- src/domain/webBundleAnalysis.ts | 8 +- .../applications/sourceMapPrefixCli.test.ts | 126 ++++++++++++++---- .../browser/webSourceMapFetcher.test.ts | 55 +++++++- .../webSourceMapNestedCoordinates.test.ts | 2 +- .../javascriptArtifactReconstruction.test.ts | 77 ++++++++++- 13 files changed, 361 insertions(+), 70 deletions(-) create mode 100644 src/domain/sourceMapContents.test.ts diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index e0c3b23cb0..58bbce3f98 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -539,6 +539,7 @@ const collectSourceMapOriginals = ( maps: readonly Readonly>[], ): JavaScriptSourceMapObservation => { const sources: JavaScriptSourceMapOriginal[] = []; + let declarationIndex = 0; for (const map of maps) { const names = map.sources; if (!Array.isArray(names)) @@ -556,7 +557,7 @@ const collectSourceMapOriginals = ( ); const root = typeof map.sourceRoot === "string" ? map.sourceRoot : null; for (const [index, raw] of names.entries()) { - if (typeof raw !== "string") + if (raw !== null && typeof raw !== "string") return invalidSourceMap( file, "Source map contains a non-string source name.", @@ -564,7 +565,12 @@ const collectSourceMapOriginals = ( const rawContent = Array.isArray(contents) ? contents[index] : undefined; const content = typeof rawContent === "string" ? rawContent : null; sources.push({ - reference: resolveJavaScriptSourceMapReference(raw, root, file.path), + source_index: index, + declaration_index: declarationIndex++, + reference: + typeof raw === "string" + ? resolveJavaScriptSourceMapReference(raw, root, file.path) + : null, content, content_sha256: content === null ? null : sha256(content), }); diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index 630a330c51..ec49d0b9d8 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -47,7 +47,11 @@ export interface JavaScriptHtmlScriptObservation { /** One original source declared by a local source map. */ export interface JavaScriptSourceMapOriginal { - readonly reference: JavaScriptSourceMapReference; + /** Index within the leaf map's sources and sourcesContent arrays. */ + readonly source_index: number; + /** Index across the flattened map's leaf declarations. */ + readonly declaration_index: number; + readonly reference: JavaScriptSourceMapReference | null; readonly content: string | null; readonly content_sha256: string | null; } diff --git a/src/application/javascript/JavaScriptArtifactGraphDocuments.ts b/src/application/javascript/JavaScriptArtifactGraphDocuments.ts index bdea67b952..e0798bb7ba 100644 --- a/src/application/javascript/JavaScriptArtifactGraphDocuments.ts +++ b/src/application/javascript/JavaScriptArtifactGraphDocuments.ts @@ -1,6 +1,7 @@ import { addArtifactContainsEdge, addStaticInferenceEdge, + artifactLocalIdentity, createElectronRoleNode, linkElectronRoleToAsset, type JavaScriptArtifactGraphContext, @@ -118,22 +119,34 @@ export const addJavaScriptSourceMapOriginals = ( if (mapFile === undefined || mapNode === undefined) continue; const coverage = completeApplicationCoverage(); for (const original of sourceMap.sources) { + const reference = original.reference; const node = context.accumulator.addNode({ - kind: "source-module", - identity: { - strategy: "source-map-original", - stability: "source-map-exact", - source_map_sha256: mapFile.sha256, - original_source: original.reference.source_name, - source_root: original.reference.source_root, - source_sha256: original.content_sha256, - }, + kind: reference === null ? "unknown" : "source-module", + identity: + reference === null + ? artifactLocalIdentity( + mapFile.sha256, + "source-map-unnamed-declaration", + String(original.declaration_index), + ) + : { + strategy: "source-map-original", + stability: "source-map-exact", + source_map_sha256: mapFile.sha256, + original_source: reference.source_name, + source_root: reference.source_root, + source_sha256: original.content_sha256, + }, observations: [ { - label: original.reference.source_name || null, - source_map_reference: original.reference, + label: reference?.source_name || null, + source_map_reference: reference, properties: { + source_index: original.source_index, + declaration_index: original.declaration_index, + source_name_available: reference !== null, content_available: original.content !== null, + content_sha256: original.content_sha256, }, evidence: artifactObservationEvidence({ sha256: mapFile.sha256, @@ -151,8 +164,10 @@ export const addJavaScriptSourceMapOriginals = ( target_node_id: node.node_id, relation: "contains", properties: { - original_source: original.reference.source_name, - source_root: original.reference.source_root, + source_index: original.source_index, + declaration_index: original.declaration_index, + original_source: reference?.source_name ?? null, + source_root: reference?.source_root ?? null, }, evidence: artifactObservationEvidence({ sha256: mapFile.sha256, diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 3032f20ed1..0b17df0cb5 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -567,17 +567,21 @@ const normalizeSourceMap = ( const sourceJsonBytes = new Map(); const nameJsonBytes = new Map(); const originalSources: ParsedSourceMapItem["original_sources"] = []; + let declarationIndex = 0; for (const { leaf, resolvedSources } of decoded) { for (const [index, rawSource] of leaf.map.sources.entries()) { if ((index & 255) === 0) check(); const resolved = - resolvedSources[index] ?? rawSource ?? "[unknown-source]"; - const sanitized = cachedSanitized(sourcesByRaw, resolved); + rawSource === null ? null : (resolvedSources[index] ?? null); + const sanitized = + resolved === null ? null : cachedSanitized(sourcesByRaw, resolved); const content = leaf.map.sourcesContent?.[index] ?? null; consumeExpandedBytes( context.budget, - cachedJsonBytes(sourceJsonBytes, sanitized) + - sourceEntryOverheadBytes() + + (sanitized === null + ? 4 + : cachedJsonBytes(sourceJsonBytes, sanitized)) + + sourceEntryOverheadBytes(index, declarationIndex) + (originalSources.length === 0 ? 0 : 1) + (content === null ? 0 @@ -586,6 +590,8 @@ const normalizeSourceMap = ( 4), ); originalSources.push({ + source_index: index, + declaration_index: declarationIndex++, source: sanitized, artifact: content === null @@ -595,16 +601,21 @@ const normalizeSourceMap = ( } } const mappings: ParsedSourceMapItem["mappings"] = []; - for (const { leaf, rows, resolvedSources } of decoded) + declarationIndex = 0; + for (const { leaf, rows, resolvedSources } of decoded) { + const declarationBase = declarationIndex; + declarationIndex += leaf.map.sources.length; for (const [line, row] of rows.entries()) { if ((line & 255) === 0) check(); for (const segment of row) { if (!isBeforeSourceMapLeafStop(leaf, line, segment[0])) continue; if (segment.length === 1) continue; const rawSource = leaf.map.sources[segment[1]]; + if (rawSource === undefined) continue; const resolved = - resolvedSources[segment[1]] ?? rawSource ?? "[unknown-source]"; - const source = cachedSanitized(sourcesByRaw, resolved); + rawSource === null ? null : (resolvedSources[segment[1]] ?? null); + const source = + resolved === null ? null : cachedSanitized(sourcesByRaw, resolved); const name = segment.length === 5 ? (leaf.map.names[segment[4]] ?? null) : null; const generatedLine = leaf.offset.line + line + 1; @@ -612,9 +623,11 @@ const normalizeSourceMap = ( segment[0] + (line === 0 ? leaf.offset.column : 0); consumeExpandedBytes( context.budget, - cachedJsonBytes(sourceJsonBytes, source) + + (source === null ? 4 : cachedJsonBytes(sourceJsonBytes, source)) + (name === null ? 4 : cachedJsonBytes(nameJsonBytes, name)) + mappingRowBytes( + segment[1], + declarationBase + segment[1], generatedLine, generatedColumn, segment[2] + 1, @@ -623,6 +636,8 @@ const normalizeSourceMap = ( (mappings.length === 0 ? 0 : 1), ); mappings.push({ + source_index: segment[1], + declaration_index: declarationBase + segment[1], generated_line: generatedLine, generated_column: generatedColumn, source, @@ -632,6 +647,7 @@ const normalizeSourceMap = ( }); } } + } const modules = originalModuleEdges(originalSources, check, (edge) => consumeExpandedBytes( context.budget, @@ -705,15 +721,22 @@ const originalModuleEdges = ( for (const source of sources) { check(); if (source.artifact === null) continue; + const sourceName = source.source; + if (sourceName === null) { + incomplete.push( + `unnamed source at declaration ${String(source.declaration_index)}`, + ); + continue; + } const parsed = parseJavaScriptSource(source.artifact.text); check(); if (parsed === null) { - incomplete.push(source.source); + incomplete.push(sourceName); continue; } // A recovered program still yields the imports it did parse, but nodes // after an unrecoverable point are missing, so the edges are a subset. - if (parsed.errors.length > 0) incomplete.push(source.source); + if (parsed.errors.length > 0) incomplete.push(sourceName); let unboundRequires: ReadonlySet | undefined; traverseJavaScriptAst(parsed, { enter: (node) => { @@ -742,14 +765,14 @@ const originalModuleEdges = ( ) return; } - const key = `${source.source}\0${kind}\0${specifier}`; + const key = `${sourceName}\0${kind}\0${specifier}`; if (seen.has(key)) return; seen.add(key); const edge = { - from_source: source.source, + from_source: sourceName, kind, specifier, - resolved_source: resolveOriginalSource(specifier, source.source), + resolved_source: resolveOriginalSource(specifier, sourceName), }; beforeEdge(edge); edges.push(edge); @@ -840,7 +863,8 @@ const preflightResolvedSourceBytes = ( } for (const source of map.sources) { check(); - const sourceKey = source ?? ""; + if (source === null) continue; + const sourceKey = source; let resolved = sourcesForRoot.get(sourceKey); if (resolved === undefined) resolved = resolveSourceMapSource(source, map.sourceRoot, mapUrl); @@ -948,8 +972,13 @@ const sourceMapFailureReservation = ( jsonBytes(status) - jsonBytes("fetch_failed"); -const sourceEntryOverheadBytes = (): number => - Buffer.byteLength('{"source":,"artifact":null}'); +const sourceEntryOverheadBytes = ( + sourceIndex: number, + declarationIndex: number, +): number => + Buffer.byteLength( + `{"source_index":${String(sourceIndex)},"declaration_index":${String(declarationIndex)},"source":,"artifact":null}`, + ); const cachedJsonBytes = (cache: Map, value: string): number => { const cached = cache.get(value); @@ -968,13 +997,15 @@ const cachedSanitized = (cache: Map, value: string): string => { }; const mappingRowBytes = ( + sourceIndex: number, + declarationIndex: number, generatedLine: number, generatedColumn: number, originalLine: number, originalColumn: number, ): number => Buffer.byteLength( - `{"generated_line":${String(generatedLine)},"generated_column":${String(generatedColumn)},"source":,"original_line":${String(originalLine)},"original_column":${String(originalColumn)},"name":}`, + `{"source_index":${String(sourceIndex)},"declaration_index":${String(declarationIndex)},"generated_line":${String(generatedLine)},"generated_column":${String(generatedColumn)},"source":,"original_line":${String(originalLine)},"original_column":${String(originalColumn)},"name":}`, ); const resolveOriginalSource = ( diff --git a/src/domain/sourceMapContents.test.ts b/src/domain/sourceMapContents.test.ts new file mode 100644 index 0000000000..06f07197b0 --- /dev/null +++ b/src/domain/sourceMapContents.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vitest"; + +import { hasValidSourceMapContents } from "./sourceMapContents.js"; + +it("accepts absent map-level source contents and aligned nullable slots", () => { + expect(hasValidSourceMapContents(2, undefined)).toBe(true); + expect(hasValidSourceMapContents(2, null)).toBe(true); + expect(hasValidSourceMapContents(2, [null, "retained source"])).toBe(true); + expect(hasValidSourceMapContents(2, ["retained source"])).toBe(false); + expect(hasValidSourceMapContents(2, [null, 42])).toBe(false); +}); diff --git a/src/domain/sourceMapContents.ts b/src/domain/sourceMapContents.ts index 254ad72b5f..5d55810a9c 100644 --- a/src/domain/sourceMapContents.ts +++ b/src/domain/sourceMapContents.ts @@ -4,6 +4,7 @@ export const hasValidSourceMapContents = ( contents: unknown, ): boolean => contents === undefined || + contents === null || (Array.isArray(contents) && contents.length === sourceCount && contents.every( diff --git a/src/domain/sourceMapEnvelope.test.ts b/src/domain/sourceMapEnvelope.test.ts index 6359ef8895..4f8ea5c0eb 100644 --- a/src/domain/sourceMapEnvelope.test.ts +++ b/src/domain/sourceMapEnvelope.test.ts @@ -14,11 +14,31 @@ describe("source-map envelope walk", () => { const root = { version: 3, sections: [ - { offset: { line: 0, column: 0 }, map: leaf }, - { offset: { line: 10, column: 0 }, map: { ...leaf, sources: [] } }, + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { ...leaf, sources: ["b.ts"] }, + }, + { + offset: { line: 1, column: 0 }, + map: { ...leaf, sources: ["c.ts"] }, + }, + ], + }, + }, + { + offset: { line: 10, column: 0 }, + map: { ...leaf, sources: ["d.ts"] }, + }, ], }; - expect(flattenSourceMapLeaves(root)).toHaveLength(2); + expect(flattenSourceMapLeaves(root)?.map(({ sources }) => sources)).toEqual( + [["b.ts"], ["c.ts"], ["d.ts"]], + ); }); it("rejects sections without embedded maps", () => { diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts index 7be21703ee..b1cc1ff49c 100644 --- a/src/domain/sourceMapEnvelope.ts +++ b/src/domain/sourceMapEnvelope.ts @@ -37,7 +37,8 @@ export const flattenSourceMapLeaves = ( const map = pending.pop(); if (!isVersion3Map(map)) return undefined; if (Array.isArray(map.sections)) { - for (const section of 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); } diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index 240016c54d..27c79d1265 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -32,7 +32,9 @@ const findingSchema = z.object({ }); const originalSourceSchema = z.object({ - source: z.string(), + source_index: z.number().int().min(0).optional(), + declaration_index: z.number().int().min(0).optional(), + source: z.string().nullable(), artifact: webTextArtifactSchema.nullable(), }); const originalModuleEdgeSchema = z.object({ @@ -42,9 +44,11 @@ const originalModuleEdgeSchema = z.object({ resolved_source: z.string().nullable(), }); const sourceMapMappingSchema = z.object({ + source_index: z.number().int().min(0).optional(), + declaration_index: z.number().int().min(0).optional(), generated_line: z.number().int().min(1), generated_column: z.number().int().min(0), - source: z.string(), + source: z.string().nullable(), original_line: z.number().int().min(1), original_column: z.number().int().min(0), name: z.string().nullable(), diff --git a/tests/acceptance/applications/sourceMapPrefixCli.test.ts b/tests/acceptance/applications/sourceMapPrefixCli.test.ts index 4865b02897..55d94283f5 100644 --- a/tests/acceptance/applications/sourceMapPrefixCli.test.ts +++ b/tests/acceptance/applications/sourceMapPrefixCli.test.ts @@ -3,12 +3,10 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; -import { z } from "zod"; - -import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; -import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; 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)( "recovers retained prefixed source-map originals and byte identity through %s", @@ -29,27 +27,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ); await writeFile(mapPath, mapText); - let document: unknown; - if (surface === "CLI") { - const response = await cli.run({ - arguments: ["analyze-javascript-application", root, "--format", "json"], - environment: { REA_LOG_LEVEL: "silent" }, - }); - expect(response.exitCode, response.stderr).toBe(0); - document = response.json; - } else { - const { call } = await connectLocalToolsMcp(); - const response = await call("analyze_javascript_application", { - input_path: root, - format: "directory", - }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - document = response.structuredContent; - } - const { graph } = z - .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) - .parse(document).normalized_result; + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); const mapDigest = createHash("sha256").update(mapText).digest("hex"); + const originalDigest = createHash("sha256").update(original).digest("hex"); expect(graph.nodes).toContainEqual( expect.objectContaining({ kind: "source-module", @@ -57,7 +37,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( strategy: "source-map-original", source_map_sha256: mapDigest, original_source: "original.ts", - source_sha256: createHash("sha256").update(original).digest("hex"), + source_sha256: originalDigest, }), observations: expect.arrayContaining([ expect.objectContaining({ @@ -65,7 +45,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( source_name: "original.ts", map_path: "bundle.js.map", }), - properties: { content_available: true }, + properties: { + source_index: 0, + declaration_index: 0, + source_name_available: true, + content_available: true, + content_sha256: originalDigest, + }, evidence: expect.objectContaining({ artifact: expect.objectContaining({ sha256: mapDigest }), }), @@ -77,3 +63,91 @@ cliTest.for(["CLI", "stdio MCP"] as const)( expect(await readFile(mapPath, "utf8")).toBe(mapText); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains nullable source-map declarations and aligned content through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-nullable-map-"); + const original = "export const answer: number = 42;"; + const unnamed = "retained source text without a declared name"; + const maps = { + "absent.js.map": JSON.stringify({ + version: 3, + sources: ["unembedded.ts"], + sourcesContent: null, + names: [], + mappings: "AAAA", + }), + "mixed.js.map": JSON.stringify({ + version: 3, + sources: [null, "original.ts"], + sourcesContent: [unnamed, original], + names: [], + mappings: "ACAA", + }), + }; + await writeFixtureFiles(root, { + ...maps, + "absent.js": + "export const absent = 1;\n//# sourceMappingURL=absent.js.map\n", + "mixed.js": + "export const answer = 42;\n//# sourceMappingURL=mixed.js.map\n", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const digest = (text: string) => + createHash("sha256").update(text).digest("hex"); + for (const [name, mapPath, content] of [ + ["unembedded.ts", "absent.js.map", null], + ["original.ts", "mixed.js.map", original], + ] as const) { + const mapDigest = digest(maps[mapPath]); + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "source-module", + identity: expect.objectContaining({ + strategy: "source-map-original", + source_map_sha256: mapDigest, + original_source: name, + source_sha256: content === null ? null : digest(content), + }), + observations: expect.arrayContaining([ + expect.objectContaining({ + source_map_reference: expect.objectContaining({ + source_name: name, + map_path: mapPath, + }), + properties: expect.objectContaining({ + content_available: content !== null, + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ sha256: mapDigest }), + }), + }), + ]), + }), + ); + } + expect(graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + source_name_available: false, + content_available: true, + content_sha256: digest(unnamed), + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ + sha256: digest(maps["mixed.js.map"]), + }), + }), + }), + ]), + }), + ); + expect(graph.coverage.status).toBe("complete"); + for (const [path, text] of Object.entries(maps)) + expect(await readFile(join(root, path), "utf8")).toBe(text); + }, +); diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index f3bb5fadae..35fc34b6fc 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -751,7 +751,7 @@ describe("web source-map sourcesContent validation", () => { JSON.stringify({ version: 3, sources: ["source.ts"], - sourcesContent: [null], + sourcesContent: null, names: [], mappings: "", }), @@ -763,10 +763,61 @@ describe("web source-map sourcesContent validation", () => { expect(result.items[0]).toMatchObject({ status: "included", original_sources: [ - { source: `${origin}/assets/source.ts`, artifact: null }, + { + source_index: 0, + declaration_index: 0, + source: `${origin}/assets/source.ts`, + artifact: null, + }, ], }); }); + + it("keeps unnamed source slots and mapping indices", async () => { + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => + Promise.resolve( + new Response( + JSON.stringify({ + version: 3, + sources: [null, "source.ts"], + sourcesContent: ["unknown source text", "named source text"], + names: [], + mappings: "ACAA", + }), + { status: 200 }, + ), + ), + }); + + expect(result.items[0]).toMatchObject({ + status: "partial", + original_sources: [ + { + source_index: 0, + declaration_index: 0, + source: null, + artifact: { text: "unknown source text" }, + }, + { + source_index: 1, + declaration_index: 1, + source: `${origin}/assets/source.ts`, + artifact: { text: "named source text" }, + }, + ], + mappings: [ + { + source_index: 1, + declaration_index: 1, + source: `${origin}/assets/source.ts`, + }, + ], + }); + expect(result.items[0]?.limitation).toContain( + "unnamed source at declaration 0", + ); + }); }); describe("source-map original dependency syntax", () => { diff --git a/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts b/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts index ac6bc1a1d2..3ed94f72df 100644 --- a/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts +++ b/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts @@ -345,7 +345,7 @@ it.each(cases)( item.mappings.map((entry) => [ entry.generated_line, entry.generated_column, - entry.source.replace(origin + "/", ""), + entry.source?.replace(origin + "/", "") ?? null, ]), ).toEqual(expected); expect(item.artifact.sha256).toBe( diff --git a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts index b1c6952ba5..7f0a44c1de 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts @@ -1,6 +1,7 @@ -import { Readable } from "node:stream"; +import { createHash } from "node:crypto"; import { symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { Readable } from "node:stream"; import { expect, it } from "vitest"; @@ -135,6 +136,7 @@ it.each([ ["a non-array sourcesContent value", { sourcesContent: "source" }], ["a non-string, non-null source entry", { sourcesContent: [42] }], ["a source-content array with the wrong length", { sourcesContent: [] }], + ["a non-string source name", { sources: [42] }], ])( "reports %s as an invalid local source map", async (_description, content) => { @@ -187,7 +189,7 @@ it("accepts omitted and explicitly null source-map source contents", async () => JSON.stringify({ version: 3, sources: ["null.ts"], - sourcesContent: [null], + sourcesContent: null, names: [], mappings: "", }), @@ -209,6 +211,77 @@ it("accepts omitted and explicitly null source-map source contents", async () => expect(result.graph.coverage.status).toBe("complete"); }); +it("retains unnamed source declarations without shifting later source evidence", async () => { + const root = await createTestTempDirectory("rea-javascript-unnamed-source-"); + const text = JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ["prior.ts"], + sourcesContent: ["export const prior = 1;"], + names: [], + mappings: "", + }, + }, + { + offset: { line: 1, column: 0 }, + map: { + version: 3, + sources: [null, "original.ts"], + sourcesContent: [null, "export const answer: number = 42;"], + names: [], + mappings: "ACAA", + }, + }, + ], + }); + await writeFile(join(root, "bundle.js.map"), text); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const nodes = result.graph.nodes; + const original = nodes.find( + ({ kind, identity }) => + kind === "source-module" && + identity.strategy === "source-map-original" && + identity.original_source === "original.ts", + ); + const unnamed = nodes.find( + ({ kind, observations }) => + kind === "unknown" && + observations.some( + ({ properties }) => properties.source_name_available === false, + ), + ); + + expect(original?.identity).toMatchObject({ + strategy: "source-map-original", + source_sha256: createHash("sha256") + .update("export const answer: number = 42;") + .digest("hex"), + }); + expect(original?.observations[0]?.properties).toMatchObject({ + source_index: 1, + declaration_index: 2, + content_available: true, + }); + expect(unnamed?.observations[0]?.properties).toMatchObject({ + source_index: 0, + declaration_index: 1, + source_name_available: false, + content_available: false, + content_sha256: null, + }); + expect(unnamed?.identity).toMatchObject({ + strategy: "artifact-local-key", + namespace: "source-map-unnamed-declaration", + key: "1", + }); + expect(result.graph.coverage.status).toBe("complete"); +}); + it("retains every repeated content observation and containment path", async () => { const root = await createTestTempDirectory("rea-javascript-observations-"); await Promise.all(