From b3939ea9ffc79a6f0998af76b0f6c9f166d2963b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:42:35 +0000 Subject: [PATCH] feat(javascript): add bounded semantic relation graph --- src/domain/javascriptSemanticGraph.ts | 546 +++++++++++++++++ src/domain/javascriptSemanticGraphSchemas.ts | 393 ++++++++++++ src/domain/javascriptSemanticQuery.ts | 568 ++++++++++++++++++ .../javascriptSemanticQueryAssessment.ts | 110 ++++ src/domain/javascriptSemanticQuerySchemas.ts | 227 +++++++ tests/javascriptSemanticGraphQuery.test.ts | 477 +++++++++++++++ 6 files changed, 2321 insertions(+) create mode 100644 src/domain/javascriptSemanticGraph.ts create mode 100644 src/domain/javascriptSemanticGraphSchemas.ts create mode 100644 src/domain/javascriptSemanticQuery.ts create mode 100644 src/domain/javascriptSemanticQueryAssessment.ts create mode 100644 src/domain/javascriptSemanticQuerySchemas.ts create mode 100644 tests/javascriptSemanticGraphQuery.test.ts diff --git a/src/domain/javascriptSemanticGraph.ts b/src/domain/javascriptSemanticGraph.ts new file mode 100644 index 0000000000..257e3adf30 --- /dev/null +++ b/src/domain/javascriptSemanticGraph.ts @@ -0,0 +1,546 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { compareCodePoints } from "./javascriptApplicationGraph.js"; +import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; +import { + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, + JAVASCRIPT_SEMANTIC_RELATION_FAMILY, + javaScriptSemanticFingerprintInputSchema, + javaScriptSemanticFingerprintSchema, + javaScriptSemanticGraphInputSchema, + javaScriptSemanticGraphRecordSchema, + javaScriptSemanticNodeInputSchema, + javaScriptSemanticNodeSchema, + javaScriptSemanticRelationInputSchema, + javaScriptSemanticRelationSchema, + javaScriptSemanticUnknownInputSchema, + javaScriptSemanticUnknownSchema, + type JavaScriptSemanticGraphInput, + type JavaScriptSemanticGraphNode, + type JavaScriptSemanticGraphRelation, +} from "./javascriptSemanticGraphSchemas.js"; + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError( + "JavaScript semantic graph could not canonicalize data", + ); + return encoded; +}; + +const digestCanonical = (value: unknown): string => + createHash("sha256").update(canonicalJson(value)).digest("hex"); + +const uniqueSorted = ( + values: readonly Value[], +): Value[] => [...new Set(values)].sort(compareCodePoints); + +const normalizeEvidence = ( + evidence: ApplicationGraphEvidence, +): ApplicationGraphEvidence => ({ + ...evidence, + coverage: { + ...evidence.coverage, + limits: [...evidence.coverage.limits].sort((left, right) => + compareCodePoints(canonicalJson(left), canonicalJson(right)), + ), + }, + limitations: uniqueSorted(evidence.limitations), + evidence_ids: uniqueSorted(evidence.evidence_ids), +}); + +const nodeIdentity = ( + node: Pick, +) => ({ kind: node.kind, identity: node.identity }); + +const nodeId = ( + node: Pick, +): string => `jsrg_node_${digestCanonical(nodeIdentity(node))}`; + +/** Normalize one semantic entity and derive its artifact-version identifier. */ +export const createJavaScriptSemanticGraphNode = ( + input: unknown, +): JavaScriptSemanticGraphNode => { + const parsed = javaScriptSemanticNodeInputSchema.parse(input); + const semantic = { + ...parsed, + application_node_ids: uniqueSorted(parsed.application_node_ids), + evidence: normalizeEvidence(parsed.evidence), + identifier_strategy: { + strategy: "semantic-content-sha256" as const, + stability: "artifact-version" as const, + }, + }; + return javaScriptSemanticNodeSchema.parse({ + ...semantic, + node_id: nodeId(semantic), + }); +}; + +/** Normalize one semantic relationship and derive its exact identifier. */ +export const createJavaScriptSemanticGraphRelation = ( + input: unknown, +): JavaScriptSemanticGraphRelation => { + const parsed = javaScriptSemanticRelationInputSchema.parse(input); + const semantic = { + ...parsed, + evidence: normalizeEvidence(parsed.evidence), + identifier_strategy: { + strategy: "semantic-content-sha256" as const, + stability: "relationship-exact" as const, + }, + }; + return javaScriptSemanticRelationSchema.parse({ + ...semantic, + relation_id: `jsrg_relation_${digestCanonical(semantic)}`, + }); +}; + +/** Normalize one unresolved semantic frontier and derive its identifier. */ +export const createJavaScriptSemanticGraphUnknown = (input: unknown) => { + 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), + }; + return javaScriptSemanticUnknownSchema.parse({ + ...semantic, + unknown_id: `jsrg_unknown_${digestCanonical(semantic)}`, + }); +}; + +/** Normalize one function fingerprint and derive its component commitment. */ +export const createJavaScriptSemanticFingerprint = (input: unknown) => { + const parsed = javaScriptSemanticFingerprintInputSchema.parse(input); + const semantic = { + ...parsed, + components: { + ...parsed.components, + effects: uniqueSorted(parsed.components.effects), + }, + limitations: uniqueSorted(parsed.limitations), + evidence: normalizeEvidence(parsed.evidence), + }; + const fingerprintSha256 = digestCanonical(semantic.components); + return javaScriptSemanticFingerprintSchema.parse({ + ...semantic, + fingerprint_sha256: fingerprintSha256, + fingerprint_id: `jsrg_fingerprint_${digestCanonical({ + function_node_id: semantic.function_node_id, + algorithm: semantic.algorithm, + fingerprint_sha256: fingerprintSha256, + })}`, + }); +}; + +type GraphRecord = z.infer; + +const sortedUniqueIssue = ( + values: readonly string[], + path: PropertyKey[], + label: string, + context: z.RefinementCtx, +): void => { + for (let index = 1; index < values.length; index += 1) { + if (compareCodePoints(values[index - 1] ?? "", values[index] ?? "") < 0) + continue; + context.addIssue({ + code: "custom", + path: [...path, index], + message: `${label} must be sorted and unique`, + }); + return; + } +}; + +const checkCoverage = (graph: GraphRecord, context: z.RefinementCtx): void => { + const families = graph.coverage.families.map(({ family }) => family); + if ( + canonicalJson(families) !== + canonicalJson(JAVASCRIPT_SEMANTIC_RELATION_FAMILIES) + ) + context.addIssue({ + code: "custom", + path: ["coverage", "families"], + message: + "Coverage must name every semantic relation family once in canonical order", + }); + if ( + graph.coverage.status === "complete" && + (graph.coverage.truncated || + graph.coverage.omitted_nodes !== 0 || + graph.coverage.omitted_relations !== 0 || + graph.coverage.families.some(({ status }) => status !== "complete")) + ) + context.addIssue({ + code: "custom", + path: ["coverage"], + message: "Complete graph coverage cannot omit or truncate semantic facts", + }); + if (graph.coverage.truncated && graph.coverage.limits.length === 0) + context.addIssue({ + code: "custom", + path: ["coverage", "limits"], + message: "Truncated graph coverage must identify an effective limit", + }); + const relationsByFamily = new Map( + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => [family, 0]), + ); + for (const relation of graph.relations) { + const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation]; + relationsByFamily.set(family, (relationsByFamily.get(family) ?? 0) + 1); + } + const unknownsById = new Map( + graph.unknowns.map((unknown) => [unknown.unknown_id, unknown]), + ); + for (const [index, family] of graph.coverage.families.entries()) { + if (family.retained_relations !== relationsByFamily.get(family.family)) + context.addIssue({ + code: "custom", + path: ["coverage", "families", index, "retained_relations"], + message: "Family retained relation count does not match graph content", + }); + if ( + family.unknown_ids.some( + (identifier) => unknownsById.get(identifier)?.family !== family.family, + ) + ) + context.addIssue({ + code: "custom", + path: ["coverage", "families", index, "unknown_ids"], + message: "Family coverage references an unknown from another family", + }); + if ( + family.status === "complete" && + (family.omitted_relations !== 0 || family.unknown_ids.length > 0) + ) + context.addIssue({ + code: "custom", + path: ["coverage", "families", index], + message: "Complete family coverage cannot omit or retain unknown facts", + }); + } +}; + +const checkCanonicalOrder = ( + graph: GraphRecord, + context: z.RefinementCtx, +): void => { + sortedUniqueIssue( + graph.root_node_ids, + ["root_node_ids"], + "Root nodes", + context, + ); + sortedUniqueIssue( + graph.nodes.map(({ node_id }) => node_id), + ["nodes"], + "Nodes", + context, + ); + sortedUniqueIssue( + graph.relations.map(({ relation_id }) => relation_id), + ["relations"], + "Relations", + context, + ); + sortedUniqueIssue( + graph.fingerprints.map(({ fingerprint_id }) => fingerprint_id), + ["fingerprints"], + "Fingerprints", + context, + ); + sortedUniqueIssue( + graph.unknowns.map(({ unknown_id }) => unknown_id), + ["unknowns"], + "Unknown frontiers", + context, + ); + sortedUniqueIssue(graph.limitations, ["limitations"], "Limitations", context); +}; + +const checkNodes = ( + graph: GraphRecord, + nodes: ReadonlyMap, + context: z.RefinementCtx, +): void => { + for (const [index, node] of graph.nodes.entries()) { + if (node.node_id !== nodeId(node)) + context.addIssue({ + code: "custom", + path: ["nodes", index, "node_id"], + message: "Node identifier is stale", + }); + if ( + node.function_node_id !== null && + nodes.get(node.function_node_id)?.kind !== "function" + ) + context.addIssue({ + code: "custom", + path: ["nodes", index, "function_node_id"], + message: "Function owner must name a function node", + }); + } +}; + +const checkRoots = ( + graph: GraphRecord, + nodes: ReadonlyMap, + context: z.RefinementCtx, +): void => { + for (const root of graph.root_node_ids) + if (!nodes.has(root)) + context.addIssue({ + code: "custom", + path: ["root_node_ids"], + message: "Root identifier must name a semantic node", + }); +}; + +const checkRelations = ( + graph: GraphRecord, + nodes: ReadonlyMap, + context: z.RefinementCtx, +): void => { + for (const [index, relation] of graph.relations.entries()) { + const { relation_id: identifier, ...semantic } = relation; + if (identifier !== "jsrg_relation_" + digestCanonical(semantic)) + context.addIssue({ + code: "custom", + path: ["relations", index, "relation_id"], + message: "Relation identifier is stale", + }); + if ( + !nodes.has(relation.source_node_id) || + !nodes.has(relation.target_node_id) + ) + context.addIssue({ + code: "custom", + path: ["relations", index], + message: "Relation endpoints must name semantic nodes", + }); + if (relation.source_node_id === relation.target_node_id) + context.addIssue({ + code: "custom", + path: ["relations", index], + message: "Semantic relations cannot be self-referential", + }); + if ( + relation.resolution === "candidate" && + relation.evidence.state === "observed" + ) + context.addIssue({ + code: "custom", + path: ["relations", index, "evidence", "state"], + message: "Candidate relations cannot claim observed resolution", + }); + } +}; + +const checkUnknowns = ( + graph: GraphRecord, + nodes: ReadonlyMap, + context: z.RefinementCtx, +): void => { + for (const [index, unknown] of graph.unknowns.entries()) { + const { unknown_id: identifier, ...semantic } = unknown; + if (identifier !== "jsrg_unknown_" + digestCanonical(semantic)) + context.addIssue({ + code: "custom", + path: ["unknowns", index, "unknown_id"], + message: "Unknown frontier identifier is stale", + }); + if (unknown.node_id !== null && !nodes.has(unknown.node_id)) + context.addIssue({ + code: "custom", + path: ["unknowns", index, "node_id"], + message: "Unknown frontier node is absent", + }); + if ( + unknown.relation_kinds.some( + (kind) => JAVASCRIPT_SEMANTIC_RELATION_FAMILY[kind] !== unknown.family, + ) + ) + context.addIssue({ + code: "custom", + path: ["unknowns", index, "relation_kinds"], + message: "Unknown relation kinds must belong to their declared family", + }); + if (!["unknown", "unavailable"].includes(unknown.evidence.state)) + context.addIssue({ + code: "custom", + path: ["unknowns", index, "evidence", "state"], + message: "Unknown frontiers require unknown or unavailable evidence", + }); + } +}; + +const checkUnknownReferences = ( + graph: GraphRecord, + context: z.RefinementCtx, +): void => { + const unknownIds = new Set( + graph.unknowns.map(({ unknown_id }) => unknown_id), + ); + for (const [index, family] of graph.coverage.families.entries()) + if (family.unknown_ids.some((identifier) => !unknownIds.has(identifier))) + context.addIssue({ + code: "custom", + path: ["coverage", "families", index, "unknown_ids"], + message: "Family coverage references an absent unknown frontier", + }); +}; + +const checkFingerprints = ( + graph: GraphRecord, + nodes: ReadonlyMap, + context: z.RefinementCtx, +): void => { + for (const [index, fingerprint] of graph.fingerprints.entries()) { + if (nodes.get(fingerprint.function_node_id)?.kind !== "function") + context.addIssue({ + code: "custom", + path: ["fingerprints", index, "function_node_id"], + message: "Fingerprint must name a function node", + }); + if ( + fingerprint.fingerprint_sha256 !== digestCanonical(fingerprint.components) + ) + context.addIssue({ + code: "custom", + path: ["fingerprints", index, "fingerprint_sha256"], + message: "Fingerprint component commitment is stale", + }); + const expectedIdentifier = + "jsrg_fingerprint_" + + digestCanonical({ + function_node_id: fingerprint.function_node_id, + algorithm: fingerprint.algorithm, + fingerprint_sha256: fingerprint.fingerprint_sha256, + }); + if (fingerprint.fingerprint_id !== expectedIdentifier) + context.addIssue({ + code: "custom", + path: ["fingerprints", index, "fingerprint_id"], + message: "Fingerprint identifier is stale", + }); + if ( + fingerprint.status !== "complete" && + fingerprint.limitations.length === 0 + ) + context.addIssue({ + code: "custom", + path: ["fingerprints", index, "limitations"], + message: "Incomplete fingerprints require a limitation", + }); + } +}; + +const checkGraph = (graph: GraphRecord, context: z.RefinementCtx): void => { + checkCanonicalOrder(graph, context); + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + checkNodes(graph, nodes, context); + checkRoots(graph, nodes, context); + checkRelations(graph, nodes, context); + checkUnknowns(graph, nodes, context); + checkUnknownReferences(graph, context); + checkFingerprints(graph, nodes, context); + checkCoverage(graph, context); + if (graph.coverage.status !== "complete" && graph.limitations.length === 0) + context.addIssue({ + code: "custom", + path: ["limitations"], + message: "Non-complete graph coverage requires a limitation", + }); + const { graph_id: identifier, ...semantic } = graph; + if (identifier !== "jsrg_" + digestCanonical(semantic)) + context.addIssue({ + code: "custom", + path: ["graph_id"], + message: "Graph identifier is stale", + }); +}; + +/** Strict semantic graph schema with verified canonical commitments. */ +export const javaScriptSemanticGraphSchema = + javaScriptSemanticGraphRecordSchema.superRefine(checkGraph); + +/** Fully validated JavaScript Semantic Relation Graph v1. */ +export type JavaScriptSemanticGraph = z.infer< + typeof javaScriptSemanticGraphSchema +>; + +/** Normalize a complete companion graph and derive its graph ID. */ +export const createJavaScriptSemanticGraph = ( + input: unknown, +): JavaScriptSemanticGraph => { + const parsed = javaScriptSemanticGraphInputSchema.parse(input); + const semantic: JavaScriptSemanticGraphInput = { + ...parsed, + root_node_ids: uniqueSorted(parsed.root_node_ids), + nodes: [...parsed.nodes].sort((left, right) => + compareCodePoints(left.node_id, right.node_id), + ), + relations: [...parsed.relations].sort((left, right) => + compareCodePoints(left.relation_id, right.relation_id), + ), + fingerprints: [...parsed.fingerprints].sort((left, right) => + compareCodePoints(left.fingerprint_id, right.fingerprint_id), + ), + unknowns: [...parsed.unknowns].sort((left, right) => + compareCodePoints(left.unknown_id, right.unknown_id), + ), + coverage: { + ...parsed.coverage, + limits: [...parsed.coverage.limits].sort((left, right) => + compareCodePoints(canonicalJson(left), canonicalJson(right)), + ), + families: [...parsed.coverage.families] + .map((family) => ({ + ...family, + unknown_ids: uniqueSorted(family.unknown_ids), + })) + .sort((left, right) => compareCodePoints(left.family, right.family)), + }, + limitations: uniqueSorted(parsed.limitations), + }; + return javaScriptSemanticGraphSchema.parse({ + ...semantic, + graph_id: `jsrg_${digestCanonical(semantic)}`, + }); +}; + +/** Parse a stored semantic graph and reject unsupported versions or stale IDs. */ +export const parseJavaScriptSemanticGraph = ( + input: unknown, +): JavaScriptSemanticGraph => { + const envelope = z + .object({ schema: z.string(), schema_version: z.number() }) + .passthrough() + .safeParse(input); + if ( + envelope.success && + envelope.data.schema === "JavaScriptSemanticRelationGraph" && + envelope.data.schema_version !== 1 + ) + throw new TypeError( + `Unsupported JavaScript Semantic Relation Graph schema version: ${String(envelope.data.schema_version)}`, + ); + return javaScriptSemanticGraphSchema.parse(input); +}; + +/** Serialize a verified semantic graph as canonical JSON. */ +export const serializeJavaScriptSemanticGraph = (input: unknown): string => + canonicalJson(parseJavaScriptSemanticGraph(input)); + +/** Compute the canonical SHA-256 commitment of a verified semantic graph. */ +export const computeJavaScriptSemanticGraphSha256 = (input: unknown): string => + digestCanonical(parseJavaScriptSemanticGraph(input)); + +export type { JavaScriptSemanticGraphNode, JavaScriptSemanticGraphRelation }; diff --git a/src/domain/javascriptSemanticGraphSchemas.ts b/src/domain/javascriptSemanticGraphSchemas.ts new file mode 100644 index 0000000000..ad72461471 --- /dev/null +++ b/src/domain/javascriptSemanticGraphSchemas.ts @@ -0,0 +1,393 @@ +import { z } from "zod"; + +import { applicationGraphEvidenceSchema } from "./javascriptApplicationEvidenceSchemas.js"; +import { isJsonWithinLimits } from "./jsonLimits.js"; +import { jsonValueSchema } from "./jsonValue.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const boundedTextSchema = z.string().min(1).max(4_096); +const semanticNodeIdSchema = z.string().regex(/^jsrg_node_[a-f0-9]{64}$/u); +const semanticRelationIdSchema = z + .string() + .regex(/^jsrg_relation_[a-f0-9]{64}$/u); +const semanticUnknownIdSchema = z + .string() + .regex(/^jsrg_unknown_[a-f0-9]{64}$/u); + +/** Semantic entity kinds admitted by JavaScript Semantic Relation Graph v1. */ +export const JAVASCRIPT_SEMANTIC_NODE_KINDS = [ + "module", + "function", + "parameter", + "binding", + "expression", + "literal", + "property-slot", + "call-site", + "return-site", + "closure-capture", + "promise", + "task", + "event", + "listener", + "timer", + "child-process", + "stdio", + "signal", + "config-source", + "request", + "response", + "boundary", + "resource", + "unknown-frontier", +] as const; + +/** Required semantic-analysis coverage families. */ +export const JAVASCRIPT_SEMANTIC_RELATION_FAMILIES = [ + "boundary", + "call-flow", + "child-process", + "closure", + "configuration", + "data-flow", + "event", + "object-flow", + "promise-ownership", + "request", + "resource-lifecycle", + "timer", +] as const; + +/** Directed semantic relations admitted by graph v1. */ +export const JAVASCRIPT_SEMANTIC_RELATIONS = [ + "acquires", + "aggregates", + "aliases", + "argument-to-parameter", + "awaits", + "calls", + "cancels-timer", + "captures", + "chains", + "coerces", + "connects-stdio", + "consumed-by", + "constructs-request", + "creates-promise", + "defaults", + "defines", + "destructures", + "detaches-task", + "dispatches-candidate", + "forwards-signal", + "listens-error", + "listens-exit", + "owns", + "overrides", + "parses", + "reads", + "reads-argv", + "reads-config", + "reads-environment", + "reads-property", + "registers-listener", + "releases", + "removes-listener", + "returns-task", + "returns-to-call", + "schedules-timer", + "spawns", + "spreads", + "supplies-argv", + "supplies-env", + "supplies-request-field", + "validates", + "writes", + "writes-property", +] as const; + +/** Map each semantic relation to its coverage family. */ +export const JAVASCRIPT_SEMANTIC_RELATION_FAMILY = { + acquires: "resource-lifecycle", + aggregates: "promise-ownership", + aliases: "data-flow", + "argument-to-parameter": "call-flow", + awaits: "promise-ownership", + calls: "call-flow", + "cancels-timer": "timer", + captures: "closure", + chains: "promise-ownership", + coerces: "boundary", + "connects-stdio": "child-process", + "consumed-by": "request", + "constructs-request": "request", + "creates-promise": "promise-ownership", + defaults: "configuration", + defines: "data-flow", + destructures: "object-flow", + "detaches-task": "promise-ownership", + "dispatches-candidate": "event", + "forwards-signal": "child-process", + "listens-error": "child-process", + "listens-exit": "child-process", + owns: "promise-ownership", + overrides: "configuration", + parses: "boundary", + reads: "data-flow", + "reads-argv": "configuration", + "reads-config": "configuration", + "reads-environment": "configuration", + "reads-property": "object-flow", + "registers-listener": "event", + releases: "resource-lifecycle", + "removes-listener": "event", + "returns-task": "promise-ownership", + "returns-to-call": "call-flow", + "schedules-timer": "timer", + spawns: "child-process", + spreads: "object-flow", + "supplies-argv": "child-process", + "supplies-env": "child-process", + "supplies-request-field": "request", + validates: "boundary", + writes: "data-flow", + "writes-property": "object-flow", +} as const satisfies Readonly< + Record< + (typeof JAVASCRIPT_SEMANTIC_RELATIONS)[number], + (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number] + > +>; + +const relativePathSchema = z + .string() + .min(1) + .max(4_096) + .refine( + (path) => + !path.startsWith("/") && + !path.includes("\\") && + path.split("/").every((part) => !["", ".", ".."].includes(part)), + "Expected a normalized relative POSIX path without traversal", + ); + +const sourcePointSchema = z.strictObject({ + line: z.number().int().min(1), + column: z.number().int().min(0), +}); + +const sourceRangeSchema = z + .strictObject({ start: sourcePointSchema, end: sourcePointSchema }) + .superRefine((range, context) => { + if ( + range.end.line < range.start.line || + (range.end.line === range.start.line && + range.end.column < range.start.column) + ) + context.addIssue({ + code: "custom", + path: ["end"], + message: "Source range end must not precede its start", + }); + }); + +const semanticPropertiesSchema = z + .record(z.string().min(1).max(128), jsonValueSchema) + .superRefine((properties, context) => { + if (Object.keys(properties).length > 64) + context.addIssue({ + code: "custom", + message: "Semantic properties exceed 64 keys", + }); + if ( + !isJsonWithinLimits(properties, { + maxDepth: 6, + maxNodes: 512, + maxStringLength: 4_096, + }) + ) + context.addIssue({ + code: "custom", + message: "Semantic properties exceed structural limits", + }); + }); + +const semanticNodeIdentitySchema = z.strictObject({ + artifact_sha256: digestSchema, + module_path: relativePathSchema, + source_range: sourceRangeSchema.nullable(), + role_key: z.string().min(1).max(1_024), +}); + +/** One semantic entity before its canonical identifier is derived. */ +export const javaScriptSemanticNodeInputSchema = z.strictObject({ + kind: z.enum(JAVASCRIPT_SEMANTIC_NODE_KINDS), + identity: semanticNodeIdentitySchema, + function_node_id: semanticNodeIdSchema.nullable(), + application_node_ids: z + .array(z.string().regex(/^jag_node_[a-f0-9]{64}$/u)) + .max(64), + label: z.string().min(1).max(1_024).nullable(), + properties: semanticPropertiesSchema, + evidence: applicationGraphEvidenceSchema, +}); + +/** One exact, artifact-version semantic entity. */ +export const javaScriptSemanticNodeSchema = + javaScriptSemanticNodeInputSchema.extend({ + node_id: semanticNodeIdSchema, + identifier_strategy: z.strictObject({ + strategy: z.literal("semantic-content-sha256"), + stability: z.literal("artifact-version"), + }), + }); + +/** One directed semantic relationship before its ID is derived. */ +export const javaScriptSemanticRelationInputSchema = z.strictObject({ + source_node_id: semanticNodeIdSchema, + target_node_id: semanticNodeIdSchema, + relation: z.enum(JAVASCRIPT_SEMANTIC_RELATIONS), + resolution: z.enum(["resolved", "candidate"]), + properties: semanticPropertiesSchema, + evidence: applicationGraphEvidenceSchema, +}); + +/** One canonical directed semantic relationship. */ +export const javaScriptSemanticRelationSchema = + javaScriptSemanticRelationInputSchema.extend({ + relation_id: semanticRelationIdSchema, + identifier_strategy: z.strictObject({ + strategy: z.literal("semantic-content-sha256"), + stability: z.literal("relationship-exact"), + }), + }); + +/** Reasons semantic analysis can leave an explicit frontier. */ +export const JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS = [ + "ambiguous-target", + "bound-reached", + "dynamic-call", + "dynamic-property", + "eval-or-generated-code", + "incomplete-module", + "missing-source", + "unsupported-syntax", +] as const; + +/** One unresolved semantic frontier before its ID is derived. */ +export const javaScriptSemanticUnknownInputSchema = z.strictObject({ + node_id: semanticNodeIdSchema.nullable(), + family: z.enum(JAVASCRIPT_SEMANTIC_RELATION_FAMILIES), + relation_kinds: z.array(z.enum(JAVASCRIPT_SEMANTIC_RELATIONS)).min(1).max(45), + reason: z.enum(JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS), + detail: boundedTextSchema, + candidate_node_ids: z.array(semanticNodeIdSchema).max(1_000), + evidence: applicationGraphEvidenceSchema, +}); + +/** One canonical unresolved semantic frontier. */ +export const javaScriptSemanticUnknownSchema = + javaScriptSemanticUnknownInputSchema.extend({ + unknown_id: semanticUnknownIdSchema, + }); + +const fingerprintComponentsSchema = z.strictObject({ + parameter_arity: z.number().int().min(0).max(10_000), + normalized_ast_sha256: digestSchema, + control_flow_sha256: digestSchema, + relation_shape_sha256: digestSchema, + literal_set_sha256: digestSchema, + effects: z + .array( + z.enum([ + "async", + "child-process", + "event", + "network", + "promise", + "resource", + "timer", + ]), + ) + .max(7), +}); + +/** One bounded rename-resistant function fingerprint. */ +export const javaScriptSemanticFingerprintInputSchema = z.strictObject({ + function_node_id: semanticNodeIdSchema, + algorithm: z.literal("rea.javascript-semantic-function/v1"), + status: z.enum(["complete", "partial", "unavailable"]), + components: fingerprintComponentsSchema, + limitations: z.array(boundedTextSchema).max(100), + evidence: applicationGraphEvidenceSchema, +}); + +/** One canonical function fingerprint record. */ +export const javaScriptSemanticFingerprintSchema = + javaScriptSemanticFingerprintInputSchema.extend({ + fingerprint_id: z.string().regex(/^jsrg_fingerprint_[a-f0-9]{64}$/u), + fingerprint_sha256: digestSchema, + }); + +const familyCoverageSchema = z.strictObject({ + family: z.enum(JAVASCRIPT_SEMANTIC_RELATION_FAMILIES), + status: z.enum(["complete", "partial", "unknown", "unsupported"]), + retained_relations: z.number().int().min(0), + omitted_relations: z.number().int().min(0).nullable(), + unknown_ids: z.array(semanticUnknownIdSchema).max(100_000), +}); + +const graphCoverageSchema = z.strictObject({ + status: z.enum(["complete", "partial", "unknown", "unavailable"]), + truncated: z.boolean(), + omitted_nodes: z.number().int().min(0).nullable(), + omitted_relations: z.number().int().min(0).nullable(), + limits: z + .array( + z.strictObject({ + name: z.string().min(1).max(128), + value: z.number().int().min(0), + unit: z.enum(["items", "bytes", "milliseconds", "depth", "other"]), + }), + ) + .max(32), + families: z.array(familyCoverageSchema).length(12), +}); + +/** Graph content before its top-level commitment is derived. */ +export const javaScriptSemanticGraphInputSchema = z.strictObject({ + schema: z.literal("JavaScriptSemanticRelationGraph"), + schema_version: z.literal(1), + root_artifact_sha256: digestSchema, + application_graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u), + root_node_ids: z.array(semanticNodeIdSchema).min(1).max(10_000), + nodes: z.array(javaScriptSemanticNodeSchema).min(1).max(100_000), + relations: z.array(javaScriptSemanticRelationSchema).max(200_000), + fingerprints: z.array(javaScriptSemanticFingerprintSchema).max(20_000), + unknowns: z.array(javaScriptSemanticUnknownSchema).max(100_000), + coverage: graphCoverageSchema, + limitations: z.array(boundedTextSchema).max(1_000), +}); + +/** Stored JavaScript Semantic Relation Graph v1. */ +export const javaScriptSemanticGraphRecordSchema = + javaScriptSemanticGraphInputSchema.extend({ + graph_id: z.string().regex(/^jsrg_[a-f0-9]{64}$/u), + }); + +/** Canonical semantic node. */ +export type JavaScriptSemanticGraphNode = z.infer< + typeof javaScriptSemanticNodeSchema +>; +/** Canonical semantic relation. */ +export type JavaScriptSemanticGraphRelation = z.infer< + typeof javaScriptSemanticRelationSchema +>; +/** Canonical unresolved semantic frontier. */ +export type JavaScriptSemanticGraphUnknown = z.infer< + typeof javaScriptSemanticUnknownSchema +>; +/** Canonical graph content before graph ID derivation. */ +export type JavaScriptSemanticGraphInput = z.infer< + typeof javaScriptSemanticGraphInputSchema +>; diff --git a/src/domain/javascriptSemanticQuery.ts b/src/domain/javascriptSemanticQuery.ts new file mode 100644 index 0000000000..7dfe450948 --- /dev/null +++ b/src/domain/javascriptSemanticQuery.ts @@ -0,0 +1,568 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import { compareCodePoints } from "./javascriptApplicationGraph.js"; +import type { + JavaScriptSemanticGraph, + JavaScriptSemanticGraphNode, + JavaScriptSemanticGraphRelation, +} from "./javascriptSemanticGraph.js"; +import { + JAVASCRIPT_SEMANTIC_RELATION_FAMILY, + type JavaScriptSemanticGraphUnknown, +} from "./javascriptSemanticGraphSchemas.js"; +import { + javaScriptSemanticQueryInputSchema, + javaScriptSemanticQueryResultSchema, + type JavaScriptSemanticQueryInput, + type JavaScriptSemanticQueryResult, +} from "./javascriptSemanticQuerySchemas.js"; +import { + assessJavaScriptSemanticQuery, + type JavaScriptSemanticQueryFrontier, +} from "./javascriptSemanticQueryAssessment.js"; + +interface TraversalEntry { + readonly relation: JavaScriptSemanticGraphRelation; + readonly nextNodeId: string; +} + +type QueryFrontier = JavaScriptSemanticQueryFrontier; + +interface Traversal { + readonly nodeIds: Set; + readonly relationIds: Set; + readonly functionIds: Set; + readonly modules: Set; + readonly frontier: QueryFrontier[]; +} + +interface SeedAdmission { + readonly nodeIds: string[]; + readonly frontier: QueryFrontier[]; +} + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError( + "JavaScript semantic query could not canonicalize data", + ); + return encoded; +}; + +const digest = (value: unknown): string => + createHash("sha256").update(canonicalJson(value)).digest("hex"); + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort(compareCodePoints); + +/** Run one deterministic bounded traversal over a verified semantic graph. */ +export const queryJavaScriptSemanticGraph = ( + graph: JavaScriptSemanticGraph, + rawInput: unknown, +): JavaScriptSemanticQueryResult => { + const input = javaScriptSemanticQueryInputSchema.parse(rawInput); + const queryId = queryIdentifier(graph, input); + const offset = parseCursor(input.cursor, queryId); + const seeds = resolveSeeds(graph, input); + const admission = admitSeeds(graph, seeds, input); + const retainedSeeds = admission.nodeIds; + const adjacency = buildAdjacency(graph.relations, input); + const traversal = traverse(graph, retainedSeeds, adjacency, input); + const frontier = uniqueFrontier([ + ...traversal.frontier, + ...admission.frontier, + ]); + const retainedNodes = graph.nodes.filter(({ node_id }) => + traversal.nodeIds.has(node_id), + ); + const retainedRelations = graph.relations.filter(({ relation_id }) => + traversal.relationIds.has(relation_id), + ); + if (offset > retainedRelations.length) + throw new TypeError("Semantic query cursor is beyond the retained result"); + const pageRelations = retainedRelations.slice( + offset, + offset + input.limits.page_size, + ); + const pageNodeIds = new Set([ + ...retainedSeeds, + ...pageRelations.flatMap(({ source_node_id, target_node_id }) => [ + source_node_id, + target_node_id, + ]), + ]); + const pageNodes = graph.nodes.filter(({ node_id }) => + pageNodeIds.has(node_id), + ); + const relevantUnknowns = relevantUnknownFrontiers( + graph, + traversal.nodeIds, + input, + ); + const expectedMatches = expectedMatchesFor( + retainedNodes, + retainedRelations, + input, + ); + const assessment = assessJavaScriptSemanticQuery({ + graph, + totalSeeds: seeds.length, + retainedSeeds: retainedSeeds.length, + expectedMatches: expectedMatches.length, + hasExpectation: input.expected !== null, + frontier, + unknowns: relevantUnknowns, + }); + const nextOffset = offset + pageRelations.length; + const nextCursor = + nextOffset < retainedRelations.length + ? createCursor(queryId, nextOffset) + : null; + return javaScriptSemanticQueryResultSchema.parse({ + schema_version: 1, + query_id: queryId, + source_graph_id: graph.graph_id, + seed: input.seed, + direction: input.direction, + status: assessment.status, + seed_node_ids: retainedSeeds, + nodes: pageNodes, + relations: pageRelations, + unknowns: relevantUnknowns, + expected_match_node_ids: expectedMatches.map(({ node_id }) => node_id), + summary: { + total_seed_matches: seeds.length, + retained_seed_matches: retainedSeeds.length, + traversed_nodes: traversal.nodeIds.size, + traversed_relations: traversal.relationIds.size, + traversed_functions: traversal.functionIds.size, + traversed_modules: traversal.modules.size, + relevant_unknowns: relevantUnknowns.length, + }, + coverage: assessment.coverage, + page: { + offset, + size: pageRelations.length, + next_cursor: nextCursor, + }, + applied_limits: input.limits, + accepted_limit_ranges: assessment.acceptedLimitRanges, + limitations: assessment.limitations, + }); +}; + +const queryIdentifier = ( + graph: JavaScriptSemanticGraph, + input: JavaScriptSemanticQueryInput, +): string => { + const { cursor: _cursor, ...semantic } = input; + return `jsrq_${digest({ + source_graph_id: graph.graph_id, + ...semantic, + allowed_relations: + semantic.allowed_relations === undefined + ? undefined + : uniqueSorted(semantic.allowed_relations), + expected: + semantic.expected === null + ? null + : { + ...semantic.expected, + classes: uniqueSorted(semantic.expected.classes), + }, + })}`; +}; + +const createCursor = (queryId: string, offset: number): string => + `jsrqc_${String(offset)}_${digest({ query_id: queryId, offset })}`; + +const parseCursor = (cursor: string | null, queryId: string): number => { + if (cursor === null) return 0; + const match = /^jsrqc_([0-9]+)_([a-f0-9]{64})$/u.exec(cursor); + const offsetText = match?.[1]; + const commitment = match?.[2]; + if (offsetText === undefined || commitment === undefined) + throw new TypeError("Semantic query cursor is malformed"); + const offset = Number(offsetText); + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + commitment !== digest({ query_id: queryId, offset }) + ) + throw new TypeError("Semantic query cursor does not match this query"); + return offset; +}; + +const resolveSeeds = ( + graph: JavaScriptSemanticGraph, + input: JavaScriptSemanticQueryInput, +): string[] => { + const fingerprintFunctions = new Set( + input.seed.kind === "function" + ? graph.fingerprints + .filter(({ fingerprint_sha256 }) => { + const seed = input.seed; + return ( + seed.kind === "function" && + fingerprint_sha256 === seed.fingerprint_sha256 + ); + }) + .map(({ function_node_id }) => function_node_id) + : [], + ); + return graph.nodes + .filter((node) => seedMatches(node, input, fingerprintFunctions)) + .map(({ node_id }) => node_id) + .sort(compareCodePoints); +}; + +const seedMatches = ( + node: JavaScriptSemanticGraphNode, + input: JavaScriptSemanticQueryInput, + fingerprintFunctions: ReadonlySet, +): boolean => { + const seed = input.seed; + if (seed.kind === "semantic-node") return node.node_id === seed.node_id; + if (seed.kind === "application-node") + return node.application_node_ids.includes(seed.node_id); + if (seed.kind === "function") return fingerprintFunctions.has(node.node_id); + if (seed.kind === "literal") + return ( + node.kind === "literal" && + canonicalJson(node.properties.value) === canonicalJson(seed.value) + ); + if (seed.kind === "property") + return node.kind === "property-slot" && node.properties.name === seed.name; + if (seed.kind === "endpoint") + return node.kind === "request" && node.properties.endpoint === seed.value; + if (seed.kind === "event") + return ( + ["event", "listener"].includes(node.kind) && + node.properties.event_name === seed.name + ); + return node.kind === "boundary" && node.properties.field === seed.field; +}; + +const admitSeeds = ( + graph: JavaScriptSemanticGraph, + seeds: readonly string[], + input: JavaScriptSemanticQueryInput, +): SeedAdmission => { + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const retained: string[] = []; + const functions = new Set(); + const modules = new Set(); + const frontier: QueryFrontier[] = []; + for (const nodeId of seeds) { + const node = nodes.get(nodeId); + if (node === undefined) continue; + const reason = seedBlockedReason(node, { + retained, + functions, + modules, + input, + }); + if (reason !== null) { + frontier.push({ node_id: nodeId, depth: 0, reason }); + continue; + } + retained.push(nodeId); + retainOwners(node, functions, modules); + } + return { nodeIds: retained, frontier: uniqueFrontier(frontier) }; +}; + +interface SeedBounds { + readonly retained: readonly string[]; + readonly functions: ReadonlySet; + readonly modules: ReadonlySet; + readonly input: JavaScriptSemanticQueryInput; +} + +interface TraversalBounds { + readonly nodes: ReadonlyMap; + readonly nodeIds: ReadonlySet; + readonly relationIds: ReadonlySet; + readonly functionIds: ReadonlySet; + readonly modules: ReadonlySet; + readonly input: JavaScriptSemanticQueryInput; +} + +const seedBlockedReason = ( + node: JavaScriptSemanticGraphNode, + bounds: SeedBounds, +): QueryFrontier["reason"] | null => { + if (bounds.retained.length >= bounds.input.limits.max_seed_matches) + return "max-seed-matches"; + if (bounds.retained.length >= bounds.input.limits.max_nodes) + return "max-nodes"; + const functionId = functionOwner(node); + if ( + functionId !== null && + !bounds.functions.has(functionId) && + bounds.functions.size >= bounds.input.limits.max_functions + ) + return "max-functions"; + if ( + !bounds.modules.has(node.identity.module_path) && + bounds.modules.size >= bounds.input.limits.max_modules + ) + return "max-modules"; + return null; +}; + +const buildAdjacency = ( + relations: readonly JavaScriptSemanticGraphRelation[], + input: JavaScriptSemanticQueryInput, +): ReadonlyMap => { + const adjacency = new Map(); + const allowed = + input.allowed_relations === undefined + ? null + : new Set(input.allowed_relations); + for (const relation of relations) { + if (allowed !== null && !allowed.has(relation.relation)) continue; + if ( + relation.resolution === "candidate" && + !input.include_ambiguous_dynamic_edges + ) + continue; + addDirectedEntries(adjacency, relation, input.direction); + } + for (const entries of adjacency.values()) + entries.sort((left, right) => + compareCodePoints( + `${left.relation.relation_id}\0${left.nextNodeId}`, + `${right.relation.relation_id}\0${right.nextNodeId}`, + ), + ); + return adjacency; +}; + +const addDirectedEntries = ( + adjacency: Map, + relation: JavaScriptSemanticGraphRelation, + direction: JavaScriptSemanticQueryInput["direction"], +): void => { + if (direction === "callers") { + if (relation.relation === "calls") + addEntry( + adjacency, + relation.target_node_id, + relation.source_node_id, + relation, + ); + return; + } + if (direction === "ownership") { + if (!ownershipRelation(relation.relation)) return; + addEntry( + adjacency, + relation.source_node_id, + relation.target_node_id, + relation, + ); + addEntry( + adjacency, + relation.target_node_id, + relation.source_node_id, + relation, + ); + return; + } + if (direction === "backward-provenance") + addEntry( + adjacency, + relation.target_node_id, + relation.source_node_id, + relation, + ); + else + addEntry( + adjacency, + relation.source_node_id, + relation.target_node_id, + relation, + ); +}; + +const ownershipRelation = ( + relation: JavaScriptSemanticGraphRelation["relation"], +): boolean => + [ + "acquires", + "aggregates", + "awaits", + "chains", + "connects-stdio", + "creates-promise", + "detaches-task", + "owns", + "releases", + "returns-task", + "spawns", + ].includes(relation); + +const addEntry = ( + adjacency: Map, + from: string, + nextNodeId: string, + relation: JavaScriptSemanticGraphRelation, +): void => { + const entries = adjacency.get(from) ?? []; + entries.push({ relation, nextNodeId }); + adjacency.set(from, entries); +}; + +const traverse = ( + graph: JavaScriptSemanticGraph, + seeds: readonly string[], + adjacency: ReadonlyMap, + input: JavaScriptSemanticQueryInput, +): Traversal => { + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const nodeIds = new Set(seeds); + const relationIds = new Set(); + const functionIds = new Set(); + const modules = new Set(); + const frontier: QueryFrontier[] = []; + for (const nodeId of nodeIds) + retainOwners(nodes.get(nodeId), functionIds, modules); + const depths = new Map([...nodeIds].map((nodeId) => [nodeId, 0])); + const queue = [...nodeIds]; + const bounds: TraversalBounds = { + nodes, + nodeIds, + relationIds, + functionIds, + modules, + input, + }; + for (let offset = 0; offset < queue.length; offset += 1) { + const current = queue[offset]; + if (current === undefined) continue; + const depth = depths.get(current) ?? 0; + for (const entry of adjacency.get(current) ?? []) { + const reason = blockedReason(entry, depth, bounds); + if (reason !== null) { + frontier.push({ node_id: current, depth, reason }); + continue; + } + relationIds.add(entry.relation.relation_id); + if (nodeIds.has(entry.nextNodeId)) continue; + const node = nodes.get(entry.nextNodeId); + if (node === undefined) continue; + nodeIds.add(entry.nextNodeId); + retainOwners(node, functionIds, modules); + depths.set(entry.nextNodeId, depth + 1); + queue.push(entry.nextNodeId); + } + } + return { + nodeIds, + relationIds, + functionIds, + modules, + frontier: uniqueFrontier(frontier), + }; +}; + +const blockedReason = ( + entry: TraversalEntry, + depth: number, + bounds: TraversalBounds, +): QueryFrontier["reason"] | null => { + if (depth >= bounds.input.limits.max_depth) return "max-depth"; + if ( + !bounds.relationIds.has(entry.relation.relation_id) && + bounds.relationIds.size >= bounds.input.limits.max_edges + ) + return "max-edges"; + if (bounds.nodeIds.has(entry.nextNodeId)) return null; + if (bounds.nodeIds.size >= bounds.input.limits.max_nodes) return "max-nodes"; + const node = bounds.nodes.get(entry.nextNodeId); + if (node === undefined) return null; + const functionId = functionOwner(node); + if ( + functionId !== null && + !bounds.functionIds.has(functionId) && + bounds.functionIds.size >= bounds.input.limits.max_functions + ) + return "max-functions"; + if ( + !bounds.modules.has(node.identity.module_path) && + bounds.modules.size >= bounds.input.limits.max_modules + ) + return "max-modules"; + return null; +}; + +const retainOwners = ( + node: JavaScriptSemanticGraphNode | undefined, + functions: Set, + modules: Set, +): void => { + if (node === undefined) return; + const functionId = functionOwner(node); + if (functionId !== null) functions.add(functionId); + modules.add(node.identity.module_path); +}; + +const functionOwner = (node: JavaScriptSemanticGraphNode): string | null => + node.kind === "function" ? node.node_id : node.function_node_id; + +const uniqueFrontier = (frontier: readonly QueryFrontier[]): QueryFrontier[] => + [ + ...new Map( + frontier.map((item) => [ + `${item.node_id}\0${String(item.depth)}\0${item.reason}`, + item, + ]), + ).values(), + ].sort((left, right) => + compareCodePoints(canonicalJson(left), canonicalJson(right)), + ); + +const relevantUnknownFrontiers = ( + graph: JavaScriptSemanticGraph, + nodeIds: ReadonlySet, + input: JavaScriptSemanticQueryInput, +): JavaScriptSemanticGraphUnknown[] => { + const allowed = + input.allowed_relations === undefined + ? null + : new Set(input.allowed_relations); + return graph.unknowns.filter( + (unknown) => + (unknown.node_id === null || nodeIds.has(unknown.node_id)) && + (allowed === null || + unknown.relation_kinds.some((relation) => allowed.has(relation))), + ); +}; + +const expectedMatchesFor = ( + nodes: readonly JavaScriptSemanticGraphNode[], + relations: readonly JavaScriptSemanticGraphRelation[], + input: JavaScriptSemanticQueryInput, +): JavaScriptSemanticGraphNode[] => { + if (input.expected === null) return []; + const classes = new Set(input.expected.classes); + const connected = new Set( + relations.map((relation) => + input.expected?.role === "source" + ? relation.target_node_id + : relation.source_node_id, + ), + ); + return nodes.filter( + ({ kind, node_id }) => classes.has(kind) && !connected.has(node_id), + ); +}; + +/** Return a relation's declared semantic coverage family. */ +export const javaScriptSemanticRelationFamily = ( + relation: JavaScriptSemanticGraphRelation["relation"], +) => JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation]; diff --git a/src/domain/javascriptSemanticQueryAssessment.ts b/src/domain/javascriptSemanticQueryAssessment.ts new file mode 100644 index 0000000000..b735903542 --- /dev/null +++ b/src/domain/javascriptSemanticQueryAssessment.ts @@ -0,0 +1,110 @@ +import { compareCodePoints } from "./javascriptApplicationGraph.js"; +import type { JavaScriptSemanticGraph } from "./javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphUnknown } from "./javascriptSemanticGraphSchemas.js"; +import type { JavaScriptSemanticQueryResult } from "./javascriptSemanticQuerySchemas.js"; + +/** One caller-limit frontier retained by deterministic query traversal. */ +export interface JavaScriptSemanticQueryFrontier { + readonly node_id: string; + readonly depth: number; + readonly reason: + | "max-depth" + | "max-edges" + | "max-functions" + | "max-modules" + | "max-nodes" + | "max-seed-matches"; +} + +/** Inputs needed to classify one completed semantic traversal. */ +export interface JavaScriptSemanticQueryAssessmentInput { + readonly graph: JavaScriptSemanticGraph; + readonly totalSeeds: number; + readonly retainedSeeds: number; + readonly expectedMatches: number; + readonly hasExpectation: boolean; + readonly frontier: readonly JavaScriptSemanticQueryFrontier[]; + readonly unknowns: readonly JavaScriptSemanticGraphUnknown[]; +} + +/** Status, coverage, limits, and limitations derived from one traversal. */ +export interface JavaScriptSemanticQueryAssessment { + readonly status: JavaScriptSemanticQueryResult["status"]; + readonly coverage: JavaScriptSemanticQueryResult["coverage"]; + readonly limitations: string[]; + readonly acceptedLimitRanges: JavaScriptSemanticQueryResult["accepted_limit_ranges"]; +} + +/** Classify a bounded semantic traversal without promoting unknowns to absence. */ +export const assessJavaScriptSemanticQuery = ( + input: JavaScriptSemanticQueryAssessmentInput, +): JavaScriptSemanticQueryAssessment => ({ + status: queryStatus(input), + coverage: queryCoverage(input), + limitations: queryLimitations(input), + acceptedLimitRanges: { + max_seed_matches: { minimum: 1, maximum: 1_000 }, + max_nodes: { minimum: 1, maximum: 50_000 }, + max_edges: { minimum: 1, maximum: 100_000 }, + max_depth: { minimum: 0, maximum: 64 }, + max_functions: { minimum: 1, maximum: 10_000 }, + max_modules: { minimum: 1, maximum: 10_000 }, + page_size: { minimum: 1, maximum: 1_000 }, + }, +}); + +const queryCoverage = ( + input: JavaScriptSemanticQueryAssessmentInput, +): JavaScriptSemanticQueryResult["coverage"] => ({ + status: + input.graph.coverage.status === "unavailable" + ? "unavailable" + : input.frontier.length > 0 + ? "truncated" + : input.unknowns.length > 0 || + input.graph.coverage.status !== "complete" + ? "partial" + : "complete", + frontier: [...input.frontier], +}); + +const queryStatus = ( + input: JavaScriptSemanticQueryAssessmentInput, +): JavaScriptSemanticQueryResult["status"] => { + if (input.graph.coverage.status === "unavailable") return "unsupported"; + if (input.frontier.length > 0) return "truncated"; + if (input.unknowns.length > 0 || input.graph.coverage.status !== "complete") + return "partial"; + if ( + input.totalSeeds === 0 || + (input.hasExpectation && input.expectedMatches === 0) + ) + return "no-match"; + return input.totalSeeds > 1 ? "ambiguous" : "found"; +}; + +const queryLimitations = ( + input: JavaScriptSemanticQueryAssessmentInput, +): string[] => + uniqueSorted([ + "Semantic graph reachability is a static inference and does not prove runtime execution.", + ...input.graph.limitations, + ...(input.totalSeeds === input.retainedSeeds + ? [] + : [ + "Seed matches exceeded caller bounds; omitted starts remain unexplored.", + ]), + ...(input.frontier.length === 0 + ? [] + : [ + "Traversal stopped at explicit caller limits; frontier facts remain unknown.", + ]), + ...(input.unknowns.length === 0 + ? [] + : [ + "Relevant dynamic, unsupported, incomplete, or ambiguous semantics remain unknown.", + ]), + ]); + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort(compareCodePoints); diff --git a/src/domain/javascriptSemanticQuerySchemas.ts b/src/domain/javascriptSemanticQuerySchemas.ts new file mode 100644 index 0000000000..00d3e8ca3f --- /dev/null +++ b/src/domain/javascriptSemanticQuerySchemas.ts @@ -0,0 +1,227 @@ +import { z } from "zod"; + +import { + JAVASCRIPT_SEMANTIC_NODE_KINDS, + JAVASCRIPT_SEMANTIC_RELATIONS, + javaScriptSemanticNodeSchema, + javaScriptSemanticRelationSchema, + javaScriptSemanticUnknownSchema, +} from "./javascriptSemanticGraphSchemas.js"; +import { jsonValueSchema } from "./jsonValue.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const semanticNodeIdSchema = z.string().regex(/^jsrg_node_[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); + +/** Exact accepted ranges and defaults for one semantic traversal. */ +export const JAVASCRIPT_SEMANTIC_QUERY_LIMIT_RANGES = { + max_seed_matches: { minimum: 1, maximum: 1_000, default: 25 }, + max_nodes: { minimum: 1, maximum: 50_000, default: 2_000 }, + max_edges: { minimum: 1, maximum: 100_000, default: 4_000 }, + max_depth: { minimum: 0, maximum: 64, default: 12 }, + max_functions: { minimum: 1, maximum: 10_000, default: 500 }, + max_modules: { minimum: 1, maximum: 10_000, default: 500 }, + page_size: { minimum: 1, maximum: 1_000, default: 200 }, +} as const; + +const literalSeedSchema = z.strictObject({ + kind: z.literal("literal"), + value: jsonValueSchema.refine( + (value) => + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "Literal seeds accept only JSON primitive values", + ), +}); + +/** Authenticated starting points accepted by semantic tracing. */ +export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("semantic-node"), + node_id: semanticNodeIdSchema, + }), + z.strictObject({ + kind: z.literal("application-node"), + node_id: z.string().regex(/^jag_node_[a-f0-9]{64}$/u), + }), + literalSeedSchema, + z.strictObject({ + kind: z.literal("function"), + fingerprint_sha256: digestSchema, + }), + z.strictObject({ + kind: z.literal("property"), + name: z.string().min(1).max(4_096), + }), + z.strictObject({ + kind: z.literal("endpoint"), + value: z.string().min(1).max(16_384), + }), + z.strictObject({ + kind: z.literal("event"), + name: z.string().min(1).max(4_096), + }), + z.strictObject({ + kind: z.literal("boundary-field"), + field: z.string().min(1).max(4_096), + }), +]); + +/** Strict caller limits for deterministic bounded traversal. */ +export const javaScriptSemanticQueryLimitsSchema = z.strictObject({ + max_seed_matches: z.number().int().min(1).max(1_000).default(25), + max_nodes: z.number().int().min(1).max(50_000).default(2_000), + max_edges: z.number().int().min(1).max(100_000).default(4_000), + max_depth: z.number().int().min(0).max(64).default(12), + max_functions: z.number().int().min(1).max(10_000).default(500), + max_modules: z.number().int().min(1).max(10_000).default(500), + page_size: z.number().int().min(1).max(1_000).default(200), +}); + +const sourceMapAuthoritySchema = z.discriminatedUnion("authority", [ + z.strictObject({ authority: z.literal("none") }), + z.strictObject({ + authority: z.literal("authenticated"), + evidence_id: evidenceIdSchema, + source_map_sha256: digestSchema, + }), +]); + +/** Parsed pure-domain query over one authenticated companion graph. */ +export const javaScriptSemanticQueryInputSchema = z.strictObject({ + seed: javaScriptSemanticQuerySeedSchema, + direction: z.enum([ + "backward-provenance", + "forward-influence", + "callers", + "consumers", + "ownership", + ]), + allowed_relations: z + .array(z.enum(JAVASCRIPT_SEMANTIC_RELATIONS)) + .min(1) + .max(JAVASCRIPT_SEMANTIC_RELATIONS.length) + .optional(), + include_ambiguous_dynamic_edges: z.boolean().default(false), + expected: z + .strictObject({ + role: z.enum(["source", "sink"]), + classes: z.array(z.enum(JAVASCRIPT_SEMANTIC_NODE_KINDS)).min(1).max(24), + }) + .nullable() + .default(null), + source_map_authority: sourceMapAuthoritySchema.default({ authority: "none" }), + limits: javaScriptSemanticQueryLimitsSchema.default({ + max_seed_matches: 25, + max_nodes: 2_000, + max_edges: 4_000, + max_depth: 12, + max_functions: 500, + max_modules: 500, + page_size: 200, + }), + cursor: z + .string() + .regex(/^jsrqc_[0-9]+_[a-f0-9]{64}$/u) + .nullable() + .default(null), +}); + +const queryFrontierSchema = z.strictObject({ + node_id: semanticNodeIdSchema, + depth: z.number().int().min(0).max(65), + reason: z.enum([ + "max-depth", + "max-edges", + "max-functions", + "max-modules", + "max-nodes", + "max-seed-matches", + ]), +}); + +/** Deterministic, pageable semantic trace result. */ +export const javaScriptSemanticQueryResultSchema = z.strictObject({ + schema_version: z.literal(1), + query_id: z.string().regex(/^jsrq_[a-f0-9]{64}$/u), + source_graph_id: z.string().regex(/^jsrg_[a-f0-9]{64}$/u), + seed: javaScriptSemanticQuerySeedSchema, + direction: javaScriptSemanticQueryInputSchema.shape.direction, + status: z.enum([ + "found", + "no-match", + "ambiguous", + "partial", + "truncated", + "unsupported", + ]), + seed_node_ids: z.array(semanticNodeIdSchema).max(1_000), + nodes: z.array(javaScriptSemanticNodeSchema).max(3_000), + relations: z.array(javaScriptSemanticRelationSchema).max(1_000), + unknowns: z.array(javaScriptSemanticUnknownSchema).max(100_000), + expected_match_node_ids: z.array(semanticNodeIdSchema).max(50_000), + summary: z.strictObject({ + total_seed_matches: z.number().int().min(0), + retained_seed_matches: z.number().int().min(0), + traversed_nodes: z.number().int().min(0), + traversed_relations: z.number().int().min(0), + traversed_functions: z.number().int().min(0), + traversed_modules: z.number().int().min(0), + relevant_unknowns: z.number().int().min(0), + }), + coverage: z.strictObject({ + status: z.enum(["complete", "partial", "truncated", "unavailable"]), + frontier: z.array(queryFrontierSchema).max(100_000), + }), + page: z.strictObject({ + offset: z.number().int().min(0), + size: z.number().int().min(0).max(1_000), + next_cursor: z + .string() + .regex(/^jsrqc_[0-9]+_[a-f0-9]{64}$/u) + .nullable(), + }), + applied_limits: javaScriptSemanticQueryLimitsSchema, + accepted_limit_ranges: z.strictObject({ + max_seed_matches: z.strictObject({ + minimum: z.literal(1), + maximum: z.literal(1_000), + }), + max_nodes: z.strictObject({ + minimum: z.literal(1), + maximum: z.literal(50_000), + }), + max_edges: z.strictObject({ + minimum: z.literal(1), + maximum: z.literal(100_000), + }), + max_depth: z.strictObject({ + minimum: z.literal(0), + maximum: z.literal(64), + }), + max_functions: z.strictObject({ + minimum: z.literal(1), + maximum: z.literal(10_000), + }), + max_modules: z.strictObject({ + minimum: z.literal(1), + maximum: z.literal(10_000), + }), + page_size: z.strictObject({ + minimum: z.literal(1), + maximum: z.literal(1_000), + }), + }), + limitations: z.array(z.string().min(1).max(4_096)).max(1_000), +}); + +/** Validated semantic query input. */ +export type JavaScriptSemanticQueryInput = z.infer< + typeof javaScriptSemanticQueryInputSchema +>; +/** Validated semantic query result. */ +export type JavaScriptSemanticQueryResult = z.infer< + typeof javaScriptSemanticQueryResultSchema +>; diff --git a/tests/javascriptSemanticGraphQuery.test.ts b/tests/javascriptSemanticGraphQuery.test.ts new file mode 100644 index 0000000000..771e92ac46 --- /dev/null +++ b/tests/javascriptSemanticGraphQuery.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it } from "vitest"; + +import type { ApplicationGraphEvidence } from "../src/domain/javascriptApplicationEvidenceSchemas.js"; +import { + JAVASCRIPT_SEMANTIC_NODE_KINDS, + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, + JAVASCRIPT_SEMANTIC_RELATIONS, +} from "../src/domain/javascriptSemanticGraphSchemas.js"; +import { + createJavaScriptSemanticFingerprint, + createJavaScriptSemanticGraph, + createJavaScriptSemanticGraphNode, + createJavaScriptSemanticGraphRelation, + createJavaScriptSemanticGraphUnknown, + parseJavaScriptSemanticGraph, + serializeJavaScriptSemanticGraph, + type JavaScriptSemanticGraph, + type JavaScriptSemanticGraphNode, +} from "../src/domain/javascriptSemanticGraph.js"; +import { queryJavaScriptSemanticGraph } from "../src/domain/javascriptSemanticQuery.js"; +import { javaScriptSemanticQueryInputSchema } from "../src/domain/javascriptSemanticQuerySchemas.js"; + +const SHA = "a".repeat(64); +const JAG_ID = `jag_${"b".repeat(64)}`; +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const evidence = ( + state: "observed" | "inferred" = "observed", +): ApplicationGraphEvidence => ({ + authority: + state === "observed" + ? "ast-static-analysis" + : "static-relationship-inference", + state, + confidence: state === "observed" ? "exact" : "high", + artifact: { available: true, artifact_id: `art_${SHA}`, sha256: SHA }, + location: { + available: true, + value: { + kind: "source-range", + source: "bundle.js", + start: { line: 1, column: 0 }, + end: { line: 1, column: 10 }, + }, + }, + extractor: { + name: "test", + version: "1", + operation: "recover-semantic-relation", + executable_sha256: null, + }, + coverage: completeCoverage, + limitations: + state === "observed" + ? [] + : ["Static reachability does not prove runtime execution."], + evidence_ids: [], +}); + +const unknownEvidence = (): ApplicationGraphEvidence => ({ + authority: "unknown", + state: "unknown", + confidence: "unknown", + artifact: { + available: false, + reason: "unknown", + detail: "Unknown artifact.", + }, + location: { + available: false, + reason: "unresolved", + detail: "Dynamic call target.", + }, + extractor: { + name: "test", + version: "1", + operation: "retain-dynamic-call", + executable_sha256: null, + }, + coverage: { + status: "unknown", + truncated: false, + omitted_count: null, + limits: [], + }, + limitations: ["Dynamic call target remains unknown."], + evidence_ids: [], +}); + +const node = ( + kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number], + role: string, + 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 }, + }, + role_key: role, + }, + function_node_id: functionNodeId, + application_node_ids: [], + label: role, + properties, + evidence: evidence(), + }); + +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 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"), + }), + ]; + 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 coverageFamilies = JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map( + (family) => ({ + family, + status: + withUnknown && family === "call-flow" + ? ("unknown" as const) + : ("complete" as const), + retained_relations: relations.filter(({ relation }) => + relation === "defines" + ? family === "data-flow" + : relation === "captures" + ? family === "closure" + : family === "request", + ).length, + omitted_relations: withUnknown && family === "call-flow" ? null : 0, + unknown_ids: + withUnknown && family === "call-flow" ? [dynamic.unknown_id] : [], + }), + ); + const fingerprint = createJavaScriptSemanticFingerprint({ + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function/v1", + 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"), + }); + return createJavaScriptSemanticGraph({ + schema: "JavaScriptSemanticRelationGraph", + schema_version: 1, + root_artifact_sha256: SHA, + application_graph_id: JAG_ID, + root_node_ids: [module.node_id], + nodes: [request, callable, binding, literal, module], + relations, + fingerprints: [fingerprint], + unknowns, + coverage: { + status: withUnknown ? "partial" : "complete", + truncated: false, + omitted_nodes: withUnknown ? null : 0, + omitted_relations: withUnknown ? null : 0, + limits: [], + families: coverageFamilies, + }, + limitations: withUnknown ? ["Dynamic call target remains unknown."] : [], + }); +}; + +const graphWithCandidateEdge = (): JavaScriptSemanticGraph => { + const graph = fixtureGraph(); + 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; + return createJavaScriptSemanticGraph({ + ...input, + relations: [...graph.relations, candidate], + coverage: { + ...graph.coverage, + families: graph.coverage.families.map((family) => + family.family === "request" + ? { + ...family, + retained_relations: family.retained_relations + 1, + } + : family, + ), + }, + }); +}; + +const graphWithDuplicateLiteral = (): JavaScriptSemanticGraph => { + const graph = fixtureGraph(); + const duplicate = node("literal", "literal-copy", { value: "TOKEN" }); + const { graph_id: _graphId, ...input } = graph; + return createJavaScriptSemanticGraph({ + ...input, + nodes: [...graph.nodes, duplicate], + }); +}; + +describe("JavaScript semantic relation graph", () => { + it("defines every required v1 relation family and canonicalizes records", () => { + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter"); + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task"); + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("forwards-signal"); + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("validates"); + const graph = fixtureGraph(); + expect( + parseJavaScriptSemanticGraph( + JSON.parse(serializeJavaScriptSemanticGraph(graph)), + ), + ).toEqual(graph); + expect(graph.nodes.map(({ node_id }) => node_id)).toEqual( + graph.nodes.map(({ node_id }) => node_id).toSorted(), + ); + }); + + it("rejects stale identities, dangling endpoints, and incomplete coverage claims", () => { + const graph = fixtureGraph(); + const { graph_id: _graphId, ...graphInput } = graph; + expect(() => + parseJavaScriptSemanticGraph({ + ...graph, + graph_id: `jsrg_${"0".repeat(64)}`, + }), + ).toThrow(); + expect(() => + createJavaScriptSemanticGraph({ + ...graphInput, + relations: [ + { + ...graph.relations[0], + target_node_id: `jsrg_node_${"f".repeat(64)}`, + }, + ], + }), + ).toThrow(); + expect(() => + createJavaScriptSemanticGraph({ + ...graphInput, + coverage: { ...graph.coverage, omitted_nodes: 1 }, + }), + ).toThrow(/Complete graph coverage/u); + }); +}); + +describe("JavaScript semantic query", () => { + it("traces deterministic forward influence and pages without changing identity", () => { + const graph = fixtureGraph(); + const input = { + seed: { kind: "literal" as const, value: "TOKEN" }, + direction: "forward-influence" as const, + expected: { role: "sink" as const, classes: ["request" as const] }, + limits: { + max_seed_matches: 25, + max_nodes: 20, + max_edges: 20, + max_depth: 12, + max_functions: 10, + max_modules: 10, + page_size: 2, + }, + }; + const first = queryJavaScriptSemanticGraph(graph, input); + expect(first).toMatchObject({ + status: "found", + summary: { traversed_nodes: 4, traversed_relations: 3 }, + page: { offset: 0, size: 2 }, + }); + expect(first.expected_match_node_ids).toHaveLength(1); + expect(first.page.next_cursor).not.toBeNull(); + const second = queryJavaScriptSemanticGraph(graph, { + ...input, + cursor: first.page.next_cursor, + }); + expect(second.query_id).toBe(first.query_id); + expect(second.page).toMatchObject({ + offset: 2, + size: 1, + next_cursor: null, + }); + expect(second.relations[0]?.relation_id).not.toBe( + first.relations[0]?.relation_id, + ); + }); + + it("keeps relevant dynamic frontiers unknown", () => { + const result = queryJavaScriptSemanticGraph(fixtureGraph(true), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + }); + expect(result.status).toBe("partial"); + expect(result.coverage.status).toBe("partial"); + expect(result.unknowns).toMatchObject([ + { reason: "dynamic-call", relation_kinds: ["calls"] }, + ]); + }); + + it("excludes candidate edges unless the caller explicitly opts in", () => { + const graph = graphWithCandidateEdge(); + const input = { + seed: { kind: "literal" as const, value: "TOKEN" }, + direction: "forward-influence" as const, + allowed_relations: ["supplies-request-field" as const], + }; + const conservative = queryJavaScriptSemanticGraph(graph, input); + const optedIn = queryJavaScriptSemanticGraph(graph, { + ...input, + include_ambiguous_dynamic_edges: true, + }); + expect(conservative.summary.traversed_relations).toBe(0); + expect(optedIn.summary.traversed_relations).toBe(1); + expect(optedIn.relations).toMatchObject([ + { relation: "supplies-request-field", resolution: "candidate" }, + ]); + }); + + it("bounds seed ambiguity before traversal", () => { + const result = queryJavaScriptSemanticGraph(graphWithDuplicateLiteral(), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { + max_seed_matches: 1, + max_nodes: 20, + max_edges: 20, + max_depth: 12, + max_functions: 10, + max_modules: 10, + page_size: 10, + }, + }); + expect(result.summary).toMatchObject({ + total_seed_matches: 2, + retained_seed_matches: 1, + }); + expect(result.status).toBe("truncated"); + expect(result.coverage.frontier).toContainEqual( + expect.objectContaining({ reason: "max-seed-matches", depth: 0 }), + ); + }); + + it("reports exact caller limits and a deterministic truncation frontier", () => { + const result = queryJavaScriptSemanticGraph(fixtureGraph(), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { + max_seed_matches: 1, + max_nodes: 2, + max_edges: 10, + max_depth: 12, + max_functions: 10, + max_modules: 10, + page_size: 10, + }, + }); + expect(result.status).toBe("truncated"); + expect(result.coverage.frontier).toMatchObject([{ reason: "max-nodes" }]); + expect(result.applied_limits.max_nodes).toBe(2); + expect(result.accepted_limit_ranges.max_nodes).toEqual({ + minimum: 1, + maximum: 50_000, + }); + }); + + it.each([ + { + limit: { max_edges: 1 }, + reason: "max-edges", + }, + { + limit: { max_depth: 0 }, + reason: "max-depth", + }, + ])("reports the $reason frontier", ({ limit, reason }) => { + const result = queryJavaScriptSemanticGraph(fixtureGraph(), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { + max_seed_matches: 10, + max_nodes: 20, + max_edges: limit.max_edges ?? 20, + max_depth: limit.max_depth ?? 12, + max_functions: 10, + max_modules: 10, + page_size: 10, + }, + }); + expect(result.status).toBe("truncated"); + expect(result.coverage.frontier).toContainEqual( + expect.objectContaining({ reason }), + ); + }); + + it("rejects out-of-range limits and cursors from another query", () => { + expect(() => + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { max_nodes: 50_001 }, + }), + ).toThrow(); + const graph = fixtureGraph(); + const first = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { page_size: 1 }, + }); + expect(() => + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "endpoint", value: "https://example.invalid/v1" }, + direction: "backward-provenance", + limits: { page_size: 1 }, + cursor: first.page.next_cursor, + }), + ).toThrow(/cursor does not match/u); + }); +});