UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

581 lines • 24.9 kB
// 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, toForwardSlash } from "../constants.js"; import { symbolIdToFile, } from "./symbol-graph-cache.js"; import { StorageReadError } from "./symbol-graph-store.js"; /** BFS over symbol call/reference graph or reverseFileIndex. Polymorphic on target type. */ export async function getImpactRadius(cache, target, depth = 3, options) { const release = cache.acquireReader(); const readerToken = release.token; const safeDepth = Math.max(1, Math.min(depth, MAX_IMPACT_DEPTH)); const isIncomplete = options?.isIncomplete ?? (cache.meta.schemaVersion < 2); const targetKind = options?.symbolId ? "symbol" : looksLikeFilePath(target) ? "file" : "symbol"; try { if (targetKind === "file") { const reverseIndex = await cache.getReverseFileIndex(readerToken); const normTarget = toForwardSlash(target); const targetPayload = await cache.getFilePayload(normTarget, readerToken); if (!targetPayload) { return { target, targetKind, depth: safeDepth, filesByDepth: new Map(), totalFiles: 0, truncated: false, status: "not_found", message: `File '${target}' was not found in the index.`, }; } const visited = new Set([normTarget]); const filesByDepth = new Map(); let frontier = new Set([normTarget]); let truncated = false; 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; if (hop === safeDepth) { for (const calleeFile of frontier) { const callers = reverseIndex.get(calleeFile); if (!callers) continue; for (const callerFile of callers) { if (!visited.has(callerFile)) { truncated = true; break; } } if (truncated) break; } } } let totalFiles = 0; for (const arr of filesByDepth.values()) totalFiles += arr.length; if (totalFiles === 0 && isIncomplete) { return { target, targetKind, depth: safeDepth, filesByDepth, totalFiles: 0, truncated: false, status: "unsupported_or_incomplete", message: `The symbol graph is incomplete (schema v${cache.meta.schemaVersion ?? 1} or incomplete language parser support). Rebuild with codebase_graph_build before relying on zero-dependent verification.`, }; } return { target, targetKind, depth: safeDepth, filesByDepth, totalFiles, truncated, status: "ok", }; } // Symbol mode: exact symbol resolution and traversal let selectedRefs = []; if (options?.symbolId) { const sFile = symbolIdToFile(options.symbolId); if (!sFile) { return { target, targetKind, depth: safeDepth, filesByDepth: new Map(), totalFiles: 0, truncated: false, status: "not_found", message: `Invalid symbolId '${options.symbolId}'.`, }; } const payload = await cache.getFilePayload(sFile, readerToken); const sym = payload?.symbols.find((s) => s.id === options.symbolId); if (!sym) { return { target, targetKind, depth: safeDepth, filesByDepth: new Map(), totalFiles: 0, truncated: false, status: "not_found", message: `Symbol with ID '${options.symbolId}' was not found in file '${sFile}'.`, }; } selectedRefs = [{ file: sFile, id: options.symbolId }]; } else { const nameIndex = await cache.getNameIndex(readerToken); let refs = nameIndex.get(target) ?? []; if (options?.file) { const normFile = toForwardSlash(options.file); refs = refs.filter((r) => r.file === normFile || r.file.endsWith(`/${normFile}`)); } if (refs.length === 0) { return { target, targetKind, depth: safeDepth, filesByDepth: new Map(), totalFiles: 0, truncated: false, status: "not_found", message: `Symbol '${target}' was not found in the symbol graph.`, }; } const distinctIds = Array.from(new Set(refs.map((r) => r.id))); if (distinctIds.length > 1) { const candidates = []; for (const ref of refs) { if (options?.limit !== undefined && candidates.length >= options.limit) break; const payload = await cache.getFilePayload(ref.file, readerToken); const sym = payload?.symbols.find((s) => s.id === ref.id); if (sym) candidates.push(sym); } const distinctFiles = Array.from(new Set(refs.map((r) => r.file))); const locDesc = distinctFiles.length === 1 ? `in file ${distinctFiles[0]}` : `across ${distinctFiles.length} files (${distinctFiles.slice(0, 5).join(", ")}${distinctFiles.length > 5 ? "..." : ""})`; return { target, targetKind, depth: safeDepth, filesByDepth: new Map(), totalFiles: 0, truncated: false, status: "ambiguous", message: `Symbol '${target}' is ambiguous (matches ${distinctIds.length} symbols ${locDesc}). Specify 'file' or 'symbolId' to disambiguate.`, candidates, totalCandidates: refs.length, }; } selectedRefs = refs; } if (cache.meta.schemaVersion < 2) { const reverseIndex = await cache.getReverseFileIndex(readerToken); const targetSymbolIds = new Set(selectedRefs.map((r) => r.id)); const visitedSymbols = new Set(targetSymbolIds); const distinctFiles = Array.from(new Set(selectedRefs.map((r) => r.file))); const visitedFiles = new Set(distinctFiles); const filesByDepth = new Map(); let frontierSymbolIds = new Set(targetSymbolIds); let frontierFiles = new Set(distinctFiles); let truncated = false; for (let hop = 1; hop <= safeDepth; hop++) { const nextHopFiles = new Set(); const nextSymbolIds = new Set(); const nextFiles = new Set(); for (const calleeFile of frontierFiles) { const potentialCallerFiles = new Set(reverseIndex.get(calleeFile) ?? []); potentialCallerFiles.add(calleeFile); for (const callerFile of potentialCallerFiles) { const callerPayload = await cache.getFilePayload(callerFile, readerToken); if (!callerPayload) continue; let matched = false; for (const edge of callerPayload.outgoingCalls) { const callsTarget = edge.calleeCandidates.some((candId) => frontierSymbolIds.has(candId)); if (callsTarget) { matched = true; if (!visitedSymbols.has(edge.callerId)) { visitedSymbols.add(edge.callerId); nextSymbolIds.add(edge.callerId); } } } if (matched) { if (!visitedFiles.has(callerFile)) { nextHopFiles.add(callerFile); visitedFiles.add(callerFile); } nextFiles.add(callerFile); } } } if (nextSymbolIds.size === 0) break; if (nextHopFiles.size > 0) { filesByDepth.set(hop, Array.from(nextHopFiles).sort()); } frontierSymbolIds = nextSymbolIds; frontierFiles = nextFiles; if (hop === safeDepth) { for (const calleeFile of frontierFiles) { const potentialCallerFiles = new Set(reverseIndex.get(calleeFile) ?? []); potentialCallerFiles.add(calleeFile); for (const callerFile of potentialCallerFiles) { const cp = await cache.getFilePayload(callerFile, readerToken); if (!cp) continue; const hasMoreCalls = cp.outgoingCalls.some((e) => !visitedSymbols.has(e.callerId) && e.calleeCandidates.some((c) => frontierSymbolIds.has(c))); if (hasMoreCalls) { truncated = true; break; } } if (truncated) break; } } } let totalFiles = 0; for (const arr of filesByDepth.values()) totalFiles += arr.length; if (totalFiles === 0 && isIncomplete) { return { target, targetKind, depth: safeDepth, filesByDepth, totalFiles: 0, truncated: false, status: "unsupported_or_incomplete", message: `The symbol graph is incomplete (schema v${cache.meta.schemaVersion ?? 1} or incomplete language parser support). Rebuild with codebase_graph_build before relying on zero-dependent verification.`, }; } return { target, targetKind, depth: safeDepth, filesByDepth, totalFiles, truncated, status: "ok", }; } const reverseSymbolIndex = await cache.getReverseSymbolIndex(readerToken); const targetSymbolIds = new Set(selectedRefs.map((r) => r.id)); const visitedSymbols = new Set(targetSymbolIds); const visitedFiles = new Set(selectedRefs.map((r) => r.file)); const filesByDepth = new Map(); let frontierSymbolIds = new Set(targetSymbolIds); let truncated = false; for (let hop = 1; hop <= safeDepth; hop++) { const nextSymbolIds = new Set(); const nextHopFiles = new Set(); for (const calleeSymId of frontierSymbolIds) { const callerIds = reverseSymbolIndex.get(calleeSymId); if (!callerIds) continue; for (const callerId of callerIds) { const callerFile = callerId.split("::")[0]; if (!visitedSymbols.has(callerId)) { visitedSymbols.add(callerId); nextSymbolIds.add(callerId); } if (!visitedFiles.has(callerFile)) { visitedFiles.add(callerFile); nextHopFiles.add(callerFile); } } } if (nextSymbolIds.size === 0) break; if (nextHopFiles.size > 0) { filesByDepth.set(hop, Array.from(nextHopFiles).sort()); } frontierSymbolIds = nextSymbolIds; if (hop === safeDepth) { for (const calleeSymId of frontierSymbolIds) { const callerIds = reverseSymbolIndex.get(calleeSymId); if (!callerIds) continue; for (const callerId of callerIds) { if (!visitedSymbols.has(callerId)) { truncated = true; break; } } if (truncated) break; } } } let totalFiles = 0; for (const arr of filesByDepth.values()) totalFiles += arr.length; if (totalFiles === 0 && isIncomplete) { return { target, targetKind, depth: safeDepth, filesByDepth, totalFiles: 0, truncated: false, status: "unsupported_or_incomplete", message: `The symbol graph is incomplete (schema v${cache.meta.schemaVersion ?? 1} or incomplete language parser support). Rebuild with codebase_graph_build before relying on zero-dependent verification.`, }; } return { target, targetKind, depth: safeDepth, filesByDepth, totalFiles, truncated, status: "ok", }; } catch (err) { if (err instanceof StorageReadError) { return { target, targetKind, depth: safeDepth, filesByDepth: new Map(), totalFiles: 0, truncated: false, status: "storage_error", message: `Storage read/integrity failure during impact analysis: ${err.message}`, }; } throw err; } finally { release(); } } /** DFS via lazy-loaded outgoing edges, cycle-safe. */ export async function getCallFlow(cache, entrypointId, depth = 5, operationToken) { const release = cache.acquireReader(operationToken); const readerToken = release.token; try { 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, readerToken); 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, readerToken); } finally { release(); } } async function walk(cache, sym, hop, maxDepth, visited, readerToken) { const node = { 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, readerToken); 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, readerToken); 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, readerToken)); } } return node; } /** * Resolve definition, callers and callees for the definitions named `name`, * optionally narrowed by `fileHint`. */ export async function getSymbolContext(cache, name, fileHint) { return (await getSymbolContextResult(cache, name, fileHint)).contexts; } /** * Resolve definition, callers and callees for the definitions named `name`, * optionally narrowed by `fileHint`, with the match counts. With `limit`, a * positive integer, resolution stops once `limit` definitions are resolved. */ export async function getSymbolContextResult(cache, name, fileHint, limit) { const release = cache.acquireReader(); const readerToken = release.token; try { const nameIndex = await cache.getNameIndex(readerToken); let refs = nameIndex.get(name) ?? []; if (fileHint) { const normalizedHint = toForwardSlash(fileHint); refs = refs.filter((r) => r.file === normalizedHint || r.file.endsWith(`/${normalizedHint}`)); } if (refs.length === 0) return { contexts: [], total: 0, fileCount: 0 }; const reverseSymbolIndex = await cache.getReverseSymbolIndex(readerToken); const out = []; for (const ref of refs) { if (limit !== undefined && out.length >= limit) break; const payload = await cache.getFilePayload(ref.file, readerToken); if (!payload) continue; const sym = payload.symbols.find((s) => s.id === ref.id); if (!sym) continue; // Callees: outgoing calls from this symbol const callees = payload.outgoingCalls .filter((e) => e.callerId === sym.id) .map((e) => ({ // Graphs produced before signalEmit was a separate marker encoded // the marker into calleeName. Normalize those persisted edges on // read so upgrading does not require a graph rebuild. name: e.calleeName.startsWith("signal:") ? e.calleeName.slice(7) : e.calleeName, resolved: e.calleeCandidates, confidence: e.confidence, kind: e.kind, })); // Callers: query reverse symbol index for callers of this symbol (schema v2) // or scan callerFiles from reverse file index (schema v1) const callers = []; if (cache.meta.schemaVersion >= 2) { const callerIds = reverseSymbolIndex.get(sym.id) ?? new Set(); // Group callerIds by file so we fetch each payload at most once const callerIdsByFile = new Map(); for (const cId of callerIds) { const cFile = symbolIdToFile(cId); if (!cFile) continue; const list = callerIdsByFile.get(cFile); if (list) list.push(cId); else callerIdsByFile.set(cFile, [cId]); } for (const [cFile, cIds] of callerIdsByFile.entries()) { const cp = await cache.getFilePayload(cFile, readerToken); if (!cp) continue; for (const e of cp.outgoingCalls) { if (e.calleeCandidates.includes(sym.id) && cIds.includes(e.callerId)) { callers.push({ file: e.callSite.file, line: e.callSite.line, symbolId: e.callerId, kind: e.kind, }); } } } } else { const reverseIndex = await cache.getReverseFileIndex(readerToken); const callerFiles = new Set(reverseIndex.get(ref.file) ?? []); callerFiles.add(ref.file); for (const cf of callerFiles) { const cp = await cache.getFilePayload(cf, readerToken); 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, kind: e.kind, }); } } } } out.push({ symbol: sym, callers, callees }); } return { contexts: out, total: refs.length, fileCount: new Set(refs.map((r) => r.file)).size }; } finally { release(); } } // ── List symbols ───────────────────────────────────────────────────────── export async function listSymbols(cache, opts) { const release = cache.acquireReader(); const readerToken = release.token; try { const limit = opts.limit ?? 200; const out = []; if (opts.file) { const payload = await cache.getFilePayload(toForwardSlash(opts.file), readerToken); if (!payload) return []; for (const s of payload.symbols) { if (s.name === "<module>") continue; out.push(s); if (out.length >= limit) break; } return out; } const nameIndex = await cache.getNameIndex(readerToken); 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, readerToken); 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; } finally { release(); } } // ── Helpers ────────────────────────────────────────────────────────────── /** Detect whether a target string looks like a file path vs a symbol name. */ export function looksLikeFilePath(s) { return s.includes("/") || s.includes("\\") || /\.[a-z]{1,5}$/i.test(s); } //# sourceMappingURL=graph-impact.js.map