diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.ts b/src/domain/javascript/javascriptApplicationChangeGraph.ts index ce0e136b3..4c80b3204 100644 --- a/src/domain/javascript/javascriptApplicationChangeGraph.ts +++ b/src/domain/javascript/javascriptApplicationChangeGraph.ts @@ -1,6 +1,6 @@ import { createJavaScriptApplicationEdge, - createJavaScriptApplicationGraph, + createImmutableJavaScriptApplicationGraphSteps, createJavaScriptApplicationNode, type JavaScriptApplicationGraph, } from "./javascriptApplicationGraph.js"; @@ -59,7 +59,7 @@ export const buildJavaScriptApplicationChangeGraph = ( const candidateEdges = uniqueEdges([...sourceEdges, ...comparisonEdges]); const rootNodeIds = preferredRoots.filter((nodeId) => retained.has(nodeId)); const fallbackRoot = mergedNodes[0]?.node_id; - const graph = createJavaScriptApplicationGraph({ + const steps = createImmutableJavaScriptApplicationGraphSteps({ schema: "JavaScriptApplicationGraph", root_node_ids: rootNodeIds.length > 0 @@ -77,7 +77,9 @@ export const buildJavaScriptApplicationChangeGraph = ( "The change graph contains compared entities and their retained relationships; it is not an executable application.", ]), }); - return { graph }; + let next = steps.next(); + while (!next.done) next = steps.next(); + return { graph: next.value }; }; const nodeCandidates = ( diff --git a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts new file mode 100644 index 000000000..c0d92cc38 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationEdge, + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + isValidatedImmutableJavaScriptApplicationGraph, + parseJavaScriptApplicationGraph, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import { + artifactEvidence, + completeCoverage, +} from "./javascriptApplicationGraph.fixture.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; +import { + applicationVersionComparisonResultSchema, + ownedApplicationVersionComparisonResultSchema, +} from "./javascriptApplicationVersionComparisonSchemas.js"; + +const LEFT_SHA = "a".repeat(64); +const RIGHT_SHA = "b".repeat(64); + +const buildGraph = ( + artifactSha: string, + version: number, +): JavaScriptApplicationGraph => { + const nodes = ["a", "b", "c"].map((name) => + createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: artifactSha, + path: `${name}.js`, + }, + observations: [ + { + label: `${name}.js`, + properties: { + structural_fingerprint_sha256: name + .charCodeAt(0) + .toString(16) + .padStart(64, "0"), + structural_fingerprint_algorithm: "fixture", + source_sha256: (name === "b" ? version : 0) + .toString(16) + .padStart(64, "0"), + }, + evidence: artifactEvidence(artifactSha, `${name}.js`), + }, + ], + }), + ); + const [a, b, c] = nodes; + if (a === undefined || b === undefined || c === undefined) + throw new Error("Missing fixture node"); + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [a.node_id], + nodes, + edges: [ + createJavaScriptApplicationEdge({ + source_node_id: a.node_id, + target_node_id: b.node_id, + relation: "imports", + properties: {}, + evidence: artifactEvidence(artifactSha, "a.js"), + }), + createJavaScriptApplicationEdge({ + source_node_id: b.node_id, + target_node_id: c.node_id, + relation: "imports", + properties: {}, + evidence: artifactEvidence(artifactSha, "b.js"), + }), + ], + coverage: completeCoverage, + limitations: [], + }); +}; + +const compare = () => + compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: LEFT_SHA, + graph: buildGraph(LEFT_SHA, 1), + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: RIGHT_SHA, + graph: buildGraph(RIGHT_SHA, 2), + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + +describe("application version comparison result", () => { + it("returns a sealed change graph that passes complete validation", () => { + const result = compare(); + expect( + result.graph.edges.some(({ relation }) => relation === "changed_from"), + ).toBe(true); + expect(isValidatedImmutableJavaScriptApplicationGraph(result.graph)).toBe( + true, + ); + expect(Object.isFrozen(result.graph.edges)).toBe(true); + expect(parseJavaScriptApplicationGraph(result.graph)).toEqual(result.graph); + expect(applicationVersionComparisonResultSchema.parse(result)).toEqual( + result, + ); + }); + + it("does not reuse a change graph without an owned proof", () => { + const result = compare(); + const copy = structuredClone(result.graph); + expect(isValidatedImmutableJavaScriptApplicationGraph(copy)).toBe(false); + expect(() => + ownedApplicationVersionComparisonResultSchema.parse({ + ...result, + graph: copy, + }), + ).toThrow(); + expect( + applicationVersionComparisonResultSchema.parse({ ...result, graph: copy }) + .graph, + ).toEqual(result.graph); + }); + + it("keeps coverage validation for owned results", () => { + const result = compare(); + expect(() => + ownedApplicationVersionComparisonResultSchema.parse({ + ...result, + coverage: { ...result.coverage, status: "partial" }, + }), + ).toThrow("Comparison coverage must match source graph completeness"); + }); +}); diff --git a/src/domain/javascript/javascriptApplicationVersionComparison.ts b/src/domain/javascript/javascriptApplicationVersionComparison.ts index 50f82c335..be60d067a 100644 --- a/src/domain/javascript/javascriptApplicationVersionComparison.ts +++ b/src/domain/javascript/javascriptApplicationVersionComparison.ts @@ -3,7 +3,7 @@ import { uniqueSorted } from "../canonicalOrdering.js"; import type { Evidence } from "../evidence.js"; import { buildJavaScriptApplicationChangeGraph } from "./javascriptApplicationChangeGraph.js"; import { - applicationVersionComparisonResultSchema, + ownedApplicationVersionComparisonResultSchema, type ApplicationVersionComparisonResult, } from "./javascriptApplicationVersionComparisonSchemas.js"; import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; @@ -76,7 +76,7 @@ export const compareJavaScriptApplicationVersions = ( evidence_links: evidenceLinks, limitations: comparisonLimitations(input), }; - return applicationVersionComparisonResultSchema.parse({ + return ownedApplicationVersionComparisonResultSchema.parse({ ...semantic, comparison_id: `javc_${digestCanonicalValue(semantic, "Application version comparison")}`, }); diff --git a/src/domain/javascript/javascriptApplicationVersionComparisonSchemas.ts b/src/domain/javascript/javascriptApplicationVersionComparisonSchemas.ts index 309c95c89..d9d54449e 100644 --- a/src/domain/javascript/javascriptApplicationVersionComparisonSchemas.ts +++ b/src/domain/javascript/javascriptApplicationVersionComparisonSchemas.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { emptyArraySchema } from "../emptyArraySchema.js"; import { evidenceSchema } from "../evidence.js"; -import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js"; +import { + isValidatedImmutableJavaScriptApplicationGraph, + javascriptApplicationGraphSchema, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; import { JAVASCRIPT_APPLICATION_NODE_KINDS } from "./javascriptApplicationGraphSchemas.js"; import { prefixedDigestSchema } from "../digests.js"; @@ -234,6 +238,14 @@ export const applicationVersionComparisonResultSchema = z }); }); +/** Reuse the exact owned change graph while validating the rest of the result. */ +export const ownedApplicationVersionComparisonResultSchema = + applicationVersionComparisonResultSchema.safeExtend({ + graph: z.custom( + isValidatedImmutableJavaScriptApplicationGraph, + ), + }); + export type ApplicationVersionComparisonItem = z.infer< typeof comparisonItemSchema >;