From 556504edcbb0f8cd9eaf81a91b18ff891b8f5457 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:36:21 +0800 Subject: [PATCH] fix(javascript): distinguish relocation from application changes Keep location-only matched pairs unchanged under complete coverage while retaining both coordinates and location dimensions. Preserve incomplete coverage as unknown and retain definite content, property, and relationship changes. Validated with focused domain, compiled CLI and MCP workflows, static checks and docs checks. A tiny public CLI bundle fixture reproduces the issue; the reported Bitwarden releases were not rerun. Refs #4520 --- docs/javascript-application-workflows.md | 10 + .../javascriptApplicationVersionItems.ts | 11 +- ...scriptApplicationVersionRelocation.test.ts | 220 ++++++++++++++++++ .../applicationVersionRelocationCli.test.ts | 95 ++++++++ 4 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 src/domain/javascript/javascriptApplicationVersionRelocation.test.ts create mode 100644 tests/acceptance/applications/applicationVersionRelocationCli.test.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 0e2b9e0e7..ab73f90b8 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -288,6 +288,16 @@ and the result includes its basis, candidates, changed dimensions, Evidence 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 +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. +Content, property, and complete relationship differences still count as +`changed`. With incomplete coverage, relocation alone remains `unknown` and +retains the location and coverage dimensions. Matching confidence is unchanged: +an inferred pair is not promoted to exact identity by this classification. + 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/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index e4fff6603..ed8ba61f0 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -144,11 +144,14 @@ const matchedItem = ( ): ApplicationVersionComparisonItem => { const dimensions = changedDimensions(pair, context); const complete = graphsComplete(context); - const observedChange = dimensions.some((dimension) => - ["content", "location", "properties"].includes(dimension), - ); + const observedChange = + dimensions.includes("content") || dimensions.includes("properties"); + // Coordinates remain comparison evidence; relocation alone is not a + // content, property, or relationship change. + const completeChange = + complete && dimensions.some((dimension) => dimension !== "location"); const status: ApplicationVersionComparisonItem["status"] = - observedChange || (complete && dimensions.length > 0) + observedChange || completeChange ? "changed" : complete ? "unchanged" diff --git a/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts b/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts new file mode 100644 index 000000000..75fac10dc --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionRelocation.test.ts @@ -0,0 +1,220 @@ +import { expect, it } from "vitest"; +import { + artifactEvidence, + completeCoverage, + fixtureNode, +} from "./javascriptApplicationGraph.fixture.js"; +import { + createJavaScriptApplicationEdge, + createJavaScriptApplicationGraph, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; +import { applicationVersionComparisonResultSchema } from "./javascriptApplicationVersionComparisonSchemas.js"; + +const graph = ( + sha: string, + offset: number, + coverage: JavaScriptApplicationGraph["coverage"], + options: { + content?: string; + permission?: string; + relation?: "handles" | "invokes"; + } = {}, +) => { + const evidence = { + ...artifactEvidence(sha, "main.js"), + location: { + available: true as const, + value: { + kind: "file-offset-range" as const, + start: offset, + end: offset + 10, + }, + }, + }; + const module = fixtureNode( + "javascript-module", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: sha, + namespace: "webpack-module", + key: "11", + }, + "module", + { + source_sha256: options.content ?? "c".repeat(64), + structural_fingerprint_sha256: "d".repeat(64), + structural_fingerprint_algorithm: "fixture", + }, + evidence, + ); + const channel = fixtureNode( + "ipc-channel", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: sha, + namespace: "ipc", + key: "unchanged-channel", + }, + "unchanged-channel", + { + channel: "unchanged-channel", + ...(options.permission === undefined + ? {} + : { permission: options.permission }), + }, + evidence, + ); + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [module.node_id], + nodes: [module, channel], + edges: [ + createJavaScriptApplicationEdge({ + source_node_id: module.node_id, + target_node_id: channel.node_id, + relation: options.relation ?? "handles", + properties: {}, + evidence, + }), + ], + coverage, + limitations: + coverage.status === "complete" + ? [] + : [ + "Controlled incomplete inventory; unobserved relationships remain unknown.", + ], + }); +}; +const compare = ( + left: JavaScriptApplicationGraph, + right: JavaScriptApplicationGraph, +) => + compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: "a".repeat(64), + graph: left, + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: "b".repeat(64), + graph: right, + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); +const partialCoverage: JavaScriptApplicationGraph["coverage"] = { + ...completeCoverage, + status: "partial", + omitted_count: null, +}; + +it("keeps relocated exact modules and inferred channels unchanged while retaining both coordinates", () => { + const left = graph("a".repeat(64), 10, completeCoverage), + right = graph("b".repeat(64), 50, completeCoverage); + const result = applicationVersionComparisonResultSchema.parse( + JSON.parse(JSON.stringify(compare(left, right))), + ); + expect(result.summary).toEqual({ + unchanged: 2, + changed: 0, + added: 0, + removed: 0, + unknown: 0, + }); + expect(result.items).toHaveLength(2); + for (const item of result.items) + expect(item).toMatchObject({ + status: "unchanged", + dimensions: ["location"], + }); + expect(result.matching).toMatchObject({ + exact_module_source_digest: 1, + semantic_key: 1, + }); + expect(result.graph.nodes).toHaveLength(4); + expect( + result.graph.nodes.map((node) => node.observations[0]?.evidence.location), + ).toEqual( + expect.arrayContaining([ + left.nodes[0]?.observations[0]?.evidence.location, + right.nodes[0]?.observations[0]?.evidence.location, + ]), + ); + expect( + result.graph.edges.filter((edge) => edge.relation === "changed_from"), + ).toEqual([]); +}); + +it.each([false, true])( + "keeps relocation and uncertain relationships unknown under incomplete coverage (relationship change: %s)", + (changeRelation) => { + const result = compare( + graph("a".repeat(64), 10, completeCoverage), + graph("b".repeat(64), 50, partialCoverage, { + relation: changeRelation ? "invokes" : "handles", + }), + ); + expect(result.summary).toEqual({ + unchanged: 0, + changed: 0, + added: 0, + removed: 0, + unknown: 2, + }); + for (const item of result.items) + expect(item).toMatchObject({ + status: "unknown", + dimensions: changeRelation + ? ["location", "relationships", "coverage"] + : ["location", "coverage"], + }); + }, +); + +it("still classifies complete relationship changes beside relocation as changed", () => { + const result = compare( + graph("a".repeat(64), 10, completeCoverage), + graph("b".repeat(64), 50, completeCoverage, { relation: "invokes" }), + ); + expect(result.summary.changed).toBe(2); + for (const item of result.items) + expect(item.dimensions).toEqual(["location", "relationships"]); +}); + +it("retains definite content changes under partial coverage alongside relocation", () => { + const result = compare( + graph("a".repeat(64), 10, completeCoverage), + graph("b".repeat(64), 50, partialCoverage, { content: "e".repeat(64) }), + ); + expect( + result.items.find((item) => item.node_kind === "javascript-module"), + ).toMatchObject({ + status: "changed", + dimensions: ["content", "location", "properties"], + }); + expect( + result.items.find((item) => item.node_kind === "ipc-channel")?.status, + ).toBe("unknown"); +}); + +it("retains definite property changes beside relocation", () => { + const result = compare( + graph("a".repeat(64), 10, completeCoverage), + graph("b".repeat(64), 50, partialCoverage, { permission: "changed" }), + ); + expect( + result.items.find((item) => item.node_kind === "ipc-channel"), + ).toMatchObject({ + status: "changed", + dimensions: ["location", "properties"], + }); + expect( + result.items.find((item) => item.node_kind === "javascript-module")?.status, + ).toBe("unknown"); +}); diff --git a/tests/acceptance/applications/applicationVersionRelocationCli.test.ts b/tests/acceptance/applications/applicationVersionRelocationCli.test.ts new file mode 100644 index 000000000..c371c2031 --- /dev/null +++ b/tests/acceptance/applications/applicationVersionRelocationCli.test.ts @@ -0,0 +1,95 @@ +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( + "retains unchanged bundled modules after an earlier edit shifts their source offsets", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-relocation-"); + const bundle = + "globalThis.webpackChunkApp.push([[1], {11(module,exports){exports.value=11;},22(module,exports){exports.value=22;}}]);"; + 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 ? "" : "/* earlier bundle edit */\n"}${bundle}`, + ); + const response = await cli.run({ + arguments: ["analyze-javascript-application", application, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + versions.push(parseEvidence(response.json)); + } + const [left, right] = versions; + if (left === undefined || right === undefined) + throw new Error("Missing analyzed versions"); + const analyses = versions.map((value) => + javascriptApplicationAnalysisResultSchema.parse(value.normalized_result), + ); + const modules = analyses.map((value) => + value.graph.nodes.filter( + (node) => + node.kind === "javascript-module" && + node.observations.some( + ({ properties }) => properties.module_key !== undefined, + ), + ), + ); + expect(modules[0]).toHaveLength(2); + expect(modules[1]).toHaveLength(2); + const input = join(root, "comparison.json"); + await writeFile(input, JSON.stringify({ left, right })); + const response = await cli.run({ + arguments: ["compare-application-versions", input, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + const result = applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(result.coverage.status).toBe("complete-within-inputs"); + const moduleItems = result.items.filter((item) => + modules[0]?.some((node) => node.node_id === item.left_node_id), + ); + expect(moduleItems).toHaveLength(2); + for (const item of moduleItems) { + expect(item).toMatchObject({ + status: "unchanged", + dimensions: ["location"], + match: { basis: "exact-module-source-digest" }, + }); + 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( + "string", + ); + expect(a?.observations[0]?.properties.source_sha256).toBe( + b?.observations[0]?.properties.source_sha256, + ); + expect(a?.observations[0]?.evidence.location).not.toEqual( + b?.observations[0]?.evidence.location, + ); + expect( + result.graph.nodes.find((node) => node.node_id === item.left_node_id), + ).toEqual(a); + expect( + result.graph.nodes.find((node) => node.node_id === item.right_node_id), + ).toEqual(b); + } + expect(result.summary.unchanged).toBeGreaterThanOrEqual(2); + expect( + result.items.find((item) => item.node_kind === "javascript-asset"), + ).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["content"]), + }); + }, +);