diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index ab73f90b87..92310f4d2c 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -289,7 +289,8 @@ links, limitations, and a `changed_from` graph containing all compared nodes, their observations, and relationships. A matched pair whose only differing dimension is `location` counts as -`unchanged` when both input graphs have complete coverage. This includes an +`unchanged` when both input graphs have complete coverage and its neighboring +relationship identities are resolved. This includes an identical module shifted by an earlier bundle edit and a named IPC channel observed at a new offset. The item still reports `dimensions: ["location"]` and both node IDs; the graph retains both original observations and coordinates. @@ -298,6 +299,16 @@ Content, property, and complete relationship differences still count as retains the location and coverage dimensions. Matching confidence is unchanged: an inferred pair is not promoted to exact identity by this classification. +Complete inventory does not resolve ambiguous or unmatched relationship targets. +A matched module with identical bytes can therefore remain `unknown` when a +neighbor, including its containing chunk, has no cross-version match. The item +retains its exact content match, coordinates, candidate inventory, and an explicit +relationship-identity limitation. `coverage` is added to its dimensions only for +incomplete inventory. Definite content, property, or complete topology differences +remain `changed` while retaining any separate relationship uncertainty. +An unknown item with no observed content, property, or relationship difference +does not emit a `changed_from` edge; its pairing and uncertainty remain in the item. + One-sided absence is `added` or `removed` only when the opposite input graph has complete coverage. With partial, unavailable, or truncated input, the same condition is `unknown`. The comparison retains every classified item and diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.ts b/src/domain/javascript/javascriptApplicationChangeGraph.ts index 3935038447..acc1ae6c10 100644 --- a/src/domain/javascript/javascriptApplicationChangeGraph.ts +++ b/src/domain/javascript/javascriptApplicationChangeGraph.ts @@ -118,6 +118,11 @@ const changedFromEdge = ( if ( item.match.status !== "matched" || item.status === "unchanged" || + // Pairing and uncertainty remain in the item; neither establishes a change. + (item.status === "unknown" && + !item.dimensions.some( + (dimension) => dimension !== "location" && dimension !== "coverage", + )) || left === null || right === null || left === right || diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index ed8ba61f00..6782c77efb 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -142,7 +142,8 @@ const matchedItem = ( pair: ApplicationVersionNodePair, context: ItemContext, ): ApplicationVersionComparisonItem => { - const dimensions = changedDimensions(pair, context); + const relationships = compareRelationships(pair, context); + const dimensions = changedDimensions(pair, relationships); const complete = graphsComplete(context); const observedChange = dimensions.includes("content") || dimensions.includes("properties"); @@ -153,11 +154,11 @@ const matchedItem = ( const status: ApplicationVersionComparisonItem["status"] = observedChange || completeChange ? "changed" - : complete + : complete && !relationships.unresolved ? "unchanged" : "unknown"; const finalDimensions = - status === "unknown" && !dimensions.includes("coverage") + status === "unknown" && !complete ? [...dimensions, "coverage" as const] : dimensions; const semantic: ComparisonItemSemantic = { @@ -168,7 +169,19 @@ const matchedItem = ( match: matchedMatch(pair), dimensions: finalDimensions, evidence_links: itemEvidenceLinks(pair.left, pair.right, context), - limitations: pairLimitations(pair, status), + limitations: [ + ...matchLimitations(pair), + ...(status === "unknown" && !complete + ? [ + "Incomplete graph coverage prevents a complete node-level conclusion.", + ] + : []), + ...(relationships.unresolved + ? [ + "Relationship target identity remains unresolved; equal node kinds do not establish cross-version alignment.", + ] + : []), + ], }; return itemWithId(semantic); }; @@ -320,7 +333,7 @@ const INCOMPLETE_GRAPH_LIMITATION = const changedDimensions = ( pair: ApplicationVersionNodePair, - context: ItemContext, + relationships: RelationshipComparison, ): ApplicationVersionComparisonItem["dimensions"] => { const dimensions: ApplicationVersionComparisonItem["dimensions"] = []; const leftContent = contentIdentity(pair.left); @@ -340,11 +353,7 @@ const changedDimensions = ( canonicalJson(properties(pair.right)) ) dimensions.push("properties"); - if ( - relationshipSignature(pair.left, "left", context) !== - relationshipSignature(pair.right, "right", context) - ) - dimensions.push("relationships"); + if (relationships.different) dimensions.push("relationships"); return dimensions; }; @@ -409,32 +418,56 @@ const indexRelationships = ( }; }; -const relationshipSignature = ( +/** Positive topology differences and unresolved neighbor identity are independent facts. */ +interface RelationshipComparison { + readonly different: boolean; + readonly unresolved: boolean; +} + +const compareRelationships = ( + pair: ApplicationVersionNodePair, + context: ItemContext, +): RelationshipComparison => { + const left = relationshipFacts(pair.left, "left", context); + const right = relationshipFacts(pair.right, "right", context); + return { + different: + left.paired !== right.paired || + left.unpairedShape !== right.unpairedShape, + unresolved: left.unresolved || right.unresolved, + }; +}; + +const relationshipFacts = ( node: ApplicationNode, side: "left" | "right", context: ItemContext, -): string => { +): { + readonly paired: string; + readonly unpairedShape: string; + readonly unresolved: boolean; +} => { const { nodeById, adjacency } = side === "left" ? context.leftRelationships : context.rightRelationships; const paired = side === "left" ? context.pairByLeft : context.pairByRight; - const signatures = (adjacency.get(node.node_id) ?? []).map((relationship) => - edgeSignature({ ...relationship, paired, nodeById }), - ); - return canonicalJson(signatures.sort(compareUnicodeCodePoints)); -}; - -interface EdgeSignatureContext { - readonly direction: "in" | "out"; - readonly relation: ApplicationEdge["relation"]; - readonly neighborId: string; - readonly paired: ReadonlyMap; - readonly nodeById: ReadonlyMap; -} - -const edgeSignature = (context: EdgeSignatureContext): string => { - const mapped = context.paired.get(context.neighborId); - const neighbor = context.nodeById.get(context.neighborId); - return `${context.direction}\0${context.relation}\0${mapped ?? `unmatched:${neighbor?.kind ?? "unknown"}`}`; + const matched: string[] = []; + const unpaired: string[] = []; + for (const { direction, relation, neighborId } of adjacency.get( + node.node_id, + ) ?? []) { + const mapped = paired.get(neighborId); + if (mapped !== undefined) + matched.push(`${direction}\0${relation}\0${mapped}`); + else + unpaired.push( + `${direction}\0${relation}\0${nodeById.get(neighborId)?.kind ?? "unknown"}`, + ); + } + return { + paired: canonicalJson(matched.sort(compareUnicodeCodePoints)), + unpairedShape: canonicalJson(unpaired.sort(compareUnicodeCodePoints)), + unresolved: unpaired.length > 0, + }; }; const itemEvidenceLinks = ( @@ -475,10 +508,7 @@ const indexNativeEvidence = ( return evidenceByDigest; }; -const pairLimitations = ( - pair: ApplicationVersionNodePair, - status: ApplicationVersionComparisonItem["status"], -): string[] => [ +const matchLimitations = (pair: ApplicationVersionNodePair): string[] => [ ...(pair.basis === "structural-fingerprint" ? [ "Structural fingerprint equality is rename-resistant inference; it is not byte identity or proof of semantic equivalence.", @@ -494,9 +524,6 @@ const pairLimitations = ( "Semantic-key pairing is a deterministic inference and is never promoted to exact identity.", ] : []), - ...(status === "unknown" - ? ["Incomplete graph coverage prevents a complete node-level conclusion."] - : []), ]; const itemWithId = ( diff --git a/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts b/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts new file mode 100644 index 0000000000..6988b4f3d5 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts @@ -0,0 +1,229 @@ +import { expect, it } from "vitest"; +import { + artifactEvidence, + completeCoverage, + fixtureNode, +} from "./javascriptApplicationGraph.fixture.js"; +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationEdge, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; + +const graph = ( + sha: string, + peers: "ambiguous" | "unique" | "unmatched", + options: { + relation?: "imports" | "invokes"; + target?: "peer-one" | "peer-two"; + direction?: "in" | "out"; + content?: string; + partial?: true; + } = {}, +) => { + const evidence = artifactEvidence(sha, "main.js"); + const nodes = ["main", "peer-one", "peer-two"].map((key) => + fixtureNode( + "javascript-module", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: sha, + namespace: "module", + key, + }, + key === "main" ? "main" : "peer", + { + source_sha256: + key === "main" + ? (options.content ?? "d".repeat(64)) + : peers === "ambiguous" + ? "c".repeat(64) + : peers === "unique" + ? (key === "peer-one" ? "e" : "f").repeat(64) + : (key === "peer-one" ? "1" : "2").repeat(64), + ...(key === "main" + ? { + structural_fingerprint_sha256: "9".repeat(64), + structural_fingerprint_algorithm: "fixture", + } + : {}), + }, + evidence, + ), + ); + const main = nodes[0], + target = nodes[options.target === "peer-two" ? 2 : 1]; + if (main === undefined || target === undefined) + throw new Error("Missing fixture nodes"); + return { + main, + graph: createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [main.node_id], + nodes, + edges: [ + createJavaScriptApplicationEdge({ + source_node_id: + options.direction === "in" ? target.node_id : main.node_id, + target_node_id: + options.direction === "in" ? main.node_id : target.node_id, + relation: options.relation ?? "imports", + properties: {}, + evidence, + }), + ], + coverage: options.partial + ? { ...completeCoverage, status: "partial", omitted_count: null } + : completeCoverage, + limitations: options.partial ? ["Controlled incomplete inventory."] : [], + }), + }; +}; +const compare = ( + left: ReturnType, + right: ReturnType, +) => { + const result = compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: "a".repeat(64), + graph: left.graph, + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: "b".repeat(64), + graph: right.graph, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + const item = result.items.find( + (item) => item.left_node_id === left.main.node_id, + ); + if (item === undefined) throw new Error("Missing main comparison item"); + return { result, item }; +}; + +it.each(["ambiguous", "unmatched"] as const)( + "keeps %s neighbor alignment unknown despite complete inventories", + (peers) => { + const { result, item } = compare( + graph("a".repeat(64), "ambiguous"), + graph("b".repeat(64), peers), + ); + expect(item).toMatchObject({ + status: "unknown", + dimensions: [], + match: { + status: "matched", + basis: "exact-module-source-digest", + confidence: "exact", + }, + }); + expect(item.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); + expect(item.limitations.join(" ")).not.toContain( + "Incomplete graph coverage", + ); + expect(result.coverage.status).toBe("complete-within-inputs"); + expect( + result.graph.edges.filter((edge) => edge.relation === "changed_from"), + ).toEqual([]); + expect(result.graph.nodes).toHaveLength(6); + if (peers === "ambiguous") { + expect(result.summary).toEqual({ + unchanged: 0, + added: 0, + removed: 0, + changed: 0, + unknown: 5, + }); + expect( + result.items.filter((i) => i.match.status === "ambiguous"), + ).toHaveLength(4); + } + }, +); + +it("keeps equal relationships to uniquely paired peers unchanged", () => { + const { item } = compare( + graph("a".repeat(64), "unique"), + graph("b".repeat(64), "unique"), + ); + expect(item).toMatchObject({ status: "unchanged", dimensions: [] }); + expect(item.limitations).toEqual([]); +}); + +it.each([{ relation: "invokes" as const }, { direction: "in" as const }])( + "retains positive relation/direction differences beside unknown peer identity %j", + (options) => { + const { item } = compare( + graph("a".repeat(64), "ambiguous"), + graph("b".repeat(64), "ambiguous", options), + ); + expect(item).toMatchObject({ + status: "changed", + dimensions: ["relationships"], + }); + expect(item.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); + }, +); + +it("reports a resolved target change", () => { + const { item } = compare( + graph("a".repeat(64), "unique"), + graph("b".repeat(64), "unique", { target: "peer-two" }), + ); + expect(item).toMatchObject({ + status: "changed", + dimensions: ["relationships"], + }); +}); + +it("preserves definite content change alongside unresolved relationships", () => { + const { item } = compare( + graph("a".repeat(64), "ambiguous"), + graph("b".repeat(64), "ambiguous", { content: "8".repeat(64) }), + ); + expect(item).toMatchObject({ + status: "changed", + dimensions: ["content", "properties"], + match: { basis: "structural-fingerprint" }, + }); + expect(item.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); +}); + +it.each(["imports", "invokes"] as const)( + "keeps partial inventory and neighbor identity uncertainty distinct (%s)", + (relation) => { + const { result, item } = compare( + graph("a".repeat(64), "ambiguous"), + graph("b".repeat(64), "ambiguous", { partial: true, relation }), + ); + expect(item).toMatchObject({ + status: "unknown", + dimensions: + relation === "imports" ? ["coverage"] : ["relationships", "coverage"], + }); + const changes = result.graph.edges.filter( + (edge) => edge.relation === "changed_from", + ); + expect(changes).toHaveLength(relation === "imports" ? 0 : 1); + if (relation === "invokes") + expect(changes[0]).toMatchObject({ + properties: { status: "unknown" }, + evidence: { coverage: { status: "partial" } }, + }); + expect(item.limitations.join(" ")).toContain("Incomplete graph coverage"); + expect(item.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); + }, +); diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts index 9d1704446b..889c605e04 100644 --- a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -243,7 +243,7 @@ cliTest( ); cliTest( - "matches an unchanged source module after a sibling file changes", + "retains exact source-module pairing while a changed sibling's identity is unresolved", async ({ cli }) => { const root = await createTestTempDirectory("rea-source-module-version-"); const versions = await Promise.all( @@ -309,17 +309,41 @@ cliTest( parseEvidence(response.json).normalized_result, ); - expect(result.items).toContainEqual( - expect.objectContaining({ - left_node_id: sourceModules[0]?.node_id, - right_node_id: sourceModules[1]?.node_id, - status: "unchanged", - match: expect.objectContaining({ - status: "matched", - basis: "exact-module-source-digest", - confidence: "exact", - }), - }), + const item = result.items.find( + ({ left_node_id }) => left_node_id === sourceModules[0]?.node_id, ); + expect(item).toMatchObject({ + left_node_id: sourceModules[0]?.node_id, + right_node_id: sourceModules[1]?.node_id, + status: "unknown", + dimensions: [], + match: { + status: "matched", + basis: "exact-module-source-digest", + confidence: "exact", + }, + }); + expect(item?.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); + expect(result.coverage.status).toBe("complete-within-inputs"); + const mainId = sourceModules[0]?.node_id; + const neighbors = new Set( + analyses[0]?.graph.edges.flatMap((edge) => + edge.source_node_id === mainId + ? [edge.target_node_id] + : edge.target_node_id === mainId + ? [edge.source_node_id] + : [], + ), + ); + expect( + result.items.some( + (other) => + other.left_node_id !== null && + neighbors.has(other.left_node_id) && + other.match.status !== "matched", + ), + ).toBe(true); }, ); diff --git a/tests/acceptance/applications/applicationVersionRelocationCli.test.ts b/tests/acceptance/applications/applicationVersionRelocationCli.test.ts index c371c20315..17b7edad8a 100644 --- a/tests/acceptance/applications/applicationVersionRelocationCli.test.ts +++ b/tests/acceptance/applications/applicationVersionRelocationCli.test.ts @@ -8,7 +8,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; cliTest( - "retains unchanged bundled modules after an earlier edit shifts their source offsets", + "retains exact factory bytes and relocation while exposing unresolved chunk alignment", async ({ cli }) => { const root = await createTestTempDirectory("rea-version-relocation-"); const bundle = @@ -62,10 +62,13 @@ cliTest( expect(moduleItems).toHaveLength(2); for (const item of moduleItems) { expect(item).toMatchObject({ - status: "unchanged", + status: "unknown", dimensions: ["location"], match: { basis: "exact-module-source-digest" }, }); + expect(item.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); const a = modules[0]?.find((node) => node.node_id === item.left_node_id), b = modules[1]?.find((node) => node.node_id === item.right_node_id); expect(typeof a?.observations[0]?.properties.source_sha256).toBe( @@ -84,7 +87,7 @@ cliTest( result.graph.nodes.find((node) => node.node_id === item.right_node_id), ).toEqual(b); } - expect(result.summary.unchanged).toBeGreaterThanOrEqual(2); + expect(result.summary.unknown).toBeGreaterThanOrEqual(2); expect( result.items.find((item) => item.node_kind === "javascript-asset"), ).toMatchObject({ @@ -93,3 +96,76 @@ cliTest( }); }, ); + +cliTest( + "preserves ambiguous imported factory identity through public version comparison", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-ambiguous-import-"); + const bundle = + "globalThis.webpackChunkApp.push([[1], {11:function(module,exports,__webpack_require__){exports.value=__webpack_require__(22).value;},22:function(module,exports){exports.value=1;},33:function(module,exports){exports.value=1;}}]);"; + const versions = []; + for (const side of [1, 2]) { + const application = join(root, String(side)); + await mkdir(application); + await writeFile( + join(application, "main.js"), + `${side === 1 ? "" : "/* shifted */\n"}${bundle}`, + ); + const run = await cli.run({ + arguments: ["analyze-javascript-application", application, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(run.exitCode, run.stderr).toBe(0); + versions.push(parseEvidence(run.json)); + } + const [left, right] = versions; + if (left === undefined || right === undefined) + throw new Error("Missing versions"); + const analyses = versions.map((e) => + javascriptApplicationAnalysisResultSchema.parse(e.normalized_result), + ); + const main = analyses[0]?.graph.nodes.find( + (n) => + n.kind === "javascript-module" && + n.observations.some((o) => o.properties.module_key === "11"), + ); + const peer = analyses[0]?.graph.nodes.find( + (n) => + n.kind === "javascript-module" && + n.observations.some((o) => o.properties.module_key === "22"), + ); + if (main === undefined || peer === undefined) + throw new Error("Missing fixture factories"); + expect(analyses[0]?.graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source_node_id: main.node_id, + target_node_id: peer.node_id, + relation: "imports", + }), + ]), + ); + const input = join(root, "compare.json"); + await writeFile(input, JSON.stringify({ left, right })); + const run = await cli.run({ + arguments: ["compare-application-versions", input, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(run.exitCode, run.stderr).toBe(0); + const result = applicationVersionComparisonResultSchema.parse( + parseEvidence(run.json).normalized_result, + ); + const item = result.items.find((i) => i.left_node_id === main.node_id), + target = result.items.find((i) => i.left_node_id === peer.node_id); + expect(result.coverage.status).toBe("complete-within-inputs"); + expect(item).toMatchObject({ + status: "unknown", + match: { basis: "exact-module-source-digest", confidence: "exact" }, + }); + expect(item?.limitations.join(" ")).toMatch( + /relationship.*identity.*unresolved/iu, + ); + expect(target?.match).toMatchObject({ status: "ambiguous" }); + expect(target?.match.candidate_right_node_ids).toHaveLength(2); + }, +);