diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 306e8d3163..11dbf62de5 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -77,15 +77,23 @@ export const matchJavaScriptApplicationVersions = ( ): ApplicationVersionMatchingProjection => { const left = new Map(leftNodes.map((node) => [node.node_id, node])); const right = new Map(rightNodes.map((node) => [node.node_id, node])); - const leftCandidates = new Map>(); - const rightCandidates = new Map>(); const pairs: ApplicationVersionNodePair[] = []; + const ambiguousGroups: AmbiguousGroup[] = []; for (const tier of matchTiers()) - applyTier({ left, right, pairs, leftCandidates, rightCandidates, tier }); + applyTier({ left, right, pairs, ambiguousGroups, tier }); const remainingLeft = sortedNodes(left.values()); const remainingRight = sortedNodes(right.values()); - const leftIds = new Set(remainingLeft.map(({ node_id: id }) => id)); - const rightIds = new Set(remainingRight.map(({ node_id: id }) => id)); + const leftCandidates = new Map>(); + const rightCandidates = new Map>(); + // Later tiers can pair members of an ambiguous group. Expand only survivors, + // without pairing groups that became unique after those later matches. + for (const group of ambiguousGroups) { + const leftGroup = group.left.filter((node) => left.has(node.node_id)); + if (leftGroup.length === 0) continue; + const rightGroup = group.right.filter((node) => right.has(node.node_id)); + if (rightGroup.length === 0) continue; + recordCandidates(leftGroup, rightGroup, leftCandidates, rightCandidates); + } return { pairs: pairs.sort((a, b) => compareUnicodeCodePoints( @@ -95,17 +103,21 @@ export const matchJavaScriptApplicationVersions = ( ), unmatchedLeft: remainingLeft, unmatchedRight: remainingRight, - candidatesForLeft: filteredCandidates(leftCandidates, rightIds), - candidatesForRight: filteredCandidates(rightCandidates, leftIds), + candidatesForLeft: sortedCandidates(leftCandidates), + candidatesForRight: sortedCandidates(rightCandidates), }; }; +interface AmbiguousGroup { + readonly left: readonly ApplicationNode[]; + readonly right: readonly ApplicationNode[]; +} + interface TierContext { readonly left: Map; readonly right: Map; readonly pairs: ApplicationVersionNodePair[]; - readonly leftCandidates: Map>; - readonly rightCandidates: Map>; + readonly ambiguousGroups: AmbiguousGroup[]; readonly tier: MatchTier; } @@ -114,15 +126,12 @@ const applyTier = (context: TierContext): void => { const rightByKey = nodesByKey(context.right.values(), context.tier.key); for (const key of [...leftByKey.keys()].sort(compareUnicodeCodePoints)) { const leftGroup = leftByKey.get(key) ?? []; - const rightGroup = rightByKey.get(key) ?? []; - if (rightGroup.length === 0) continue; - recordCandidates( - leftGroup, - rightGroup, - context.leftCandidates, - context.rightCandidates, - ); - if (leftGroup.length !== 1 || rightGroup.length !== 1) continue; + const rightGroup = rightByKey.get(key); + if (rightGroup === undefined) continue; + if (leftGroup.length !== 1 || rightGroup.length !== 1) { + context.ambiguousGroups.push({ left: leftGroup, right: rightGroup }); + continue; + } const leftNode = leftGroup[0]; const rightNode = rightGroup[0]; if (leftNode === undefined || rightNode === undefined) continue; @@ -144,7 +153,9 @@ const nodesByKey = ( for (const node of nodes) { const key = keyFor(node); if (key === null) continue; - output.set(key, [...(output.get(key) ?? []), node]); + const group = output.get(key); + if (group === undefined) output.set(key, [node]); + else group.push(node); } return output; }; @@ -169,17 +180,14 @@ const recordCandidates = ( } }; -const filteredCandidates = ( +const sortedCandidates = ( candidates: ReadonlyMap>, - retained: ReadonlySet, ): ReadonlyMap => new Map( - [...candidates.entries()].flatMap(([nodeId, values]) => { - const filtered = [...values] - .filter((value) => retained.has(value)) - .sort(compareUnicodeCodePoints); - return filtered.length === 0 ? [] : [[nodeId, filtered] as const]; - }), + [...candidates.entries()].map(([nodeId, values]) => [ + nodeId, + [...values].sort(compareUnicodeCodePoints), + ]), ); const nodeIdentityKey = (node: ApplicationNode): string => diff --git a/src/domain/javascript/javascriptApplicationVersionMatching.test.ts b/src/domain/javascript/javascriptApplicationVersionMatching.test.ts new file mode 100644 index 0000000000..9299d4184d --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionMatching.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + artifactEvidence, + completeCoverage, + fixtureNode, +} from "./javascriptApplicationGraph.fixture.js"; +import { + createJavaScriptApplicationGraph, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; + +const ARTIFACT_SHA = "a".repeat(64); + +const moduleNode = (name: string, source: string, structure?: string) => + fixtureNode( + "javascript-module", + { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: ARTIFACT_SHA, + path: `${name}.js`, + }, + name, + { + source_sha256: source.repeat(64), + ...(structure === undefined + ? {} + : { + structural_fingerprint_sha256: structure.repeat(64), + structural_fingerprint_algorithm: "fixture", + }), + }, + artifactEvidence(ARTIFACT_SHA, `${name}.js`), + ); + +const graph = (nodes: ApplicationNode[]): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: nodes.map(({ node_id }) => node_id), + nodes, + edges: [], + coverage: completeCoverage, + limitations: [], + }); + +const compare = ( + left: JavaScriptApplicationGraph, + right: JavaScriptApplicationGraph, +) => + compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: ARTIFACT_SHA, + graph: left, + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: ARTIFACT_SHA, + graph: right, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + +describe("application version matching", () => { + it("does not revisit a source group that becomes unique after identity matching", () => { + const shared = moduleNode("shared", "1"); + const leftOnly = moduleNode("left", "1"); + const rightOnly = moduleNode("right", "1"); + const result = compare( + graph([shared, leftOnly]), + graph([shared, rightOnly]), + ); + + expect(result.matching).toMatchObject({ + exact_node_identity: 1, + exact_module_source_digest: 0, + ambiguous: 2, + unmatched: 0, + }); + expect( + result.items.find(({ left_node_id }) => left_node_id === shared.node_id), + ).toMatchObject({ + right_node_id: shared.node_id, + match: { status: "matched", basis: "exact-node-identity" }, + }); + expect( + result.items.find( + ({ left_node_id }) => left_node_id === leftOnly.node_id, + ), + ).toMatchObject({ + status: "unknown", + right_node_id: null, + match: { + status: "ambiguous", + candidate_right_node_ids: [rightOnly.node_id], + }, + }); + expect( + result.items.find( + ({ right_node_id }) => right_node_id === rightOnly.node_id, + ), + ).toMatchObject({ + status: "unknown", + left_node_id: null, + match: { + status: "ambiguous", + candidate_left_node_ids: [leftOnly.node_id], + }, + }); + }); + + it("unions all unresolved source and structural candidates without forcing pairs", () => { + const a = moduleNode("a", "1", "a"); + const b = moduleNode("b", "1", "a"); + const c = moduleNode("c", "2", "a"); + const x = moduleNode("x", "1", "a"); + const y = moduleNode("y", "1", "b"); + const z = moduleNode("z", "3", "a"); + const left = graph([a, b, c]); + const right = graph([x, y, z]); + const result = compare(left, right); + + expect(result.summary).toEqual({ + unchanged: 0, + added: 0, + removed: 0, + changed: 0, + unknown: 6, + }); + expect(result.matching).toMatchObject({ ambiguous: 6, unmatched: 0 }); + for (const [node, candidates] of [ + [a, [x, y, z]], + [b, [x, y, z]], + [c, [x, z]], + ] as const) { + expect( + result.items.find(({ left_node_id }) => left_node_id === node.node_id) + ?.match, + ).toEqual({ + status: "ambiguous", + basis: "none", + confidence: "unknown", + candidate_left_node_ids: [], + candidate_right_node_ids: candidates + .map(({ node_id }) => node_id) + .sort(), + }); + } + for (const [node, candidates] of [ + [x, [a, b, c]], + [y, [a, b]], + [z, [a, b, c]], + ] as const) { + expect( + result.items.find(({ right_node_id }) => right_node_id === node.node_id) + ?.match, + ).toEqual({ + status: "ambiguous", + basis: "none", + confidence: "unknown", + candidate_left_node_ids: candidates + .map(({ node_id }) => node_id) + .sort(), + candidate_right_node_ids: [], + }); + } + + // Permute the already validated graph so matching sees the changed order. + // Compare the full result, including item, comparison and change-graph IDs. + expect( + compare( + { ...left, nodes: [...left.nodes].reverse() }, + { ...right, nodes: [...right.nodes].reverse() }, + ), + ).toEqual(result); + }); +}); diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts new file mode 100644 index 0000000000..64e438ed36 --- /dev/null +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -0,0 +1,98 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "pairs repeated factory bodies by identity when application versions retain a shared bundle", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-matching-"); + const factoryCount = 32; + const factories = Array.from( + { length: factoryCount }, + (_, index) => `${index}: function() {}`, + ).join(","); + const bundle = `globalThis.webpackChunkApp.push([[1], {${factories}}]);`; + const versions = await Promise.all( + [1, 2].map(async (version) => { + const application = join(root, String(version)); + await mkdir(application); + await writeFile( + join(application, "package.json"), + JSON.stringify({ name: "shared-bundle", version: `${version}.0.0` }), + ); + await writeFile(join(application, "chunk.js"), bundle); + const response = await cli.run({ + arguments: ["analyze-javascript-application", application, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return parseEvidence(response.json); + }), + ); + const [left, right] = versions; + if (left === undefined || right === undefined) + throw new Error("Missing application version Evidence"); + const analyses = versions.map(({ normalized_result }) => + javascriptApplicationAnalysisResultSchema.parse(normalized_result), + ); + const factoryNodes = analyses.map(({ graph }) => { + expect(graph.coverage.status).toBe("complete"); + const nodes = graph.nodes.filter( + ({ kind, observations }) => + kind === "javascript-module" && + observations.some( + ({ properties }) => properties.module_key !== undefined, + ), + ); + expect(nodes).toHaveLength(factoryCount); + const sourceDigests = nodes.flatMap(({ observations }) => + observations.map(({ properties }) => properties.source_sha256), + ); + expect(sourceDigests.every((digest) => typeof digest === "string")).toBe( + true, + ); + expect(new Set(sourceDigests).size).toBe(1); + return nodes; + }); + const inputPath = join(root, "comparison.json"); + await writeFile(inputPath, JSON.stringify({ left, right })); + const response = await cli.run({ + arguments: ["compare-application-versions", inputPath, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + const result = applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + + expect(result.left.root_artifact_sha256).not.toBe( + result.right.root_artifact_sha256, + ); + expect(result.matching.ambiguous).toBe(0); + expect(result.coverage.status).toBe("complete-within-inputs"); + expect(factoryNodes[0]?.map(({ node_id }) => node_id)).toEqual( + factoryNodes[1]?.map(({ node_id }) => node_id), + ); + for (const node of factoryNodes[0] ?? []) { + expect( + result.items.find(({ left_node_id }) => left_node_id === node.node_id), + ).toMatchObject({ + status: "unchanged", + right_node_id: node.node_id, + match: { + status: "matched", + basis: "exact-node-identity", + candidate_left_node_ids: [], + candidate_right_node_ids: [], + }, + }); + } + }, +);