diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.ts b/src/domain/javascript/javascriptApplicationChangeGraph.ts index acc1ae6c10..08336bb0d6 100644 --- a/src/domain/javascript/javascriptApplicationChangeGraph.ts +++ b/src/domain/javascript/javascriptApplicationChangeGraph.ts @@ -1,7 +1,7 @@ import { createJavaScriptApplicationEdge, createImmutableJavaScriptApplicationGraphSteps, - createJavaScriptApplicationNode, + mergeJavaScriptApplicationNodes, type JavaScriptApplicationGraph, } from "./javascriptApplicationGraph.js"; import type { @@ -30,22 +30,16 @@ export interface ApplicationChangeGraphProjection { export const buildJavaScriptApplicationChangeGraph = ( input: ChangeGraphInput, ): ApplicationChangeGraphProjection => { - const allNodes = nodeCandidates(input); + const mergedNodes = mergeJavaScriptApplicationNodes( + nodeCandidates(input), + ).sort((left, right) => + compareUnicodeCodePoints(left.node_id, right.node_id), + ); + const retained = new Set(mergedNodes.map(({ node_id }) => node_id)); const preferredRoots = uniqueSorted([ ...input.right.root_node_ids, ...input.left.root_node_ids, - ]).filter((nodeId) => allNodes.has(nodeId)); - const orderedNodeIds = uniqueSorted([ - ...preferredRoots, - ...input.items.flatMap(({ left_node_id: left, right_node_id: right }) => [ - ...(right === null ? [] : [right]), - ...(left === null ? [] : [left]), - ]), - ]); - const retained = new Set(orderedNodeIds); - const mergedNodes = mergeNodes( - orderedNodeIds.flatMap((nodeId) => allNodes.get(nodeId) ?? []), - ); + ]).filter((nodeId) => retained.has(nodeId)); const sourceEdges = uniqueEdges([ ...input.left.edges, ...input.right.edges, @@ -88,9 +82,7 @@ export const buildJavaScriptApplicationChangeGraph = ( return { graph: next.value }; }; -const nodeCandidates = ( - input: ChangeGraphInput, -): Map => { +const nodeCandidates = (input: ChangeGraphInput): ApplicationNode[] => { const required = new Set([ ...input.left.root_node_ids, ...input.right.root_node_ids, @@ -99,11 +91,9 @@ const nodeCandidates = ( ...(right === null ? [] : [right]), ]), ]); - const output = new Map(); - for (const node of [...input.left.nodes, ...input.right.nodes]) - if (required.has(node.node_id)) - output.set(node.node_id, [...(output.get(node.node_id) ?? []), node]); - return output; + return [...input.left.nodes, ...input.right.nodes].filter((node) => + required.has(node.node_id), + ); }; const changedFromEdge = ( @@ -245,37 +235,6 @@ const sourceCoverageLimits = (input: ChangeGraphInput) => const combineOmittedCounts = (left: number | null, right: number | null) => left === null || right === null ? null : left + right; -const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => { - const groups = new Map(); - for (const node of nodes) - groups.set(node.node_id, [...(groups.get(node.node_id) ?? []), node]); - const merged = [...groups.values()].map((group) => { - const first = group[0]; - if (first === undefined) - throw new TypeError("Empty change-graph node group"); - const observations = [ - ...new Map( - group - .flatMap(({ observations: values }) => values) - .map((observation) => [observation.observation_id, observation]), - ).values(), - ].sort((left, right) => - compareUnicodeCodePoints(left.observation_id, right.observation_id), - ); - return createJavaScriptApplicationNode({ - kind: first.kind, - identity: first.identity, - observations: observations.map( - ({ observation_id: _id, identifier_strategy: _strategy, ...value }) => - value, - ), - }); - }); - return merged.sort((left, right) => - compareUnicodeCodePoints(left.node_id, right.node_id), - ); -}; - const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] => [...new Map(edges.map((edge) => [edge.edge_id, edge])).values()].sort( (left, right) => compareUnicodeCodePoints(left.edge_id, right.edge_id), diff --git a/src/domain/javascript/javascriptApplicationGraph.test.ts b/src/domain/javascript/javascriptApplicationGraph.test.ts index 5d4337406a..5a9e50568e 100644 --- a/src/domain/javascript/javascriptApplicationGraph.test.ts +++ b/src/domain/javascript/javascriptApplicationGraph.test.ts @@ -4,6 +4,7 @@ import { createJavaScriptApplicationEdge, createJavaScriptApplicationGraph, createJavaScriptApplicationNode, + mergeJavaScriptApplicationNodes, javascriptApplicationGraphSchema, parseJavaScriptApplicationGraph, serializeJavaScriptApplicationGraph, @@ -23,6 +24,58 @@ import { sourceMapReference, unknownEvidence, } from "./javascriptApplicationGraph.fixture.js"; + +it("merges observations by entity identity without changing source nodes", () => { + const evidence = artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "main.js"); + const first = contentNode(evidence, { selected: "first" }); + const second = contentNode(evidence, { selected: "second" }); + const reversedIdentity = { + sha256: APPLICATION_GRAPH_DIGESTS.asar, + stability: "global-exact" as const, + strategy: "content-digest" as const, + }; + const merged = mergeJavaScriptApplicationNodes([ + first, + { ...second, identity: reversedIdentity }, + first, + ]); + expect(merged).toHaveLength(1); + const node = firstOf(merged, "merged node"); + expect(node.node_id).toBe(first.node_id); + expect(node.observations.map(({ properties }) => properties)).toEqual( + expect.arrayContaining([{ selected: "first" }, { selected: "second" }]), + ); + expect(node.observations).toHaveLength(2); + expect(first.observations).toHaveLength(1); + expect(second.observations).toHaveLength(1); + const graph = graphForNode(node); + expect(parseJavaScriptApplicationGraph(graph)).toEqual(graph); +}); + +it.each(["kind", "identity"] as const)( + "rejects colliding entity IDs with a different %s before merging observations", + (field) => { + const node = contentNode( + artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "main.js"), + ); + const conflicting = + field === "kind" + ? { ...node, kind: "javascript-asset" as const } + : { + ...node, + identity: { + strategy: "content-digest" as const, + stability: "global-exact" as const, + sha256: "f".repeat(64), + }, + }; + expect(() => mergeJavaScriptApplicationNodes([node, conflicting])).toThrow( + "Colliding JAG node identifiers disagree", + ); + expect(node.observations).toHaveLength(1); + }, +); + const createLargeNativeExportGraph = (): JavaScriptApplicationGraph => { const properties = Object.fromEntries( Array.from({ length: 1_001 }, (_, index) => [ diff --git a/src/domain/javascript/javascriptApplicationGraph.ts b/src/domain/javascript/javascriptApplicationGraph.ts index 22504a12b8..2769d358bc 100644 --- a/src/domain/javascript/javascriptApplicationGraph.ts +++ b/src/domain/javascript/javascriptApplicationGraph.ts @@ -502,6 +502,51 @@ export const createJavaScriptApplicationNode = ( }); }; +/** Merge authenticated entities, retaining every distinct observation. */ +export const mergeJavaScriptApplicationNodes = ( + nodes: readonly ApplicationNode[], +): ApplicationNode[] => { + const groups = new Map< + string, + { + node: ApplicationNode; + observations: Map; + } + >(); + for (const node of nodes) { + const group = groups.get(node.node_id); + if (group === undefined) { + groups.set(node.node_id, { + node, + observations: new Map( + node.observations.map((observation) => [ + observation.observation_id, + observation, + ]), + ), + }); + continue; + } + if ( + node.kind !== group.node.kind || + canonicalJson(node.identity) !== canonicalJson(group.node.identity) + ) + throw new TypeError("Colliding JAG node identifiers disagree"); + for (const observation of node.observations) + group.observations.set(observation.observation_id, observation); + } + return [...groups.values()].map(({ node, observations }) => + createJavaScriptApplicationNode({ + kind: node.kind, + identity: node.identity, + observations: [...observations.values()].map( + ({ observation_id: _id, identifier_strategy: _strategy, ...value }) => + value, + ), + }), + ); +}; + /** Build one normalized directed relationship and derive its semantic ID. */ export const createJavaScriptApplicationEdge = ( input: unknown, diff --git a/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts b/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts index 864b9d6f79..1d915def3f 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationGraph.ts @@ -1,6 +1,6 @@ import { createJavaScriptApplicationGraph, - createJavaScriptApplicationNode, + mergeJavaScriptApplicationNodes, type JavaScriptApplicationGraph, } from "./javascriptApplicationGraph.js"; import type { @@ -39,7 +39,7 @@ export interface ReconciledApplicationGraphProjection { export const buildReconciledApplicationGraph = ( input: ReconciledGraphInput, ): ReconciledApplicationGraphProjection => { - const merged = mergeNodes([ + const merged = mergeJavaScriptApplicationNodes([ ...input.layers.flatMap(({ graph }) => graph.nodes), ...input.runtime.nodes, ]); @@ -210,45 +210,6 @@ const graphCoverage = ( limits: [], }; -const mergeNodes = (nodes: readonly ApplicationNode[]): ApplicationNode[] => { - const grouped = new Map(); - for (const node of nodes) { - const group = grouped.get(node.node_id); - if (group === undefined) grouped.set(node.node_id, [node]); - else group.push(node); - } - const merged = [...grouped.values()].map((group) => { - const first = group[0]; - if (first === undefined) throw new TypeError("Empty JAG node group"); - if ( - group.some( - ({ kind, identity }) => - kind !== first.kind || - JSON.stringify(identity) !== JSON.stringify(first.identity), - ) - ) - throw new TypeError("Colliding JAG node identifiers disagree"); - const observations = [ - ...new Map( - group - .flatMap(({ observations: values }) => values) - .map((observation) => [observation.observation_id, observation]), - ).values(), - ].sort((left, right) => - compareUnicodeCodePoints(left.observation_id, right.observation_id), - ); - return createJavaScriptApplicationNode({ - kind: first.kind, - identity: first.identity, - observations: observations.map( - ({ observation_id: _id, identifier_strategy: _strategy, ...value }) => - value, - ), - }); - }); - return merged; -}; - const uniqueEdges = (edges: readonly ApplicationEdge[]): ApplicationEdge[] => [ ...new Map(edges.map((edge) => [edge.edge_id, edge])).values(), ];