From e3bbb9ac453da8d15f3b32511180451613710616 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:10:50 +0800 Subject: [PATCH] refactor(javascript)!: share semantic evidence at fact construction Intern shared provenance in a graph-scoped context registry while retaining exact locations on every fact. Semantic traces carry their referenced context subset, and portable graph validation rejects stale or dangling commitments. BREAKING CHANGE: Semantic graph facts now store evidence context IDs and locations. Graph and semantic trace results require an inline evidence_contexts table. --- docs/javascript-application-workflows.md | 9 + .../ReconstructionObligationCandidates.ts | 7 +- .../JavaScriptSemanticGraphAsyncProjection.ts | 48 ++- .../JavaScriptSemanticGraphBuilder.ts | 18 +- ...riptSemanticGraphChildProcessProjection.ts | 21 +- .../JavaScriptSemanticGraphConstruction.ts | 84 ++-- .../JavaScriptSemanticGraphDataProjection.ts | 21 +- .../JavaScriptSemanticGraphEvidence.ts | 5 +- ...criptSemanticGraphFingerprintProjection.ts | 47 ++- .../JavaScriptSemanticGraphFlowProjection.ts | 56 +-- ...JavaScriptSemanticGraphObjectProjection.ts | 21 +- ...vaScriptSemanticGraphResourceProjection.ts | 25 +- .../JavaScriptSemanticGraphResources.test.ts | 15 +- .../JavaScriptSemanticGraphValueProjection.ts | 68 +-- .../javascriptExportShapeComparisonExample.ts | 42 +- .../javascriptRuntimeReconciliationExample.ts | 32 +- src/domain/hopperErrors.ts | 1 - .../javascriptApplicationEvidenceSchemas.ts | 13 + .../javascript/javascriptSemanticGraph.ts | 187 ++++++++- .../javascriptSemanticGraphQuery.test.ts | 397 +++++++++++++----- .../javascriptSemanticGraphSchemas.ts | 38 +- .../javascriptSemanticGraphTraversal.test.ts | 393 +++++++++++------ .../javascript/javascriptSemanticQuery.ts | 8 + .../javascriptSemanticQuerySchemas.ts | 2 + .../mcp/applicationExportShapeMcp.test.ts | 15 +- 25 files changed, 1109 insertions(+), 464 deletions(-) diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 63be6935ff..e7ae76acbb 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -23,6 +23,15 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic relation graph and reports when it is unavailable rather than treating missing data as an empty graph. +The semantic graph wire format stores shared non-location provenance once in +its required `evidence_contexts` table. Each node, relation, fingerprint, and +unknown retains its exact `evidence.location` and names the owning context with +`evidence.context_id`. This is a breaking representation change: consumers +reading fields such as `evidence.authority` must look up the context by ID and +combine its fields with the fact's location. Semantic trace results include the +canonical context subset referenced by their returned facts, so those facts +remain self-contained in the response. + ## Feature tracing Select one literal seed kind: node ID, route, string, API, IPC channel, module, diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 005f9b7679..b01f88d7e7 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -12,6 +12,7 @@ import type { ReconstructionObligation, ReviewedReconstructionObligation, } from "../domain/reconstructionObligationLedgerSchemas.js"; +import { resolveJavaScriptSemanticEvidence } from "../domain/javascript/javascriptSemanticGraph.js"; import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js"; import { applicationObligationPolicy, @@ -111,6 +112,10 @@ const deriveApplicationCandidates = ( for (const node of source.semanticGraph.nodes) { const candidatePolicy = semanticObligationPolicy(node.kind); if (candidatePolicy === undefined) continue; + const evidenceContext = resolveJavaScriptSemanticEvidence( + source.semanticGraph, + node.evidence, + ); addCandidate( candidates, generatedCandidate({ @@ -125,7 +130,7 @@ const deriveApplicationCandidates = ( graphReference( evidence, records, - node.evidence, + evidenceContext, `${source.semanticGraph.graph_id}/node/${node.node_id}`, ), ], diff --git a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts index bd638fc72b..a8814281f3 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts @@ -120,18 +120,21 @@ const addEventUnknown = ( ): void => { addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: event.node_id, - family: "event", - relation_kinds: [relation], - reason: "ambiguous-target", - detail: - operation.eventName === null - ? `Dynamic ${operation.method} event name remains unresolved.` - : `Static ${operation.method} emitter identity is partial.`, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, operation.location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: event.node_id, + family: "event", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: + operation.eventName === null + ? `Dynamic ${operation.method} event name remains unresolved.` + : `Static ${operation.method} emitter identity is partial.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }, + context.state.evidenceContexts, + ), ); }; @@ -210,14 +213,17 @@ const projectTimerCancellation = ( if (operation.resolution === "complete") return; addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: callSite?.node_id ?? null, - family: "timer", - relation_kinds: ["cancels-timer"], - reason: "ambiguous-target", - detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, operation.location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: callSite?.node_id ?? null, + family: "timer", + relation_kinds: ["cancels-timer"], + reason: "ambiguous-target", + detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }, + context.state.evidenceContexts, + ), ); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 7736c10109..a951a7a55e 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -3,7 +3,10 @@ import { createImmutableJavaScriptSemanticGraphSteps, type JavaScriptSemanticGraph, } from "../../domain/javascript/javascriptSemanticGraph.js"; -import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import type { + JavaScriptSemanticFingerprint, + JavaScriptSemanticGraphNode, +} from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATION_FAMILY, @@ -141,7 +144,7 @@ export interface JavaScriptSemanticGraphProjection { export const createJavaScriptSemanticGraphProjection = (): JavaScriptSemanticGraphProjection => { const state = emptyState({ nodes: [] }); - const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = []; + const fingerprints: JavaScriptSemanticFingerprint[] = []; let truncatedFiles = 0; const projectSource = ( file: JavaScriptArtifactFile, @@ -178,6 +181,7 @@ export const createJavaScriptSemanticGraphProjection = root_artifact_sha256: rootArtifactSha256, application_graph_id: applicationGraph.graph_id, root_node_ids: [...state.roots], + evidence_contexts: state.evidenceContexts.contexts, nodes: [...state.nodes.values()], relations: [...state.relations.values()], fingerprints, @@ -229,6 +233,7 @@ export const createJavaScriptSemanticGraphProjection = state.relations.clear(); state.unknowns.clear(); state.roots.clear(); + state.evidenceContexts.clear(); fingerprints.length = 0; return graph; }; @@ -253,7 +258,7 @@ const projectFile = ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, state: BuilderState, -): JavaScriptSemanticGraph["fingerprints"][number][] => { +): JavaScriptSemanticFingerprint[] => { const moduleNode = retainNode(state, file, { kind: "module", roleKey: "module", @@ -335,7 +340,12 @@ const projectFile = ( projectSemanticResources(context); projectSemanticClosureCaptures(context); projectSemanticFrontiers(context); - return projectSemanticFunctionFingerprints(file, ir, callableNodes); + return projectSemanticFunctionFingerprints( + file, + ir, + callableNodes, + state.evidenceContexts, + ); }; const createReturnSiteNodes = ( diff --git a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts index 4a600d5958..d7f44faaad 100644 --- a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts @@ -223,14 +223,17 @@ const addChildUnknown = (input: ChildUnknownInput): void => { input; addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: node?.node_id ?? null, - family: "child-process", - relation_kinds: relationKinds, - reason: "ambiguous-target", - detail, - candidate_node_ids: candidateNodeIds, - evidence: unknownSemanticEvidence(context.file, location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: node?.node_id ?? null, + family: "child-process", + relation_kinds: relationKinds, + reason: "ambiguous-target", + detail, + candidate_node_ids: candidateNodeIds, + evidence: unknownSemanticEvidence(context.file, location), + }, + context.state.evidenceContexts, + ), ); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts index 0b9ba90353..ebe27fe43d 100644 --- a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts @@ -4,6 +4,7 @@ import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValu import { createJavaScriptSemanticGraphNode, createJavaScriptSemanticGraphRelation, + JavaScriptSemanticEvidenceContextRegistry, javaScriptSemanticNodeId, } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; @@ -25,6 +26,7 @@ export interface SemanticGraphProjectionState { readonly nodes: Map; readonly relations: Map; readonly unknowns: Map; + readonly evidenceContexts: JavaScriptSemanticEvidenceContextRegistry; readonly roots: Set; readonly applicationNodeIdsByLocation: ReadonlyMap; /** @@ -76,6 +78,7 @@ export const createSemanticGraphProjectionState = ( nodes: new Map(), relations: new Map(), unknowns: new Map(), + evidenceContexts: new JavaScriptSemanticEvidenceContextRegistry(), roots: new Set(), applicationNodeIdsByLocation: index.identifiers, }; @@ -109,20 +112,23 @@ export const constructSemanticGraphNode = ( input: SemanticNodeConstructionInput, state: SemanticGraphProjectionState, ): JavaScriptSemanticGraphNode => - createJavaScriptSemanticGraphNode({ - kind: input.kind, - identity: { - artifact_sha256: file.sha256, - module_path: file.path, - source_range: input.location, - role_key: input.roleKey, + createJavaScriptSemanticGraphNode( + { + kind: input.kind, + identity: { + artifact_sha256: file.sha256, + module_path: file.path, + source_range: input.location, + role_key: input.roleKey, + }, + function_node_id: input.functionNodeId, + application_node_ids: matchingApplicationNodeIds(file, input, state), + label: input.label === null ? null : javascriptDisplayText(input.label), + properties: input.properties ?? {}, + evidence: observedSemanticEvidence(file, input.location), }, - function_node_id: input.functionNodeId, - application_node_ids: matchingApplicationNodeIds(file, input, state), - label: input.label === null ? null : javascriptDisplayText(input.label), - properties: input.properties ?? {}, - evidence: observedSemanticEvidence(file, input.location), - }); + state.evidenceContexts, + ); /** Reuse retained identities and reject exhausted budgets before allocation. */ export const retainSemanticGraphNode = ( @@ -183,14 +189,21 @@ export const addSemanticGraphRelation = ( input.source.node_id === input.target.node_id ) return; - const value = createJavaScriptSemanticGraphRelation({ - source_node_id: input.source.node_id, - target_node_id: input.target.node_id, - relation: input.relation, - resolution: input.resolution ?? "candidate", - properties: input.properties ?? {}, - evidence: input.evidence ?? inferredSemanticEvidence(input.source), - }); + const value = createJavaScriptSemanticGraphRelation( + { + source_node_id: input.source.node_id, + target_node_id: input.target.node_id, + relation: input.relation, + resolution: input.resolution ?? "candidate", + properties: input.properties ?? {}, + evidence: + input.evidence ?? + inferredSemanticEvidence( + state.evidenceContexts.resolve(input.source.evidence), + ), + }, + state.evidenceContexts, + ); if (state.relations.has(value.relation_id)) return; state.relations.set(value.relation_id, value); }; @@ -211,20 +224,23 @@ export const addSemanticFallbackRoot = ( ): void => { const node = addSemanticGraphNode( state, - createJavaScriptSemanticGraphNode({ - kind: "module", - identity: { - artifact_sha256: rootArtifactSha256, - module_path: "unknown-semantic-root", - source_range: null, - role_key: "artifact-root", + createJavaScriptSemanticGraphNode( + { + kind: "module", + identity: { + artifact_sha256: rootArtifactSha256, + module_path: "unknown-semantic-root", + source_range: null, + role_key: "artifact-root", + }, + function_node_id: null, + application_node_ids: [], + label: "unavailable semantic root", + properties: {}, + evidence: unavailableSemanticRootEvidence(rootArtifactSha256), }, - function_node_id: null, - application_node_ids: [], - label: "unavailable semantic root", - properties: {}, - evidence: unavailableSemanticRootEvidence(rootArtifactSha256), - }), + state.evidenceContexts, + ), ); if (node !== null) state.roots.add(node.node_id); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index b87bc6b3e0..51efcd2ec6 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -307,14 +307,17 @@ const addDataUnknown = (input: DataUnknownInput): void => { } = input; addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: node.node_id, - family, - relation_kinds: relationKinds, - reason: "ambiguous-target", - detail, - candidate_node_ids: candidateNodeIds, - evidence: unknownSemanticEvidence(context.file, location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: node.node_id, + family, + relation_kinds: relationKinds, + reason: "ambiguous-target", + detail, + candidate_node_ids: candidateNodeIds, + evidence: unknownSemanticEvidence(context.file, location), + }, + context.state.evidenceContexts, + ), ); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts index d802015931..d408170f77 100644 --- a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts +++ b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts @@ -1,5 +1,4 @@ import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; -import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -44,9 +43,9 @@ export const observedSemanticEvidence = ( /** Conservative static relationship evidence derived from one syntax node. */ export const inferredSemanticEvidence = ( - source: JavaScriptSemanticGraphNode, + source: ApplicationGraphEvidence, ): ApplicationGraphEvidence => ({ - ...source.evidence, + ...source, authority: "static-relationship-inference", state: "inferred", confidence: "high", diff --git a/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts index 30d1506ed8..10b7ab495c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts @@ -1,8 +1,11 @@ import { createJavaScriptSemanticFingerprint, - type JavaScriptSemanticGraph, + type JavaScriptSemanticEvidenceContextRegistry, } from "../../domain/javascript/javascriptSemanticGraph.js"; -import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import type { + JavaScriptSemanticFingerprint, + JavaScriptSemanticGraphNode, +} from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; @@ -12,28 +15,32 @@ export const projectSemanticFunctionFingerprints = ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, callableNodes: ReadonlyMap, -): JavaScriptSemanticGraph["fingerprints"][number][] => + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, +): JavaScriptSemanticFingerprint[] => ir.functionFingerprints.flatMap((fingerprint) => { const callable = callableNodes.get(fingerprint.callableId); if (callable === undefined) return []; return [ - createJavaScriptSemanticFingerprint({ - function_node_id: callable.node_id, - algorithm: "rea.javascript-semantic-function", - status: fingerprint.status, - components: { - parameter_arity: fingerprint.components.parameterArity, - normalized_ast_sha256: fingerprint.components.normalizedAstSha256, - control_flow_sha256: fingerprint.components.controlFlowSha256, - relation_shape_sha256: fingerprint.components.relationShapeSha256, - literal_set_sha256: fingerprint.components.literalSetSha256, - effects: fingerprint.components.effects, + createJavaScriptSemanticFingerprint( + { + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function", + status: fingerprint.status, + components: { + parameter_arity: fingerprint.components.parameterArity, + normalized_ast_sha256: fingerprint.components.normalizedAstSha256, + control_flow_sha256: fingerprint.components.controlFlowSha256, + relation_shape_sha256: fingerprint.components.relationShapeSha256, + literal_set_sha256: fingerprint.components.literalSetSha256, + effects: fingerprint.components.effects, + }, + limitations: fingerprint.limitations, + evidence: observedSemanticEvidence( + file, + callable.identity.source_range, + ), }, - limitations: fingerprint.limitations, - evidence: observedSemanticEvidence( - file, - callable.identity.source_range, - ), - }), + evidenceContexts, + ), ]; }); diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index 6195bbed38..be801ab866 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -179,18 +179,21 @@ const addPromiseUnknown = ( const relation = operation.kind === "aggregate" ? "aggregates" : "chains"; addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: promise.node_id, - family: "promise-ownership", - relation_kinds: [relation], - reason: "ambiguous-target", - detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`, - candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => { - const candidate = promiseNodes.get(identifier); - return candidate === undefined ? [] : [candidate.node_id]; - }), - evidence: unknownSemanticEvidence(context.file, operation.location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: promise.node_id, + family: "promise-ownership", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`, + candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => { + const candidate = promiseNodes.get(identifier); + return candidate === undefined ? [] : [candidate.node_id]; + }), + evidence: unknownSemanticEvidence(context.file, operation.location), + }, + context.state.evidenceContexts, + ), ); }; @@ -254,19 +257,22 @@ export const projectSemanticFrontiers = ( : frontier.kind === "dynamic-scope" ? (["reads", "writes"] as const) : (["reads-property", "writes-property"] as const); - const unknown = createJavaScriptSemanticGraphUnknown({ - node_id: - frontier.callableId === null - ? context.moduleNode.node_id - : (context.callableNodes.get(frontier.callableId)?.node_id ?? - context.moduleNode.node_id), - family, - relation_kinds: [...relationKinds], - reason: frontier.kind, - detail: frontier.reason, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, frontier.location), - }); + const unknown = createJavaScriptSemanticGraphUnknown( + { + node_id: + frontier.callableId === null + ? context.moduleNode.node_id + : (context.callableNodes.get(frontier.callableId)?.node_id ?? + context.moduleNode.node_id), + family, + relation_kinds: [...relationKinds], + reason: frontier.kind, + detail: frontier.reason, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, frontier.location), + }, + context.state.evidenceContexts, + ); addSemanticGraphUnknown(context.state, unknown); } }; diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index f4c94d21eb..1b71b27846 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -168,14 +168,17 @@ const addObjectUnknown = ( const relation = OBJECT_RELATIONS[operation.kind]; addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: node?.node_id ?? null, - family: "object-flow", - relation_kinds: [relation], - reason: "ambiguous-target", - detail: `Static ${operation.kind} property ${operation.propertyPath === null ? "path is unresolved" : semanticPropertyPointer(operation.propertyPath)} has ${target.presence} presence; its receiver is ${target.receiverResolved ? "a retained container" : "unresolved or not a retained container"}; object identity is ${operation.resolution}.`, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, operation.location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: node?.node_id ?? null, + family: "object-flow", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: `Static ${operation.kind} property ${operation.propertyPath === null ? "path is unresolved" : semanticPropertyPointer(operation.propertyPath)} has ${target.presence} presence; its receiver is ${target.receiverResolved ? "a retained container" : "unresolved or not a retained container"}; object identity is ${operation.resolution}.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }, + context.state.evidenceContexts, + ), ); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts index ffadc0448e..7417eb4baa 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts @@ -69,16 +69,19 @@ const projectRelease = ( if (operation.resolution === "complete") return; addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: callSite?.node_id ?? null, - family: "resource-lifecycle", - relation_kinds: ["releases"], - reason: "ambiguous-target", - detail: `Static ${operation.method} resource ownership is ambiguous.`, - candidate_node_ids: resources.map( - ({ node_id: identifier }) => identifier, - ), - evidence: unknownSemanticEvidence(context.file, operation.location), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: callSite?.node_id ?? null, + family: "resource-lifecycle", + relation_kinds: ["releases"], + reason: "ambiguous-target", + detail: `Static ${operation.method} resource ownership is ambiguous.`, + candidate_node_ids: resources.map( + ({ node_id: identifier }) => identifier, + ), + evidence: unknownSemanticEvidence(context.file, operation.location), + }, + context.state.evidenceContexts, + ), ); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts index 6f77760110..afedb6f96f 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts @@ -4,6 +4,7 @@ import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.j import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; +import { resolveJavaScriptSemanticEvidence } from "../../domain/javascript/javascriptSemanticGraph.js"; import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; const SHA256 = "a".repeat(64); @@ -335,11 +336,15 @@ it("uses unavailable evidence when no source produced semantic IR", () => { applicationGraph: { graph_id: GRAPH_ID, nodes: [] }, analysis: emptyAnalysis(), }); - expect(graph.nodes[0]?.evidence).toMatchObject({ - authority: "unknown", - state: "unavailable", - location: { available: false, reason: "not-observed" }, - }); + const root = graph.nodes[0]; + if (root === undefined) throw new Error("Expected unavailable semantic root"); + expect(resolveJavaScriptSemanticEvidence(graph, root.evidence)).toMatchObject( + { + authority: "unknown", + state: "unavailable", + location: { available: false, reason: "not-observed" }, + }, + ); }); const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({ diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index bcc9db2941..b8002fcd0c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -96,20 +96,23 @@ const projectValue = (input: ValueProjectionInput): void => { if (container.coverage.status === "partial") addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: target.node_id, - family: "object-flow", - relation_kinds: ["writes-property"], - reason: "ambiguous-target", - detail: `Initializer container has unknown ${ - value.status === "object" ? "properties" : "items" - }${container.coverage.omitted === null ? " with an unknown omitted count" : `; ${container.coverage.omitted} omitted`}.`, - candidate_node_ids: [target.node_id], - evidence: unknownSemanticEvidence( - context.file, - binding.definitions[0]?.location ?? null, - ), - }), + createJavaScriptSemanticGraphUnknown( + { + node_id: target.node_id, + family: "object-flow", + relation_kinds: ["writes-property"], + reason: "ambiguous-target", + detail: `Initializer container has unknown ${ + value.status === "object" ? "properties" : "items" + }${container.coverage.omitted === null ? " with an unknown omitted count" : `; ${container.coverage.omitted} omitted`}.`, + candidate_node_ids: [target.node_id], + evidence: unknownSemanticEvidence( + context.file, + binding.definitions[0]?.location ?? null, + ), + }, + context.state.evidenceContexts, + ), ); } else if ( value.status === "unknown" || @@ -120,24 +123,27 @@ const projectValue = (input: ValueProjectionInput): void => { const evidence = observedSemanticEvidence(context.file, location); addSemanticGraphUnknown( context.state, - createJavaScriptSemanticGraphUnknown({ - node_id: target.node_id, - family: "data-flow", - relation_kinds: ["defines"], - reason: - value.resourceLimit === undefined - ? "unknown-value" - : "resource-limit", - detail: `${value.reason} Unknown value at ${role}.`, - candidate_node_ids: [target.node_id], - evidence: { - ...evidence, - authority: "unknown", - state: "unknown", - confidence: "unknown", - limitations: [value.reason], + createJavaScriptSemanticGraphUnknown( + { + node_id: target.node_id, + family: "data-flow", + relation_kinds: ["defines"], + reason: + value.resourceLimit === undefined + ? "unknown-value" + : "resource-limit", + detail: `${value.reason} Unknown value at ${role}.`, + candidate_node_ids: [target.node_id], + evidence: { + ...evidence, + authority: "unknown", + state: "unknown", + confidence: "unknown", + limitations: [value.reason], + }, }, - }), + context.state.evidenceContexts, + ), ); } }; diff --git a/src/contracts/javascript/javascriptExportShapeComparisonExample.ts b/src/contracts/javascript/javascriptExportShapeComparisonExample.ts index 2213678599..65464336df 100644 --- a/src/contracts/javascript/javascriptExportShapeComparisonExample.ts +++ b/src/contracts/javascript/javascriptExportShapeComparisonExample.ts @@ -13,6 +13,7 @@ import { import { createJavaScriptSemanticGraph, createJavaScriptSemanticGraphNode, + JavaScriptSemanticEvidenceContextRegistry, } from "../../domain/javascript/javascriptSemanticGraph.js"; import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; @@ -184,30 +185,35 @@ const exampleEvidence = (property: "count" | "total") => { coverage: partialApplicationCoverage([], null), limitations, }); - const module = createJavaScriptSemanticGraphNode({ - kind: "module", - identity: { - artifact_sha256: artifact.sha256, - module_path: modulePath, - source_range: null, - role_key: "example-module", + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const module = createJavaScriptSemanticGraphNode( + { + kind: "module", + identity: { + artifact_sha256: artifact.sha256, + module_path: modulePath, + source_range: null, + role_key: "example-module", + }, + function_node_id: null, + application_node_ids: [exported.node_id], + label: modulePath, + properties: {}, + evidence: { + ...evidence, + authority: "ast-static-analysis", + state: "observed", + confidence: "exact", + }, }, - function_node_id: null, - application_node_ids: [exported.node_id], - label: modulePath, - properties: {}, - evidence: { - ...evidence, - authority: "ast-static-analysis", - state: "observed", - confidence: "exact", - }, - }); + evidenceContexts, + ); const semanticGraph = createJavaScriptSemanticGraph({ schema: "JavaScriptSemanticRelationGraph", root_artifact_sha256: metadata.root_artifact_sha256, application_graph_id: graph.graph_id, root_node_ids: [module.node_id], + evidence_contexts: evidenceContexts.contexts, nodes: [module], relations: [], fingerprints: [], diff --git a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts index c6eacc8a94..4be73fab7b 100644 --- a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts +++ b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts @@ -7,6 +7,7 @@ import { createWebTextArtifact } from "../../domain/webContentArtifact.js"; import { createJavaScriptSemanticGraph, createJavaScriptSemanticGraphNode, + JavaScriptSemanticEvidenceContextRegistry, } from "../../domain/javascript/javascriptSemanticGraph.js"; import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; @@ -70,25 +71,30 @@ const graph = createJavaScriptApplicationGraph({ coverage: completeCoverage, limitations: [], }); -const semanticNode = createJavaScriptSemanticGraphNode({ - kind: "module", - identity: { - artifact_sha256: applicationSha256, - module_path: "renderer.js", - source_range: null, - role_key: "example-module", +const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); +const semanticNode = createJavaScriptSemanticGraphNode( + { + kind: "module", + identity: { + artifact_sha256: applicationSha256, + module_path: "renderer.js", + source_range: null, + role_key: "example-module", + }, + function_node_id: null, + application_node_ids: [asset.node_id], + label: "renderer.js", + properties: {}, + evidence: graphEvidence, }, - function_node_id: null, - application_node_ids: [asset.node_id], - label: "renderer.js", - properties: {}, - evidence: graphEvidence, -}); + evidenceContexts, +); const semanticGraph = createJavaScriptSemanticGraph({ schema: "JavaScriptSemanticRelationGraph", root_artifact_sha256: applicationSha256, application_graph_id: graph.graph_id, root_node_ids: [semanticNode.node_id], + evidence_contexts: evidenceContexts.contexts, nodes: [semanticNode], relations: [], fingerprints: [], diff --git a/src/domain/hopperErrors.ts b/src/domain/hopperErrors.ts index 68c5b9ee47..8cb8490811 100644 --- a/src/domain/hopperErrors.ts +++ b/src/domain/hopperErrors.ts @@ -5,7 +5,6 @@ import type { } from "./providerOperationHealth.js"; import { hopperStartupFailure, - type HopperStartupDiagnostic, type HopperStartupFailureCode, type HopperStartupFailureDiagnostic, } from "./hopperStartupFailure.js"; diff --git a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts index a53db2a74b..65bce595b5 100644 --- a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts +++ b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts @@ -269,6 +269,14 @@ const applicationGraphEvidenceBaseSchema = z.strictObject({ evidence_ids: z.array(prefixedDigestSchema("ev")), }); +/** Shared provenance fields apart from the fact-specific evidence location. */ +export const applicationGraphEvidenceContextSchema = + applicationGraphEvidenceBaseSchema.omit({ location: true }); + +/** Exact coordinates retained on each graph fact. */ +export const applicationGraphEvidenceLocationSchema = + applicationLocationReferenceSchema; + type EvidenceValue = z.infer; const checkAuthorityState = ( @@ -476,6 +484,11 @@ export type ApplicationGraphEvidence = z.infer< typeof applicationGraphEvidenceSchema >; +/** Provenance shared by facts with the same non-location evidence. */ +export type ApplicationGraphEvidenceContext = z.infer< + typeof applicationGraphEvidenceContextSchema +>; + /** Explicit stable identity strategy for one application entity. */ export type ApplicationNodeIdentity = z.infer< typeof applicationNodeIdentitySchema diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index 032a2802cc..e6cf1d5302 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -6,7 +6,11 @@ import { uniqueSorted } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { canonicalJsonDigestSteps } from "../canonicalJsonDigestSteps.js"; import { freezeOwnedJsonSnapshotSteps } from "../immutableJson.js"; -import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; +import type { + ApplicationGraphEvidence, + ApplicationGraphEvidenceContext, +} from "./javascriptApplicationEvidenceSchemas.js"; +import { applicationGraphEvidenceSchema } from "./javascriptApplicationEvidenceSchemas.js"; import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATION_FAMILY, @@ -23,6 +27,10 @@ import { type JavaScriptSemanticGraphInput, type JavaScriptSemanticGraphNode, type JavaScriptSemanticGraphRelation, + type JavaScriptSemanticGraphUnknown, + type JavaScriptSemanticFingerprint, + type JavaScriptSemanticEvidenceContext, + type JavaScriptSemanticEvidenceReference, } from "./javascriptSemanticGraphSchemas.js"; const normalizeEvidence = ( @@ -42,6 +50,60 @@ const normalizeEvidence = ( evidence_ids: uniqueSorted(evidence.evidence_ids), }); +const evidenceContextId = (context: ApplicationGraphEvidenceContext): string => + `jsrg_evidence_${digestCanonicalValue(context, "JavaScript semantic evidence context")}`; + +/** Graph-scoped owner for canonical provenance shared by semantic facts. */ +export class JavaScriptSemanticEvidenceContextRegistry { + readonly #contexts = new Map(); + + /** Intern normalized non-location provenance at fact construction. */ + intern( + evidence: ApplicationGraphEvidence, + ): JavaScriptSemanticEvidenceReference { + const normalized = normalizeEvidence(evidence); + const { location, ...context } = normalized; + const contextId = evidenceContextId(context); + const row = { ...context, context_id: contextId }; + const existing = this.#contexts.get(contextId); + if ( + existing !== undefined && + canonicalJson(existing, "JavaScript semantic graph") !== + canonicalJson(row, "JavaScript semantic graph") + ) + throw new TypeError( + "JavaScript semantic evidence context digest collision", + ); + this.#contexts.set(contextId, row); + return { context_id: contextId, location }; + } + + /** Resolve one already interned fact reference for derived evidence. */ + resolve( + reference: JavaScriptSemanticEvidenceReference, + ): ApplicationGraphEvidence { + const context = this.#contexts.get(reference.context_id); + if (context === undefined) + throw new TypeError( + "Semantic evidence reference names an absent context", + ); + const { context_id: _contextId, ...evidenceContext } = context; + return { ...evidenceContext, location: reference.location }; + } + + /** Return the deterministic context table owned by this graph projection. */ + get contexts(): JavaScriptSemanticEvidenceContext[] { + return [...this.#contexts.values()].sort((left, right) => + compareUnicodeCodePoints(left.context_id, right.context_id), + ); + } + + /** Release interned contexts after the owning graph is finalized. */ + clear(): void { + this.#contexts.clear(); + } +} + /** Derive a semantic node ID from its kind and exact artifact identity. */ export const javaScriptSemanticNodeId = ( node: Pick, @@ -51,12 +113,13 @@ export const javaScriptSemanticNodeId = ( /** Normalize one semantic entity and derive its artifact-version identifier. */ export const createJavaScriptSemanticGraphNode = ( input: unknown, + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, ): JavaScriptSemanticGraphNode => { const parsed = javaScriptSemanticNodeInputSchema.parse(input); const semantic = { ...parsed, application_node_ids: uniqueSorted(parsed.application_node_ids), - evidence: normalizeEvidence(parsed.evidence), + evidence: evidenceContexts.intern(parsed.evidence), identifier_strategy: { strategy: "semantic-content-sha256" as const, stability: "artifact-version" as const, @@ -71,11 +134,12 @@ export const createJavaScriptSemanticGraphNode = ( /** Normalize one semantic relationship and derive its exact identifier. */ export const createJavaScriptSemanticGraphRelation = ( input: unknown, + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, ): JavaScriptSemanticGraphRelation => { const parsed = javaScriptSemanticRelationInputSchema.parse(input); const semantic = { ...parsed, - evidence: normalizeEvidence(parsed.evidence), + evidence: evidenceContexts.intern(parsed.evidence), identifier_strategy: { strategy: "semantic-content-sha256" as const, stability: "relationship-exact" as const, @@ -88,13 +152,16 @@ export const createJavaScriptSemanticGraphRelation = ( }; /** Normalize one unresolved semantic frontier and derive its identifier. */ -export const createJavaScriptSemanticGraphUnknown = (input: unknown) => { +export const createJavaScriptSemanticGraphUnknown = ( + input: unknown, + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, +): JavaScriptSemanticGraphUnknown => { const parsed = javaScriptSemanticUnknownInputSchema.parse(input); const semantic = { ...parsed, relation_kinds: uniqueSorted(parsed.relation_kinds), candidate_node_ids: uniqueSorted(parsed.candidate_node_ids), - evidence: normalizeEvidence(parsed.evidence), + evidence: evidenceContexts.intern(parsed.evidence), }; return javaScriptSemanticUnknownSchema.parse({ ...semantic, @@ -103,7 +170,10 @@ export const createJavaScriptSemanticGraphUnknown = (input: unknown) => { }; /** Normalize one function fingerprint and derive its component commitment. */ -export const createJavaScriptSemanticFingerprint = (input: unknown) => { +export const createJavaScriptSemanticFingerprint = ( + input: unknown, + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, +): JavaScriptSemanticFingerprint => { const parsed = javaScriptSemanticFingerprintInputSchema.parse(input); const semantic = { ...parsed, @@ -112,7 +182,7 @@ export const createJavaScriptSemanticFingerprint = (input: unknown) => { effects: uniqueSorted(parsed.components.effects), }, limitations: uniqueSorted(parsed.limitations), - evidence: normalizeEvidence(parsed.evidence), + evidence: evidenceContexts.intern(parsed.evidence), }; const fingerprintSha256 = digestCanonicalValue( semantic.components, @@ -252,6 +322,12 @@ const checkCanonicalOrder = ( "Root nodes", context, ); + sortedUniqueIssue( + graph.evidence_contexts.map(({ context_id }) => context_id), + ["evidence_contexts"], + "Evidence contexts", + context, + ); sortedUniqueIssue( graph.nodes.map(({ node_id }) => node_id), ["nodes"], @@ -279,6 +355,66 @@ const checkCanonicalOrder = ( sortedUniqueIssue(graph.limitations, ["limitations"], "Limitations", context); }; +function* checkEvidenceContextReferencesSteps( + graph: GraphRecord, + context: GraphIssueReporter, +): Generator { + const contexts = new Map( + graph.evidence_contexts.map((value) => [value.context_id, value]), + ); + const referenced = new Set(); + const validateReferences = function* ( + entities: readonly { evidence: JavaScriptSemanticEvidenceReference }[], + entityKind: "nodes" | "relations" | "unknowns" | "fingerprints", + ): Generator { + for (const [index, { evidence }] of entities.entries()) { + if (index % 256 === 0) yield; + referenced.add(evidence.context_id); + const evidenceContext = contexts.get(evidence.context_id); + if (evidenceContext === undefined) { + context.addIssue({ + code: "custom", + path: [entityKind, index, "evidence", "context_id"], + message: "Evidence reference names an absent context", + }); + continue; + } + const { context_id: _contextId, ...evidenceValue } = evidenceContext; + if ( + !applicationGraphEvidenceSchema.safeParse({ + ...evidenceValue, + location: evidence.location, + }).success + ) + context.addIssue({ + code: "custom", + path: [entityKind, index, "evidence"], + message: "Evidence context is incompatible with its fact location", + }); + } + }; + yield* validateReferences(graph.nodes, "nodes"); + yield* validateReferences(graph.relations, "relations"); + yield* validateReferences(graph.unknowns, "unknowns"); + yield* validateReferences(graph.fingerprints, "fingerprints"); + for (const [index, evidenceContext] of graph.evidence_contexts.entries()) { + if (index % 256 === 0) yield; + const { context_id: identifier, ...value } = evidenceContext; + if (identifier !== evidenceContextId(value)) + context.addIssue({ + code: "custom", + path: ["evidence_contexts", index, "context_id"], + message: "Evidence context identifier is stale", + }); + if (!referenced.has(identifier)) + context.addIssue({ + code: "custom", + path: ["evidence_contexts", index], + message: "Evidence context is not referenced by a graph fact", + }); + } +} + function* checkNodesSteps( graph: GraphRecord, nodes: ReadonlyMap, @@ -323,6 +459,7 @@ function* checkRootsSteps( function* checkRelationsSteps( graph: GraphRecord, nodes: ReadonlyMap, + evidenceContexts: ReadonlyMap, context: GraphIssueReporter, ): Generator { for (const [index, relation] of graph.relations.entries()) { @@ -355,7 +492,7 @@ function* checkRelationsSteps( }); if ( relation.resolution === "candidate" && - relation.evidence.state === "observed" + evidenceContexts.get(relation.evidence.context_id)?.state === "observed" ) context.addIssue({ code: "custom", @@ -368,6 +505,7 @@ function* checkRelationsSteps( function* checkUnknownsSteps( graph: GraphRecord, nodes: ReadonlyMap, + evidenceContexts: ReadonlyMap, context: GraphIssueReporter, ): Generator { for (const [index, unknown] of graph.unknowns.entries()) { @@ -405,7 +543,10 @@ function* checkUnknownsSteps( path: ["unknowns", index, "relation_kinds"], message: "Unknown relation kinds must belong to their declared family", }); - if (!["unknown", "unavailable"].includes(unknown.evidence.state)) + const evidenceState = evidenceContexts.get( + unknown.evidence.context_id, + )?.state; + if (evidenceState !== "unknown" && evidenceState !== "unavailable") context.addIssue({ code: "custom", path: ["unknowns", index, "evidence", "state"], @@ -485,6 +626,13 @@ function* checkGraphContentSteps( context: GraphIssueReporter, ): Generator { checkCanonicalOrder(graph, context); + yield* checkEvidenceContextReferencesSteps(graph, context); + const evidenceContexts = new Map( + graph.evidence_contexts.map((evidenceContext) => [ + evidenceContext.context_id, + evidenceContext, + ]), + ); const nodes = new Map(); for (const [index, node] of graph.nodes.entries()) { if (index % 256 === 0) yield; @@ -492,8 +640,8 @@ function* checkGraphContentSteps( } yield* checkNodesSteps(graph, nodes, context); yield* checkRootsSteps(graph, nodes, context); - yield* checkRelationsSteps(graph, nodes, context); - yield* checkUnknownsSteps(graph, nodes, context); + yield* checkRelationsSteps(graph, nodes, evidenceContexts, context); + yield* checkUnknownsSteps(graph, nodes, evidenceContexts, context); checkUnknownReferences(graph, context); yield* checkFingerprintsSteps(graph, nodes, context); yield* checkCoverageSteps(graph, context); @@ -538,11 +686,28 @@ export type JavaScriptSemanticGraph = z.infer< typeof javaScriptSemanticGraphSchema >; +/** Resolve one fact's compact provenance without changing its stored shape. */ +export const resolveJavaScriptSemanticEvidence = ( + graph: Pick, + reference: JavaScriptSemanticEvidenceReference, +): ApplicationGraphEvidence => { + const context = graph.evidence_contexts.find( + ({ context_id }) => context_id === reference.context_id, + ); + if (context === undefined) + throw new TypeError("Semantic evidence reference names an absent context"); + const { context_id: _contextId, ...evidenceContext } = context; + return { ...evidenceContext, location: reference.location }; +}; + const normalizeGraphInput = ( parsed: JavaScriptSemanticGraphInput, ): JavaScriptSemanticGraphInput => ({ ...parsed, root_node_ids: uniqueSorted(parsed.root_node_ids), + evidence_contexts: [...parsed.evidence_contexts].sort((left, right) => + compareUnicodeCodePoints(left.context_id, right.context_id), + ), nodes: [...parsed.nodes].sort((left, right) => compareUnicodeCodePoints(left.node_id, right.node_id), ), diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index ba39bcf709..ddf3015f08 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -16,6 +16,9 @@ import { createJavaScriptSemanticGraphUnknown, createImmutableJavaScriptSemanticGraphSteps, isValidatedImmutableJavaScriptSemanticGraph, + JavaScriptSemanticEvidenceContextRegistry, + javaScriptSemanticGraphSchema, + resolveJavaScriptSemanticEvidence, type JavaScriptSemanticGraph, } from "./javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "./javascriptSemanticGraphSchemas.js"; @@ -100,80 +103,111 @@ const node = ( kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number], role: string, properties: Record = {}, + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, options: { readonly functionNodeId?: string | null; readonly modulePath?: string; } = {}, ): JavaScriptSemanticGraphNode => - createJavaScriptSemanticGraphNode({ - kind, - identity: { - artifact_sha256: SHA, - module_path: options.modulePath ?? "bundle.js", - source_range: { - start: { line: 1, column: role.length }, - end: { line: 1, column: role.length + 1 }, + createJavaScriptSemanticGraphNode( + { + kind, + identity: { + artifact_sha256: SHA, + module_path: options.modulePath ?? "bundle.js", + source_range: { + start: { line: 1, column: role.length }, + end: { line: 1, column: role.length + 1 }, + }, + role_key: role, }, - role_key: role, + function_node_id: options.functionNodeId ?? null, + application_node_ids: [], + label: role, + properties, + evidence: evidence(), }, - function_node_id: options.functionNodeId ?? null, - application_node_ids: [], - label: role, - properties, - evidence: evidence(), - }); + evidenceContexts, + ); it("accepts semantic module paths longer than the former schema ceiling", () => { const modulePath = `${"segment/".repeat(600)}entry.js`; + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); expect( - node("module", "long-path", {}, { modulePath }).identity.module_path, + node("module", "long-path", {}, evidenceContexts, { modulePath }).identity + .module_path, ).toBe(modulePath); }); -const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { - const module = node("module", "module"); - const literal = node("literal", "literal", { value: "TOKEN" }); - const binding = node("binding", "binding"); - const callable = node("function", "function"); - const request = node("request", "request", { - endpoint: "https://example.invalid/v1", - }); +const fixtureInput = (withUnknown = false): JavaScriptSemanticGraphInput => { + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const module = node("module", "module", {}, evidenceContexts); + const literal = node( + "literal", + "literal", + { value: "TOKEN" }, + evidenceContexts, + ); + const binding = node("binding", "binding", {}, evidenceContexts); + const callable = node("function", "function", {}, evidenceContexts); + const request = node( + "request", + "request", + { + endpoint: "https://example.invalid/v1", + }, + evidenceContexts, + ); const relations = [ - createJavaScriptSemanticGraphRelation({ - source_node_id: literal.node_id, - target_node_id: binding.node_id, - relation: "defines", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - createJavaScriptSemanticGraphRelation({ - source_node_id: binding.node_id, - target_node_id: callable.node_id, - relation: "captures", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - createJavaScriptSemanticGraphRelation({ - source_node_id: callable.node_id, - target_node_id: request.node_id, - relation: "constructs-request", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), + createJavaScriptSemanticGraphRelation( + { + source_node_id: literal.node_id, + target_node_id: binding.node_id, + relation: "defines", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), + createJavaScriptSemanticGraphRelation( + { + source_node_id: binding.node_id, + target_node_id: callable.node_id, + relation: "captures", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), + createJavaScriptSemanticGraphRelation( + { + source_node_id: callable.node_id, + target_node_id: request.node_id, + relation: "constructs-request", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), ]; - const dynamic = createJavaScriptSemanticGraphUnknown({ - node_id: callable.node_id, - family: "call-flow", - relation_kinds: ["calls"], - reason: "dynamic-call", - detail: "Computed callee is unresolved.", - candidate_node_ids: [], - evidence: unknownEvidence(), - }); - const unknowns = withUnknown ? [dynamic] : []; + const dynamic = withUnknown + ? createJavaScriptSemanticGraphUnknown( + { + node_id: callable.node_id, + family: "call-flow", + relation_kinds: ["calls"], + reason: "dynamic-call", + detail: "Computed callee is unresolved.", + candidate_node_ids: [], + evidence: unknownEvidence(), + }, + evidenceContexts, + ) + : null; + const unknowns = dynamic === null ? [] : [dynamic]; const coverageFamilies = JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map( (family) => ({ family, @@ -190,29 +224,33 @@ const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { ).length, omitted_relations: withUnknown && family === "call-flow" ? null : 0, unknown_ids: - withUnknown && family === "call-flow" ? [dynamic.unknown_id] : [], + dynamic !== null && family === "call-flow" ? [dynamic.unknown_id] : [], }), ); - const fingerprint = createJavaScriptSemanticFingerprint({ - function_node_id: callable.node_id, - algorithm: "rea.javascript-semantic-function", - status: "complete", - components: { - parameter_arity: 0, - normalized_ast_sha256: "1".repeat(64), - control_flow_sha256: "2".repeat(64), - relation_shape_sha256: "3".repeat(64), - literal_set_sha256: "4".repeat(64), - effects: ["network"], + const fingerprint = createJavaScriptSemanticFingerprint( + { + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function", + status: "complete", + components: { + parameter_arity: 0, + normalized_ast_sha256: "1".repeat(64), + control_flow_sha256: "2".repeat(64), + relation_shape_sha256: "3".repeat(64), + literal_set_sha256: "4".repeat(64), + effects: ["network"], + }, + limitations: [], + evidence: evidence("inferred"), }, - limitations: [], - evidence: evidence("inferred"), - }); - return createJavaScriptSemanticGraph({ + evidenceContexts, + ); + return { schema: "JavaScriptSemanticRelationGraph", root_artifact_sha256: SHA, application_graph_id: JAG_ID, root_node_ids: [module.node_id], + evidence_contexts: evidenceContexts.contexts, nodes: [request, callable, binding, literal, module], relations, fingerprints: [fingerprint], @@ -226,7 +264,24 @@ const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { families: coverageFamilies, }, limitations: withUnknown ? ["Dynamic call target remains unknown."] : [], - }); + }; +}; + +const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => + createJavaScriptSemanticGraph(fixtureInput(withUnknown)); + +const registryForGraph = ( + graph: JavaScriptSemanticGraph, +): JavaScriptSemanticEvidenceContextRegistry => { + const registry = new JavaScriptSemanticEvidenceContextRegistry(); + for (const { evidence: reference } of [ + ...graph.nodes, + ...graph.relations, + ...graph.unknowns, + ...graph.fingerprints, + ]) + registry.intern(resolveJavaScriptSemanticEvidence(graph, reference)); + return registry; }; it("defines every required relation family and canonicalizes records", () => { @@ -247,7 +302,6 @@ it("defines every required relation family and canonicalizes records", () => { it("rejects stale identities, dangling endpoints, and incomplete coverage claims", () => { const graph = fixtureGraph(); - const { graph_id: _graphId, ...graphInput } = graph; expect(() => parseJavaScriptSemanticGraph({ ...graph, @@ -256,10 +310,10 @@ it("rejects stale identities, dangling endpoints, and incomplete coverage claims ).toThrow(); expect(() => createJavaScriptSemanticGraph({ - ...graphInput, + ...fixtureInput(), relations: [ { - ...graph.relations[0], + ...fixtureInput().relations[0], target_node_id: `jsrg_node_${"f".repeat(64)}`, }, ], @@ -267,27 +321,174 @@ it("rejects stale identities, dangling endpoints, and incomplete coverage claims ).toThrow(); expect(() => createJavaScriptSemanticGraph({ - ...graphInput, - coverage: { ...graph.coverage, omitted_nodes: 1 }, + ...fixtureInput(), + coverage: { ...fixtureInput().coverage, omitted_nodes: 1 }, }), ).toThrow(/Complete graph coverage/u); - const graphWithUnknown = fixtureGraph(true); - const { graph_id: _unknownGraphId, ...unknownGraphInput } = graphWithUnknown; - const originalUnknown = graphWithUnknown.unknowns[0]; + const unknownGraphInput = fixtureInput(true); + const originalUnknown = unknownGraphInput.unknowns[0]; if (originalUnknown === undefined) throw new Error("Expected unknown frontier"); const { unknown_id: _unknownId, ...unknownInput } = originalUnknown; - expect(() => - createJavaScriptSemanticGraph({ - ...unknownGraphInput, - unknowns: [ - createJavaScriptSemanticGraphUnknown({ - ...unknownInput, - candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], - }), + const validatedUnknownGraph = + createJavaScriptSemanticGraph(unknownGraphInput); + const evidenceContexts = registryForGraph(validatedUnknownGraph); + unknownGraphInput.unknowns = [ + createJavaScriptSemanticGraphUnknown( + { + ...unknownInput, + candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], + evidence: resolveJavaScriptSemanticEvidence( + validatedUnknownGraph, + originalUnknown.evidence, + ), + }, + evidenceContexts, + ), + ]; + unknownGraphInput.evidence_contexts = evidenceContexts.contexts; + expect(() => createJavaScriptSemanticGraph(unknownGraphInput)).toThrow( + /candidate node is absent/u, + ); +}); + +it("validates evidence context ownership and references", () => { + const graph = fixtureGraph(); + const alteredContext = { + ...graph, + evidence_contexts: graph.evidence_contexts.map((context, index) => + index === 0 + ? { ...context, context_id: `jsrg_evidence_${"f".repeat(64)}` } + : context, + ), + }; + const alteredIssues = javaScriptSemanticGraphSchema.safeParse(alteredContext); + expect(alteredIssues.success).toBe(false); + if (alteredIssues.success) throw new Error("Expected invalid context digest"); + expect(alteredIssues.error.issues.map(({ message }) => message)).toContain( + "Evidence context identifier is stale", + ); + expect(alteredIssues.error.issues.map(({ message }) => message)).toContain( + "Evidence reference names an absent context", + ); + + const changedPayload = javaScriptSemanticGraphSchema.safeParse({ + ...graph, + evidence_contexts: graph.evidence_contexts.map((context, index) => + index === 0 ? { ...context, authority: "user-assertion" } : context, + ), + }); + expect(changedPayload.success).toBe(false); + if (changedPayload.success) + throw new Error("Expected changed context payload rejection"); + expect(changedPayload.error.issues.map(({ message }) => message)).toContain( + "Evidence context identifier is stale", + ); + + const withUnknown = fixtureGraph(true); + const extraContext = withUnknown.evidence_contexts.find( + ({ context_id }) => + !graph.evidence_contexts.some( + (existing) => existing.context_id === context_id, + ), + ); + if (extraContext === undefined) + throw new Error("Expected the unknown fact to own a distinct context"); + const orphanIssues = javaScriptSemanticGraphSchema.safeParse({ + ...graph, + evidence_contexts: [...graph.evidence_contexts, extraContext].toSorted( + (left, right) => left.context_id.localeCompare(right.context_id), + ), + }); + expect(orphanIssues.success).toBe(false); + if (orphanIssues.success) + throw new Error("Expected orphan context rejection"); + expect(orphanIssues.error.issues.map(({ message }) => message)).toContain( + "Evidence context is not referenced by a graph fact", + ); + + for (const fact of [ + "nodes", + "relations", + "unknowns", + "fingerprints", + ] as const) { + const facts = withUnknown[fact]; + const first = facts[0]; + if (first === undefined) throw new Error(`Expected a ${fact} fact`); + const dangling = javaScriptSemanticGraphSchema.safeParse({ + ...withUnknown, + [fact]: [ + { + ...first, + evidence: { + ...first.evidence, + context_id: `jsrg_evidence_${"e".repeat(64)}`, + }, + }, + ...facts.slice(1), ], - }), - ).toThrow(/candidate node is absent/u); + }); + expect(dangling.success, fact).toBe(false); + if (dangling.success) + throw new Error(`Expected dangling ${fact} reference`); + expect( + dangling.error.issues.map(({ message }) => message), + fact, + ).toContain("Evidence reference names an absent context"); + } + + const observedNode = graph.nodes[0]; + if (observedNode === undefined) throw new Error("Expected observed node"); + const impossibleEvidence = javaScriptSemanticGraphSchema.safeParse({ + ...graph, + nodes: [ + { + ...observedNode, + evidence: { + ...observedNode.evidence, + location: { + available: false, + reason: "not-observed", + detail: "Location removed from an observed fact.", + }, + }, + }, + ...graph.nodes.slice(1), + ], + }); + expect(impossibleEvidence.success).toBe(false); + if (impossibleEvidence.success) + throw new Error("Expected incompatible evidence context and location"); + expect( + impossibleEvidence.error.issues.map(({ message }) => message), + ).toContain("Evidence context is incompatible with its fact location"); +}); + +it("shares provenance contexts while preserving fact-specific locations and unknown IDs", () => { + const graph = fixtureGraph(true); + const first = graph.unknowns[0]; + if (first === undefined) throw new Error("Expected an unknown frontier"); + const evidenceContexts = registryForGraph(graph); + const { unknown_id: _unknownId, ...unknown } = first; + const second = createJavaScriptSemanticGraphUnknown( + { + ...unknown, + evidence: { + ...resolveJavaScriptSemanticEvidence(graph, first.evidence), + location: { + available: false, + reason: "unresolved", + detail: "Another source location remains unresolved.", + }, + }, + }, + evidenceContexts, + ); + expect(second.evidence.context_id).toBe(first.evidence.context_id); + expect(second.evidence.location).not.toEqual(first.evidence.location); + expect(second.unknown_id).not.toBe(first.unknown_id); + expect(evidenceContexts.contexts).toEqual(graph.evidence_contexts); }); const complete = (steps: Iterator): Value => { @@ -298,7 +499,7 @@ const complete = (steps: Iterator): Value => { }; it("captures nested input before yielding and preserves the synchronous graph commitment", () => { - const { graph_id: _graphId, ...input } = fixtureGraph(true); + const input = fixtureInput(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] }; @@ -346,17 +547,16 @@ const invalidGraphCases: { message: "Node identifier is stale", }, { - label: "relation identity, endpoints and authority", + label: "relation 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", + message: "Relation endpoints must name semantic nodes", }, { label: "unknown identity and candidates", @@ -364,7 +564,6 @@ const invalidGraphCases: { input.unknowns = input.unknowns.map((unknown) => ({ ...unknown, candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], - evidence: evidence(), })); }, message: "Unknown frontier candidate node is absent", @@ -398,7 +597,7 @@ const invalidGraphCases: { it.each(invalidGraphCases)( "retains full validation diagnostics for $label in owned steps", ({ change, message }) => { - const { graph_id: _graphId, ...input } = fixtureGraph(true); + const input = fixtureInput(true); change(input); const issues = (action: () => unknown) => { try { diff --git a/src/domain/javascript/javascriptSemanticGraphSchemas.ts b/src/domain/javascript/javascriptSemanticGraphSchemas.ts index 64e7041984..fb03f1a4d2 100644 --- a/src/domain/javascript/javascriptSemanticGraphSchemas.ts +++ b/src/domain/javascript/javascriptSemanticGraphSchemas.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import { applicationGraphEvidenceSchema } from "./javascriptApplicationEvidenceSchemas.js"; +import { + applicationGraphEvidenceContextSchema, + applicationGraphEvidenceLocationSchema, + applicationGraphEvidenceSchema, +} from "./javascriptApplicationEvidenceSchemas.js"; import { jsonValueSchema } from "../jsonValue.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; @@ -13,6 +17,21 @@ const semanticRelationIdSchema = z const semanticUnknownIdSchema = z .string() .regex(/^jsrg_unknown_[a-f0-9]{64}$/u); +const semanticEvidenceContextIdSchema = z + .string() + .regex(/^jsrg_evidence_[a-f0-9]{64}$/u); + +/** One canonical owner for provenance shared by semantic graph facts. */ +export const javaScriptSemanticEvidenceContextSchema = + applicationGraphEvidenceContextSchema.extend({ + context_id: semanticEvidenceContextIdSchema, + }); + +/** Location-specific reference to one exact graph-level evidence context. */ +export const javaScriptSemanticEvidenceReferenceSchema = z.strictObject({ + context_id: semanticEvidenceContextIdSchema, + location: applicationGraphEvidenceLocationSchema, +}); /** Semantic entity kinds admitted by JavaScript Semantic Relation Graph. */ export const JAVASCRIPT_SEMANTIC_NODE_KINDS = [ @@ -213,6 +232,7 @@ export const javaScriptSemanticNodeInputSchema = z.strictObject({ /** One exact, artifact-version semantic entity. */ export const javaScriptSemanticNodeSchema = javaScriptSemanticNodeInputSchema.extend({ + evidence: javaScriptSemanticEvidenceReferenceSchema, node_id: semanticNodeIdSchema, identifier_strategy: z.strictObject({ strategy: z.literal("semantic-content-sha256"), @@ -233,6 +253,7 @@ export const javaScriptSemanticRelationInputSchema = z.strictObject({ /** One canonical directed semantic relationship. */ export const javaScriptSemanticRelationSchema = javaScriptSemanticRelationInputSchema.extend({ + evidence: javaScriptSemanticEvidenceReferenceSchema, relation_id: semanticRelationIdSchema, identifier_strategy: z.strictObject({ strategy: z.literal("semantic-content-sha256"), @@ -269,6 +290,7 @@ export const javaScriptSemanticUnknownInputSchema = z.strictObject({ /** One canonical unresolved semantic frontier. */ export const javaScriptSemanticUnknownSchema = javaScriptSemanticUnknownInputSchema.extend({ + evidence: javaScriptSemanticEvidenceReferenceSchema, unknown_id: semanticUnknownIdSchema, }); @@ -304,6 +326,7 @@ export const javaScriptSemanticFingerprintInputSchema = z.strictObject({ /** One canonical function fingerprint record. */ export const javaScriptSemanticFingerprintSchema = javaScriptSemanticFingerprintInputSchema.extend({ + evidence: javaScriptSemanticEvidenceReferenceSchema, fingerprint_id: prefixedDigestSchema("jsrg_fingerprint"), fingerprint_sha256: digestSchema, }); @@ -337,6 +360,7 @@ export const javaScriptSemanticGraphInputSchema = z.strictObject({ root_artifact_sha256: digestSchema, application_graph_id: prefixedDigestSchema("jag"), root_node_ids: z.array(semanticNodeIdSchema).min(1), + evidence_contexts: z.array(javaScriptSemanticEvidenceContextSchema), nodes: z.array(javaScriptSemanticNodeSchema).min(1), relations: z.array(javaScriptSemanticRelationSchema), fingerprints: z.array(javaScriptSemanticFingerprintSchema), @@ -355,6 +379,14 @@ export const javaScriptSemanticGraphRecordSchema = export type JavaScriptSemanticGraphNode = z.infer< typeof javaScriptSemanticNodeSchema >; +/** Compact provenance on one graph fact. */ +export type JavaScriptSemanticEvidenceReference = z.infer< + typeof javaScriptSemanticEvidenceReferenceSchema +>; +/** Content-addressed graph-level provenance context. */ +export type JavaScriptSemanticEvidenceContext = z.infer< + typeof javaScriptSemanticEvidenceContextSchema +>; /** Canonical semantic relation. */ export type JavaScriptSemanticGraphRelation = z.infer< typeof javaScriptSemanticRelationSchema @@ -363,6 +395,10 @@ export type JavaScriptSemanticGraphRelation = z.infer< export type JavaScriptSemanticGraphUnknown = z.infer< typeof javaScriptSemanticUnknownSchema >; +/** Canonical function fingerprint with a compact evidence reference. */ +export type JavaScriptSemanticFingerprint = z.infer< + typeof javaScriptSemanticFingerprintSchema +>; /** Canonical graph content before graph ID derivation. */ export type JavaScriptSemanticGraphInput = z.infer< typeof javaScriptSemanticGraphInputSchema diff --git a/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts b/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts index 532c1fe280..e42af295f1 100644 --- a/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts @@ -5,6 +5,7 @@ import { JAVASCRIPT_SEMANTIC_NODE_KINDS, JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATION_FAMILY, + type JavaScriptSemanticGraphInput, } from "./javascriptSemanticGraphSchemas.js"; import { createJavaScriptSemanticFingerprint, @@ -12,6 +13,8 @@ import { createJavaScriptSemanticGraphNode, createJavaScriptSemanticGraphRelation, createJavaScriptSemanticGraphUnknown, + JavaScriptSemanticEvidenceContextRegistry, + resolveJavaScriptSemanticEvidence, type JavaScriptSemanticGraph, } from "./javascriptSemanticGraph.js"; import type { @@ -96,71 +99,92 @@ const unknownEvidence = (): ApplicationGraphEvidence => ({ const node = ( kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number], role: string, + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, properties: Record = {}, functionNodeId: string | null = null, ): JavaScriptSemanticGraphNode => - createJavaScriptSemanticGraphNode({ - kind, - identity: { - artifact_sha256: SHA, - module_path: "bundle.js", - source_range: { - start: { line: 1, column: role.length }, - end: { line: 1, column: role.length + 1 }, + createJavaScriptSemanticGraphNode( + { + kind, + identity: { + artifact_sha256: SHA, + module_path: "bundle.js", + source_range: { + start: { line: 1, column: role.length }, + end: { line: 1, column: role.length + 1 }, + }, + role_key: role, }, - role_key: role, + function_node_id: functionNodeId, + application_node_ids: [], + label: role, + properties, + evidence: evidence(), }, - function_node_id: functionNodeId, - application_node_ids: [], - label: role, - properties, - evidence: evidence(), - }); + evidenceContexts, + ); const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { - const module = node("module", "module"); - const literal = node("literal", "literal", { value: "TOKEN" }); - const binding = node("binding", "binding"); - const callable = node("function", "function"); - const request = node("request", "request", { + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const module = node("module", "module", evidenceContexts); + const literal = node("literal", "literal", evidenceContexts, { + value: "TOKEN", + }); + const binding = node("binding", "binding", evidenceContexts); + const callable = node("function", "function", evidenceContexts); + const request = node("request", "request", evidenceContexts, { endpoint: "https://example.invalid/v1", }); const relations = [ - createJavaScriptSemanticGraphRelation({ - source_node_id: literal.node_id, - target_node_id: binding.node_id, - relation: "defines", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - createJavaScriptSemanticGraphRelation({ - source_node_id: binding.node_id, - target_node_id: callable.node_id, - relation: "captures", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - createJavaScriptSemanticGraphRelation({ - source_node_id: callable.node_id, - target_node_id: request.node_id, - relation: "constructs-request", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), + createJavaScriptSemanticGraphRelation( + { + source_node_id: literal.node_id, + target_node_id: binding.node_id, + relation: "defines", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), + createJavaScriptSemanticGraphRelation( + { + source_node_id: binding.node_id, + target_node_id: callable.node_id, + relation: "captures", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), + createJavaScriptSemanticGraphRelation( + { + source_node_id: callable.node_id, + target_node_id: request.node_id, + relation: "constructs-request", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), ]; - const dynamic = createJavaScriptSemanticGraphUnknown({ - node_id: callable.node_id, - family: "call-flow", - relation_kinds: ["calls"], - reason: "dynamic-call", - detail: "Computed callee is unresolved.", - candidate_node_ids: [], - evidence: unknownEvidence(), - }); - const unknowns = withUnknown ? [dynamic] : []; + const dynamic = withUnknown + ? createJavaScriptSemanticGraphUnknown( + { + node_id: callable.node_id, + family: "call-flow", + relation_kinds: ["calls"], + reason: "dynamic-call", + detail: "Computed callee is unresolved.", + candidate_node_ids: [], + evidence: unknownEvidence(), + }, + evidenceContexts, + ) + : null; + const unknowns = dynamic === null ? [] : [dynamic]; const coverageFamilies = JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map( (family) => ({ family, @@ -177,29 +201,33 @@ const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { ).length, omitted_relations: withUnknown && family === "call-flow" ? null : 0, unknown_ids: - withUnknown && family === "call-flow" ? [dynamic.unknown_id] : [], + dynamic !== null && family === "call-flow" ? [dynamic.unknown_id] : [], }), ); - const fingerprint = createJavaScriptSemanticFingerprint({ - function_node_id: callable.node_id, - algorithm: "rea.javascript-semantic-function", - status: "complete", - components: { - parameter_arity: 0, - normalized_ast_sha256: "1".repeat(64), - control_flow_sha256: "2".repeat(64), - relation_shape_sha256: "3".repeat(64), - literal_set_sha256: "4".repeat(64), - effects: ["network"], + const fingerprint = createJavaScriptSemanticFingerprint( + { + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function", + status: "complete", + components: { + parameter_arity: 0, + normalized_ast_sha256: "1".repeat(64), + control_flow_sha256: "2".repeat(64), + relation_shape_sha256: "3".repeat(64), + literal_set_sha256: "4".repeat(64), + effects: ["network"], + }, + limitations: [], + evidence: evidence("inferred"), }, - limitations: [], - evidence: evidence("inferred"), - }); + evidenceContexts, + ); return createJavaScriptSemanticGraph({ schema: "JavaScriptSemanticRelationGraph", root_artifact_sha256: SHA, application_graph_id: JAG_ID, root_node_ids: [module.node_id], + evidence_contexts: evidenceContexts.contexts, nodes: [request, callable, binding, literal, module], relations, fingerprints: [fingerprint], @@ -218,22 +246,27 @@ const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { const graphWithCandidateEdge = (): JavaScriptSemanticGraph => { const graph = fixtureGraph(); + const input = constructionInput(graph); + const evidenceContexts = registryForGraph(graph); const literal = graph.nodes.find(({ kind }) => kind === "literal"); const request = graph.nodes.find(({ kind }) => kind === "request"); if (literal === undefined || request === undefined) throw new TypeError("Semantic fixture nodes are missing"); - const candidate = createJavaScriptSemanticGraphRelation({ - source_node_id: literal.node_id, - target_node_id: request.node_id, - relation: "supplies-request-field", - resolution: "candidate", - properties: { field: "authorization" }, - evidence: evidence("inferred"), - }); - const { graph_id: _graphId, ...input } = graph; + const candidate = createJavaScriptSemanticGraphRelation( + { + source_node_id: literal.node_id, + target_node_id: request.node_id, + relation: "supplies-request-field", + resolution: "candidate", + properties: { field: "authorization" }, + evidence: evidence("inferred"), + }, + evidenceContexts, + ); return createJavaScriptSemanticGraph({ ...input, - relations: [...graph.relations, candidate], + evidence_contexts: evidenceContexts.contexts, + relations: [...input.relations, candidate], coverage: { ...graph.coverage, families: graph.coverage.families.map((family) => @@ -248,6 +281,27 @@ const graphWithCandidateEdge = (): JavaScriptSemanticGraph => { }); }; +const constructionInput = ( + graph: JavaScriptSemanticGraph, +): JavaScriptSemanticGraphInput => { + const { graph_id: _graphId, ...input } = graph; + return input; +}; + +const registryForGraph = ( + graph: JavaScriptSemanticGraph, +): JavaScriptSemanticEvidenceContextRegistry => { + const registry = new JavaScriptSemanticEvidenceContextRegistry(); + for (const { evidence: reference } of [ + ...graph.nodes, + ...graph.relations, + ...graph.unknowns, + ...graph.fingerprints, + ]) + registry.intern(resolveJavaScriptSemanticEvidence(graph, reference)); + return registry; +}; + it("returns every relevant unknown inline", () => { const graph = fixtureGraph(true); const callable = graph.nodes.find(({ kind }) => kind === "function"); @@ -279,28 +333,55 @@ it("returns the complete deterministic forward influence result inline", () => { expect(first.relations).toHaveLength(3); }); +it("returns only the referenced evidence contexts with resolvable trace facts", () => { + const graph = fixtureGraph(); + const result = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + }); + const expectedContextIds = [ + ...new Set([ + ...result.nodes.map(({ evidence }) => evidence.context_id), + ...result.relations.map(({ evidence }) => evidence.context_id), + ...result.unknowns.map(({ evidence }) => evidence.context_id), + ]), + ].sort(); + expect(result.evidence_contexts.map(({ context_id }) => context_id)).toEqual( + expectedContextIds, + ); + for (const node of result.nodes) + expect(resolveJavaScriptSemanticEvidence(result, node.evidence)).toEqual( + resolveJavaScriptSemanticEvidence(graph, node.evidence), + ); +}); + it("traverses paths beyond the former fixed depth ceiling", () => { const original = fixtureGraph(); + const input = constructionInput(original); + const evidenceContexts = registryForGraph(original); const request = original.nodes.find(({ kind }) => kind === "request"); if (request === undefined) throw new TypeError("Missing request node"); const chain = Array.from({ length: 70 }, (_, index) => - node("binding", `long-chain-${index}`), + node("binding", `long-chain-${index}`, evidenceContexts), ); const chainRelations = chain.map((target, index) => - createJavaScriptSemanticGraphRelation({ - source_node_id: chain[index - 1]?.node_id ?? request.node_id, - target_node_id: target.node_id, - relation: "reads", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), + createJavaScriptSemanticGraphRelation( + { + source_node_id: chain[index - 1]?.node_id ?? request.node_id, + target_node_id: target.node_id, + relation: "reads", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ), ); - const { graph_id: _graphId, ...input } = original; const graph = createJavaScriptSemanticGraph({ ...input, - nodes: [...original.nodes, ...chain], - relations: [...original.relations, ...chainRelations], + evidence_contexts: evidenceContexts.contexts, + nodes: [...input.nodes, ...chain], + relations: [...input.relations, ...chainRelations], coverage: { ...original.coverage, families: original.coverage.families.map((family) => @@ -329,7 +410,12 @@ it("accepts deeply nested semantic properties", () => { for (let index = 0; index < 12; index += 1) nested = { [`level-${String(index)}`]: nested }; - const result = node("binding", "deep-properties", { nested }); + const result = node( + "binding", + "deep-properties", + new JavaScriptSemanticEvidenceContextRegistry(), + { nested }, + ); expect(result.properties).toEqual({ nested }); }); @@ -369,41 +455,50 @@ it("excludes candidate edges unless the caller explicitly opts in", () => { }); it("retains every structural application node linked to one semantic node", () => { - const original = node("module", "module"); - const linked = createJavaScriptSemanticGraphNode({ - kind: original.kind, - identity: original.identity, - function_node_id: original.function_node_id, - application_node_ids: Array.from( - { length: 65 }, - (_, index) => `jag_node_${index.toString(16).padStart(64, "0")}`, - ), - label: original.label, - properties: original.properties, - evidence: original.evidence, - }); + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const original = node("module", "module", evidenceContexts); + const linked = createJavaScriptSemanticGraphNode( + { + kind: original.kind, + identity: original.identity, + function_node_id: original.function_node_id, + application_node_ids: Array.from( + { length: 65 }, + (_, index) => `jag_node_${index.toString(16).padStart(64, "0")}`, + ), + label: original.label, + properties: original.properties, + evidence: evidenceContexts.resolve(original.evidence), + }, + evidenceContexts, + ); expect(linked.application_node_ids).toHaveLength(65); }); it("retains all explicit unresolved candidate node identifiers", () => { - const unknown = createJavaScriptSemanticGraphUnknown({ - node_id: null, - family: "call-flow", - relation_kinds: ["calls"], - reason: "ambiguous-target", - detail: "All candidate targets are retained.", - candidate_node_ids: Array.from( - { length: 1_001 }, - (_, index) => `jsrg_node_${index.toString(16).padStart(64, "0")}`, - ), - evidence: unknownEvidence(), - }); + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const unknown = createJavaScriptSemanticGraphUnknown( + { + node_id: null, + family: "call-flow", + relation_kinds: ["calls"], + reason: "ambiguous-target", + detail: "All candidate targets are retained.", + candidate_node_ids: Array.from( + { length: 1_001 }, + (_, index) => `jsrg_node_${index.toString(16).padStart(64, "0")}`, + ), + evidence: unknownEvidence(), + }, + evidenceContexts, + ); expect(unknown.candidate_node_ids).toHaveLength(1_001); }); const directionalGraph = ( nodes: readonly JavaScriptSemanticGraphNode[], relations: readonly JavaScriptSemanticGraphRelation[], + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, partial = false, ): JavaScriptSemanticGraph => createJavaScriptSemanticGraph({ @@ -411,6 +506,7 @@ const directionalGraph = ( root_artifact_sha256: SHA, application_graph_id: JAG_ID, root_node_ids: nodes.slice(0, 1).map(({ node_id }) => node_id), + evidence_contexts: evidenceContexts.contexts, nodes: [...nodes], relations: [...relations], fingerprints: [], @@ -439,16 +535,20 @@ const relationBetween = ( source: JavaScriptSemanticGraphNode, target: JavaScriptSemanticGraphNode, relation: JavaScriptSemanticGraphRelation["relation"], + evidenceContexts: JavaScriptSemanticEvidenceContextRegistry, resolution: JavaScriptSemanticGraphRelation["resolution"] = "resolved", ) => - createJavaScriptSemanticGraphRelation({ - source_node_id: source.node_id, - target_node_id: target.node_id, - relation, - resolution, - properties: {}, - evidence: evidence("inferred"), - }); + createJavaScriptSemanticGraphRelation( + { + source_node_id: source.node_id, + target_node_id: target.node_id, + relation, + resolution, + properties: {}, + evidence: evidence("inferred"), + }, + evidenceContexts, + ); it.each([ { @@ -489,25 +589,28 @@ it.each([ ] as const)( "limits $direction ambiguity to traversable $relation candidates (incoming=$incoming)", ({ direction, relation, incoming, relevant }) => { - const seed = node("function", "directional-seed"); - const reached = node("function", "directional-reached"); - const other = node("function", "directional-other"); + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const seed = node("function", "directional-seed", evidenceContexts); + const reached = node("function", "directional-reached", evidenceContexts); + const other = node("function", "directional-other", evidenceContexts); const reverse = direction === "callers" || direction === "backward-provenance"; const resolved = relationBetween( reverse ? reached : seed, reverse ? seed : reached, direction === "ownership" ? "owns" : "calls", + evidenceContexts, ); const candidate = relationBetween( incoming ? other : reached, incoming ? reached : other, relation, + evidenceContexts, "candidate", ); const nodes = [seed, reached, other]; const control = queryJavaScriptSemanticGraph( - directionalGraph(nodes, [resolved]), + directionalGraph(nodes, [resolved], evidenceContexts), { seed: { kind: "semantic-node", node_id: seed.node_id }, direction, @@ -519,7 +622,7 @@ it.each([ }); for (const include of [false, true]) { const result = queryJavaScriptSemanticGraph( - directionalGraph(nodes, [resolved, candidate]), + directionalGraph(nodes, [resolved, candidate], evidenceContexts), { seed: { kind: "semantic-node", node_id: seed.node_id }, direction, @@ -535,19 +638,33 @@ it.each([ expect(result.relations).toEqual(control.relations); } else { expect(result.nodes).toHaveLength(3); - expect(result.relations).toContainEqual(candidate); + expect(result.relations).toContainEqual( + expect.objectContaining({ + source_node_id: candidate.source_node_id, + target_node_id: candidate.target_node_id, + relation: candidate.relation, + resolution: candidate.resolution, + }), + ); } } }, ); it("excludes explicitly filtered candidates without hiding partial graph coverage", () => { - const seed = node("function", "filtered-seed"); - const other = node("function", "filtered-other"); - const candidate = relationBetween(seed, other, "reads", "candidate"); + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); + const seed = node("function", "filtered-seed", evidenceContexts); + const other = node("function", "filtered-other", evidenceContexts); + const candidate = relationBetween( + seed, + other, + "reads", + evidenceContexts, + "candidate", + ); for (const partial of [false, true]) { const result = queryJavaScriptSemanticGraph( - directionalGraph([seed, other], [candidate], partial), + directionalGraph([seed, other], [candidate], evidenceContexts, partial), { seed: { kind: "semantic-node", node_id: seed.node_id }, direction: "forward-influence", @@ -563,8 +680,9 @@ it("excludes explicitly filtered candidates without hiding partial graph coverag }); it("keeps candidate uncertainty at the end of a long reachable path", () => { + const evidenceContexts = new JavaScriptSemanticEvidenceContextRegistry(); const chain = Array.from({ length: 72 }, (_, index) => - node("binding", `candidate-chain-${index}`), + node("binding", `candidate-chain-${index}`, evidenceContexts), ); const relations = chain.slice(1).map((target, index) => { const source = chain[index]; @@ -573,6 +691,7 @@ it("keeps candidate uncertainty at the end of a long reachable path", () => { source, target, "reads", + evidenceContexts, index === 70 ? "candidate" : "resolved", ); }); @@ -580,7 +699,7 @@ it("keeps candidate uncertainty at the end of a long reachable path", () => { if (seed === undefined) throw new TypeError("Missing chain seed"); for (const include of [false, true]) { const result = queryJavaScriptSemanticGraph( - directionalGraph(chain, relations), + directionalGraph(chain, relations, evidenceContexts), { seed: { kind: "semantic-node", node_id: seed.node_id }, direction: "forward-influence", diff --git a/src/domain/javascript/javascriptSemanticQuery.ts b/src/domain/javascript/javascriptSemanticQuery.ts index e793ccedee..6439d8954f 100644 --- a/src/domain/javascript/javascriptSemanticQuery.ts +++ b/src/domain/javascript/javascriptSemanticQuery.ts @@ -68,6 +68,11 @@ export const queryJavaScriptSemanticGraph = ( result.retainedRelations, input, ); + const retainedContextIds = new Set([ + ...result.retainedNodes.map(({ evidence }) => evidence.context_id), + ...result.retainedRelations.map(({ evidence }) => evidence.context_id), + ...relevantUnknowns.map(({ evidence }) => evidence.context_id), + ]); const assessment = assessJavaScriptSemanticQuery({ graph, totalSeeds: seeds.length, @@ -83,6 +88,9 @@ export const queryJavaScriptSemanticGraph = ( direction: input.direction, status: assessment.status, seed_node_ids: retainedSeeds, + evidence_contexts: graph.evidence_contexts.filter(({ context_id }) => + retainedContextIds.has(context_id), + ), nodes: result.retainedNodes, relations: result.retainedRelations, unknowns: relevantUnknowns, diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index 0cd94e2806..d9c93446a4 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -5,6 +5,7 @@ import { JAVASCRIPT_SEMANTIC_RELATIONS, javaScriptSemanticNodeSchema, javaScriptSemanticRelationSchema, + javaScriptSemanticEvidenceContextSchema, javaScriptSemanticUnknownSchema, } from "./javascriptSemanticGraphSchemas.js"; import { jsonValueSchema } from "../jsonValue.js"; @@ -93,6 +94,7 @@ export const javaScriptSemanticQueryResultSchema = z.strictObject({ direction: javaScriptSemanticQueryInputSchema.shape.direction, status: z.enum(["found", "no-match", "ambiguous", "partial", "unsupported"]), seed_node_ids: z.array(semanticNodeIdSchema), + evidence_contexts: z.array(javaScriptSemanticEvidenceContextSchema), nodes: z.array(javaScriptSemanticNodeSchema), relations: z.array(javaScriptSemanticRelationSchema), unknowns: z.array(javaScriptSemanticUnknownSchema), diff --git a/tests/boundary/mcp/applicationExportShapeMcp.test.ts b/tests/boundary/mcp/applicationExportShapeMcp.test.ts index a35b92abce..8ba6bc0bf5 100644 --- a/tests/boundary/mcp/applicationExportShapeMcp.test.ts +++ b/tests/boundary/mcp/applicationExportShapeMcp.test.ts @@ -88,7 +88,10 @@ it("traces example semantic modules with their parsed artifact digests", async ( const module = analysis.semantic_graph.nodes.find( ({ kind }) => kind === "module", ); - if (module === undefined || !module.evidence.artifact.available) + const evidenceContext = analysis.semantic_graph.evidence_contexts.find( + ({ context_id }) => context_id === module?.evidence.context_id, + ); + if (module === undefined || !evidenceContext?.artifact.available) throw new Error("Example must retain an artifact-backed semantic module"); const response = await client.callTool({ name: "trace_javascript_semantics", @@ -103,11 +106,19 @@ it("traces example semantic modules with their parsed artifact digests", async ( expect(response.isError).not.toBe(true); expect(response.structuredContent).toMatchObject({ normalized_result: { + evidence_contexts: expect.arrayContaining([ + expect.objectContaining({ + context_id: module.evidence.context_id, + artifact: expect.objectContaining({ + sha256: evidenceContext.artifact.sha256, + }), + }), + ]), nodes: expect.arrayContaining([ expect.objectContaining({ node_id: module.node_id, identity: expect.objectContaining({ - artifact_sha256: module.evidence.artifact.sha256, + artifact_sha256: evidenceContext.artifact.sha256, }), }), ]),