From 037709a52c0824f4d2bbee90d8e2642b2d88d364 Mon Sep 17 00:00:00 2001 From: keemsisi Date: Fri, 9 Oct 2026 23:34:21 +0100 Subject: [PATCH] perf(javascript): validate the version change graph once (#1370) compareJavaScriptApplicationVersions built the change graph with createJavaScriptApplicationGraph, which validates it and re-derives every node, observation, edge and graph ID, then parsed the result with a schema that embeds the full graph schema, repeating all of that work. Seal the change graph with the owned immutable factory and accept that exact sealed graph in the result parse, as owned analysis results already do. Items, coverage and the public result schema are unchanged; output is byte-identical. --- .../javascriptApplicationChangeGraph.ts | 8 +- ...scriptApplicationVersionComparison.test.ts | 140 ++++++++++++++++++ .../javascriptApplicationVersionComparison.ts | 4 +- ...riptApplicationVersionComparisonSchemas.ts | 14 +- 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 src/domain/javascript/javascriptApplicationVersionComparison.test.ts 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 >;