From ed8fcae94b699b62b44162f880faa077a36c455d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:05:02 +0800 Subject: [PATCH] fix(javascript): store source map URLs once --- docs/javascript-application-graph.md | 8 + .../JavaScriptArtifactGraphFindings.ts | 70 ++++---- .../javascriptSourceMapComments.test.ts | 27 ++- .../javascriptSourceMapLocations.test.ts | 169 +++++++++++++++--- 4 files changed, 212 insertions(+), 62 deletions(-) diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index bbe4093ade..fd87b00eef 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -73,6 +73,14 @@ The directed relation vocabulary is `contains`, `loads`, `imports`, `maps_to`, and `changed_from`. Provider names are deliberately absent from both vocabularies. +For a `maps_to` edge targeting a source-map node, the edge carries +`resolved_path` and `declared_url_sha256`. The target node's reference +observation carries that same digest and the exact `declared_url`; match the +digest to select the URL belonging to that edge when several references share +one physical source map. The full URL is kept out of node identity, display +label, and edge properties, so large inline data URLs are stored once per +source-map reference. + For example, a source-owned synthetic fixture proves this path without using a third-party application: diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index edd9ea920c..cff140220a 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -283,6 +285,9 @@ const addSourceMapEdge = ( context: JavaScriptArtifactGraphContext, input: FindingInput, ): void => { + const declaredUrlSha256 = createHash("sha256") + .update(input.value.declared_url) + .digest("hex"); const resolvedPath = resolveArtifactPathByContext({ declaredPath: input.value.declared_url, sourcePath: input.file.path, @@ -293,38 +298,43 @@ const addSourceMapEdge = ( resolvedPath === null ? undefined : context.filesByPath.get(resolvedPath); const resolvedNode = resolvedPath === null ? undefined : context.fileNodes.get(resolvedPath); - const target = - resolvedFile?.kind === "source-map" && resolvedNode !== undefined - ? resolvedNode - : context.accumulator.addNode({ - kind: "source-map", - identity: artifactLocalIdentity( + const isAvailable = + resolvedFile?.kind === "source-map" && resolvedNode !== undefined; + const target = context.accumulator.addNode({ + kind: "source-map", + identity: + isAvailable && resolvedNode !== undefined + ? resolvedNode.identity + : artifactLocalIdentity( input.file.sha256, "unresolved-source-map", - input.value.declared_url, + declaredUrlSha256, ), - observations: [ - { - label: input.value.declared_url, - properties: { - declared_url: input.value.declared_url, - resolved_path: resolvedPath, - available: false, - }, - evidence: staticInferenceEvidence({ - sha256: input.file.sha256, - path: input.file.path, - range: input.value.location, - operation: "discover-source-map", - coverage: input.coverage, - confidence: "medium", - limitations: [ - "The declared source map was not present in the inventoried artifact.", - ], - }), - }, - ], - }); + observations: [ + { + label: "source map reference", + properties: { + declared_url: input.value.declared_url, + declared_url_sha256: declaredUrlSha256, + resolved_path: resolvedPath, + available: isAvailable, + }, + evidence: staticInferenceEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "discover-source-map", + coverage: input.coverage, + confidence: "medium", + limitations: isAvailable + ? [] + : [ + "The declared source map was not present in the inventoried artifact.", + ], + }), + }, + ], + }); addStaticInferenceEdge(context, { source: input.asset, target, @@ -333,7 +343,7 @@ const addSourceMapEdge = ( coverage: input.coverage, relation: "maps_to", properties: { - declared_url: input.value.declared_url, + declared_url_sha256: declaredUrlSha256, resolved_path: resolvedPath, }, }); diff --git a/tests/boundary/filesystem/javascriptSourceMapComments.test.ts b/tests/boundary/filesystem/javascriptSourceMapComments.test.ts index 46625dde21..aaaae82f4b 100644 --- a/tests/boundary/filesystem/javascriptSourceMapComments.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapComments.test.ts @@ -20,8 +20,12 @@ it("creates source-map graph relationships only for real comments", async () => ).toEqual([]); expect( withoutDirective.graph.edges.filter( - ({ relation, properties }) => - relation === "maps_to" && properties.declared_url !== undefined, + ({ relation, target_node_id }) => + relation === "maps_to" && + withoutDirective.graph.nodes.some( + ({ kind, node_id }) => + kind === "source-map" && node_id === target_node_id, + ), ), ).toEqual([]); @@ -32,12 +36,17 @@ it("creates source-map graph relationships only for real comments", async () => expect( withDirective.graph.nodes.filter(({ kind }) => kind === "source-map"), ).toHaveLength(1); + const nodes = new Map( + withDirective.graph.nodes.map((node) => [node.node_id, node]), + ); + const sourceMapEdge = withDirective.graph.edges.find( + ({ relation, target_node_id }) => + relation === "maps_to" && + nodes.get(target_node_id)?.kind === "source-map", + ); expect( - withDirective.graph.edges - .filter( - ({ relation, properties }) => - relation === "maps_to" && properties.declared_url !== undefined, - ) - .map(({ properties }) => properties.declared_url), - ).toEqual(["real.map"]); + nodes + .get(sourceMapEdge?.target_node_id ?? "") + ?.observations.map(({ properties }) => properties.declared_url), + ).toContain("real.map"); }); diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index e8be59a4b0..278324b544 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -6,6 +6,10 @@ import { expect, it } from "vitest"; import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + parseJavaScriptApplicationGraph, + serializeJavaScriptApplicationGraph, +} from "../../../src/domain/javascript/javascriptApplicationGraph.js"; it.each(["\n", "\r\n", "\r", "\u2028", "\u2029"])( "preserves source-map graph evidence coordinates after %j", @@ -16,33 +20,139 @@ it.each(["\n", "\r\n", "\r", "\u2028", "\u2029"])( await writeFile(join(root, "app.js"), source); const result = await reconstructJavaScriptArtifact({ input_path: root }); expect(result.statistics.parse_failures).toBe(0); - const edges = result.graph.edges.filter( - ({ relation, properties }) => - relation === "maps_to" && properties.declared_url === "app.js.map", + const nodes = new Map( + result.graph.nodes.map((node) => [node.node_id, node]), ); - expect(edges).toHaveLength(1); - expect(edges[0]?.evidence).toMatchObject({ - state: "inferred", - artifact: { - sha256: createHash("sha256").update(source).digest("hex"), + const edge = result.graph.edges.find( + ({ relation, target_node_id }) => + relation === "maps_to" && + nodes.get(target_node_id)?.kind === "source-map", + ); + expect(edge).toMatchObject({ + properties: { + declared_url_sha256: createHash("sha256") + .update("app.js.map") + .digest("hex"), + resolved_path: null, }, - location: { - available: true, - value: { - kind: "source-range", - source: "app.js", - start: { line: 2, column: 2 }, - end: { line: 2, column: directive.length + 2 }, + evidence: { + state: "inferred", + artifact: { + sha256: createHash("sha256").update(source).digest("hex"), + }, + location: { + available: true, + value: { + kind: "source-range", + source: "app.js", + start: { line: 2, column: 2 }, + end: { line: 2, column: directive.length + 2 }, + }, }, }, }); - expect(edges[0]?.properties).toMatchObject({ - declared_url: "app.js.map", - resolved_path: null, - }); + expect( + nodes + .get(edge?.target_node_id ?? "") + ?.observations.some( + ({ properties }) => properties.declared_url === "app.js.map", + ), + ).toBe(true); }, ); +it("owns an inline source-map URL once and recovers it from the target after JSON roundtrip", async () => { + const root = await createTestTempDirectory("rea-inline-source-map-url-"); + const sourceContent = "source;".repeat(16_384); + const sourceMap = JSON.stringify({ + version: 3, + sources: ["src/app.js"], + sourcesContent: [sourceContent], + names: [], + mappings: "AAAA", + }); + const declaredUrl = `data:application/json;base64,${Buffer.from(sourceMap).toString("base64")}`; + await writeFile( + join(root, "app.js"), + `const app = true;\n//# sourceMappingURL=${declaredUrl}\n`, + ); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const serialized = serializeJavaScriptApplicationGraph(result.graph); + const graph = parseJavaScriptApplicationGraph(JSON.parse(serialized)); + const edge = graph.edges.find( + ({ relation, target_node_id }) => + relation === "maps_to" && + graph.nodes.some( + ({ kind, node_id }) => + kind === "source-map" && node_id === target_node_id, + ), + ); + const target = graph.nodes.find( + ({ node_id }) => node_id === edge?.target_node_id, + ); + + const edgeUrlSha256 = edge?.properties.declared_url_sha256; + const observation = target?.observations.find( + ({ properties }) => properties.declared_url_sha256 === edgeUrlSha256, + ); + expect(edgeUrlSha256).toBe( + createHash("sha256").update(declaredUrl).digest("hex"), + ); + expect(observation?.properties.declared_url).toBe(declaredUrl); + expect(observation?.properties).toMatchObject({ + resolved_path: null, + available: false, + }); + expect(serialized.split(declaredUrl)).toHaveLength(2); +}); + +it("associates each source-map URL with its edge when aliases share one physical map", async () => { + const root = await createTestTempDirectory("rea-source-map-aliases-"); + await mkdir(join(root, "dist")); + await writeFile( + join(root, "dist", "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }), + ); + await writeFile( + join(root, "dist", "one.js"), + "//# sourceMappingURL=app.js.map\n", + ); + await writeFile( + join(root, "dist", "two.js"), + "//# sourceMappingURL=./app.js.map\n", + ); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const serialized = serializeJavaScriptApplicationGraph(result.graph); + const graph = parseJavaScriptApplicationGraph(JSON.parse(serialized)); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const mapEdges = graph.edges.filter( + ({ relation, target_node_id }) => + relation === "maps_to" && + nodes.get(target_node_id)?.kind === "source-map", + ); + expect(mapEdges).toHaveLength(2); + expect(mapEdges[0]?.target_node_id).toBe(mapEdges[1]?.target_node_id); + expect( + mapEdges.map((edge) => { + const target = nodes.get(edge.target_node_id); + const observation = target?.observations.find( + ({ properties }) => + properties.declared_url_sha256 === + edge.properties.declared_url_sha256, + ); + return { + declared_url: observation?.properties.declared_url, + resolved_path: edge.properties.resolved_path, + }; + }), + ).toEqual([ + { declared_url: "app.js.map", resolved_path: "dist/app.js.map" }, + { declared_url: "./app.js.map", resolved_path: "dist/app.js.map" }, + ]); +}); + it("resolves a bare source-map file name and worker URL next to the script", async () => { const root = await createTestTempDirectory("rea-source-map-relative-"); await mkdir(join(root, "dist")); @@ -72,13 +182,26 @@ it("resolves a bare source-map file name and worker URL next to the script", asy const result = await reconstructJavaScriptArtifact({ input_path: root }); const nodes = new Map(result.graph.nodes.map((node) => [node.node_id, node])); const mapEdge = result.graph.edges.find( - ({ relation, properties }) => - relation === "maps_to" && properties.declared_url === "app.js.map", + ({ relation, target_node_id }) => + relation === "maps_to" && + nodes.get(target_node_id)?.kind === "source-map", ); - expect(mapEdge?.properties).toMatchObject({ - declared_url: "app.js.map", + expect(mapEdge?.properties).toEqual({ + declared_url_sha256: createHash("sha256") + .update("app.js.map") + .digest("hex"), resolved_path: "dist/app.js.map", }); + expect( + nodes + .get(mapEdge?.target_node_id ?? "") + ?.observations.some( + ({ properties }) => + properties.declared_url === "app.js.map" && + properties.resolved_path === "dist/app.js.map" && + properties.available === true, + ), + ).toBe(true); expect(nodes.get(mapEdge?.target_node_id ?? "")?.identity.strategy).toBe( "content-digest", );