From c356c42f4fa6dbcee51eb3e0cd4afb1ac04dd6f9 Mon Sep 17 00:00:00 2001 From: Giancarlo Erra Date: Tue, 21 Apr 2026 13:26:10 +0100 Subject: [PATCH] feat(impact): add symbol-level call graph and Impact Analysis tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/config.ts | 17 + src/constants.ts | 38 + src/index.ts | 56 ++ src/services/code-graph.ts | 214 +++++- src/services/graph-entrypoints.ts | 134 ++++ src/services/graph-impact.ts | 255 +++++++ src/services/graph-symbol-resolution.ts | 109 +++ src/services/graph-symbols.ts | 957 ++++++++++++++++++++++++ src/services/qdrant.ts | 2 +- src/services/symbol-graph-cache.ts | 283 +++++++ src/services/symbol-graph-store.ts | 368 +++++++++ src/tools/graph-tools.ts | 189 +++++ src/types.ts | 97 +++ 13 files changed, 2712 insertions(+), 7 deletions(-) create mode 100644 src/services/graph-entrypoints.ts create mode 100644 src/services/graph-impact.ts create mode 100644 src/services/graph-symbol-resolution.ts create mode 100644 src/services/graph-symbols.ts create mode 100644 src/services/symbol-graph-cache.ts create mode 100644 src/services/symbol-graph-store.ts diff --git a/src/config.ts b/src/config.ts index 096eb22..e443484 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 */ diff --git a/src/constants.ts b/src/constants.ts index 409e36e..183209c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 (a–z + `_` 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> = { + 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([ diff --git a/src/index.ts b/src/index.ts index 2ac4e72..8d40b23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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( diff --git a/src/services/code-graph.ts b/src/services/code-graph.ts index 9887bff..6b67b23 100644 --- a/src/services/code-graph.ts +++ b/src/services/code-graph.ts @@ -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, + outgoingCallsByFile: Map, +): Promise { + 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 !== ""); + 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 !== "").length; + totalEdges += outgoingCalls.length; + } + + // Build sharded indices + const nameShards = new Map>(); + for (const key of allNameShardKeys()) nameShards.set(key, {}); + for (const [file, symbols] of symbolsByFile.entries()) { + for (const sym of symbols) { + if (sym.name === "") 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>(); + 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 { 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, progress?: GraphBuildProgress, -): Promise { +): Promise; + outgoingCallsByFile: Map; +}> { ensureDynamicLanguages(); const resolvedPath = path.resolve(projectPath); @@ -389,6 +575,8 @@ export async function buildCodeGraph( const nodesMap = new Map(); const edges: CodeGraphEdge[] = []; + const symbolsByFile = new Map(); + const outgoingCallsByFile = new Map(); // 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, }; } diff --git a/src/services/graph-entrypoints.ts b/src/services/graph-entrypoints.ts new file mode 100644 index 0000000..2e2a4cb --- /dev/null +++ b/src/services/graph-entrypoints.ts @@ -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(); + 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 !== "") { + 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 !== "") { + 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 === "") continue; + if (/^test[_A-Z]/.test(s.name) || /^Test[A-Z_]/.test(s.name)) { + out.push({ symbol: s, reason: "test" }); + } + } + + return out; +} diff --git a/src/services/graph-impact.ts b/src/services/graph-impact.ts new file mode 100644 index 0000000..cfb5b03 --- /dev/null +++ b/src/services/graph-impact.ts @@ -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; + 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 { + 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; + 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(); + const filesByDepth = new Map(); + let frontier = new Set(seedFiles); + for (const f of seedFiles) visited.add(f); + + for (let hop = 1; hop <= safeDepth; hop++) { + const next = new Set(); + 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 { + 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(); + return await walk(cache, sym, 0, safeDepth, visited); +} + +async function walk( + cache: SymbolGraphCache, + sym: SymbolNode, + hop: number, + maxDepth: number, + visited: Set, +): Promise { + 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 { + 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 { + 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 === "") 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); +} diff --git a/src/services/graph-symbol-resolution.ts b/src/services/graph-symbol-resolution.ts new file mode 100644 index 0000000..e3c01e9 --- /dev/null +++ b/src/services/graph-symbol-resolution.ts @@ -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, + outgoingCallsByFile: Map, +): void { + // Build a fast lookup: file → Map + const symbolIndexByFile = new Map>(); + for (const [file, syms] of symbolsByFile.entries()) { + const idx = new Map(); + for (const s of syms) { + if (s.name === "") 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(); + 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, +): 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; +} diff --git a/src/services/graph-symbols.ts b/src/services/graph-symbols.ts new file mode 100644 index 0000000..4dd500f --- /dev/null +++ b/src/services/graph-symbols.ts @@ -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, "", 1), + name: "", + qualifiedName: "", + 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, + })); +} diff --git a/src/services/qdrant.ts b/src/services/qdrant.ts index 585149d..3026fc0 100644 --- a/src/services/qdrant.ts +++ b/src/services/qdrant.ts @@ -37,7 +37,7 @@ async function withRetry( let client: QdrantClient | null = null; -function getClient(): QdrantClient { +export function getClient(): QdrantClient { if (!client) { client = new QdrantClient( QDRANT_URL diff --git a/src/services/symbol-graph-cache.ts b/src/services/symbol-graph-cache.ts new file mode 100644 index 0000000..3c53531 --- /dev/null +++ b/src/services/symbol-graph-cache.ts @@ -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 { + private map = new Map(); + 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 { + 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 | null = null; + /** calleeFile → set of caller files (lazy-loaded as a whole) */ + private reverseFileIndex: Map> | null = null; + /** lazy per-file payloads, LRU-bounded */ + fileDataLru: LRUCache; + + 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> { + if (this.nameIndex) return this.nameIndex; + const merged = new Map(); + 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>> { + if (this.reverseFileIndex) return this.reverseFileIndex; + const merged = new Map>(); + 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 { + 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(); + +/** Get or build the cache for a project (loads meta from Qdrant lazily). */ +export async function getSymbolGraphCache( + projectId: string, +): Promise { + 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"); +} diff --git a/src/services/symbol-graph-store.ts b/src/services/symbol-graph-store.ts new file mode 100644 index 0000000..26d9a9c --- /dev/null +++ b/src/services/symbol-graph-store.ts @@ -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(); + +/** Ensure a single collection exists (idempotent, cached after first success). */ +async function ensureCollection(name: string): Promise { + 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 { + await Promise.all([ + ensureCollection(symgraphMetaCollectionName(projectId)), + ensureCollection(symgraphFileCollectionName(projectId)), + ensureCollection(symgraphIndexCollectionName(projectId)), + ]); +} + +// ── Meta ───────────────────────────────────────────────────────────────── + +export async function saveSymbolGraphMeta( + projectId: string, + meta: SymbolGraphMeta, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +): Promise { + 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 | 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) ?? 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, +): Promise { + 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 | 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) ?? 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 { + 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, +}; diff --git a/src/tools/graph-tools.ts b/src/tools/graph-tools.ts index 89c2b48..967f6c8 100644 --- a/src/tools/graph-tools.ts +++ b/src/tools/graph-tools.ts @@ -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(); + 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; diff --git a/src/types.ts b/src/types.ts index 56354b2..34b15c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +}