From 9405bd660def9946afaa5fcc1d99921a816911bf Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:15:17 +0800 Subject: [PATCH] fix(javascript): allow cancellation during semantic graph commitment (#1218) Move whole-graph commitment and batched integrity checks into owned domain steps while retaining eager input capture, canonical IDs, full validation and completion-only immutable proofs. Verified complete real Notion identity parity and actual stdio MCP cancellation, Evidence retention and connection reuse. Refs #1065. --- docs/testing.md | 9 + .../JavaScriptApplicationCancellation.test.ts | 35 ++++ .../JavaScriptArtifactReconstruction.ts | 2 +- .../javascript/javascriptSemanticGraph.ts | 180 +++++++++++------- .../javascriptSemanticGraphQuery.test.ts | 131 +++++++++++++ 5 files changed, 287 insertions(+), 70 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 4b77bd650..1422874cb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,6 +46,15 @@ claim, even if a directory name calls it acceptance. Emit a verifier's final success report only after awaited cleanup. Observe final process lineage after closing its owned resources. +Owned JavaScript semantic graphs capture and schema-parse caller input before +returning an iterator. Whole-graph hashing, batched integrity checks and sealing +run through the application cancellation driver; only full completion grants +the private immutable-graph proof. Regression coverage checks canonical IDs, +validation diagnostics, nested caller mutations across yields, cancellation +before result validation and subsequent SDK requests. Single-file parsing, +schema cloning and canonical sorting remain synchronous phases; measure them +separately when assessing cancellation latency on real applications. + The Vitest runner releases cached native process inspectors during awaited worker teardown. Fork termination does not run Node's normal exit hooks; a per-file teardown would instead retire shared inspectors before later files diff --git a/src/application/javascript/JavaScriptApplicationCancellation.test.ts b/src/application/javascript/JavaScriptApplicationCancellation.test.ts index 570722048..1457046ef 100644 --- a/src/application/javascript/JavaScriptApplicationCancellation.test.ts +++ b/src/application/javascript/JavaScriptApplicationCancellation.test.ts @@ -7,6 +7,41 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; describe("JavaScript analysis cancellation before publication", () => { + it("interrupts semantic graph commitment before result validation", async () => { + const root = await createTestTempDirectory("rea-js-cancel-semantic-"); + await writeFile( + join(root, "main.js"), + Array.from( + { length: 500 }, + (_, index) => + `export function inspect${index}(value) { return JSON.stringify(value); }`, + ).join("\n"), + ); + const controller = new AbortController(); + const phases: string[] = []; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + phases.push(event.phase); + if (event.phase === "seal_javascript_semantic_graph") + setImmediate(() => controller.abort()); + }, + }, + }, + ); + if (result.ok) + throw new Error("Cancelled commitment must not publish Evidence"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { reason: "cancelled" }, + }); + expect(phases).toContain("seal_javascript_semantic_graph"); + expect(phases).not.toContain("validate_javascript_application_result"); + }); + it.each([ "parse_javascript_source", "build_javascript_application_graph", diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 515229108..3c34207b8 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -122,7 +122,7 @@ export const reconstructJavaScriptArtifact = async ( ); await reportPhase( "seal_javascript_semantic_graph", - "Sealing the validated semantic graph", + "Validating and sealing the semantic graph", ); const semanticGraph = await completeJavaScriptAnalysisSteps( semanticGraphSteps, diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index 9c288726c..fa26feb84 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 { canonicalJsonDigestSteps } from "../canonicalJsonDigestSteps.js"; import { freezeOwnedJsonSnapshotSteps } from "../immutableJson.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { @@ -159,10 +160,10 @@ const sortedUniqueIssue = ( } }; -const checkCoverage = ( +function* checkCoverageSteps( graph: GraphRecord, context: GraphIssueReporter, -): void => { +): Generator { const families = graph.coverage.families.map(({ family }) => family); if ( canonicalJson(families, "JavaScript semantic graph") !== @@ -198,13 +199,16 @@ const checkCoverage = ( const relationsByFamily = new Map( JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => [family, 0]), ); - for (const relation of graph.relations) { + for (const [index, relation] of graph.relations.entries()) { + if (index % 256 === 0) yield; const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation]; relationsByFamily.set(family, (relationsByFamily.get(family) ?? 0) + 1); } - const unknownsById = new Map( - graph.unknowns.map((unknown) => [unknown.unknown_id, unknown]), - ); + const unknownsById = new Map(); + for (const [index, unknown] of graph.unknowns.entries()) { + if (index % 256 === 0) yield; + unknownsById.set(unknown.unknown_id, unknown); + } for (const [index, family] of graph.coverage.families.entries()) { if (family.retained_relations !== relationsByFamily.get(family.family)) context.addIssue({ @@ -232,7 +236,7 @@ const checkCoverage = ( message: "Complete family coverage cannot omit or retain unknown facts", }); } -}; +} const checkCanonicalOrder = ( graph: GraphRecord, @@ -271,12 +275,13 @@ const checkCanonicalOrder = ( sortedUniqueIssue(graph.limitations, ["limitations"], "Limitations", context); }; -const checkNodes = ( +function* checkNodesSteps( graph: GraphRecord, nodes: ReadonlyMap, context: GraphIssueReporter, -): void => { +): Generator { for (const [index, node] of graph.nodes.entries()) { + if (index % 256 === 0) yield; if (node.node_id !== javaScriptSemanticNodeId(node)) context.addIssue({ code: "custom", @@ -293,28 +298,31 @@ const checkNodes = ( message: "Function owner must name a function node", }); } -}; +} -const checkRoots = ( +function* checkRootsSteps( graph: GraphRecord, nodes: ReadonlyMap, context: GraphIssueReporter, -): void => { - for (const root of graph.root_node_ids) +): Generator { + for (const [index, root] of graph.root_node_ids.entries()) { + if (index % 256 === 0) yield; if (!nodes.has(root)) context.addIssue({ code: "custom", path: ["root_node_ids"], message: "Root identifier must name a semantic node", }); -}; + } +} -const checkRelations = ( +function* checkRelationsSteps( graph: GraphRecord, nodes: ReadonlyMap, context: GraphIssueReporter, -): void => { +): Generator { for (const [index, relation] of graph.relations.entries()) { + if (index % 256 === 0) yield; const { relation_id: identifier, ...semantic } = relation; if ( identifier !== @@ -350,14 +358,15 @@ const checkRelations = ( message: "Candidate relations cannot claim observed resolution", }); } -}; +} -const checkUnknowns = ( +function* checkUnknownsSteps( graph: GraphRecord, nodes: ReadonlyMap, context: GraphIssueReporter, -): void => { +): Generator { for (const [index, unknown] of graph.unknowns.entries()) { + if (index % 256 === 0) yield; const { unknown_id: identifier, ...semantic } = unknown; if ( identifier !== @@ -397,7 +406,7 @@ const checkUnknowns = ( message: "Unknown frontiers require unknown or unavailable evidence", }); } -}; +} const checkUnknownReferences = ( graph: GraphRecord, @@ -415,12 +424,13 @@ const checkUnknownReferences = ( }); }; -const checkFingerprints = ( +function* checkFingerprintsSteps( graph: GraphRecord, nodes: ReadonlyMap, context: GraphIssueReporter, -): void => { +): Generator { for (const [index, fingerprint] of graph.fingerprints.entries()) { + if (index % 256 === 0) yield; if (nodes.get(fingerprint.function_node_id)?.kind !== "function") context.addIssue({ code: "custom", @@ -462,27 +472,41 @@ const checkFingerprints = ( message: "Incomplete fingerprints require a limitation", }); } -}; +} -const checkGraphContent = ( +function* checkGraphContentSteps( graph: GraphRecord, context: GraphIssueReporter, -): void => { +): Generator { checkCanonicalOrder(graph, context); - const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); - checkNodes(graph, nodes, context); - checkRoots(graph, nodes, context); - checkRelations(graph, nodes, context); - checkUnknowns(graph, nodes, context); + const nodes = new Map(); + for (const [index, node] of graph.nodes.entries()) { + if (index % 256 === 0) yield; + nodes.set(node.node_id, node); + } + yield* checkNodesSteps(graph, nodes, context); + yield* checkRootsSteps(graph, nodes, context); + yield* checkRelationsSteps(graph, nodes, context); + yield* checkUnknownsSteps(graph, nodes, context); checkUnknownReferences(graph, context); - checkFingerprints(graph, nodes, context); - checkCoverage(graph, context); + yield* checkFingerprintsSteps(graph, nodes, context); + yield* checkCoverageSteps(graph, context); if (graph.coverage.status !== "complete" && graph.limitations.length === 0) context.addIssue({ code: "custom", path: ["limitations"], message: "Non-complete graph coverage requires a limitation", }); +} + +const checkGraphContent = ( + graph: GraphRecord, + context: GraphIssueReporter, +): void => { + const steps = checkGraphContentSteps(graph, context); + while (!steps.next().done) { + // Synchronous callers apply the same integrity checks without scheduling. + } }; const checkGraph = (graph: GraphRecord, context: GraphIssueReporter): void => { @@ -508,43 +532,48 @@ export type JavaScriptSemanticGraph = z.infer< typeof javaScriptSemanticGraphSchema >; +const normalizeGraphInput = ( + parsed: JavaScriptSemanticGraphInput, +): JavaScriptSemanticGraphInput => ({ + ...parsed, + root_node_ids: uniqueSorted(parsed.root_node_ids), + nodes: [...parsed.nodes].sort((left, right) => + compareCodePoints(left.node_id, right.node_id), + ), + relations: [...parsed.relations].sort((left, right) => + compareCodePoints(left.relation_id, right.relation_id), + ), + fingerprints: [...parsed.fingerprints].sort((left, right) => + compareCodePoints(left.fingerprint_id, right.fingerprint_id), + ), + unknowns: [...parsed.unknowns].sort((left, right) => + compareCodePoints(left.unknown_id, right.unknown_id), + ), + coverage: { + ...parsed.coverage, + limits: [...parsed.coverage.limits].sort((left, right) => + compareCodePoints( + canonicalJson(left, "JavaScript semantic graph"), + canonicalJson(right, "JavaScript semantic graph"), + ), + ), + families: [...parsed.coverage.families] + .map((family) => ({ + ...family, + unknown_ids: uniqueSorted(family.unknown_ids), + })) + .sort((left, right) => compareCodePoints(left.family, right.family)), + }, + limitations: uniqueSorted(parsed.limitations), +}); + /** Normalize a complete companion graph and derive its graph ID. */ export const createJavaScriptSemanticGraph = ( input: unknown, ): JavaScriptSemanticGraph => { - const parsed = javaScriptSemanticGraphInputSchema.parse(input); - const semantic: JavaScriptSemanticGraphInput = { - ...parsed, - root_node_ids: uniqueSorted(parsed.root_node_ids), - nodes: [...parsed.nodes].sort((left, right) => - compareCodePoints(left.node_id, right.node_id), - ), - relations: [...parsed.relations].sort((left, right) => - compareCodePoints(left.relation_id, right.relation_id), - ), - fingerprints: [...parsed.fingerprints].sort((left, right) => - compareCodePoints(left.fingerprint_id, right.fingerprint_id), - ), - unknowns: [...parsed.unknowns].sort((left, right) => - compareCodePoints(left.unknown_id, right.unknown_id), - ), - coverage: { - ...parsed.coverage, - limits: [...parsed.coverage.limits].sort((left, right) => - compareCodePoints( - canonicalJson(left, "JavaScript semantic graph"), - canonicalJson(right, "JavaScript semantic graph"), - ), - ), - families: [...parsed.coverage.families] - .map((family) => ({ - ...family, - unknown_ids: uniqueSorted(family.unknown_ids), - })) - .sort((left, right) => compareCodePoints(left.family, right.family)), - }, - limitations: uniqueSorted(parsed.limitations), - }; + const semantic = normalizeGraphInput( + javaScriptSemanticGraphInputSchema.parse(input), + ); // The input schema already cloned and validated every field. Validate the // normalized relationships in place, then derive the only added field. The // public schema still verifies arbitrary records and their commitments. @@ -564,15 +593,28 @@ export const createJavaScriptSemanticGraph = ( const validatedImmutableSemanticGraphs = new WeakSet(); -/** Clone and validate synchronously, then return steps to seal the owned graph. */ +/** Clone input synchronously; commit, check and seal the owned graph in steps. */ export const createImmutableJavaScriptSemanticGraphSteps = ( input: unknown, ): Generator => - sealValidatedSemanticGraphSteps(createJavaScriptSemanticGraph(input)); + validateAndSealSemanticGraphSteps( + normalizeGraphInput(javaScriptSemanticGraphInputSchema.parse(input)), + ); -function* sealValidatedSemanticGraphSteps( - graph: JavaScriptSemanticGraph, +function* validateAndSealSemanticGraphSteps( + semantic: JavaScriptSemanticGraphInput, ): Generator { + const graph: GraphRecord = { + ...semantic, + graph_id: `jsrg_${yield* canonicalJsonDigestSteps(semantic)}`, + }; + const issues: SemanticGraphIssue[] = []; + yield* checkGraphContentSteps(graph, { + addIssue: (issue) => { + issues.push(issue); + }, + }); + if (issues.length > 0) throw new z.ZodError(issues); yield* freezeOwnedJsonSnapshotSteps(graph); validatedImmutableSemanticGraphs.add(graph); return graph; diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index 1e9dec8e5..e6c426f45 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -1,10 +1,12 @@ import { expect, it } from "vitest"; +import { z } from "zod"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { JAVASCRIPT_SEMANTIC_NODE_KINDS, JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATIONS, + type JavaScriptSemanticGraphInput, } from "./javascriptSemanticGraphSchemas.js"; import { createJavaScriptSemanticFingerprint, @@ -12,6 +14,8 @@ import { createJavaScriptSemanticGraphNode, createJavaScriptSemanticGraphRelation, createJavaScriptSemanticGraphUnknown, + createImmutableJavaScriptSemanticGraphSteps, + isValidatedImmutableJavaScriptSemanticGraph, type JavaScriptSemanticGraph, type JavaScriptSemanticGraphNode, } from "./javascriptSemanticGraph.js"; @@ -285,3 +289,130 @@ it("rejects stale identities, dangling endpoints, and incomplete coverage claims }), ).toThrow(/candidate node is absent/u); }); + +const complete = (steps: Iterator): Value => { + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +}; + +it("captures nested input before yielding and preserves the synchronous graph commitment", () => { + const { graph_id: _graphId, ...input } = fixtureGraph(true); + const node = input.nodes[0]; + if (node === undefined) throw new Error("Expected fixture node"); + const nested = { text: 'Unicode: \u{1f600} \ud800 " \\ ', rows: [1, 2, 3] }; + node.properties = { nested, padding: "x".repeat(64_000) }; + input.nodes.reverse(); + input.relations.reverse(); + input.coverage.families.reverse(); + const expected = createJavaScriptSemanticGraph(input); + const steps = createImmutableJavaScriptSemanticGraphSteps(input); + expect(steps.next().done).toBe(false); + nested.text = "Changed while the owned graph is being hashed."; + nested.rows.push(4); + input.relations.length = 0; + input.coverage.families.length = 0; + const graph = complete(steps); + expect(graph).toEqual(expected); + expect( + parseJavaScriptSemanticGraph( + JSON.parse(serializeJavaScriptSemanticGraph(graph)), + ), + ).toEqual(expected); + expect(isValidatedImmutableJavaScriptSemanticGraph(graph)).toBe(true); + expect(isValidatedImmutableJavaScriptSemanticGraph(expected)).toBe(false); + expect(Object.isFrozen(nested)).toBe(false); +}); + +const invalidGraphCases: { + readonly label: string; + readonly change: (input: JavaScriptSemanticGraphInput) => void; + readonly message: string; +}[] = [ + { + label: "node identity and ownership", + change: (input) => { + input.nodes = input.nodes.map((node, index) => + index === 0 + ? { + ...node, + node_id: `jsrg_node_${"f".repeat(64)}`, + function_node_id: `jsrg_node_${"e".repeat(64)}`, + } + : node, + ); + }, + message: "Node identifier is stale", + }, + { + label: "relation identity, endpoints and authority", + change: (input) => { + input.relations = input.relations.map((relation) => ({ + ...relation, + source_node_id: `jsrg_node_${"f".repeat(64)}`, + target_node_id: `jsrg_node_${"f".repeat(64)}`, + resolution: "candidate", + evidence: evidence(), + })); + }, + message: "Relation identifier is stale", + }, + { + label: "unknown identity and candidates", + change: (input) => { + input.unknowns = input.unknowns.map((unknown) => ({ + ...unknown, + candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], + evidence: evidence(), + })); + }, + message: "Unknown frontier candidate node is absent", + }, + { + label: "fingerprint identity and components", + change: (input) => { + input.fingerprints = input.fingerprints.map((fingerprint) => ({ + ...fingerprint, + function_node_id: `jsrg_node_${"f".repeat(64)}`, + fingerprint_sha256: "f".repeat(64), + components: { ...fingerprint.components, parameter_arity: 42 }, + })); + }, + message: "Fingerprint component commitment is stale", + }, + { + label: "coverage completeness and references", + change: (input) => { + input.coverage.status = "complete"; + input.coverage.families = input.coverage.families.map((family) => ({ + ...family, + retained_relations: family.retained_relations + 1, + unknown_ids: [`jsrg_unknown_${"f".repeat(64)}`], + })); + }, + message: "Family retained relation count does not match graph content", + }, +]; + +it.each(invalidGraphCases)( + "retains full validation diagnostics for $label in owned steps", + ({ change, message }) => { + const { graph_id: _graphId, ...input } = fixtureGraph(true); + change(input); + const issues = (action: () => unknown) => { + try { + action(); + } catch (error: unknown) { + if (error instanceof z.ZodError) return error.issues; + throw error; + } + throw new Error("Expected invalid graph to fail validation"); + }; + const expected = issues(() => createJavaScriptSemanticGraph(input)); + expect(expected.map(({ message }) => message)).toContain(message); + const steps = createImmutableJavaScriptSemanticGraphSteps(input); + expect(steps.next().done).toBe(false); + expect(issues(() => complete(steps))).toEqual(expected); + }, +);