diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 7e931904f6..731d29b82c 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -27,6 +27,19 @@ interface ItemContext { readonly rightNativeEvidence: readonly Evidence[]; readonly pairByLeft: ReadonlyMap; readonly pairByRight: ReadonlyMap; + readonly leftRelationships: RelationshipIndex; + readonly rightRelationships: RelationshipIndex; +} + +interface IndexedRelationship { + readonly direction: "in" | "out"; + readonly relation: ApplicationEdge["relation"]; + readonly neighborId: string; +} + +interface RelationshipIndex { + readonly nodeById: ReadonlyMap; + readonly adjacency: ReadonlyMap; } type OneSidedNode = @@ -53,7 +66,10 @@ type ComparisonItemSemantic< /** Classify matched, unmatched, and ambiguous nodes without inventing absence. */ export const classifyJavaScriptApplicationVersions = ( matching: ApplicationVersionMatchingProjection, - context: Omit, + context: Omit< + ItemContext, + "pairByLeft" | "pairByRight" | "leftRelationships" | "rightRelationships" + >, ): ApplicationVersionItemProjection => { const pairByLeft = new Map( matching.pairs.map(({ left, right }) => [ @@ -67,7 +83,13 @@ export const classifyJavaScriptApplicationVersions = ( `pair:${left.node_id}:${right.node_id}`, ]), ); - const fullContext: ItemContext = { ...context, pairByLeft, pairByRight }; + const fullContext: ItemContext = { + ...context, + pairByLeft, + pairByRight, + leftRelationships: indexRelationships(context.leftGraph), + rightRelationships: indexRelationships(context.rightGraph), + }; const leftItems = matching.unmatchedLeft.map((node) => { const candidates = matching.candidatesForLeft.get(node.node_id) ?? []; return unmatchedItem( @@ -339,37 +361,45 @@ const properties = (node: ApplicationNode) => compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)), ); +const indexRelationships = ( + graph: JavaScriptApplicationGraph, +): RelationshipIndex => { + const adjacency = new Map(); + const add = (nodeId: string, relationship: IndexedRelationship): void => { + const existing = adjacency.get(nodeId); + if (existing === undefined) adjacency.set(nodeId, [relationship]); + else existing.push(relationship); + }; + for (const edge of graph.edges) { + add(edge.source_node_id, { + direction: "out", + relation: edge.relation, + neighborId: edge.target_node_id, + }); + if (edge.target_node_id !== edge.source_node_id) + add(edge.target_node_id, { + direction: "in", + relation: edge.relation, + neighborId: edge.source_node_id, + }); + } + return { + nodeById: new Map(graph.nodes.map((value) => [value.node_id, value])), + adjacency, + }; +}; + const relationshipSignature = ( node: ApplicationNode, side: "left" | "right", context: ItemContext, ): string => { - const graph = side === "left" ? context.leftGraph : context.rightGraph; + const { nodeById, adjacency } = + side === "left" ? context.leftRelationships : context.rightRelationships; const paired = side === "left" ? context.pairByLeft : context.pairByRight; - const nodeById = new Map(graph.nodes.map((value) => [value.node_id, value])); - const signatures = graph.edges.flatMap((edge) => { - if (edge.source_node_id === node.node_id) - return [ - edgeSignature({ - direction: "out", - relation: edge.relation, - neighborId: edge.target_node_id, - paired, - nodeById, - }), - ]; - if (edge.target_node_id === node.node_id) - return [ - edgeSignature({ - direction: "in", - relation: edge.relation, - neighborId: edge.source_node_id, - paired, - nodeById, - }), - ]; - return []; - }); + const signatures = (adjacency.get(node.node_id) ?? []).map((relationship) => + edgeSignature({ ...relationship, paired, nodeById }), + ); return canonicalJson(signatures.sort(compareUnicodeCodePoints)); }; diff --git a/src/domain/javascript/javascriptApplicationVersionRelationships.test.ts b/src/domain/javascript/javascriptApplicationVersionRelationships.test.ts new file mode 100644 index 0000000000..40b0dc0780 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionRelationships.test.ts @@ -0,0 +1,175 @@ +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 NAMES = ["a", "b", "c", "d"] as const; +type ModuleName = (typeof NAMES)[number]; +type Relation = "imports" | "contains"; +type EdgeSpec = readonly [ModuleName, ModuleName, Relation]; + +const sourceDigest = (name: string): string => + name.charCodeAt(0).toString(16).padStart(64, "0"); + +const buildGraph = ( + artifactSha: string, + edges: readonly EdgeSpec[], + order: "forward" | "reverse" = "forward", +): JavaScriptApplicationGraph => { + const names = order === "forward" ? [...NAMES] : [...NAMES].reverse(); + const nodes = names.map((name) => + createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: artifactSha, + path: `${name}.js`, + }, + observations: [ + { + label: `${name}.js`, + properties: { source_sha256: sourceDigest(name) }, + evidence: artifactEvidence(artifactSha, `${name}.js`), + }, + ], + }), + ); + const idOf = (name: ModuleName): string => { + const node = nodes.find(({ identity }) => + identity.strategy === "canonical-path" + ? identity.path === `${name}.js` + : false, + ); + if (node === undefined) throw new Error(`Missing fixture node ${name}`); + return node.node_id; + }; + const ordered = order === "forward" ? [...edges] : [...edges].reverse(); + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [idOf("a")], + nodes, + edges: ordered.map(([source, target, relation]) => + createJavaScriptApplicationEdge({ + source_node_id: idOf(source), + target_node_id: idOf(target), + relation, + properties: {}, + evidence: artifactEvidence(artifactSha, `${source}.js`), + }), + ), + coverage: completeCoverage, + limitations: [], + }); +}; + +const relationshipChanges = ( + left: JavaScriptApplicationGraph, + right: JavaScriptApplicationGraph, +): string[] => { + 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: [], + }); + expect(result.summary.added + result.summary.removed).toBe(0); + return result.items + .filter(({ dimensions }) => dimensions.includes("relationships")) + .map(({ left_node_id: id }) => { + const node = left.nodes.find(({ node_id: nodeId }) => nodeId === id); + const label = node?.observations[0]?.label; + if (label === undefined || label === null) + throw new Error("Changed item has no node"); + return label; + }) + .sort(); +}; + +const BASE_EDGES: readonly EdgeSpec[] = [ + ["a", "b", "imports"], + ["b", "c", "imports"], + ["a", "c", "contains"], + ["c", "b", "imports"], +]; + +describe("application version relationship comparison", () => { + it("reports no relationship change for identical topology in any node or edge order", () => { + expect( + relationshipChanges( + buildGraph(LEFT_SHA, BASE_EDGES), + buildGraph(RIGHT_SHA, BASE_EDGES, "reverse"), + ), + ).toEqual([]); + }); + + it("flags both endpoints when an edge relation changes", () => { + const changed: readonly EdgeSpec[] = [ + ["a", "b", "imports"], + ["b", "c", "contains"], + ["a", "c", "contains"], + ["c", "b", "imports"], + ]; + expect( + relationshipChanges( + buildGraph(LEFT_SHA, BASE_EDGES), + buildGraph(RIGHT_SHA, changed), + ), + ).toEqual(["b.js", "c.js"]); + }); + + it("flags a node whose only change is an incoming edge", () => { + const left: readonly EdgeSpec[] = [["a", "b", "imports"]]; + const right: readonly EdgeSpec[] = [ + ["a", "b", "imports"], + ["c", "b", "imports"], + ]; + expect( + relationshipChanges( + buildGraph(LEFT_SHA, left), + buildGraph(RIGHT_SHA, right), + ), + ).toEqual(["b.js", "c.js"]); + }); + + it("flags a node when an edge is re-pointed at a different matched neighbour", () => { + const left: readonly EdgeSpec[] = [["a", "b", "imports"]]; + const right: readonly EdgeSpec[] = [["a", "c", "imports"]]; + expect( + relationshipChanges( + buildGraph(LEFT_SHA, left), + buildGraph(RIGHT_SHA, right), + ), + ).toEqual(["a.js", "b.js", "c.js"]); + }); + + it("distinguishes edge direction between the same pair", () => { + const left: readonly EdgeSpec[] = [["a", "b", "imports"]]; + const right: readonly EdgeSpec[] = [["b", "a", "imports"]]; + expect( + relationshipChanges( + buildGraph(LEFT_SHA, left), + buildGraph(RIGHT_SHA, right), + ), + ).toEqual(["a.js", "b.js"]); + }); +});