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
This commit is contained in:
morluto committed 2026-10-17 12:36:21 +08:00
1 parent 1e36e707b1
commit 556504edcb
4 files changed
+332 -4

No files matched your search

+10
View File
@@ -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
@@ -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"
@@ -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");
});
@@ -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"]),
});
},
);