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
This commit is contained in:
morluto committed 2026-10-12 11:10:49 +08:00
1 parent c0904b4c1a
commit ee50ed121e
13 files changed
+361 -70

No files matched your search

@@ -539,6 +539,7 @@ const collectSourceMapOriginals = (
maps: readonly Readonly<Record<string, unknown>>[],
): 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),
});
@@ -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;
}
@@ -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,
+48 -17
View File
@@ -567,17 +567,21 @@ const normalizeSourceMap = (
const sourceJsonBytes = new Map<string, number>();
const nameJsonBytes = new Map<string, number>();
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<string> | 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<string, number>, value: string): number => {
const cached = cache.get(value);
@@ -968,13 +997,15 @@ const cachedSanitized = (cache: Map<string, string>, 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 = (
+11
View File
@@ -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);
});
+1
View File
@@ -4,6 +4,7 @@ export const hasValidSourceMapContents = (
contents: unknown,
): boolean =>
contents === undefined ||
contents === null ||
(Array.isArray(contents) &&
contents.length === sourceCount &&
contents.every(
+23 -3
View File
@@ -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", () => {
+2 -1
View File
@@ -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);
}
+6 -2
View File
@@ -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(),
@@ -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);
},
);
@@ -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", () => {
@@ -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(
@@ -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(