From ec6db0f0630c4b0b35f0db84c0030784c1aa58a6 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:40:21 +0800 Subject: [PATCH] perf(javascript): reuse validated immutable graphs (#1112) * perf(javascript): reuse validated immutable graphs * fix(ci): follow the moved process CLI boundary test --- .github/workflows/ci.yml | 2 +- docs/mcp-contracts.md | 9 +- .../JavaScriptApplicationCancellation.test.ts | 2 + .../JavaScriptApplicationService.ts | 18 +- .../JavaScriptArtifactGraphBuilder.ts | 26 +- .../JavaScriptArtifactReconstruction.ts | 31 +- .../JavaScriptSemanticGraphBuilder.ts | 151 +++++----- .../javascriptApplicationAnalysis.ts | 143 +++++++--- ...javascriptApplicationAnalysisSteps.test.ts | 267 ++++++++++++++++++ .../javascript/javascriptApplicationGraph.ts | 25 ++ .../javascript/javascriptSemanticGraph.ts | 25 ++ 11 files changed, 582 insertions(+), 117 deletions(-) create mode 100644 src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5100f8a5e..099208716a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -334,7 +334,7 @@ jobs: - name: Process filesystem snapshot descriptor regressions run: npm run test:focused -- src/process/capture/FilesystemSnapshot.test.ts - name: Native Darwin ownership and process cleanup regressions - run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/cli/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts + run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/process/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts reconstruction-readiness: name: Reconstruction readiness diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 2028ede159..fe7a4df8a3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -86,11 +86,14 @@ Derived comparisons and reconstruction verification yield before computation and before publication, so cancellation cannot race with successful Evidence. `analyze_javascript_application` also yields between reconstruction phases and -during complete-result sealing, Evidence JSON validation, and canonical hashing. +during graph/result sealing, cross-graph binding checks, Evidence JSON validation, +and canonical hashing. Its final result validation reuses exact graphs whose +owned constructors validated and completely sealed them; imported graphs still +receive full schema and commitment checks. Cancellation observed before completion returns `cancelled` and prevents the provisional result from entering the session ledger; prior Evidence stays usable. -Single-file parsing, graph completion, and full application-result schema -validation still run synchronously, so control messages can wait for those +Single-file parsing, graph construction, and validation of imported graphs +still run synchronously, so control messages can wait for those phases to release the event loop. A rejected client promise alone does not establish that the server has stopped its work. diff --git a/src/application/javascript/JavaScriptApplicationCancellation.test.ts b/src/application/javascript/JavaScriptApplicationCancellation.test.ts index e5160ffe5d..8b2cc75043 100644 --- a/src/application/javascript/JavaScriptApplicationCancellation.test.ts +++ b/src/application/javascript/JavaScriptApplicationCancellation.test.ts @@ -10,7 +10,9 @@ describe("JavaScript analysis cancellation before publication", () => { it.each([ "parse_javascript_source", "build_javascript_application_graph", + "seal_javascript_application_graph", "build_javascript_semantic_graph", + "seal_javascript_semantic_graph", "validate_javascript_application_result", "create_javascript_application_evidence", "seal_javascript_application_result", diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 0bd2ead7a7..7576c8f4bc 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { analyzeJavaScriptApplicationInputSchema, - javascriptApplicationAnalysisResultSchema, + parseOwnedJavaScriptApplicationAnalysisSteps, } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; @@ -17,6 +17,7 @@ import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScr import { assertJavaScriptAnalysisActive, checkpointJavaScriptAnalysis, + completeJavaScriptAnalysisSteps, } from "./JavaScriptAnalysisControl.js"; import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js"; import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; @@ -63,14 +64,17 @@ export const analyzeJavaScriptApplicationValidated = async ( phase: "validate_javascript_application_result", completed: 0, total: 1, - message: "Validating the application analysis result", + message: "Validating result metadata and cross-graph bindings", }); await checkpointJavaScriptAnalysis(options.signal); - const result = javascriptApplicationAnalysisResultSchema.parse({ - ...application, - summary, - limitations: reconstructed.graph.limitations, - }); + const result = await completeJavaScriptAnalysisSteps( + parseOwnedJavaScriptApplicationAnalysisSteps({ + ...application, + summary, + limitations: reconstructed.graph.limitations, + }), + options.signal, + ); await options.progress?.report({ phase: "create_javascript_application_evidence", completed: 0, diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index beddcca9c2..c043cfb001 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,6 +1,7 @@ import type { ArtifactInventorySnapshot } from "../../artifacts/inventory/ArtifactInventory.js"; import { createJavaScriptApplicationGraph, + createImmutableJavaScriptApplicationGraphSteps, type JavaScriptApplicationGraph, } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; @@ -46,7 +47,26 @@ export const buildJavaScriptArtifactGraph = ( snapshot: ArtifactInventorySnapshot, fileSet: JavaScriptArtifactFileSet, analysis: JavaScriptModuleArtifactAnalysis, -): JavaScriptApplicationGraph => { +): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph( + buildJavaScriptArtifactGraphInput(snapshot, fileSet, analysis), + ); + +/** Build a validated application graph with cooperative immutable ownership. */ +export const buildImmutableJavaScriptArtifactGraphSteps = ( + snapshot: ArtifactInventorySnapshot, + fileSet: JavaScriptArtifactFileSet, + analysis: JavaScriptModuleArtifactAnalysis, +): Generator => + createImmutableJavaScriptApplicationGraphSteps( + buildJavaScriptArtifactGraphInput(snapshot, fileSet, analysis), + ); + +const buildJavaScriptArtifactGraphInput = ( + snapshot: ArtifactInventorySnapshot, + fileSet: JavaScriptArtifactFileSet, + analysis: JavaScriptModuleArtifactAnalysis, +): unknown => { const accumulator = new JavaScriptArtifactGraphAccumulator(); const root = createJavaScriptArtifactRootNode(accumulator, snapshot); const context: JavaScriptArtifactGraphContext = { @@ -74,7 +94,7 @@ export const buildJavaScriptArtifactGraph = ( addJavaScriptHtmlRoles(context); addJavaScriptSourceMapOriginals(context); const coverage = graphCoverage(context); - return createJavaScriptApplicationGraph({ + return { schema: "JavaScriptApplicationGraph", root_node_ids: packageRoots.length === 0 @@ -84,7 +104,7 @@ export const buildJavaScriptArtifactGraph = ( edges: accumulator.edges(), coverage, limitations: graphLimitations(context, coverage.status), - }); + }; }; const graphCoverage = (context: JavaScriptArtifactGraphContext) => { diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 4d4f502fbd..5152291081 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -7,7 +7,7 @@ import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascript import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { analyzeAndProjectJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js"; -import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js"; +import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js"; import { javascriptArtifactReconstructionInputSchema, type JavaScriptArtifactReconstructionInput, @@ -16,7 +16,10 @@ import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/Artifa import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import type { ProgressReporter } from "../ProgressReporter.js"; -import { checkpointJavaScriptAnalysis } from "./JavaScriptAnalysisControl.js"; +import { + checkpointJavaScriptAnalysis, + completeJavaScriptAnalysisSteps, +} from "./JavaScriptAnalysisControl.js"; /** Application-layer result retaining local diagnostics outside the canonical graph. */ export interface JavaScriptArtifactReconstructionResult { @@ -95,16 +98,36 @@ export const reconstructJavaScriptArtifact = async ( "build_javascript_application_graph", "Constructing application and Electron boundary relationships", ); - const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + files, + analysis, + ); + await reportPhase( + "seal_javascript_application_graph", + "Sealing the validated application graph", + ); + const graph = await completeJavaScriptAnalysisSteps( + applicationGraphSteps, + signal, + ); await reportPhase( "build_javascript_semantic_graph", "Binding and validating static semantic relationships", ); - const semanticGraph = semanticProjection.finish( + const semanticGraphSteps = semanticProjection.finishImmutableSteps( snapshot.manifest.root_sha256, graph, analysis, ); + await reportPhase( + "seal_javascript_semantic_graph", + "Sealing the validated semantic graph", + ); + const semanticGraph = await completeJavaScriptAnalysisSteps( + semanticGraphSteps, + signal, + ); await checkpointJavaScriptAnalysis(signal); return { input_path: path, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 424ab9fdfd..c8183e410a 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -1,5 +1,6 @@ import { createJavaScriptSemanticGraph, + createImmutableJavaScriptSemanticGraphSteps, type JavaScriptSemanticGraph, type JavaScriptSemanticGraphNode, } from "../../domain/javascript/javascriptSemanticGraph.js"; @@ -131,6 +132,11 @@ export interface JavaScriptSemanticGraphProjection { applicationGraph: BuilderInput["applicationGraph"], analysis: Pick, ) => JavaScriptSemanticGraph; + readonly finishImmutableSteps: ( + rootArtifactSha256: string, + applicationGraph: BuilderInput["applicationGraph"], + analysis: Pick, + ) => Generator; } /** Accumulate semantic nodes while each source IR is still file-local. */ @@ -161,71 +167,90 @@ export const createJavaScriptSemanticGraphProjection = if (state.fileNodesDropped) truncatedFiles += 1; state.fileNodeBudget = null; }; + const finishWith = ( + factory: (input: unknown) => Value, + rootArtifactSha256: string, + applicationGraph: BuilderInput["applicationGraph"], + analysis: Pick, + ): Value => { + bindSemanticGraphApplicationNodes(state, applicationGraph); + if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state); + const unknowns = [...state.unknowns.values()]; + const graph = factory({ + schema: "JavaScriptSemanticRelationGraph", + root_artifact_sha256: rootArtifactSha256, + application_graph_id: applicationGraph.graph_id, + root_node_ids: [...state.roots], + nodes: [...state.nodes.values()], + relations: [...state.relations.values()], + fingerprints, + unknowns, + coverage: { + status: truncatedFiles > 0 ? "partial" : "unknown", + truncated: truncatedFiles > 0, + omitted_nodes: truncatedFiles > 0 ? null : 0, + omitted_relations: truncatedFiles > 0 ? null : 0, + limits: + truncatedFiles > 0 + ? [ + { + name: "semantic_graph_node_ceiling", + value: SEMANTIC_GRAPH_NODE_CEILING, + unit: "items" as const, + }, + ] + : [], + families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({ + family, + status: semanticFamilyStatus(family, analysis), + retained_relations: [...state.relations.values()].filter( + (relation) => + JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation] === + family, + ).length, + omitted_relations: truncatedFiles > 0 ? null : 0, + unknown_ids: unknowns + .filter((unknown) => unknown.family === family) + .map(({ unknown_id: identifier }) => identifier), + })), + }, + limitations: [ + "The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.", + "Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.", + "Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.", + "Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.", + "Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.", + "Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.", + "Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.", + "Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.", + "Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.", + "Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.", + "Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.", + ], + }); + state.nodes.clear(); + state.relations.clear(); + state.unknowns.clear(); + state.roots.clear(); + fingerprints.length = 0; + return graph; + }; return { projectFile: projectSource, - finish: (rootArtifactSha256, applicationGraph, analysis) => { - bindSemanticGraphApplicationNodes(state, applicationGraph); - if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state); - const unknowns = [...state.unknowns.values()]; - const graph = createJavaScriptSemanticGraph({ - schema: "JavaScriptSemanticRelationGraph", - root_artifact_sha256: rootArtifactSha256, - application_graph_id: applicationGraph.graph_id, - root_node_ids: [...state.roots], - nodes: [...state.nodes.values()], - relations: [...state.relations.values()], - fingerprints, - unknowns, - coverage: { - status: truncatedFiles > 0 ? "partial" : "unknown", - truncated: truncatedFiles > 0, - omitted_nodes: truncatedFiles > 0 ? null : 0, - omitted_relations: truncatedFiles > 0 ? null : 0, - limits: - truncatedFiles > 0 - ? [ - { - name: "semantic_graph_node_ceiling", - value: SEMANTIC_GRAPH_NODE_CEILING, - unit: "items" as const, - }, - ] - : [], - families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({ - family, - status: semanticFamilyStatus(family, analysis), - retained_relations: [...state.relations.values()].filter( - (relation) => - JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation] === - family, - ).length, - omitted_relations: truncatedFiles > 0 ? null : 0, - unknown_ids: unknowns - .filter((unknown) => unknown.family === family) - .map(({ unknown_id: identifier }) => identifier), - })), - }, - limitations: [ - "The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.", - "Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.", - "Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.", - "Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.", - "Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.", - "Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.", - "Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.", - "Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.", - "Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.", - "Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.", - "Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.", - ], - }); - state.nodes.clear(); - state.relations.clear(); - state.unknowns.clear(); - state.roots.clear(); - fingerprints.length = 0; - return graph; - }, + finish: (rootArtifactSha256, applicationGraph, analysis) => + finishWith( + createJavaScriptSemanticGraph, + rootArtifactSha256, + applicationGraph, + analysis, + ), + finishImmutableSteps: (rootArtifactSha256, applicationGraph, analysis) => + finishWith( + createImmutableJavaScriptSemanticGraphSteps, + rootArtifactSha256, + applicationGraph, + analysis, + ), }; }; diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index 10e17f745b..b7f867a84a 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -1,7 +1,15 @@ import { z } from "zod"; -import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js"; -import { javaScriptSemanticGraphSchema } from "./javascriptSemanticGraph.js"; +import { + javascriptApplicationGraphSchema, + isValidatedImmutableJavaScriptApplicationGraph, + type JavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import { + javaScriptSemanticGraphSchema, + isValidatedImmutableJavaScriptSemanticGraph, + type JavaScriptSemanticGraph, +} from "./javascriptSemanticGraph.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; @@ -51,43 +59,62 @@ const reconstructionStatisticsSchema = z.strictObject({ truncated_scopes: countSchema, }); -/** JavaScript application analysis with an authenticated semantic companion. */ -export const javascriptApplicationAnalysisResultSchema = z - .strictObject({ - input_path: z.string().min(1), - format: z.enum(["asar", "directory"]), - root_artifact_sha256: digestSchema, - inventory_manifest_id: prefixedDigestSchema("agm"), - inventory_graph_sha256: digestSchema, - graph: javascriptApplicationGraphSchema, - summary: electronBoundarySummarySchema, - statistics: reconstructionStatisticsSchema, - limitations: z.array(z.string().min(1)), - semantic_graph: javaScriptSemanticGraphSchema, - }) - .superRefine((result, context) => { - if (result.semantic_graph.application_graph_id !== result.graph.graph_id) - context.addIssue({ - code: "custom", - path: ["semantic_graph", "application_graph_id"], - message: "Semantic graph must commit the containing application graph", - }); - if ( - result.semantic_graph.root_artifact_sha256 !== result.root_artifact_sha256 - ) - context.addIssue({ - code: "custom", - path: ["semantic_graph", "root_artifact_sha256"], - message: "Semantic graph must commit the containing root artifact", - }); - const applicationNodeIds = new Set( - result.graph.nodes.map(({ node_id }) => node_id), - ); - for (const [nodeIndex, node] of result.semantic_graph.nodes.entries()) +const applicationAnalysisResultShape = z.strictObject({ + input_path: z.string().min(1), + format: z.enum(["asar", "directory"]), + root_artifact_sha256: digestSchema, + inventory_manifest_id: prefixedDigestSchema("agm"), + inventory_graph_sha256: digestSchema, + graph: javascriptApplicationGraphSchema, + summary: electronBoundarySummarySchema, + statistics: reconstructionStatisticsSchema, + limitations: z.array(z.string().min(1)), + semantic_graph: javaScriptSemanticGraphSchema, +}); + +type BindingIssue = { + readonly code: "custom"; + readonly path: PropertyKey[]; + readonly message: string; +}; + +function* checkApplicationBindingsSteps( + result: z.infer, + context: { readonly addIssue: (issue: BindingIssue) => void }, +): Generator { + if (result.semantic_graph.application_graph_id !== result.graph.graph_id) + context.addIssue({ + code: "custom", + path: ["semantic_graph", "application_graph_id"], + message: "Semantic graph must commit the containing application graph", + }); + if ( + result.semantic_graph.root_artifact_sha256 !== result.root_artifact_sha256 + ) + context.addIssue({ + code: "custom", + path: ["semantic_graph", "root_artifact_sha256"], + message: "Semantic graph must commit the containing root artifact", + }); + const applicationNodeIds = new Set(); + let examined = 0; + try { + for (const node of result.graph.nodes) { + applicationNodeIds.add(node.node_id); + if (++examined === 4096) { + examined = 0; + yield; + } + } + for (const [nodeIndex, node] of result.semantic_graph.nodes.entries()) { + if (++examined === 4096) { + examined = 0; + yield; + } for (const [ identifierIndex, identifier, - ] of node.application_node_ids.entries()) + ] of node.application_node_ids.entries()) { if (!applicationNodeIds.has(identifier)) context.addIssue({ code: "custom", @@ -100,8 +127,52 @@ export const javascriptApplicationAnalysisResultSchema = z ], message: "Semantic node references an absent application node", }); + if (++examined === 4096) { + examined = 0; + yield; + } + } + } + } finally { + applicationNodeIds.clear(); + } +} + +/** JavaScript application analysis with an authenticated semantic companion. */ +export const javascriptApplicationAnalysisResultSchema = + applicationAnalysisResultShape.superRefine((result, context) => { + const steps = checkApplicationBindingsSteps(result, context); + while (!steps.next().done) { + // Public/imported results still receive complete synchronous validation. + } }); +const ownedAnalysisResultShape = applicationAnalysisResultShape.extend({ + graph: z.custom( + isValidatedImmutableJavaScriptApplicationGraph, + ), + semantic_graph: z.custom( + isValidatedImmutableJavaScriptSemanticGraph, + ), +}); + +/** Reuse exact owned graphs while validating metadata and cross-graph bindings. */ +export function* parseOwnedJavaScriptApplicationAnalysisSteps( + input: unknown, +): Generator { + const prepared = ownedAnalysisResultShape.safeParse(input); + if (!prepared.success) + return javascriptApplicationAnalysisResultSchema.parse(input); + const issues: BindingIssue[] = []; + yield* checkApplicationBindingsSteps(prepared.data, { + addIssue: (issue) => { + issues.push(issue); + }, + }); + if (issues.length > 0) throw new z.ZodError(issues); + return prepared.data; +} + /** Parsed public JavaScript application analysis request. */ export type AnalyzeJavaScriptApplicationInput = z.infer< typeof analyzeJavaScriptApplicationInputSchema diff --git a/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts b/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts new file mode 100644 index 0000000000..c96ccece77 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { freezeJsonSnapshot } from "../immutableJson.js"; +import { + javascriptApplicationAnalysisResultSchema, + parseOwnedJavaScriptApplicationAnalysisSteps, +} from "./javascriptApplicationAnalysis.js"; +import { + createJavaScriptApplicationGraph, + createImmutableJavaScriptApplicationGraphSteps, + isValidatedImmutableJavaScriptApplicationGraph, +} from "./javascriptApplicationGraph.js"; +import { + createJavaScriptSemanticGraph, + createImmutableJavaScriptSemanticGraphSteps, + isValidatedImmutableJavaScriptSemanticGraph, +} from "./javascriptSemanticGraph.js"; + +const complete = (steps: Iterator): Value => { + let next = steps.next(); + while (!next.done) next = steps.next(); + return next.value; +}; + +const example = () => + javascriptApplicationAnalysisResultSchema.parse( + JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result, + ); + +const ownedExample = () => { + const result = example(); + const { graph_id: _applicationId, ...application } = result.graph; + const { graph_id: _semanticId, ...semantic } = result.semantic_graph; + return { + ...result, + graph: complete( + createImmutableJavaScriptApplicationGraphSteps(application), + ), + semantic_graph: complete( + createImmutableJavaScriptSemanticGraphSteps(semantic), + ), + }; +}; + +const issuesFrom = (action: () => unknown) => { + try { + action(); + } catch (error: unknown) { + if (error instanceof z.ZodError) return error.issues; + throw error; + } + throw new Error("Expected complete result validation to reject the input"); +}; + +describe("owned JavaScript graph validation", () => { + it("reuses exact sealed graphs and preserves all public result fields", () => { + const input = ownedExample(); + const result = complete( + parseOwnedJavaScriptApplicationAnalysisSteps(input), + ); + expect(result).toEqual( + javascriptApplicationAnalysisResultSchema.parse(input), + ); + expect(result.graph).toBe(input.graph); + expect(result.semantic_graph).toBe(input.semantic_graph); + expect(result.summary).not.toBe(input.summary); + expect(result.statistics).not.toBe(input.statistics); + expect(Object.isFrozen(result.graph.nodes[0]?.observations)).toBe(true); + expect(Object.isFrozen(result.semantic_graph.nodes[0]?.properties)).toBe( + true, + ); + expect(isValidatedImmutableJavaScriptApplicationGraph(result.graph)).toBe( + true, + ); + expect( + isValidatedImmutableJavaScriptSemanticGraph(result.semantic_graph), + ).toBe(true); + expect( + isValidatedImmutableJavaScriptApplicationGraph(result.graph.nodes), + ).toBe(false); + }); + + it("keeps ordinary constructors mutable and clones inputs before sealing", () => { + const input = example(); + const { graph_id: _applicationId, ...application } = input.graph; + const { graph_id: _semanticId, ...semantic } = input.semantic_graph; + const ordinaryApplication = createJavaScriptApplicationGraph(application); + const ordinarySemantic = createJavaScriptSemanticGraph(semantic); + expect(Object.isFrozen(ordinaryApplication)).toBe(false); + expect(Object.isFrozen(ordinarySemantic)).toBe(false); + const applicationSteps = + createImmutableJavaScriptApplicationGraphSteps(application); + const semanticSteps = createImmutableJavaScriptSemanticGraphSteps(semantic); + application.limitations.push("Changed after factory input was cloned."); + semantic.limitations.push("Changed after factory input was cloned."); + expect(complete(applicationSteps)).toEqual(ordinaryApplication); + expect(complete(semanticSteps)).toEqual(ordinarySemantic); + expect( + isValidatedImmutableJavaScriptApplicationGraph(ordinaryApplication), + ).toBe(false); + expect(isValidatedImmutableJavaScriptSemanticGraph(ordinarySemantic)).toBe( + false, + ); + }); + + it.each(["mutable", "shallow-frozen", "json-sealed"])( + "fully validates %s imported graphs instead of trusting their IDs", + (representation) => { + const input = example(); + input.graph.graph_id = `jag_${"f".repeat(64)}`; + if (representation === "shallow-frozen") Object.freeze(input.graph); + if (representation === "json-sealed") freezeJsonSnapshot(input.graph); + expect(isValidatedImmutableJavaScriptApplicationGraph(input.graph)).toBe( + false, + ); + const issues = issuesFrom(() => + complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)), + ); + expect(issues).toEqual( + issuesFrom(() => + javascriptApplicationAnalysisResultSchema.parse(input), + ), + ); + expect( + issues.some(({ path }) => path.join("/") === "graph/graph_id"), + ).toBe(true); + }, + ); + + it("fully parses valid imported graphs without conferring an owned proof", () => { + const input = example(); + const parsed = complete( + parseOwnedJavaScriptApplicationAnalysisSteps(input), + ); + expect(parsed).toEqual(input); + expect(parsed.graph).not.toBe(input.graph); + expect(parsed.semantic_graph).not.toBe(input.semantic_graph); + expect( + isValidatedImmutableJavaScriptSemanticGraph(parsed.semantic_graph), + ).toBe(false); + }); + + it("rejects a forged semantic graph paired with an authenticated application graph", () => { + const input = ownedExample(); + input.semantic_graph = freezeJsonSnapshot({ + ...input.semantic_graph, + graph_id: `jsrg_${"f".repeat(64)}`, + }); + expect( + isValidatedImmutableJavaScriptSemanticGraph(input.semantic_graph), + ).toBe(false); + expect( + issuesFrom(() => + complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)), + ), + ).toEqual( + issuesFrom(() => javascriptApplicationAnalysisResultSchema.parse(input)), + ); + }); + + it("does not authenticate an abandoned sealing operation", () => { + const input = example(); + const { graph_id: _semanticId, ...semantic } = input.semantic_graph; + const originalNode = semantic.nodes[0]; + if (originalNode === undefined) throw new Error("Expected a semantic node"); + semantic.nodes[0] = { + ...originalNode, + properties: { + rows: Array.from({ length: 10_000 }, (_, value) => ({ value })), + }, + }; + const mutable = createJavaScriptSemanticGraph(semantic); + const { graph_id: _mutableId, ...mutableInput } = mutable; + const steps = createImmutableJavaScriptSemanticGraphSteps(mutableInput); + expect(steps.next().done).toBe(false); + steps.return(mutable); + expect(isValidatedImmutableJavaScriptSemanticGraph(mutable)).toBe(false); + const sealed = complete( + createImmutableJavaScriptSemanticGraphSteps(mutableInput), + ); + expect(sealed).toEqual(mutable); + expect(isValidatedImmutableJavaScriptSemanticGraph(sealed)).toBe(true); + }); +}); + +describe("owned JavaScript result bindings", () => { + it.each(["application", "root", "node"])( + "retains the %s binding check for individually authenticated graphs", + (binding) => { + const input = ownedExample(); + if (binding === "root") input.root_artifact_sha256 = "e".repeat(64); + else { + const { graph_id: _semanticId, ...semantic } = input.semantic_graph; + if (binding === "application") + semantic.application_graph_id = `jag_${"e".repeat(64)}`; + else { + const node = semantic.nodes[0]; + if (node === undefined) throw new Error("Expected a semantic node"); + semantic.nodes = [ + { ...node, application_node_ids: [`jag_node_${"e".repeat(64)}`] }, + ...semantic.nodes.slice(1), + ]; + } + input.semantic_graph = complete( + createImmutableJavaScriptSemanticGraphSteps(semantic), + ); + } + expect( + issuesFrom(() => + complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)), + ), + ).toEqual( + issuesFrom(() => + javascriptApplicationAnalysisResultSchema.parse(input), + ), + ); + }, + ); + + it("yields while checking a wide binding set and closes an abandoned iteration", () => { + const input = ownedExample(); + const { graph_id: _semanticId, ...semantic } = input.semantic_graph; + const node = semantic.nodes[0]; + if (node === undefined) throw new Error("Expected a semantic node"); + semantic.nodes = [ + { + ...node, + application_node_ids: Array.from( + { length: 10_000 }, + (_, index) => `jag_node_${index.toString(16).padStart(64, "0")}`, + ), + }, + ...semantic.nodes.slice(1), + ]; + input.semantic_graph = complete( + createImmutableJavaScriptSemanticGraphSteps(semantic), + ); + const steps = parseOwnedJavaScriptApplicationAnalysisSteps(input); + expect(steps.next().done).toBe(false); + steps.return(input); + expect(steps.next().done).toBe(true); + expect( + issuesFrom(() => + complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)), + ), + ).toEqual( + issuesFrom(() => javascriptApplicationAnalysisResultSchema.parse(input)), + ); + }); + + it("retains strict metadata validation alongside authenticated graphs", () => { + const input = { + ...ownedExample(), + statistics: { ...example().statistics, findings: -1 }, + extra: true, + }; + expect( + issuesFrom(() => + complete(parseOwnedJavaScriptApplicationAnalysisSteps(input)), + ), + ).toEqual( + issuesFrom(() => javascriptApplicationAnalysisResultSchema.parse(input)), + ); + }); +}); diff --git a/src/domain/javascript/javascriptApplicationGraph.ts b/src/domain/javascript/javascriptApplicationGraph.ts index cfb8181b5d..cff3f09bbe 100644 --- a/src/domain/javascript/javascriptApplicationGraph.ts +++ b/src/domain/javascript/javascriptApplicationGraph.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { canonicalDigest, canonicalJson } from "../comparisonSemantics.js"; import { compareCodePoints, uniqueSorted } from "../canonicalOrdering.js"; +import { freezeOwnedJsonSnapshotSteps } from "../immutableJson.js"; import type { ApplicationGraphEvidence, @@ -450,6 +451,30 @@ export const createJavaScriptApplicationGraph = ( }); }; +const validatedImmutableApplicationGraphs = new WeakSet(); + +/** Clone and validate synchronously, then return steps to seal the owned graph. */ +export const createImmutableJavaScriptApplicationGraphSteps = ( + input: unknown, +): Generator => + sealValidatedApplicationGraphSteps(createJavaScriptApplicationGraph(input)); + +function* sealValidatedApplicationGraphSteps( + graph: JavaScriptApplicationGraph, +): Generator { + yield* freezeOwnedJsonSnapshotSteps(graph); + validatedImmutableApplicationGraphs.add(graph); + return graph; +} + +/** Recognize the exact completely sealed graph produced by the owned factory. */ +export const isValidatedImmutableJavaScriptApplicationGraph = ( + value: unknown, +): value is JavaScriptApplicationGraph => + typeof value === "object" && + value !== null && + validatedImmutableApplicationGraphs.has(value); + /** Parse a stored graph and reject stale IDs. */ export const parseJavaScriptApplicationGraph = ( input: unknown, diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index 86884cba7c..9c288726cc 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { canonicalDigest, canonicalJson } from "../comparisonSemantics.js"; import { compareCodePoints, uniqueSorted } from "../canonicalOrdering.js"; +import { freezeOwnedJsonSnapshotSteps } from "../immutableJson.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, @@ -561,4 +562,28 @@ export const createJavaScriptSemanticGraph = ( return record; }; +const validatedImmutableSemanticGraphs = new WeakSet(); + +/** Clone and validate synchronously, then return steps to seal the owned graph. */ +export const createImmutableJavaScriptSemanticGraphSteps = ( + input: unknown, +): Generator => + sealValidatedSemanticGraphSteps(createJavaScriptSemanticGraph(input)); + +function* sealValidatedSemanticGraphSteps( + graph: JavaScriptSemanticGraph, +): Generator { + yield* freezeOwnedJsonSnapshotSteps(graph); + validatedImmutableSemanticGraphs.add(graph); + return graph; +} + +/** Recognize the exact completely sealed graph produced by the owned factory. */ +export const isValidatedImmutableJavaScriptSemanticGraph = ( + value: unknown, +): value is JavaScriptSemanticGraph => + typeof value === "object" && + value !== null && + validatedImmutableSemanticGraphs.has(value); + export type { JavaScriptSemanticGraphNode, JavaScriptSemanticGraphRelation };