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