diff --git a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts index c0d92cc382..d73dceaf6b 100644 --- a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { createEvidence } from "../evidence.js"; import { createJavaScriptApplicationEdge, createJavaScriptApplicationGraph, @@ -96,7 +97,137 @@ const compare = () => rightNativeEvidence: [], }); +const unrelatedGraph = (artifactSha: string): JavaScriptApplicationGraph => { + const node = createJavaScriptApplicationNode({ + kind: "javascript-chunk", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: artifactSha, + path: "unrelated.js", + }, + observations: [ + { + label: "unrelated.js", + properties: {}, + evidence: artifactEvidence(artifactSha, "unrelated.js"), + }, + ], + }); + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: completeCoverage, + limitations: [], + }); +}; + +const nativeEvidence = (sha256: string | undefined, ordinal: number) => + createEvidence( + sha256 === undefined + ? undefined + : { + path: `/fixture/native-${ordinal}.js`, + sha256, + format: "javascript", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "analyze_javascript_module", + parameters: { ordinal }, + confidence: "observed", + authority: "shipped-artifact", + result: { ordinal }, + }, + ); + describe("application version comparison result", () => { + it("links every exact digest observation only to its paired or unmatched item", () => { + const left = buildGraph(LEFT_SHA, 1); + const right = buildGraph(RIGHT_SHA, 2); + const leftEvidenceId = `ev_${"1".repeat(64)}`; + const rightEvidenceId = `ev_${"2".repeat(64)}`; + const leftBDigest = `${"0".repeat(63)}1`; + const leftNative = [ + nativeEvidence(leftBDigest, 1), + nativeEvidence(leftBDigest, 2), + nativeEvidence("9".repeat(64), 3), + ]; + const rightNative = [ + nativeEvidence("0".repeat(64), 4), + nativeEvidence(undefined, 6), + ]; + const unrelatedRight = unrelatedGraph(RIGHT_SHA); + const unmatched = compareJavaScriptApplicationVersions({ + left: { + evidenceId: leftEvidenceId, + rootArtifactSha256: LEFT_SHA, + graph: left, + }, + right: { + evidenceId: rightEvidenceId, + rootArtifactSha256: RIGHT_SHA, + graph: unrelatedRight, + }, + leftNativeEvidence: leftNative, + rightNativeEvidence: rightNative, + }); + const leftB = left.nodes.find(({ observations }) => + observations.some(({ label }) => label === "b.js"), + ); + if (leftB === undefined) throw new Error("Missing left b.js node"); + const unmatchedB = unmatched.items.find( + ({ left_node_id: id }) => id === leftB.node_id, + ); + expect(unmatchedB?.evidence_links).toEqual( + [ + leftEvidenceId, + rightEvidenceId, + leftNative[0]!.evidence_id, + leftNative[1]!.evidence_id, + ].sort(), + ); + const unrelatedItem = unmatched.items.find( + ({ right_node_id: id }) => id === unrelatedRight.nodes[0]?.node_id, + ); + expect(unrelatedItem?.evidence_links).toEqual( + [leftEvidenceId, rightEvidenceId].sort(), + ); + + const paired = compareJavaScriptApplicationVersions({ + left: { + evidenceId: leftEvidenceId, + rootArtifactSha256: LEFT_SHA, + graph: left, + }, + right: { + evidenceId: rightEvidenceId, + rootArtifactSha256: RIGHT_SHA, + graph: right, + }, + leftNativeEvidence: [nativeEvidence("0".repeat(64), 5)], + rightNativeEvidence: rightNative, + }); + const leftA = left.nodes.find(({ observations }) => + observations.some(({ label }) => label === "a.js"), + ); + if (leftA === undefined) throw new Error("Missing left a.js node"); + const pairedA = paired.items.find( + ({ left_node_id: id }) => id === leftA.node_id, + ); + expect(pairedA?.match.status).toBe("matched"); + expect(pairedA?.evidence_links).toEqual( + [ + leftEvidenceId, + rightEvidenceId, + nativeEvidence("0".repeat(64), 5).evidence_id, + rightNative[0]!.evidence_id, + ].sort(), + ); + }); + it("returns a sealed change graph that passes complete validation", () => { const result = compare(); expect( diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 731d29b82c..e4fff66034 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -23,14 +23,27 @@ interface ItemContext { readonly rightGraph: JavaScriptApplicationGraph; readonly leftEvidenceId: string; readonly rightEvidenceId: string; - readonly leftNativeEvidence: readonly Evidence[]; - readonly rightNativeEvidence: readonly Evidence[]; + readonly leftNativeEvidenceByDigest: ReadonlyMap; + readonly rightNativeEvidenceByDigest: ReadonlyMap; readonly pairByLeft: ReadonlyMap; readonly pairByRight: ReadonlyMap; readonly leftRelationships: RelationshipIndex; readonly rightRelationships: RelationshipIndex; } +type ItemContextInput = Omit< + ItemContext, + | "leftNativeEvidenceByDigest" + | "rightNativeEvidenceByDigest" + | "pairByLeft" + | "pairByRight" + | "leftRelationships" + | "rightRelationships" +> & { + readonly leftNativeEvidence: readonly Evidence[]; + readonly rightNativeEvidence: readonly Evidence[]; +}; + interface IndexedRelationship { readonly direction: "in" | "out"; readonly relation: ApplicationEdge["relation"]; @@ -66,10 +79,7 @@ type ComparisonItemSemantic< /** Classify matched, unmatched, and ambiguous nodes without inventing absence. */ export const classifyJavaScriptApplicationVersions = ( matching: ApplicationVersionMatchingProjection, - context: Omit< - ItemContext, - "pairByLeft" | "pairByRight" | "leftRelationships" | "rightRelationships" - >, + context: ItemContextInput, ): ApplicationVersionItemProjection => { const pairByLeft = new Map( matching.pairs.map(({ left, right }) => [ @@ -84,7 +94,14 @@ export const classifyJavaScriptApplicationVersions = ( ]), ); const fullContext: ItemContext = { - ...context, + leftGraph: context.leftGraph, + rightGraph: context.rightGraph, + leftEvidenceId: context.leftEvidenceId, + rightEvidenceId: context.rightEvidenceId, + leftNativeEvidenceByDigest: indexNativeEvidence(context.leftNativeEvidence), + rightNativeEvidenceByDigest: indexNativeEvidence( + context.rightNativeEvidence, + ), pairByLeft, pairByRight, leftRelationships: indexRelationships(context.leftGraph), @@ -426,24 +443,33 @@ const itemEvidenceLinks = ( ...new Set([ context.leftEvidenceId, context.rightEvidenceId, - ...nativeLinks(left, context.leftNativeEvidence), - ...nativeLinks(right, context.rightNativeEvidence), + ...nativeLinks(left, context.leftNativeEvidenceByDigest), + ...nativeLinks(right, context.rightNativeEvidenceByDigest), ]), ].sort(compareUnicodeCodePoints); const nativeLinks = ( node: ApplicationNode | undefined, + evidenceByDigest: ReadonlyMap, +): readonly string[] => { + const identity = node === undefined ? null : contentIdentity(node); + if (identity === null) return []; + const digest = identity.replace(/^content:/u, ""); + return evidenceByDigest.get(digest) ?? []; +}; + +const indexNativeEvidence = ( evidence: readonly Evidence[], -): string[] => { - const digest = - node === undefined - ? null - : contentIdentity(node)?.replace(/^content:/u, ""); - return digest === null - ? [] - : evidence - .filter(({ subject }) => subject?.digest.sha256 === digest) - .map(({ evidence_id: id }) => id); +): ReadonlyMap => { + const evidenceByDigest = new Map(); + for (const { evidence_id: id, subject } of evidence) { + const digest = subject?.digest.sha256; + if (digest === undefined) continue; + const ids = evidenceByDigest.get(digest); + if (ids === undefined) evidenceByDigest.set(digest, [id]); + else ids.push(id); + } + return evidenceByDigest; }; const pairLimitations = (