From 388b907c923133096a3387272367cbfb04ff1c04 Mon Sep 17 00:00:00 2001 From: Shenrui Ma <115921185+Shenrui-Ma@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:52:13 +0800 Subject: [PATCH] perf(javascript): defer ambiguity expansion in version comparisons (#2962) perf(javascript): avoid quadratic temporary matching state Repeated source groups can allocate candidates that later identity matches discard. Retain the original ambiguous groups until all tiers finish, expand only unmatched survivors, and append to locally owned key buckets in place. Constraint: Preserve matching priority, full ambiguity, and public output. Directive: Do not re-match residual groups that become unique after later tiers. Tested: 59 relevant tests, npm run check, and test discovery on current main. Tested: CLI benchmarks and full-suite failure comparison on the recorded base. Not-tested: Full suite on the updated main; native Linux and Windows workflows. --- .../javascriptApplicationVersionKeys.ts | 62 +++--- ...vascriptApplicationVersionMatching.test.ts | 181 ++++++++++++++++++ .../applicationVersionMatchingCli.test.ts | 98 ++++++++++ 3 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 src/domain/javascript/javascriptApplicationVersionMatching.test.ts create mode 100644 tests/acceptance/applications/applicationVersionMatchingCli.test.ts diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 306e8d316..11dbf62de 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 000000000..9299d4184 --- /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 000000000..64e438ed3 --- /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: [], + }, + }); + } + }, +);