diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 0e2b9e0e74..ab73f90b87 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 e4fff66034..ed8ba61f00 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 0000000000..75fac10dc5 --- /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 0000000000..c371c20315 --- /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"]), + }); + }, +);