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

@@ -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");
});