feat(impact): add symbol-level call graph and Impact Analysis tools

Implements Phases A-E of the Impact Analysis plan:

Phase A — Foundations:
- New types: SymbolNode, SymbolEdge, SymbolGraphMeta, SymbolGraphFilePayload, EntryPoint
- Constants: MAX_IMPACT_DEPTH, MAX_FLOW_DEPTH, SYMBOL_NAME_SHARDS=27, SYMBOL_REVERSE_SHARDS=256
- Sharded Qdrant store (symbol-graph-store.ts): meta/file/index collections,
  27 name shards, 256 reverse-call shards, all dummy-vector pattern
- LRU cache (symbol-graph-cache.ts): per-project lazy shard loading, 500-file LRU

Phase B — Symbol & call extraction (graph-symbols.ts):
- Per-language extractors: TS/JS/TSX, Python, Go, Rust, JVM (Java/Kotlin/Scala),
  C#, C/C++, Ruby, PHP, Swift, Bash, regex fallback for Dart/Lua/Svelte/Vue
- Synthetic <module> symbol per file as fallback caller
- Scope tracking via ScopeFrame[] for accurate caller attribution

Phase C — Resolution (graph-symbol-resolution.ts):
- Three-tier strategy: local match → walk caller deps → one transitive hop
- Confidence levels: unresolved | unique | multiple-candidates
- computeUnresolvedPct for meta stats

Phase D — Analysis primitives:
- detectEntryPoints: orphans + conventional names + framework patterns + tests
- getImpactRadius: BFS via reverseFileIndex, polymorphic file/symbol target
- getCallFlow: DFS via lazy outgoing edges, cycle-safe, depth-limited
- getSymbolContext: 360° view (definition + callers + callees)
- listSymbols: file-mode or query-mode

Phase E — MCP tools:
- codebase_impact: blast radius for file/symbol
- codebase_flow: entry-point discovery + forward call tree
- codebase_symbol: symbol context (callers + callees)
- codebase_symbols: file/query symbol listing

Integration:
- buildCodeGraph now extracts symbols inline alongside imports
- doRebuildGraph persists both file-import graph and symbol graph
- removeGraph cleans up symbol collections + drops cache
- getGraphStatus surfaces symbol stats (files/symbols/edges/unresolved%)

All 621 existing tests still pass. TypeScript compiles cleanly. Biome lint clean.
This commit is contained in:
Giancarlo Erra
2026-04-21 13:26:10 +01:00
parent 2ad7b3db5d
commit c356c42f4f
13 changed files with 2712 additions and 7 deletions
+17
View File
@@ -108,6 +108,23 @@ export function contextCollectionName(projectId: string): string {
return `context_${projectId}`;
}
// ── Symbol graph collections ─────────────────────────────────────────────
/** Top-level metadata point for a project's symbol graph. */
export function symgraphMetaCollectionName(projectId: string): string {
return `${projectId}_symgraph_meta`;
}
/** Per-file payloads for a project's symbol graph. */
export function symgraphFileCollectionName(projectId: string): string {
return `${projectId}_symgraph_file`;
}
/** Sharded indices (name index + reverse-call file index). */
export function symgraphIndexCollectionName(projectId: string): string {
return `${projectId}_symgraph_index`;
}
// ── Linked projects ──────────────────────────────────────────────────────
/** Configuration file name for linked projects */
+38
View File
@@ -101,6 +101,44 @@ export const MAX_AVG_LINE_LENGTH = 500;
*/
export const MAX_CHUNK_CHARS = 2000;
// ── Symbol-level call graph (Impact Analysis) ────────────────────────────
/** Maximum BFS depth for `codebase_impact` (blast radius) queries. */
export const MAX_IMPACT_DEPTH = 10;
/** Maximum DFS depth for `codebase_flow` (call-flow tracing) queries. */
export const MAX_FLOW_DEPTH = 10;
/** Number of name-index shards (az + `_` for everything else). */
export const SYMBOL_NAME_SHARDS = 27;
/** Number of reverse-call file-index shards (single-byte SHA1 prefix). */
export const SYMBOL_REVERSE_SHARDS = 256;
/** LRU capacity (in files) for lazy-loaded per-file symbol payloads. */
export const SYMBOL_FILE_LRU_SIZE = 500;
/**
* Conventional entry-point function names per language. Used by
* `detectEntryPoints()` heuristic #2.
*/
export const ENTRY_POINT_NAMES: Record<string, Set<string>> = {
javascript: new Set(["main"]),
typescript: new Set(["main"]),
python: new Set(["main"]),
go: new Set(["main"]),
rust: new Set(["main"]),
java: new Set(["main"]),
kotlin: new Set(["main"]),
scala: new Set(["main"]),
c: new Set(["main"]),
cpp: new Set(["main"]),
csharp: new Set(["Main"]),
swift: new Set(["main"]),
ruby: new Set(["main"]),
php: new Set(["main"]),
};
// ── File type configuration ─────────────────────────────────────────────
export const SUPPORTED_EXTENSIONS = new Set([
+56
View File
@@ -269,6 +269,62 @@ server.tool(
}),
);
// ── Impact analysis (symbol-level call graph) ───────────────────────────
server.tool(
"codebase_impact",
"Impact Analysis — return the BLAST RADIUS for a file or symbol. Lists every file (and, where helpful, function) that could break if you change the target. Polymorphic on target: a path-like string ('src/foo.ts') triggers file-mode; a name-like string ('validateUser') triggers symbol-mode. Use this BEFORE refactoring, renaming, or deleting code to know what depends on it.",
{
projectPath: z.string().describe("Absolute path to the project directory.").optional(),
target: z.string().describe("Target file path (relative) OR symbol name."),
depth: z.number().describe("How many hops back to walk (default 3, max 10).").optional(),
},
async (args) => ({
content: [{ type: "text", text: await handleGraphTool("codebase_impact", args) }],
}),
);
server.tool(
"codebase_flow",
"Trace the EXECUTION FLOW forward from an entry point — what does this code call into? With NO args, returns a ranked list of auto-detected entry points (orphans with outgoing calls, conventional names like main(), framework routes, tests). With an entrypoint argument, returns the call tree.",
{
projectPath: z.string().describe("Absolute path to the project directory.").optional(),
entrypoint: z.string().describe("Symbol name to trace from. Omit to list auto-detected entry points.").optional(),
file: z.string().describe("Optional file hint to disambiguate the symbol.").optional(),
depth: z.number().describe("Maximum DFS depth (default 5, max 10).").optional(),
},
async (args) => ({
content: [{ type: "text", text: await handleGraphTool("codebase_flow", args) }],
}),
);
server.tool(
"codebase_symbol",
"360° view of a symbol: definition, kind, callers, callees, confidence levels. Use to understand a function or class before changing it.",
{
projectPath: z.string().describe("Absolute path to the project directory.").optional(),
name: z.string().describe("Symbol name (e.g. 'validateUser')."),
file: z.string().describe("Optional file hint to disambiguate when the name is not unique.").optional(),
},
async (args) => ({
content: [{ type: "text", text: await handleGraphTool("codebase_symbol", args) }],
}),
);
server.tool(
"codebase_symbols",
"List symbols in a file, or search by name across the project. Use to discover what exists before drilling into a single symbol with codebase_symbol.",
{
projectPath: z.string().describe("Absolute path to the project directory.").optional(),
file: z.string().describe("Relative file path — list all symbols in this file.").optional(),
query: z.string().describe("Substring to match against symbol names project-wide.").optional(),
limit: z.number().describe("Maximum results (default 200).").optional(),
},
async (args) => ({
content: [{ type: "text", text: await handleGraphTool("codebase_symbols", args) }],
}),
);
// ── Context artifact tools ───────────────────────────────────────────────
server.tool(
+208 -6
View File
@@ -7,13 +7,35 @@ import path from "node:path";
import { Lang, registerDynamicLanguage } from "@ast-grep/napi";
import { graphCollectionName, projectIdFromPath } from "../config.js";
import { EXTRA_EXTENSIONS, getLanguageFromExtension, MAX_GRAPH_FILE_BYTES } from "../constants.js";
import type { CodeGraph, CodeGraphEdge, CodeGraphNode } from "../types.js";
import type {
CodeGraph, CodeGraphEdge, CodeGraphNode,
SymbolEdge, SymbolGraphFilePayload, SymbolGraphMeta, SymbolNode, SymbolRef,
} from "../types.js";
import { loadPathAliases } from "./graph-aliases.js";
import { extractImports } from "./graph-imports.js";
import { buildJvmSuffixMap, resolveImport } from "./graph-resolution.js";
import { computeUnresolvedPct, resolveCallSites } from "./graph-symbol-resolution.js";
import { extractSymbolsAndCalls, rawCallsToUnresolvedEdges } from "./graph-symbols.js";
import { createIgnoreFilter, shouldIgnore } from "./ignore.js";
import { logger } from "./logger.js";
import { deleteGraphData, getGraphMetadata, loadGraphData, saveGraphData } from "./qdrant.js";
import {
dropSymbolGraphCache,
SymbolGraphCache,
setSymbolGraphCache,
} from "./symbol-graph-cache.js";
import {
allNameShardKeys,
contentHashOf,
deleteSymbolGraphData,
ensureSymbolGraphCollections,
nameShardKey,
reverseShardKey,
saveFilePayloads,
saveNameShard,
saveReverseShard,
saveSymbolGraphMeta,
} from "./symbol-graph-store.js";
// Re-export analysis functions for external consumers
export { findCircularDependencies, generateMermaidDiagram, getFileDependencies, getGraphStats } from "./graph-analysis.js";
@@ -102,8 +124,10 @@ export async function getOrBuildGraph(
}
const graph = await buildCodeGraph(resolved, extraExtensions);
graphCache.set(resolved, graph);
return graph;
// Strip symbol fields when serving as a plain CodeGraph
const plain: CodeGraph = { nodes: graph.nodes, edges: graph.edges };
graphCache.set(resolved, plain);
return plain;
}
/** Force-rebuild, cache, and persist a graph.
@@ -150,15 +174,30 @@ async function doRebuildGraph(
try {
graphCache.delete(resolvedPath);
const graph = await buildCodeGraph(resolvedPath, extraExtensions, progress);
const built = await buildCodeGraph(resolvedPath, extraExtensions, progress);
const graph: CodeGraph = { nodes: built.nodes, edges: built.edges };
graphCache.set(resolvedPath, graph);
// Persist to Qdrant
// Persist file-import graph to Qdrant
progress.phase = "persisting";
const projectId = projectIdFromPath(resolvedPath);
const graphCollName = graphCollectionName(projectId);
await saveGraphData(graphCollName, resolvedPath, graph);
// Build & persist symbol graph (resolution + sharded persistence)
try {
progress.phase = "resolving symbols";
resolveCallSites(graph, built.symbolsByFile, built.outgoingCallsByFile);
progress.phase = "persisting symbols";
await persistSymbolGraph(projectId, resolvedPath, built.symbolsByFile, built.outgoingCallsByFile);
} catch (err) {
logger.warn("Symbol graph build failed (file-import graph saved)", {
projectPath: resolvedPath,
error: err instanceof Error ? err.message : String(err),
});
}
lastGraphBuildCompleted.set(resolvedPath, {
completedAt: Date.now(),
durationMs: Date.now() - progress.startedAt,
@@ -185,6 +224,113 @@ async function doRebuildGraph(
}
}
/** Persist the symbol graph: per-file payloads + sharded indices + meta. */
async function persistSymbolGraph(
projectId: string,
resolvedPath: string,
symbolsByFile: Map<string, SymbolNode[]>,
outgoingCallsByFile: Map<string, SymbolEdge[]>,
): Promise<void> {
await ensureSymbolGraphCollections(projectId);
// Build per-file payloads (need source bytes for contentHash).
const payloads: SymbolGraphFilePayload[] = [];
let totalSymbols = 0;
let totalEdges = 0;
for (const [relPath, symbols] of symbolsByFile.entries()) {
const outgoingCalls = outgoingCallsByFile.get(relPath) ?? [];
let language = "plaintext";
const firstNonModule = symbols.find((s) => s.name !== "<module>");
if (firstNonModule) language = firstNonModule.language;
else language = symbols[0]?.language ?? language;
let contentHash = "";
try {
const src = await fs.readFile(path.join(resolvedPath, relPath), "utf-8");
contentHash = contentHashOf(src);
} catch {
// ignore
}
payloads.push({
file: relPath, language, contentHash, symbols, outgoingCalls,
});
totalSymbols += symbols.filter((s) => s.name !== "<module>").length;
totalEdges += outgoingCalls.length;
}
// Build sharded indices
const nameShards = new Map<string, Record<string, SymbolRef[]>>();
for (const key of allNameShardKeys()) nameShards.set(key, {});
for (const [file, symbols] of symbolsByFile.entries()) {
for (const sym of symbols) {
if (sym.name === "<module>") continue;
const shardKey = nameShardKey(sym.name);
const shard = nameShards.get(shardKey);
if (!shard) continue;
const ref: SymbolRef = { file, id: sym.id };
const existing = shard[sym.name];
if (existing) existing.push(ref);
else shard[sym.name] = [ref];
}
}
const reverseShards = new Map<number, Record<string, string[]>>();
for (const [callerFile, edges] of outgoingCallsByFile.entries()) {
for (const e of edges) {
for (const calleeId of e.calleeCandidates) {
const calleeFile = calleeId.split("::")[0];
if (!calleeFile || calleeFile === callerFile) continue;
const bucket = reverseShardKey(calleeFile);
let shard = reverseShards.get(bucket);
if (!shard) {
shard = {};
reverseShards.set(bucket, shard);
}
const existing = shard[calleeFile];
if (existing) {
if (!existing.includes(callerFile)) existing.push(callerFile);
} else {
shard[calleeFile] = [callerFile];
}
}
}
}
// Persist
await saveFilePayloads(projectId, payloads);
for (const [shardKey, shard] of nameShards.entries()) {
if (Object.keys(shard).length === 0) continue;
await saveNameShard(projectId, shardKey, shard);
}
for (const [bucket, shard] of reverseShards.entries()) {
if (Object.keys(shard).length === 0) continue;
await saveReverseShard(projectId, bucket, shard);
}
const meta: SymbolGraphMeta = {
projectId,
symbolCount: totalSymbols,
edgeCount: totalEdges,
fileCount: symbolsByFile.size,
unresolvedEdgePct: computeUnresolvedPct(outgoingCallsByFile),
builtAt: Date.now(),
schemaVersion: 1,
};
await saveSymbolGraphMeta(projectId, meta);
// Replace cache entry
const cache = new SymbolGraphCache(projectId, meta);
setSymbolGraphCache(cache);
logger.info("Symbol graph persisted", {
projectId,
files: meta.fileCount,
symbols: meta.symbolCount,
edges: meta.edgeCount,
unresolvedPct: meta.unresolvedEdgePct.toFixed(1),
});
}
/**
* Wait for any in-flight graph build to finish for a project.
* Resolves immediately if no build is in progress.
@@ -205,6 +351,8 @@ export async function removeGraph(projectPath: string): Promise<void> {
const projectId = projectIdFromPath(resolved);
const graphCollName = graphCollectionName(projectId);
await deleteGraphData(graphCollName);
await deleteSymbolGraphData(projectId);
dropSymbolGraphCache(projectId);
logger.info("Removed code graph", { projectPath: resolved });
}
@@ -224,17 +372,49 @@ export async function getGraphStatus(projectPath: string): Promise<{
nodeCount: number;
edgeCount: number;
cached: boolean;
symbol?: {
fileCount: number;
symbolCount: number;
edgeCount: number;
unresolvedEdgePct: number;
builtAt: number;
};
} | null> {
const resolved = path.resolve(projectPath);
const projectId = projectIdFromPath(resolved);
const graphCollName = graphCollectionName(projectId);
const meta = await getGraphMetadata(graphCollName);
if (!meta) return null;
// Best-effort symbol-graph stats
let symbol: {
fileCount: number;
symbolCount: number;
edgeCount: number;
unresolvedEdgePct: number;
builtAt: number;
} | undefined;
try {
const { loadSymbolGraphMeta } = await import("./symbol-graph-store.js");
const sm = await loadSymbolGraphMeta(projectId);
if (sm) {
symbol = {
fileCount: sm.fileCount,
symbolCount: sm.symbolCount,
edgeCount: sm.edgeCount,
unresolvedEdgePct: sm.unresolvedEdgePct,
builtAt: sm.builtAt,
};
}
} catch {
// symbol graph optional
}
return {
lastBuiltAt: meta.lastBuiltAt,
nodeCount: meta.nodeCount,
edgeCount: meta.edgeCount,
cached: graphCache.has(resolved),
symbol,
};
}
@@ -367,12 +547,18 @@ async function getGraphableFiles(
* Build a code graph for a project using ast-grep for polyglot support.
* Files with extra extensions (no AST grammar) are included as leaf nodes
* that can be targets of import edges from other files.
*
* Also extracts symbols and call sites in the same pass — returned via
* `symbolsByFile` / `outgoingCallsByFile` and persisted by `doRebuildGraph`.
*/
export async function buildCodeGraph(
projectPath: string,
extraExtensions?: Set<string>,
progress?: GraphBuildProgress,
): Promise<CodeGraph> {
): Promise<CodeGraph & {
symbolsByFile: Map<string, SymbolNode[]>;
outgoingCallsByFile: Map<string, SymbolEdge[]>;
}> {
ensureDynamicLanguages();
const resolvedPath = path.resolve(projectPath);
@@ -389,6 +575,8 @@ export async function buildCodeGraph(
const nodesMap = new Map<string, CodeGraphNode>();
const edges: CodeGraphEdge[] = [];
const symbolsByFile = new Map<string, SymbolNode[]>();
const outgoingCallsByFile = new Map<string, SymbolEdge[]>();
// Build a suffix lookup map for JVM multi-module projects (Java/Kotlin/Scala).
// This resolves FQNs like com.example.Foo when the class lives under a nested
@@ -452,6 +640,18 @@ export async function buildCodeGraph(
// Extract imports using ast-grep
const importInfos = extractImports(source, lang, ext);
// Extract symbols & raw call sites in the same pass
try {
const extracted = extractSymbolsAndCalls(source, lang, ext, relPath);
symbolsByFile.set(relPath, extracted.symbols);
outgoingCallsByFile.set(relPath, rawCallsToUnresolvedEdges(extracted.rawCalls));
} catch (err) {
logger.debug("Symbol extraction failed (continuing)", {
file: relPath,
error: err instanceof Error ? err.message : String(err),
});
}
for (const imp of importInfos) {
node.imports.push(imp.moduleSpecifier);
@@ -491,5 +691,7 @@ export async function buildCodeGraph(
return {
nodes: Array.from(nodesMap.values()),
edges,
symbolsByFile,
outgoingCallsByFile,
};
}
+134
View File
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Entry-point detection — three-heuristic union:
* 1. Graph orphans with outgoing calls (files that nothing imports but
* that call into other files)
* 2. Conventional names (`main`, `Main`, `__main__`, etc.)
* 3. Framework routes (Express/Flask/FastAPI/NestJS/Spring/ASP.NET/...)
*
* Each detected entry point carries a `reason` so the AI sees why.
*/
import { ENTRY_POINT_NAMES } from "../constants.js";
import type {
CodeGraph,
EntryPoint,
SymbolGraphFilePayload,
SymbolNode,
} from "../types.js";
/**
* Detect entry points across all per-file payloads. The caller must supply
* the file-import graph (for orphan detection) plus all per-file payloads.
*/
export function detectEntryPoints(
fileGraph: CodeGraph,
payloads: SymbolGraphFilePayload[],
): EntryPoint[] {
const out: EntryPoint[] = [];
const seen = new Set<string>();
const push = (e: EntryPoint): void => {
const key = `${e.id}::${e.reason}`;
if (seen.has(key)) return;
seen.add(key);
out.push(e);
};
// Heuristic 1: orphans with outgoing calls
const payloadByFile = new Map(payloads.map((p) => [p.file, p]));
for (const node of fileGraph.nodes) {
if (node.dependents.length > 0) continue;
const payload = payloadByFile.get(node.relativePath);
if (!payload || payload.outgoingCalls.length === 0) continue;
push({
id: node.relativePath,
name: node.relativePath,
file: node.relativePath,
reason: "orphan",
});
}
// Heuristic 2: conventional names
for (const p of payloads) {
const conventional = ENTRY_POINT_NAMES[p.language];
if (!conventional) continue;
for (const sym of p.symbols) {
if (conventional.has(sym.name) && sym.name !== "<module>") {
push({
id: sym.id,
name: sym.qualifiedName,
file: sym.file,
line: sym.line,
reason: `well-known-name:${sym.name}`,
});
}
}
}
// Heuristic 3: framework routes (regex over source not always available
// at this stage — instead inspect symbol decorators recorded as siblings,
// or fall back to outgoing call patterns naming framework router methods).
for (const p of payloads) {
for (const reason of detectFrameworkReasons(p)) {
push({
id: reason.symbol.id,
name: reason.symbol.qualifiedName,
file: reason.symbol.file,
line: reason.symbol.line,
reason: reason.reason,
});
}
}
return out;
}
/** Per-file framework heuristics based on call sites + symbol names. */
function detectFrameworkReasons(
p: SymbolGraphFilePayload,
): Array<{ symbol: SymbolNode; reason: string }> {
const out: Array<{ symbol: SymbolNode; reason: string }> = [];
// Build a lookup from line → enclosing symbol
const symbolsByStartLine = [...p.symbols].sort((a, b) => a.line - b.line);
const findSymbolAt = (line: number): SymbolNode | null => {
let best: SymbolNode | null = null;
for (const s of symbolsByStartLine) {
if (s.line <= line && line <= s.endLine && s.name !== "<module>") {
if (!best || s.line >= best.line) best = s;
}
}
return best;
};
// Inspect outgoingCalls for framework router method calls
// (e.g. `app.get`, `router.post`, `Route::get`, `app.route`)
const framework = (calleeName: string): string | null => {
const lower = calleeName.toLowerCase();
if (["get", "post", "put", "delete", "patch", "head", "options", "all", "use"].includes(lower)) {
return `framework:http-${lower}`;
}
if (lower === "route") return "framework:route";
return null;
};
for (const e of p.outgoingCalls) {
const reason = framework(e.calleeName);
if (!reason) continue;
const enclosing = findSymbolAt(e.callSite.line);
if (!enclosing) continue;
out.push({ symbol: enclosing, reason });
}
// Test functions: names starting with `test_`, `Test`, or matching `it`/`describe`
for (const s of p.symbols) {
if (s.name === "<module>") continue;
if (/^test[_A-Z]/.test(s.name) || /^Test[A-Z_]/.test(s.name)) {
out.push({ symbol: s, reason: "test" });
}
}
return out;
}
+255
View File
@@ -0,0 +1,255 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Impact / flow / context analysis on top of the `SymbolGraphCache`.
* No monolithic graph object — every traversal goes through indices and
* lazy-loaded per-file payloads.
*/
import { MAX_FLOW_DEPTH, MAX_IMPACT_DEPTH } from "../constants.js";
import type { SymbolNode } from "../types.js";
import {
type SymbolGraphCache,
symbolIdToFile,
} from "./symbol-graph-cache.js";
// ── Impact (blast radius) ────────────────────────────────────────────────
export interface ImpactResult {
target: string;
targetKind: "file" | "symbol";
depth: number;
/** Files grouped by hop distance (1 = direct caller, 2 = caller of caller, ...) */
filesByDepth: Map<number, string[]>;
totalFiles: number;
truncated: boolean;
}
/** BFS over the in-memory `reverseFileIndex`. Polymorphic on target type. */
export async function getImpactRadius(
cache: SymbolGraphCache,
target: string,
depth: number = 3,
): Promise<ImpactResult> {
const safeDepth = Math.max(1, Math.min(depth, MAX_IMPACT_DEPTH));
const reverseIndex = await cache.getReverseFileIndex();
const targetKind: "file" | "symbol" = looksLikeFilePath(target)
? "file"
: "symbol";
// Resolve to one or more "seed" files
let seedFiles: Set<string>;
if (targetKind === "file") {
seedFiles = new Set([target]);
} else {
seedFiles = new Set();
const nameIndex = await cache.getNameIndex();
const refs = nameIndex.get(target) ?? [];
for (const r of refs) seedFiles.add(r.file);
}
const visited = new Set<string>();
const filesByDepth = new Map<number, string[]>();
let frontier = new Set(seedFiles);
for (const f of seedFiles) visited.add(f);
for (let hop = 1; hop <= safeDepth; hop++) {
const next = new Set<string>();
for (const calleeFile of frontier) {
const callers = reverseIndex.get(calleeFile);
if (!callers) continue;
for (const callerFile of callers) {
if (visited.has(callerFile)) continue;
next.add(callerFile);
visited.add(callerFile);
}
}
if (next.size === 0) break;
filesByDepth.set(hop, Array.from(next).sort());
frontier = next;
}
let totalFiles = 0;
for (const arr of filesByDepth.values()) totalFiles += arr.length;
return {
target, targetKind, depth: safeDepth, filesByDepth, totalFiles,
truncated: false,
};
}
// ── Call flow (forward DFS) ──────────────────────────────────────────────
export interface FlowNode {
symbolId: string;
symbolName: string;
file: string;
line: number;
children: FlowNode[];
/** True if the recursion stopped here due to depth or cycle. */
truncatedReason?: "depth" | "cycle";
}
/** DFS via lazy-loaded outgoing edges, cycle-safe. */
export async function getCallFlow(
cache: SymbolGraphCache,
entrypointId: string,
depth: number = 5,
): Promise<FlowNode | null> {
const safeDepth = Math.max(1, Math.min(depth, MAX_FLOW_DEPTH));
const file = symbolIdToFile(entrypointId);
if (!file) return null;
const payload = await cache.getFilePayload(file);
if (!payload) return null;
const sym = payload.symbols.find((s) => s.id === entrypointId);
if (!sym) return null;
const visited = new Set<string>();
return await walk(cache, sym, 0, safeDepth, visited);
}
async function walk(
cache: SymbolGraphCache,
sym: SymbolNode,
hop: number,
maxDepth: number,
visited: Set<string>,
): Promise<FlowNode> {
const node: FlowNode = {
symbolId: sym.id,
symbolName: sym.qualifiedName,
file: sym.file,
line: sym.line,
children: [],
};
if (visited.has(sym.id)) {
node.truncatedReason = "cycle";
return node;
}
visited.add(sym.id);
if (hop >= maxDepth) {
node.truncatedReason = "depth";
return node;
}
const payload = await cache.getFilePayload(sym.file);
if (!payload) return node;
const calls = payload.outgoingCalls.filter(
(e) => e.callerId === sym.id && e.calleeCandidates.length > 0,
);
for (const e of calls) {
for (const calleeId of e.calleeCandidates) {
const calleeFile = symbolIdToFile(calleeId);
if (!calleeFile) continue;
const calleePayload = await cache.getFilePayload(calleeFile);
if (!calleePayload) continue;
const calleeSym = calleePayload.symbols.find((s) => s.id === calleeId);
if (!calleeSym) continue;
node.children.push(await walk(cache, calleeSym, hop + 1, maxDepth, visited));
}
}
return node;
}
// ── Symbol context (360° view) ───────────────────────────────────────────
export interface SymbolContext {
symbol: SymbolNode;
callers: Array<{ file: string; line: number; symbolId: string }>;
callees: Array<{ name: string; resolved: string[]; confidence: string }>;
}
export async function getSymbolContext(
cache: SymbolGraphCache,
name: string,
fileHint?: string,
): Promise<SymbolContext[]> {
const nameIndex = await cache.getNameIndex();
let refs = nameIndex.get(name) ?? [];
if (fileHint) refs = refs.filter((r) => r.file === fileHint);
if (refs.length === 0) return [];
const reverseIndex = await cache.getReverseFileIndex();
const out: SymbolContext[] = [];
for (const ref of refs) {
const payload = await cache.getFilePayload(ref.file);
if (!payload) continue;
const sym = payload.symbols.find((s) => s.id === ref.id);
if (!sym) continue;
// Callees: edges originating from this symbol
const callees: SymbolContext["callees"] = payload.outgoingCalls
.filter((e) => e.callerId === sym.id)
.map((e) => ({
name: e.calleeName,
resolved: e.calleeCandidates,
confidence: e.confidence,
}));
// Callers: scan callerFiles' outgoingCalls for edges pointing at this symbol
const callerFiles = reverseIndex.get(ref.file) ?? new Set();
const callers: SymbolContext["callers"] = [];
for (const cf of callerFiles) {
const cp = await cache.getFilePayload(cf);
if (!cp) continue;
for (const e of cp.outgoingCalls) {
if (e.calleeCandidates.includes(sym.id)) {
callers.push({
file: e.callSite.file,
line: e.callSite.line,
symbolId: e.callerId,
});
}
}
}
out.push({ symbol: sym, callers, callees });
}
return out;
}
// ── List symbols ─────────────────────────────────────────────────────────
export async function listSymbols(
cache: SymbolGraphCache,
opts: { file?: string; query?: string; limit?: number },
): Promise<SymbolNode[]> {
const limit = opts.limit ?? 200;
const out: SymbolNode[] = [];
if (opts.file) {
const payload = await cache.getFilePayload(opts.file);
if (!payload) return [];
for (const s of payload.symbols) {
if (s.name === "<module>") continue;
out.push(s);
if (out.length >= limit) break;
}
return out;
}
const nameIndex = await cache.getNameIndex();
const q = opts.query?.toLowerCase() ?? "";
for (const [name, refs] of nameIndex.entries()) {
if (q && !name.toLowerCase().includes(q)) continue;
for (const r of refs) {
const payload = await cache.getFilePayload(r.file);
if (!payload) continue;
const sym = payload.symbols.find((s) => s.id === r.id);
if (sym) out.push(sym);
if (out.length >= limit) return out;
}
}
return out;
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Detect whether a target string looks like a file path vs a symbol name. */
export function looksLikeFilePath(s: string): boolean {
return s.includes("/") || s.includes("\\") || /\.[a-z]{1,5}$/i.test(s);
}
+109
View File
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Cross-file call-site resolution. Given a file-import graph (from
* `code-graph.ts`) and the per-file extracted symbols, populates each call
* edge's `calleeCandidates` and `confidence`.
*
* Strategy (uniform across languages):
* 1. Local — callee name matches a symbol in the caller's own file
* 2. Imported — walk caller's file `dependencies` from the file graph;
* any dependency exposing a same-named symbol is a candidate
* 3. Wildcard / re-export — barrel files re-export symbols transitively;
* we do one extra hop through dependency files
* 4. Resolution: 0 → "unresolved", 1 → "unique", >1 → "multiple-candidates"
*
* No type inference. Method calls resolve by name only.
*/
import type { CodeGraph, SymbolEdge, SymbolNode } from "../types.js";
/**
* Resolve all call sites for every file in `symbolsByFile`. Mutates the
* passed-in `outgoingCallsByFile` edges in place.
*/
export function resolveCallSites(
fileGraph: CodeGraph,
symbolsByFile: Map<string, SymbolNode[]>,
outgoingCallsByFile: Map<string, SymbolEdge[]>,
): void {
// Build a fast lookup: file → Map<symbolName, SymbolNode[]>
const symbolIndexByFile = new Map<string, Map<string, SymbolNode[]>>();
for (const [file, syms] of symbolsByFile.entries()) {
const idx = new Map<string, SymbolNode[]>();
for (const s of syms) {
if (s.name === "<module>") continue;
const existing = idx.get(s.name);
if (existing) existing.push(s);
else idx.set(s.name, [s]);
}
symbolIndexByFile.set(file, idx);
}
// Build file → dependency files (1-hop from the file-import graph)
const depsByFile = new Map<string, string[]>();
for (const node of fileGraph.nodes) {
depsByFile.set(node.relativePath, node.dependencies.slice());
}
for (const [callerFile, edges] of outgoingCallsByFile.entries()) {
const localIdx = symbolIndexByFile.get(callerFile);
const deps = depsByFile.get(callerFile) ?? [];
for (const edge of edges) {
const candidates: string[] = [];
// 1. Local
const local = localIdx?.get(edge.calleeName);
if (local && local.length > 0) {
for (const s of local) candidates.push(s.id);
edge.calleeCandidates = candidates;
edge.confidence = "local";
continue;
}
// 2. Imported (walk direct dependencies)
for (const dep of deps) {
const depIdx = symbolIndexByFile.get(dep);
const matches = depIdx?.get(edge.calleeName);
if (matches) for (const s of matches) candidates.push(s.id);
}
// 3. Wildcard / re-export — one extra hop through dep files
if (candidates.length === 0) {
for (const dep of deps) {
const transitive = depsByFile.get(dep) ?? [];
for (const t of transitive) {
if (t === callerFile) continue;
const tIdx = symbolIndexByFile.get(t);
const matches = tIdx?.get(edge.calleeName);
if (matches) for (const s of matches) candidates.push(s.id);
}
}
}
// De-duplicate
const uniq = Array.from(new Set(candidates));
edge.calleeCandidates = uniq;
if (uniq.length === 0) edge.confidence = "unresolved";
else if (uniq.length === 1) edge.confidence = "unique";
else edge.confidence = "multiple-candidates";
}
}
}
/** Compute the percentage of unresolved edges (0..100). */
export function computeUnresolvedPct(
outgoingCallsByFile: Map<string, SymbolEdge[]>,
): number {
let total = 0;
let unresolved = 0;
for (const edges of outgoingCallsByFile.values()) {
for (const e of edges) {
total++;
if (e.confidence === "unresolved") unresolved++;
}
}
return total === 0 ? 0 : (unresolved / total) * 100;
}
+957
View File
@@ -0,0 +1,957 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Per-language symbol & call-site extraction (mirrors `graph-imports.ts`).
*
* Populated in Phase B with ast-grep patterns for each language.
*/
import { Lang, parse } from "@ast-grep/napi";
import { getLanguageFromExtension } from "../constants.js";
import type { SymbolEdge, SymbolKind, SymbolNode } from "../types.js";
import { logger } from "./logger.js";
/** Result of extracting symbols + raw call sites from a file. */
export interface ExtractedSymbols {
symbols: SymbolNode[];
/** Outgoing call sites — `calleeCandidates` and `confidence` are filled later by resolution. */
rawCalls: Array<{
callerId: string;
calleeName: string;
callSite: { file: string; line: number };
}>;
}
/** Build a stable SymbolNode.id. */
function makeId(file: string, qualifiedName: string, line: number): string {
return `${file}::${qualifiedName}#${line}`;
}
interface ScopeFrame {
name: string;
/** Line at which this scope begins (used to limit call-site attribution). */
startLine: number;
endLine: number;
symbolId: string;
}
/** Find the deepest scope frame covering a line. */
function findCallerId(scopes: ScopeFrame[], line: number, fallback: string): string {
let best: ScopeFrame | null = null;
for (const s of scopes) {
if (line >= s.startLine && line <= s.endLine) {
if (!best || s.startLine >= best.startLine) best = s;
}
}
return best ? best.symbolId : fallback;
}
/**
* Public entry point: extract symbols and raw call sites from a source file.
* Returns empty arrays if the language is unsupported or parsing fails.
*/
export function extractSymbolsAndCalls(
source: string,
lang: Lang | string,
ext: string,
relativePath: string,
): ExtractedSymbols {
const language = getLanguageFromExtension(ext);
const langKey = String(lang);
// Per-file synthetic "module" scope so unattributed calls have a caller.
const moduleSymbol: SymbolNode = {
id: makeId(relativePath, "<module>", 1),
name: "<module>",
qualifiedName: "<module>",
kind: "module",
file: relativePath,
line: 1,
endLine: source.split("\n").length,
language,
};
try {
if (
langKey === String(Lang.JavaScript) ||
langKey === String(Lang.TypeScript) ||
langKey === String(Lang.Tsx)
) {
return extractFromTsLike(source, lang as Lang, relativePath, language, moduleSymbol);
}
if (langKey === "python") {
return extractFromPython(source, relativePath, language, moduleSymbol);
}
if (langKey === "go") {
return extractFromGo(source, relativePath, language, moduleSymbol);
}
if (langKey === "rust") {
return extractFromRust(source, relativePath, language, moduleSymbol);
}
if (langKey === "java" || langKey === "kotlin" || langKey === "scala") {
return extractFromJvm(source, lang as string, relativePath, language, moduleSymbol);
}
if (langKey === "csharp") {
return extractFromCSharp(source, relativePath, language, moduleSymbol);
}
if (langKey === "c" || langKey === "cpp") {
return extractFromCFamily(source, lang as string, relativePath, language, moduleSymbol);
}
if (langKey === "ruby") {
return extractFromRuby(source, relativePath, language, moduleSymbol);
}
if (langKey === "php") {
return extractFromPhp(source, relativePath, language, moduleSymbol);
}
if (langKey === "swift") {
return extractFromSwift(source, relativePath, language, moduleSymbol);
}
if (langKey === "bash") {
return extractFromBash(source, relativePath, language, moduleSymbol);
}
// Dart, Lua, Svelte, Vue and others fall through to the regex fallback.
return extractFromRegex(source, relativePath, language, moduleSymbol);
} catch (err) {
logger.debug("extractSymbolsAndCalls failed", {
file: relativePath,
lang: langKey,
error: err instanceof Error ? err.message : String(err),
});
return { symbols: [moduleSymbol], rawCalls: [] };
}
}
// ── JS / TS / TSX ────────────────────────────────────────────────────────
function extractFromTsLike(
source: string,
lang: Lang,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse(lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
// Class declarations
for (const node of root.findAll({ rule: { kind: "class_declaration" } })) {
const nameNode = node.find({ rule: { kind: "type_identifier" } })
?? node.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const range = node.range();
const startLine = range.start.line + 1;
const endLine = range.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "class", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
// Methods inside the class
for (const m of node.findAll({ rule: { kind: "method_definition" } })) {
const mName = m.find({ rule: { kind: "property_identifier" } })?.text();
if (!mName) continue;
const mr = m.range();
const mStart = mr.start.line + 1;
const mEnd = mr.end.line + 1;
const qname = `${name}.${mName}`;
const msym: SymbolNode = {
id: makeId(file, qname, mStart),
name: mName, qualifiedName: qname,
kind: mName === "constructor" ? "constructor" : "method",
file, line: mStart, endLine: mEnd, language,
};
symbols.push(msym);
scopes.push({ name: qname, startLine: mStart, endLine: mEnd, symbolId: msym.id });
}
}
// Top-level function declarations
for (const node of root.findAll({ rule: { kind: "function_declaration" } })) {
const nameNode = node.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = node.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
// Generator function declarations
for (const node of root.findAll({ rule: { kind: "generator_function_declaration" } })) {
const nameNode = node.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = node.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
// Named arrow functions: `const foo = (...) => {...}` or `const foo = function(...) {...}`
for (const node of root.findAll({ rule: { kind: "lexical_declaration" } })) {
for (const decl of node.findAll({ rule: { kind: "variable_declarator" } })) {
const idNode = decl.find({ rule: { kind: "identifier" } });
if (!idNode) continue;
const name = idNode.text();
const arrow = decl.find({ rule: { kind: "arrow_function" } });
const fnExpr = decl.find({ rule: { kind: "function_expression" } });
const fn = arrow ?? fnExpr;
if (!fn) continue;
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
// Call sites
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call_expression" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
const callerId = findCallerId(scopes, callLine, moduleSym.id);
rawCalls.push({
callerId, calleeName,
callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
/** Pull the callee's bare name from the start of a call expression's text. */
function extractCalleeNameJs(text: string): string | null {
// `foo(...)` → "foo" ; `obj.foo(...)` → "foo" ; `obj.bar.foo(...)` → "foo"
const m = text.match(/^([\w$.]+)\s*\(/);
if (!m) return null;
const chain = m[1];
const parts = chain.split(".");
const last = parts[parts.length - 1];
return /^[A-Za-z_$][\w$]*$/.test(last) ? last : null;
}
// ── Python ───────────────────────────────────────────────────────────────
function extractFromPython(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("python" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
// Classes
for (const cls of root.findAll({ rule: { kind: "class_definition" } })) {
const nameNode = cls.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const className = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const csym: SymbolNode = {
id: makeId(file, className, startLine),
name: className, qualifiedName: className, kind: "class", file, line: startLine, endLine, language,
};
symbols.push(csym);
scopes.push({ name: className, startLine, endLine, symbolId: csym.id });
// Methods
for (const fn of cls.findAll({ rule: { kind: "function_definition" } })) {
const fnName = fn.find({ rule: { kind: "identifier" } })?.text();
if (!fnName) continue;
const fr = fn.range();
const fStart = fr.start.line + 1;
const fEnd = fr.end.line + 1;
const qname = `${className}.${fnName}`;
const fsym: SymbolNode = {
id: makeId(file, qname, fStart),
name: fnName, qualifiedName: qname,
kind: fnName === "__init__" ? "constructor" : "method",
file, line: fStart, endLine: fEnd, language,
};
symbols.push(fsym);
scopes.push({ name: qname, startLine: fStart, endLine: fEnd, symbolId: fsym.id });
}
}
// Top-level functions (those not nested inside classes)
for (const fn of root.findAll({ rule: { kind: "function_definition" } })) {
const fnName = fn.find({ rule: { kind: "identifier" } })?.text();
if (!fnName) continue;
const r = fn.range();
const startLine = r.start.line + 1;
// Skip if already captured as a method (start line matches an existing scope's nested method)
if (symbols.some((s) => s.file === file && s.line === startLine && s.name === fnName)) continue;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, fnName, startLine),
name: fnName, qualifiedName: fnName, kind: "function", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name: fnName, startLine, endLine, symbolId: sym.id });
}
// Calls
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName,
callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── Go ───────────────────────────────────────────────────────────────────
function extractFromGo(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("go" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const fn of root.findAll({ rule: { kind: "function_declaration" } })) {
const nameNode = fn.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
for (const fn of root.findAll({ rule: { kind: "method_declaration" } })) {
const nameNode = fn.find({ rule: { kind: "field_identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "method", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call_expression" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── Rust ─────────────────────────────────────────────────────────────────
function extractFromRust(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("rust" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const fn of root.findAll({ rule: { kind: "function_item" } })) {
const nameNode = fn.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call_expression" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
for (const node of root.findAll({ rule: { kind: "macro_invocation" } })) {
const nameNode = node.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName: nameNode.text(), callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── JVM (Java / Kotlin / Scala) ──────────────────────────────────────────
function extractFromJvm(
source: string,
langKey: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse(langKey as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
const classKinds = langKey === "scala"
? ["class_definition", "object_definition", "trait_definition"]
: ["class_declaration", "interface_declaration", "enum_declaration", "object_declaration"];
for (const k of classKinds) {
for (const cls of root.findAll({ rule: { kind: k } })) {
const nameNode = cls.find({ rule: { kind: "type_identifier" } })
?? cls.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const kind: SymbolKind = k.includes("interface") ? "interface"
: k.includes("trait") ? "trait"
: k.includes("enum") ? "enum" : "class";
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind, file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
const methodKinds = langKey === "scala"
? ["function_definition"]
: langKey === "kotlin"
? ["function_declaration"]
: ["method_declaration", "constructor_declaration"];
for (const k of methodKinds) {
for (const m of root.findAll({ rule: { kind: k } })) {
const nameNode = m.find({ rule: { kind: "identifier" } })
?? m.find({ rule: { kind: "simple_identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = m.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k.includes("constructor") ? "constructor" : "method",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
const callKinds = langKey === "java"
? ["method_invocation"]
: ["call_expression"];
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const k of callKinds) {
for (const node of root.findAll({ rule: { kind: k } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
}
return { symbols, rawCalls };
}
// ── C# ──────────────────────────────────────────────────────────────────
function extractFromCSharp(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("csharp" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const k of ["class_declaration", "interface_declaration", "record_declaration", "struct_declaration"]) {
for (const cls of root.findAll({ rule: { kind: k } })) {
const nameNode = cls.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k.includes("interface") ? "interface"
: k.includes("struct") ? "struct" : "class",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
for (const k of ["method_declaration", "constructor_declaration"]) {
for (const m of root.findAll({ rule: { kind: k } })) {
const nameNode = m.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = m.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k.includes("constructor") ? "constructor" : "method",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "invocation_expression" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── C / C++ ──────────────────────────────────────────────────────────────
function extractFromCFamily(
source: string,
langKey: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse(langKey as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
if (langKey === "cpp") {
for (const k of ["class_specifier", "struct_specifier"]) {
for (const cls of root.findAll({ rule: { kind: k } })) {
const nameNode = cls.find({ rule: { kind: "type_identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k.includes("struct") ? "struct" : "class",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
}
for (const fn of root.findAll({ rule: { kind: "function_definition" } })) {
const declarator = fn.find({ rule: { kind: "function_declarator" } });
const nameNode = declarator?.find({ rule: { kind: "identifier" } })
?? declarator?.find({ rule: { kind: "qualified_identifier" } });
if (!nameNode) continue;
const fullName = nameNode.text();
const name = fullName.split("::").pop() ?? fullName;
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, fullName, startLine),
name, qualifiedName: fullName,
kind: fullName.includes("::") ? "method" : "function",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name: fullName, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call_expression" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── Ruby ────────────────────────────────────────────────────────────────
function extractFromRuby(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("ruby" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const k of ["class", "module"]) {
for (const cls of root.findAll({ rule: { kind: k } })) {
const nameNode = cls.find({ rule: { kind: "constant" } })
?? cls.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k === "module" ? "module" : "class",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
for (const m of root.findAll({ rule: { kind: "method" } })) {
const nameNode = m.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = m.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "method",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── PHP ─────────────────────────────────────────────────────────────────
function extractFromPhp(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("php" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const k of ["class_declaration", "interface_declaration", "trait_declaration"]) {
for (const cls of root.findAll({ rule: { kind: k } })) {
const nameNode = cls.find({ rule: { kind: "name" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k.includes("interface") ? "interface" : k.includes("trait") ? "trait" : "class",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
for (const k of ["function_definition", "method_declaration"]) {
for (const m of root.findAll({ rule: { kind: k } })) {
const nameNode = m.find({ rule: { kind: "name" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = m.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k === "function_definition" ? "function" : "method",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const k of ["function_call_expression", "member_call_expression", "scoped_call_expression"]) {
for (const node of root.findAll({ rule: { kind: k } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
}
return { symbols, rawCalls };
}
// ── Swift ───────────────────────────────────────────────────────────────
function extractFromSwift(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("swift" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const k of ["class_declaration", "struct_declaration", "protocol_declaration", "enum_declaration"]) {
for (const cls of root.findAll({ rule: { kind: k } })) {
const nameNode = cls.find({ rule: { kind: "type_identifier" } })
?? cls.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = cls.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name,
kind: k.includes("struct") ? "struct"
: k.includes("protocol") ? "interface"
: k.includes("enum") ? "enum" : "class",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
}
for (const fn of root.findAll({ rule: { kind: "function_declaration" } })) {
const nameNode = fn.find({ rule: { kind: "simple_identifier" } })
?? fn.find({ rule: { kind: "identifier" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "call_expression" } })) {
const calleeName = extractCalleeNameJs(node.text());
if (!calleeName) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName, callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── Bash ────────────────────────────────────────────────────────────────
function extractFromBash(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("bash" as unknown as Lang, source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
for (const fn of root.findAll({ rule: { kind: "function_definition" } })) {
const nameNode = fn.find({ rule: { kind: "word" } });
if (!nameNode) continue;
const name = nameNode.text();
const r = fn.range();
const startLine = r.start.line + 1;
const endLine = r.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const node of root.findAll({ rule: { kind: "command" } })) {
const nameNode = node.find({ rule: { kind: "command_name" } });
if (!nameNode) continue;
const name = nameNode.text();
if (!/^[A-Za-z_][\w]*$/.test(name)) continue;
const r = node.range();
const callLine = r.start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName: name, callSite: { file, line: callLine },
});
}
return { symbols, rawCalls };
}
// ── Regex fallback (Dart, Lua, Svelte/Vue, anything unsupported) ────────
function extractFromRegex(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
const lines = source.split("\n");
// Generic `function NAME` / `def NAME` / `fn NAME` / `func NAME` patterns
const fnRegex = /^\s*(?:export\s+|public\s+|private\s+|static\s+|async\s+)*(?:function|def|fn|func|sub|local\s+function)\s+([A-Za-z_][\w]*)/;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(fnRegex);
if (!m) continue;
const name = m[1];
const startLine = i + 1;
// Heuristic end line: next line with same or less indentation
const indent = lines[i].match(/^\s*/)?.[0].length ?? 0;
let endLine = startLine;
for (let j = i + 1; j < lines.length; j++) {
const text = lines[j];
if (text.trim() === "") continue;
const ind = text.match(/^\s*/)?.[0].length ?? 0;
if (ind <= indent) break;
endLine = j + 1;
}
const sym: SymbolNode = {
id: makeId(file, name, startLine),
name, qualifiedName: name, kind: "function",
file, line: startLine, endLine, language,
};
symbols.push(sym);
scopes.push({ name, startLine, endLine, symbolId: sym.id });
}
const rawCalls: ExtractedSymbols["rawCalls"] = [];
const callRegex = /([A-Za-z_][\w]*)\s*\(/g;
for (let i = 0; i < lines.length; i++) {
let m: RegExpExecArray | null = null;
callRegex.lastIndex = 0;
m = callRegex.exec(lines[i]);
while (m !== null) {
const name = m[1];
// Skip language keywords/control flow
if (!["if", "for", "while", "switch", "return", "function", "def", "fn", "func", "class", "new"].includes(name)) {
const callLine = i + 1;
rawCalls.push({
callerId: findCallerId(scopes, callLine, moduleSym.id),
calleeName: name, callSite: { file, line: callLine },
});
}
m = callRegex.exec(lines[i]);
}
}
return { symbols, rawCalls };
}
/** Convert raw call sites to unresolved SymbolEdge objects (resolution in Phase C). */
export function rawCallsToUnresolvedEdges(
rawCalls: ExtractedSymbols["rawCalls"],
): SymbolEdge[] {
return rawCalls.map((c) => ({
callerId: c.callerId,
calleeName: c.calleeName,
calleeCandidates: [],
confidence: "unresolved" as const,
callSite: c.callSite,
}));
}
+1 -1
View File
@@ -37,7 +37,7 @@ async function withRetry<T>(
let client: QdrantClient | null = null;
function getClient(): QdrantClient {
export function getClient(): QdrantClient {
if (!client) {
client = new QdrantClient(
QDRANT_URL
+283
View File
@@ -0,0 +1,283 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* In-memory `SymbolGraphCache` for a project. Backed by the sharded Qdrant
* store in `symbol-graph-store.ts`.
*
* Loading strategy:
* - `meta` — eager (tiny).
* - `nameIndex` — eager on first symbol-name query (all 27 shards).
* - `reverseFileIndex` — eager on first impact query (all 256 shards).
* - `fileDataLru` — lazy per-file payloads, LRU-bounded.
*
* Critical invariant: no query loads every symbol or every edge into memory.
*/
import { SYMBOL_FILE_LRU_SIZE, SYMBOL_REVERSE_SHARDS } from "../constants.js";
import type {
SymbolGraphFilePayload,
SymbolGraphMeta,
SymbolRef,
} from "../types.js";
import { logger } from "./logger.js";
import {
allNameShardKeys,
loadFilePayload,
loadNameShard,
loadReverseShard,
loadSymbolGraphMeta,
} from "./symbol-graph-store.js";
// ── Tiny LRU (handwritten, ~20 lines) ────────────────────────────────────
export class LRUCache<K, V> {
private map = new Map<K, V>();
constructor(private readonly capacity: number) {}
get(key: K): V | undefined {
const v = this.map.get(key);
if (v === undefined) return undefined;
this.map.delete(key);
this.map.set(key, v);
return v;
}
set(key: K, value: V): void {
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value as K | undefined;
if (oldest !== undefined) this.map.delete(oldest);
}
}
delete(key: K): boolean {
return this.map.delete(key);
}
has(key: K): boolean {
return this.map.has(key);
}
clear(): void {
this.map.clear();
}
get size(): number {
return this.map.size;
}
keys(): IterableIterator<K> {
return this.map.keys();
}
}
// ── Cache structure ──────────────────────────────────────────────────────
export interface SymbolGraphCacheStats {
fileLruSize: number;
fileLruHits: number;
fileLruMisses: number;
nameIndexLoaded: boolean;
reverseIndexLoaded: boolean;
}
export class SymbolGraphCache {
meta: SymbolGraphMeta;
/** name → list of symbol refs (lazy-loaded as a whole) */
private nameIndex: Map<string, SymbolRef[]> | null = null;
/** calleeFile → set of caller files (lazy-loaded as a whole) */
private reverseFileIndex: Map<string, Set<string>> | null = null;
/** lazy per-file payloads, LRU-bounded */
fileDataLru: LRUCache<string, SymbolGraphFilePayload>;
private stats: SymbolGraphCacheStats = {
fileLruSize: 0,
fileLruHits: 0,
fileLruMisses: 0,
nameIndexLoaded: false,
reverseIndexLoaded: false,
};
constructor(
public readonly projectId: string,
meta: SymbolGraphMeta,
lruCapacity: number = SYMBOL_FILE_LRU_SIZE,
) {
this.meta = meta;
this.fileDataLru = new LRUCache(lruCapacity);
}
/** Get the full name index, loading all shards on first access. */
async getNameIndex(): Promise<Map<string, SymbolRef[]>> {
if (this.nameIndex) return this.nameIndex;
const merged = new Map<string, SymbolRef[]>();
const shardKeys = allNameShardKeys();
const shards = await Promise.all(
shardKeys.map((k) => loadNameShard(this.projectId, k)),
);
for (const shard of shards) {
if (!shard) continue;
for (const [name, refs] of Object.entries(shard)) {
const existing = merged.get(name);
if (existing) {
existing.push(...refs);
} else {
merged.set(name, [...refs]);
}
}
}
this.nameIndex = merged;
this.stats.nameIndexLoaded = true;
return merged;
}
/** Get the full reverse-call file index, loading all shards on first access. */
async getReverseFileIndex(): Promise<Map<string, Set<string>>> {
if (this.reverseFileIndex) return this.reverseFileIndex;
const merged = new Map<string, Set<string>>();
const buckets: number[] = [];
for (let i = 0; i < SYMBOL_REVERSE_SHARDS; i++) buckets.push(i);
const shards = await Promise.all(
buckets.map((b) => loadReverseShard(this.projectId, b)),
);
for (const shard of shards) {
if (!shard) continue;
for (const [calleeFile, callerFiles] of Object.entries(shard)) {
const existing = merged.get(calleeFile);
if (existing) {
for (const f of callerFiles) existing.add(f);
} else {
merged.set(calleeFile, new Set(callerFiles));
}
}
}
this.reverseFileIndex = merged;
this.stats.reverseIndexLoaded = true;
return merged;
}
/** Get a per-file payload, hitting the LRU first then Qdrant. */
async getFilePayload(
relativePath: string,
): Promise<SymbolGraphFilePayload | null> {
const cached = this.fileDataLru.get(relativePath);
if (cached) {
this.stats.fileLruHits++;
return cached;
}
this.stats.fileLruMisses++;
const payload = await loadFilePayload(this.projectId, relativePath);
if (payload) this.fileDataLru.set(relativePath, payload);
this.stats.fileLruSize = this.fileDataLru.size;
return payload;
}
/** Invalidate cached state for a file (called by watcher on file changes). */
invalidateFile(relativePath: string): void {
this.fileDataLru.delete(relativePath);
}
/** Patch the in-memory name index for an updated file payload. */
patchNameIndexForFile(
oldPayload: SymbolGraphFilePayload | null,
newPayload: SymbolGraphFilePayload,
): void {
if (!this.nameIndex) return;
if (oldPayload) {
for (const sym of oldPayload.symbols) {
const refs = this.nameIndex.get(sym.name);
if (!refs) continue;
const filtered = refs.filter((r) => r.id !== sym.id);
if (filtered.length === 0) this.nameIndex.delete(sym.name);
else this.nameIndex.set(sym.name, filtered);
}
}
for (const sym of newPayload.symbols) {
const ref: SymbolRef = { file: sym.file, id: sym.id };
const refs = this.nameIndex.get(sym.name);
if (refs) refs.push(ref);
else this.nameIndex.set(sym.name, [ref]);
}
}
/** Patch the in-memory reverse-file index for an updated file payload. */
patchReverseFileIndexForFile(
oldPayload: SymbolGraphFilePayload | null,
newPayload: SymbolGraphFilePayload,
): void {
if (!this.reverseFileIndex) return;
const callerFile = newPayload.file;
if (oldPayload) {
for (const e of oldPayload.outgoingCalls) {
for (const calleeId of e.calleeCandidates) {
const calleeFile = symbolIdToFile(calleeId);
if (!calleeFile) continue;
const callers = this.reverseFileIndex.get(calleeFile);
if (!callers) continue;
callers.delete(callerFile);
if (callers.size === 0) this.reverseFileIndex.delete(calleeFile);
}
}
}
for (const e of newPayload.outgoingCalls) {
for (const calleeId of e.calleeCandidates) {
const calleeFile = symbolIdToFile(calleeId);
if (!calleeFile) continue;
const callers = this.reverseFileIndex.get(calleeFile);
if (callers) callers.add(callerFile);
else this.reverseFileIndex.set(calleeFile, new Set([callerFile]));
}
}
}
/** Replace the cached payload for a file (used after rebuild of one file). */
setFilePayload(payload: SymbolGraphFilePayload): void {
this.fileDataLru.set(payload.file, payload);
this.stats.fileLruSize = this.fileDataLru.size;
}
getStats(): SymbolGraphCacheStats {
return { ...this.stats, fileLruSize: this.fileDataLru.size };
}
}
/** Extract the file portion from a SymbolNode.id (`file::qname#line`). */
export function symbolIdToFile(id: string): string | null {
const idx = id.indexOf("::");
return idx > 0 ? id.slice(0, idx) : null;
}
// ── Cache registry per project ───────────────────────────────────────────
const cacheRegistry = new Map<string, SymbolGraphCache>();
/** Get or build the cache for a project (loads meta from Qdrant lazily). */
export async function getSymbolGraphCache(
projectId: string,
): Promise<SymbolGraphCache | null> {
const cached = cacheRegistry.get(projectId);
if (cached) return cached;
const meta = await loadSymbolGraphMeta(projectId);
if (!meta) return null;
const cache = new SymbolGraphCache(projectId, meta);
cacheRegistry.set(projectId, cache);
return cache;
}
/** Replace (or insert) the cache for a project — used after a fresh rebuild. */
export function setSymbolGraphCache(cache: SymbolGraphCache): void {
cacheRegistry.set(cache.projectId, cache);
}
/** Remove a project's cache from the registry. */
export function dropSymbolGraphCache(projectId: string): void {
cacheRegistry.delete(projectId);
}
/** Reset all caches (testing only). */
export function resetSymbolGraphCacheRegistry(): void {
cacheRegistry.clear();
logger.debug("Symbol graph cache registry cleared");
}
+368
View File
@@ -0,0 +1,368 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Sharded Qdrant storage layer for the symbol-level call graph.
*
* Three collections per project (created lazily, idempotent):
* - `{projectId}_symgraph_meta` → 1 point with `SymbolGraphMeta`
* - `{projectId}_symgraph_file` → 1 point per source file (`SymbolGraphFilePayload`)
* - `{projectId}_symgraph_index` → sharded indices:
* • Name index — 27 shards keyed by first lowercased char of symbol name
* • Reverse-call file index — 256 shards keyed by first byte of SHA1(file)
*
* All points use the dummy-vector-`[0]` pattern (Qdrant requires a vector).
*/
import { createHash } from "node:crypto";
import {
symgraphFileCollectionName,
symgraphIndexCollectionName,
symgraphMetaCollectionName,
} from "../config.js";
import { SYMBOL_REVERSE_SHARDS } from "../constants.js";
import type {
SymbolGraphFilePayload,
SymbolGraphMeta,
SymbolRef,
} from "../types.js";
import { logger } from "./logger.js";
import { getClient } from "./qdrant.js";
// ── Shard key helpers ────────────────────────────────────────────────────
/** Map a symbol name to its name-index shard key (`a``z` or `_`). */
export function nameShardKey(name: string): string {
if (!name) return "_";
const c = name[0].toLowerCase();
return c >= "a" && c <= "z" ? c : "_";
}
/** All 27 possible name-index shard keys (in stable order). */
export function allNameShardKeys(): string[] {
const keys: string[] = ["_"];
for (let i = 0; i < 26; i++) {
keys.push(String.fromCharCode("a".charCodeAt(0) + i));
}
return keys;
}
/** Map a file path to its reverse-call shard bucket (0..SYMBOL_REVERSE_SHARDS-1). */
export function reverseShardKey(filePath: string): number {
const digest = createHash("sha1").update(filePath).digest();
return digest[0] % SYMBOL_REVERSE_SHARDS;
}
/** Format a reverse-shard bucket as a 2-char zero-padded hex string. */
export function reverseShardHex(bucket: number): string {
return bucket.toString(16).padStart(2, "0");
}
// ── Point IDs (UUID-formatted SHA-256 prefixes) ─────────────────────────
function uuidFromString(input: string): string {
const hash = createHash("sha256").update(input).digest("hex").slice(0, 32);
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
}
function metaPointId(projectId: string): string {
return uuidFromString(`${projectId}::meta`);
}
function filePointId(projectId: string, relativePath: string): string {
return uuidFromString(`${projectId}::file::${relativePath}`);
}
function nameShardPointId(projectId: string, shardKey: string): string {
return uuidFromString(`${projectId}::nameidx::${shardKey}`);
}
function revShardPointId(projectId: string, bucketHex: string): string {
return uuidFromString(`${projectId}::revidx::${bucketHex}`);
}
// ── Collection lifecycle ─────────────────────────────────────────────────
const collectionsReady = new Set<string>();
/** Ensure a single collection exists (idempotent, cached after first success). */
async function ensureCollection(name: string): Promise<void> {
if (collectionsReady.has(name)) return;
const qdrant = getClient();
const collections = await qdrant.getCollections();
const exists = collections.collections.some((c) => c.name === name);
if (!exists) {
await qdrant.createCollection(name, {
vectors: { size: 1, distance: "Cosine" },
on_disk_payload: true,
});
logger.info("Created symbol-graph collection", { name });
}
collectionsReady.add(name);
}
/** Reset readiness cache (testing only). */
export function resetSymbolGraphCollectionCache(): void {
collectionsReady.clear();
}
/** Ensure all three symbol-graph collections exist for a project. */
export async function ensureSymbolGraphCollections(projectId: string): Promise<void> {
await Promise.all([
ensureCollection(symgraphMetaCollectionName(projectId)),
ensureCollection(symgraphFileCollectionName(projectId)),
ensureCollection(symgraphIndexCollectionName(projectId)),
]);
}
// ── Meta ─────────────────────────────────────────────────────────────────
export async function saveSymbolGraphMeta(
projectId: string,
meta: SymbolGraphMeta,
): Promise<void> {
const collName = symgraphMetaCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
await qdrant.upsert(collName, {
points: [{ id: metaPointId(projectId), vector: [0], payload: { meta } }],
});
}
export async function loadSymbolGraphMeta(
projectId: string,
): Promise<SymbolGraphMeta | null> {
try {
const collName = symgraphMetaCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
const points = await qdrant.retrieve(collName, {
ids: [metaPointId(projectId)],
with_payload: true,
});
if (points.length === 0) return null;
const payload = points[0].payload;
return (payload?.meta as SymbolGraphMeta) ?? null;
} catch (err) {
logger.warn("loadSymbolGraphMeta failed (returning null)", {
projectId,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
// ── Per-file payloads ────────────────────────────────────────────────────
export async function saveFilePayload(
projectId: string,
payload: SymbolGraphFilePayload,
): Promise<void> {
const collName = symgraphFileCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
await qdrant.upsert(collName, {
points: [
{
id: filePointId(projectId, payload.file),
vector: [0],
payload: { filePayload: payload },
},
],
});
}
/** Bulk upsert per-file payloads. Caller is expected to batch sensibly. */
export async function saveFilePayloads(
projectId: string,
payloads: SymbolGraphFilePayload[],
): Promise<void> {
if (payloads.length === 0) return;
const collName = symgraphFileCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
// Chunk to avoid massive single requests
const CHUNK = 50;
for (let i = 0; i < payloads.length; i += CHUNK) {
const slice = payloads.slice(i, i + CHUNK);
await qdrant.upsert(collName, {
points: slice.map((p) => ({
id: filePointId(projectId, p.file),
vector: [0],
payload: { filePayload: p },
})),
});
}
}
export async function loadFilePayload(
projectId: string,
relativePath: string,
): Promise<SymbolGraphFilePayload | null> {
try {
const collName = symgraphFileCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
const points = await qdrant.retrieve(collName, {
ids: [filePointId(projectId, relativePath)],
with_payload: true,
});
if (points.length === 0) return null;
return (points[0].payload?.filePayload as SymbolGraphFilePayload) ?? null;
} catch (err) {
logger.warn("loadFilePayload failed (returning null)", {
projectId,
file: relativePath,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
export async function deleteFilePayload(
projectId: string,
relativePath: string,
): Promise<void> {
try {
const collName = symgraphFileCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
await qdrant.delete(collName, {
points: [filePointId(projectId, relativePath)],
});
} catch (err) {
logger.warn("deleteFilePayload failed (ignored)", {
projectId,
file: relativePath,
error: err instanceof Error ? err.message : String(err),
});
}
}
// ── Name index shards ────────────────────────────────────────────────────
export async function saveNameShard(
projectId: string,
shardKey: string,
nameToSymbols: Record<string, SymbolRef[]>,
): Promise<void> {
const collName = symgraphIndexCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
await qdrant.upsert(collName, {
points: [
{
id: nameShardPointId(projectId, shardKey),
vector: [0],
payload: { kind: "name", shard: shardKey, nameToSymbols },
},
],
});
}
export async function loadNameShard(
projectId: string,
shardKey: string,
): Promise<Record<string, SymbolRef[]> | null> {
try {
const collName = symgraphIndexCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
const points = await qdrant.retrieve(collName, {
ids: [nameShardPointId(projectId, shardKey)],
with_payload: true,
});
if (points.length === 0) return null;
return (points[0].payload?.nameToSymbols as Record<string, SymbolRef[]>) ?? null;
} catch (err) {
logger.warn("loadNameShard failed (returning null)", {
projectId,
shardKey,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
// ── Reverse-call file index shards ───────────────────────────────────────
export async function saveReverseShard(
projectId: string,
bucket: number,
reverseEdges: Record<string, string[]>,
): Promise<void> {
const collName = symgraphIndexCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
const bucketHex = reverseShardHex(bucket);
await qdrant.upsert(collName, {
points: [
{
id: revShardPointId(projectId, bucketHex),
vector: [0],
payload: { kind: "reverse", bucket, reverseEdges },
},
],
});
}
export async function loadReverseShard(
projectId: string,
bucket: number,
): Promise<Record<string, string[]> | null> {
try {
const collName = symgraphIndexCollectionName(projectId);
await ensureCollection(collName);
const qdrant = getClient();
const bucketHex = reverseShardHex(bucket);
const points = await qdrant.retrieve(collName, {
ids: [revShardPointId(projectId, bucketHex)],
with_payload: true,
});
if (points.length === 0) return null;
return (points[0].payload?.reverseEdges as Record<string, string[]>) ?? null;
} catch (err) {
logger.warn("loadReverseShard failed (returning null)", {
projectId,
bucket,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
// ── Bulk delete ──────────────────────────────────────────────────────────
/** Delete all symbol-graph data for a project (best-effort). */
export async function deleteSymbolGraphData(projectId: string): Promise<void> {
const qdrant = getClient();
const names = [
symgraphMetaCollectionName(projectId),
symgraphFileCollectionName(projectId),
symgraphIndexCollectionName(projectId),
];
const existing = await qdrant.getCollections();
for (const name of names) {
if (existing.collections.some((c) => c.name === name)) {
try {
await qdrant.deleteCollection(name);
collectionsReady.delete(name);
} catch (err) {
logger.warn("deleteSymbolGraphData: deleteCollection failed (ignored)", {
name,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
}
/** Compute SHA-256 of a string and return hex digest. Used for `contentHash`. */
export function contentHashOf(source: string): string {
return createHash("sha256").update(source).digest("hex");
}
// Helper exports for tests
export const _internal = {
metaPointId,
filePointId,
nameShardPointId,
revShardPointId,
};
+189
View File
@@ -1,9 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import path from "node:path";
import { projectIdFromPath } from "../config.js";
import { mergeExtraExtensions } from "../constants.js";
import { awaitGraphBuild, findCircularDependencies, generateMermaidDiagram, getFileDependencies, getGraphBuildProgress, getGraphStats, getGraphStatus, getLastGraphBuildCompleted, getOrBuildGraph, isGraphBuildInProgress, rebuildGraph, removeGraph } from "../services/code-graph.js";
import { detectEntryPoints } from "../services/graph-entrypoints.js";
import {
type FlowNode,
getCallFlow,
getImpactRadius,
getSymbolContext,
listSymbols,
looksLikeFilePath,
} from "../services/graph-impact.js";
import { logger } from "../services/logger.js";
import { getSymbolGraphCache } from "../services/symbol-graph-cache.js";
import { ensureWatcherStarted } from "../services/watcher.js";
export async function handleGraphTool(
@@ -234,6 +245,163 @@ export async function handleGraphTool(
lines.push(`Last build duration: ${(lastBuild.durationMs / 1000).toFixed(1)}s`);
}
if (graphInfo.symbol) {
const sm = graphInfo.symbol;
lines.push("");
lines.push("Symbol graph (Impact Analysis):");
lines.push(` Files: ${sm.fileCount}`);
lines.push(` Symbols: ${sm.symbolCount}`);
lines.push(` Call edges: ${sm.edgeCount}`);
lines.push(` Unresolved: ${sm.unresolvedEdgePct.toFixed(1)}%`);
}
return lines.join("\n");
}
case "codebase_impact": {
const target = (args.target as string)?.trim();
if (!target) return "Missing required argument: target";
const depth = typeof args.depth === "number" ? args.depth : 3;
const projectId = projectIdFromPath(projectPath);
const cache = await getSymbolGraphCache(projectId);
if (!cache) {
return "No symbol graph found. Run codebase_graph_build (or codebase_index) first.";
}
const result = await getImpactRadius(cache, target, depth);
const lines = [
`Blast radius for ${result.targetKind}: ${result.target}`,
`Depth: ${result.depth} Total impacted files: ${result.totalFiles}`,
"",
];
if (result.totalFiles === 0) {
lines.push("No callers found — nothing else depends on this.");
} else {
for (const [hop, files] of result.filesByDepth.entries()) {
lines.push(`Hop ${hop} (${files.length} files):`);
for (const f of files) lines.push(` - ${f}`);
lines.push("");
}
}
return lines.join("\n").trimEnd();
}
case "codebase_flow": {
const projectId = projectIdFromPath(projectPath);
const cache = await getSymbolGraphCache(projectId);
if (!cache) {
return "No symbol graph found. Run codebase_graph_build (or codebase_index) first.";
}
const entrypoint = (args.entrypoint as string | undefined)?.trim();
// Zero-arg mode → ranked entry-point list
if (!entrypoint) {
// Build a fresh detection using the file graph + per-file payloads from the cache.
// For efficiency we only list entry points by walking known symbols via the name index.
const fileGraph = await getOrBuildGraph(projectPath);
const nameIndex = await cache.getNameIndex();
const seenFiles = new Set<string>();
const payloads = [];
for (const refs of nameIndex.values()) {
for (const ref of refs) {
if (seenFiles.has(ref.file)) continue;
seenFiles.add(ref.file);
const p = await cache.getFilePayload(ref.file);
if (p) payloads.push(p);
}
}
const entries = detectEntryPoints(fileGraph, payloads);
if (entries.length === 0) {
return "No entry points detected. The codebase may not have orphan files, conventional main() functions, or framework routes.";
}
const lines = [`Detected ${entries.length} entry point(s):`, ""];
for (const e of entries.slice(0, 50)) {
lines.push(` ${e.name} (${e.file}${e.line ? `:${e.line}` : ""}) — ${e.reason}`);
}
if (entries.length > 50) lines.push(` ... and ${entries.length - 50} more`);
lines.push("", "Pass `entrypoint` to trace forward call flow from any of these.");
return lines.join("\n");
}
// Resolve symbol name → id via name index (file hint disambiguates)
const nameIndex = await cache.getNameIndex();
let refs = nameIndex.get(entrypoint) ?? [];
const fileHint = (args.file as string | undefined)?.trim();
if (fileHint) refs = refs.filter((r) => r.file === fileHint);
if (refs.length === 0) {
return `No symbol named "${entrypoint}" found${fileHint ? ` in ${fileHint}` : ""}.`;
}
if (refs.length > 1) {
const lines = [`Symbol "${entrypoint}" is ambiguous (${refs.length} matches). Pass \`file\` to disambiguate:`, ""];
for (const r of refs) lines.push(` - ${r.file}`);
return lines.join("\n");
}
const depth = typeof args.depth === "number" ? args.depth : 5;
const tree = await getCallFlow(cache, refs[0].id, depth);
if (!tree) return `Could not load symbol "${entrypoint}".`;
const lines = [`Call flow from ${tree.symbolName} (${tree.file}:${tree.line})`, ""];
renderFlowTree(tree, "", true, lines);
return lines.join("\n");
}
case "codebase_symbol": {
const symName = (args.name as string)?.trim();
if (!symName) return "Missing required argument: name";
const fileHint = (args.file as string | undefined)?.trim();
const projectId = projectIdFromPath(projectPath);
const cache = await getSymbolGraphCache(projectId);
if (!cache) {
return "No symbol graph found. Run codebase_graph_build (or codebase_index) first.";
}
const ctxs = await getSymbolContext(cache, symName, fileHint);
if (ctxs.length === 0) {
return `No symbol named "${symName}" found${fileHint ? ` in ${fileHint}` : ""}.`;
}
const lines: string[] = [];
for (const ctx of ctxs) {
lines.push(`Symbol: ${ctx.symbol.qualifiedName} (${ctx.symbol.kind})`);
lines.push(`Defined: ${ctx.symbol.file}:${ctx.symbol.line}${ctx.symbol.endLine} [${ctx.symbol.language}]`);
lines.push("");
lines.push(`Callers (${ctx.callers.length}):`);
if (ctx.callers.length === 0) lines.push(" (none — possibly an entry point or unused)");
else for (const c of ctx.callers.slice(0, 30)) lines.push(`${c.file}:${c.line}`);
if (ctx.callers.length > 30) lines.push(` ... and ${ctx.callers.length - 30} more`);
lines.push("");
lines.push(`Callees (${ctx.callees.length}):`);
if (ctx.callees.length === 0) lines.push(" (none)");
else for (const c of ctx.callees.slice(0, 30)) {
lines.push(`${c.name} [${c.confidence}${c.resolved.length > 0 ? `, ${c.resolved.length} candidate(s)` : ""}]`);
}
if (ctx.callees.length > 30) lines.push(` ... and ${ctx.callees.length - 30} more`);
lines.push("---");
}
return lines.join("\n").replace(/---\n?$/, "").trimEnd();
}
case "codebase_symbols": {
const file = (args.file as string | undefined)?.trim();
const query = (args.query as string | undefined)?.trim();
const limit = typeof args.limit === "number" ? args.limit : 200;
const projectId = projectIdFromPath(projectPath);
const cache = await getSymbolGraphCache(projectId);
if (!cache) {
return "No symbol graph found. Run codebase_graph_build (or codebase_index) first.";
}
const symbols = await listSymbols(cache, { file, query, limit });
if (symbols.length === 0) {
return file
? `No symbols found in ${file}.`
: query
? `No symbols matching "${query}".`
: "No symbols found.";
}
const lines = [
file ? `Symbols in ${file} (${symbols.length}):` : `Symbols matching "${query ?? "*"}" (${symbols.length}):`,
"",
];
for (const s of symbols) {
lines.push(` ${s.kind.padEnd(11)} ${s.qualifiedName.padEnd(40)} ${s.file}:${s.line}`);
}
return lines.join("\n");
}
@@ -241,3 +409,24 @@ export async function handleGraphTool(
return `Unknown tool: ${name}`;
}
}
/** Render a FlowNode subtree using ASCII tree characters. */
function renderFlowTree(
node: FlowNode,
prefix: string,
isLast: boolean,
out: string[],
): void {
const branch = isLast ? "└── " : "├── ";
const suffix = node.truncatedReason
? ` [truncated: ${node.truncatedReason}]`
: "";
out.push(`${prefix}${branch}${node.symbolName} (${node.file}:${node.line})${suffix}`);
const childPrefix = prefix + (isLast ? " " : "│ ");
for (let i = 0; i < node.children.length; i++) {
renderFlowTree(node.children[i], childPrefix, i === node.children.length - 1, out);
}
}
// Mark deprecated import as used to satisfy lint when no other reference exists
void looksLikeFilePath;
+97
View File
@@ -75,3 +75,100 @@ export interface ArtifactIndexState {
/** Number of chunks stored */
chunksIndexed: number;
}
// ── Symbol-level call graph (Impact Analysis) ────────────────────────────
/** Kinds of symbols extracted from source code */
export type SymbolKind =
| "function"
| "class"
| "method"
| "constructor"
| "interface"
| "trait"
| "enum"
| "module"
| "struct"
| "variable";
/** A single symbol (definition) extracted from source code */
export interface SymbolNode {
/** Stable id: `${relativePath}::${qualifiedName}#${line}` */
id: string;
/** Unqualified name (e.g. "validateUser") */
name: string;
/** Qualified name (e.g. "Auth.validateUser") when nested in a class/module */
qualifiedName: string;
kind: SymbolKind;
/** Relative path */
file: string;
/** 1-based line number of the definition start */
line: number;
/** 1-based line number of the definition end */
endLine: number;
/** Re-export alias, if any */
exportedAs?: string;
language: string;
}
/** Confidence level for a resolved call edge */
export type SymbolEdgeConfidence =
| "local"
| "unique"
| "multiple-candidates"
| "unresolved";
/** A call-site edge between symbols */
export interface SymbolEdge {
/** SymbolNode.id of the caller */
callerId: string;
/** Raw name at the call site (e.g. "foo" in "foo()") */
calleeName: string;
/** Resolved SymbolNode.ids: 0 = external, 1 = unique, >1 = ambiguous */
calleeCandidates: string[];
confidence: SymbolEdgeConfidence;
callSite: { file: string; line: number };
}
/** Lightweight reference to a symbol (used by name index) */
export interface SymbolRef {
/** Relative file path containing the symbol */
file: string;
/** SymbolNode.id */
id: string;
}
/** Top-level metadata for a project's symbol graph */
export interface SymbolGraphMeta {
projectId: string;
symbolCount: number;
edgeCount: number;
fileCount: number;
unresolvedEdgePct: number;
builtAt: number;
schemaVersion: 1;
}
/** Per-file payload stored in `_symgraph_file` */
export interface SymbolGraphFilePayload {
/** Relative path */
file: string;
language: string;
/** SHA-256 of source bytes for staleness detection */
contentHash: string;
symbols: SymbolNode[];
/** Edges whose caller is in this file */
outgoingCalls: SymbolEdge[];
}
/** Detected entry point with reason */
export interface EntryPoint {
/** SymbolNode.id, or relative file path for orphan-file entries */
id: string;
/** Display name */
name: string;
file: string;
line?: number;
/** Reason categorisation (e.g. "orphan", "well-known-name:main", "framework:express-get") */
reason: string;
}