rea/src/domain/javascriptSemanticQuery.ts

515 lines
15 KiB
TypeScript

import { compareCodePoints } from "./javascriptApplicationGraph.js";
import type {
JavaScriptSemanticGraph,
JavaScriptSemanticGraphNode,
JavaScriptSemanticGraphRelation,
} from "./javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphUnknown } from "./javascriptSemanticGraphSchemas.js";
import { isJavaScriptSemanticOwnershipRelation as ownershipRelation } from "./javascriptSemanticQueryRelations.js";
import {
javaScriptSemanticQueryInputSchema,
javaScriptSemanticQueryResultSchema,
type JavaScriptSemanticQueryInput,
type JavaScriptSemanticQueryResult,
} from "./javascriptSemanticQuerySchemas.js";
import {
assessJavaScriptSemanticQuery,
type JavaScriptSemanticQueryFrontier,
} from "./javascriptSemanticQueryAssessment.js";
import {
canonicalJavaScriptSemanticQueryJson as canonicalJson,
createJavaScriptSemanticQueryCursor as createCursor,
JavaScriptSemanticQueryCursorError,
javaScriptSemanticQueryIdentifier as queryIdentifier,
parseJavaScriptSemanticQueryCursor as parseCursor,
resolveJavaScriptSemanticQuerySeeds as resolveSeeds,
} from "./javascriptSemanticQueryIdentity.js";
interface TraversalEntry {
readonly relation: JavaScriptSemanticGraphRelation;
readonly nextNodeId: string;
}
type QueryFrontier = JavaScriptSemanticQueryFrontier;
interface Traversal {
readonly nodeIds: Set<string>;
readonly relationIds: Set<string>;
readonly functionIds: Set<string>;
readonly modules: Set<string>;
readonly frontier: QueryFrontier[];
}
interface SeedAdmission {
readonly nodeIds: string[];
readonly frontier: QueryFrontier[];
}
interface QueryPage {
readonly retainedNodes: JavaScriptSemanticGraphNode[];
readonly retainedRelations: JavaScriptSemanticGraphRelation[];
readonly pageRelations: JavaScriptSemanticGraphRelation[];
readonly pageNodes: JavaScriptSemanticGraphNode[];
readonly nextCursor: string | null;
}
interface QueryPageInput {
readonly graph: JavaScriptSemanticGraph;
readonly traversal: Traversal;
readonly query: JavaScriptSemanticQueryInput;
readonly queryId: string;
readonly offset: number;
readonly retainedSeeds: readonly string[];
}
/** 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 page = createQueryPage({
graph,
traversal,
query: input,
queryId,
offset,
retainedSeeds,
});
const allRelevantUnknowns = relevantUnknownFrontiers(
graph,
traversal.nodeIds,
input,
);
const relevantUnknowns = allRelevantUnknowns.slice(
0,
input.limits.max_unknowns,
);
const candidateRelations = relevantCandidateRelationCount(
graph,
traversal.nodeIds,
input,
);
const expectedMatches = expectedMatchesFor(
page.retainedNodes,
page.retainedRelations,
input,
);
const assessment = assessJavaScriptSemanticQuery({
graph,
totalSeeds: seeds.length,
retainedSeeds: retainedSeeds.length,
expectedMatches: expectedMatches.length,
hasExpectation: input.expected !== null,
frontier,
unknowns: relevantUnknowns,
candidateRelations,
unknownsTruncated: allRelevantUnknowns.length > relevantUnknowns.length,
});
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: page.pageNodes,
relations: page.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: allRelevantUnknowns.length,
retained_unknowns: relevantUnknowns.length,
},
coverage: assessment.coverage,
page: {
offset,
size: page.pageRelations.length,
next_cursor: page.nextCursor,
},
applied_limits: input.limits,
accepted_limit_ranges: assessment.acceptedLimitRanges,
limitations: assessment.limitations,
});
};
const createQueryPage = ({
graph,
traversal,
query,
queryId,
offset,
retainedSeeds,
}: QueryPageInput): QueryPage => {
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 JavaScriptSemanticQueryCursorError(
"Semantic query cursor is beyond the retained result",
);
const pageRelations = retainedRelations.slice(
offset,
offset + query.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 nextOffset = offset + pageRelations.length;
return {
retainedNodes,
retainedRelations,
pageRelations,
pageNodes,
nextCursor:
nextOffset < retainedRelations.length
? createCursor(queryId, nextOffset)
: null,
};
};
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<string>();
const modules = new Set<string>();
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<string>;
readonly modules: ReadonlySet<string>;
readonly input: JavaScriptSemanticQueryInput;
}
interface TraversalBounds {
readonly nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly nodeIds: ReadonlySet<string>;
readonly relationIds: ReadonlySet<string>;
readonly functionIds: ReadonlySet<string>;
readonly modules: ReadonlySet<string>;
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<string, TraversalEntry[]> => {
const adjacency = new Map<string, TraversalEntry[]>();
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<string, TraversalEntry[]>,
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 addEntry = (
adjacency: Map<string, TraversalEntry[]>,
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<string, TraversalEntry[]>,
input: JavaScriptSemanticQueryInput,
): Traversal => {
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
const nodeIds = new Set(seeds);
const relationIds = new Set<string>();
const functionIds = new Set<string>();
const modules = new Set<string>();
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<string>,
modules: Set<string>,
): 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<string>,
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 relevantCandidateRelationCount = (
graph: JavaScriptSemanticGraph,
nodeIds: ReadonlySet<string>,
input: JavaScriptSemanticQueryInput,
): number => {
const allowed =
input.allowed_relations === undefined
? null
: new Set(input.allowed_relations);
return graph.relations.filter(
(relation) =>
relation.resolution === "candidate" &&
(allowed === null || allowed.has(relation.relation)) &&
(nodeIds.has(relation.source_node_id) ||
nodeIds.has(relation.target_node_id)),
).length;
};
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),
);
};