From 4d980d017622d3175d86aaf3b0dcfbdf4ca3d091 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:51:04 +0800 Subject: [PATCH] fix(javascript): preserve unresolved version relationships Keep positive topology differences separate from unpaired neighbor identity. Preserve exact content pairing and source observations, report unresolved relationships without inventing incomplete inventory, and omit comparison edges when no substantive difference was observed. Validated with 45 focused domain/CLI/MCP tests, nine expanded domain controls, check:fast, and docs:check. Clean baseline fails seven controls and passes two positive controls. Controlled static fixtures only; no target execution or large-bundle profiling. Refs #4681 --- docs/javascript-application-workflows.md | 13 +- .../javascriptApplicationChangeGraph.ts | 5 + .../javascriptApplicationVersionItems.ts | 101 +++++--- ...tionVersionRelationshipUncertainty.test.ts | 229 ++++++++++++++++++ .../applicationVersionMatchingCli.test.ts | 48 +++- .../applicationVersionRelocationCli.test.ts | 82 ++++++- 6 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 src/domain/javascript/javascriptApplicationVersionRelationshipUncertainty.test.ts 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); + }, +);