diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.test.ts b/src/domain/javascript/javascriptApplicationChangeGraph.test.ts new file mode 100644 index 000000000..2bbd653af --- /dev/null +++ b/src/domain/javascript/javascriptApplicationChangeGraph.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationEdge, + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import { + artifactEvidence, + completeCoverage, +} from "./javascriptApplicationGraph.fixture.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; + +const LEFT_SHA = "a".repeat(64); +const RIGHT_SHA = "b".repeat(64); + +const digest = (value: number): string => value.toString(16).padStart(64, "0"); + +const buildGraph = ( + artifactSha: string, + changed: number, +): JavaScriptApplicationGraph => { + const nodes = ["a", "b", "c", "d"].map((name, index) => + createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: artifactSha, + path: `${name}.js`, + }, + observations: [ + { + label: `${name}.js`, + properties: { + structural_fingerprint_sha256: digest(index + 1), + structural_fingerprint_algorithm: "fixture", + source_sha256: digest(index === 1 || index === 3 ? changed : 0), + }, + evidence: artifactEvidence(artifactSha, `${name}.js`), + }, + ], + }), + ); + const [a, b] = nodes; + if (a === undefined || b === undefined) + throw new Error("Missing fixture node"); + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [a.node_id], + nodes, + edges: [ + createJavaScriptApplicationEdge({ + source_node_id: a.node_id, + target_node_id: b.node_id, + relation: "imports", + properties: {}, + evidence: artifactEvidence(artifactSha, "a.js"), + }), + ], + coverage: completeCoverage, + limitations: [], + }); +}; + +describe("application change graph", () => { + it("anchors each changed_from edge to its own right-hand observation", () => { + const left = buildGraph(LEFT_SHA, 1); + const right = buildGraph(RIGHT_SHA, 2); + const result = compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: LEFT_SHA, + graph: left, + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: RIGHT_SHA, + graph: right, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + const changedFrom = result.graph.edges.filter( + ({ relation }) => relation === "changed_from", + ); + expect(changedFrom).toHaveLength(2); + for (const edge of changedFrom) { + const source = right.nodes.find( + ({ node_id: id }) => id === edge.source_node_id, + ); + const target = left.nodes.find( + ({ node_id: id }) => id === edge.target_node_id, + ); + expect(source?.observations[0]?.label).toBe( + target?.observations[0]?.label, + ); + expect(edge.evidence.artifact).toEqual( + source?.observations[0]?.evidence.artifact, + ); + expect(edge.evidence.location).toEqual( + source?.observations[0]?.evidence.location, + ); + } + }); +}); diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.ts b/src/domain/javascript/javascriptApplicationChangeGraph.ts index 4c80b3204..134fff520 100644 --- a/src/domain/javascript/javascriptApplicationChangeGraph.ts +++ b/src/domain/javascript/javascriptApplicationChangeGraph.ts @@ -53,8 +53,14 @@ export const buildJavaScriptApplicationChangeGraph = ( ({ source_node_id: source, target_node_id: target }) => retained.has(source) && retained.has(target), ); + const leftById = new Map( + input.left.nodes.map((node) => [node.node_id, node]), + ); + const rightById = new Map( + input.right.nodes.map((node) => [node.node_id, node]), + ); const comparisonEdges = input.items.flatMap((item) => - changedFromEdge(item, input, retained), + changedFromEdge(item, input, retained, leftById, rightById), ); const candidateEdges = uniqueEdges([...sourceEdges, ...comparisonEdges]); const rootNodeIds = preferredRoots.filter((nodeId) => retained.has(nodeId)); @@ -104,6 +110,8 @@ const changedFromEdge = ( item: ApplicationVersionComparisonItem, input: ChangeGraphInput, retained: ReadonlySet, + leftById: ReadonlyMap, + rightById: ReadonlyMap, ): ApplicationEdge[] => { const left = item.left_node_id; const right = item.right_node_id; @@ -117,7 +125,12 @@ const changedFromEdge = ( !retained.has(right) ) return []; - const evidence = comparisonEvidence(item, input); + const evidence = comparisonEvidence( + item, + input, + leftById.get(left), + rightById.get(right), + ); return [ createJavaScriptApplicationEdge({ source_node_id: right, @@ -138,13 +151,9 @@ const changedFromEdge = ( const comparisonEvidence = ( item: ApplicationVersionComparisonItem, input: ChangeGraphInput, + left: ApplicationNode | undefined, + right: ApplicationNode | undefined, ): ApplicationGraphEvidence => { - const right = input.right.nodes.find( - ({ node_id: id }) => id === item.right_node_id, - ); - const left = input.left.nodes.find( - ({ node_id: id }) => id === item.left_node_id, - ); const source = right?.observations[0]?.evidence ?? left?.observations[0]?.evidence; const complete =