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
JavaScript
// 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