/** * KHAELOR * File: src/repograph/service.ts * Description: RepoGraphService — incremental symbol index, symbols/refs queries, file skeletons for the context engine (v2 design §3). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import * as path from "node:path"; import { globToRegExp, walkFiles } from "../workspace/walk.js"; import type { Workspace } from "../workspace/index.js"; import { extractFile, isIndexablePath } from "./extractor.js"; import type { ExtractedImport, ExtractedSymbol, SymbolKind } from "./extractor.js"; export interface IndexedFile { path: string; // relative, posix separators mtimeMs: number; symbols: ExtractedSymbol[]; imports: ExtractedImport[]; } export interface SymbolQueryHit { symbol: string; kind: SymbolKind; file: string; line: number; signature: string; docComment?: string; } export interface RefQueryHit { file: string; line: number; context: string; } export interface IndexStats { files: number; symbols: number; indexedAt: number; durationMs: number; } const REFRESH_INTERVAL_MS = 5_000; const MAX_REF_FILES = 4_000; function toWildcardRegex(query: string): RegExp { if (query.includes("*")) { const escaped = query.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); return new RegExp(`^${escaped.join(".*")}$`, "i"); } return new RegExp(`^${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i"); } /** * The semantic repository index (v2 §3). Incremental by mtime; queries * refresh lazily (debounced) so results never go stale by more than the * refresh interval. Reference queries scan file contents at query time with * a word-boundary regex over the indexed file set — precise enough for * blast-radius work without storing every identifier. */ export class RepoGraphService { readonly #workspace: Workspace; readonly #files = new Map(); #lastRefresh = 0; #stats: IndexStats = { files: 0, symbols: 0, indexedAt: 0, durationMs: 0 }; #refreshing: Promise | null = null; constructor(options: { workspace: Workspace }) { this.#workspace = options.workspace; } get stats(): IndexStats { return this.#stats; } /** Walk the repo and (re)index changed files. Serialized; cheap when fresh. */ async ensureIndexed(force = false): Promise { if (!force && Date.now() - this.#lastRefresh < REFRESH_INTERVAL_MS) return; if (this.#refreshing !== null) return this.#refreshing; this.#refreshing = this.#refresh().finally(() => { this.#refreshing = null; }); return this.#refreshing; } async #refresh(): Promise { const startedAt = Date.now(); const cwd = this.#workspace.cwd(); const walked = await walkFiles(cwd, { builtinIgnores: ["node_modules", ".git", "dist", "references"] }); const seen = new Set(); for (const file of walked) { const rel = path.relative(cwd, file.path).split(path.sep).join("/"); if (!isIndexablePath(rel)) continue; seen.add(rel); const existing = this.#files.get(rel); if (existing !== undefined && existing.mtimeMs === file.mtimeMs) continue; try { const content = await this.#workspace.readFile(file.path); const extracted = extractFile(rel, content); this.#files.set(rel, { path: rel, mtimeMs: file.mtimeMs, symbols: extracted.symbols, imports: extracted.imports, }); } catch { this.#files.delete(rel); // binary/unreadable — drop from the index } } for (const known of [...this.#files.keys()]) { if (!seen.has(known)) this.#files.delete(known); } this.#lastRefresh = Date.now(); this.#stats = { files: this.#files.size, symbols: [...this.#files.values()].reduce((sum, file) => sum + file.symbols.length, 0), indexedAt: this.#lastRefresh, durationMs: this.#lastRefresh - startedAt, }; } /** symbols tool backend: wildcard name match, optional kind/scope filters. */ async querySymbols(query: string, kind?: string, scope?: string): Promise { await this.ensureIndexed(); // Support "class:*Controller" shorthand. let effectiveKind = kind; let effectiveQuery = query.trim(); const colon = /^(function|class|type|export|variable|method):(.+)$/.exec(effectiveQuery); if (colon !== null) { effectiveKind = colon[1] as string; effectiveQuery = (colon[2] as string).trim(); } const nameRe = toWildcardRegex(effectiveQuery); const scopeRe = scope !== undefined ? safeGlob(scope) : null; const hits: SymbolQueryHit[] = []; for (const file of this.#files.values()) { if (scopeRe !== null && !scopeRe.test(file.path)) continue; for (const symbol of file.symbols) { if (effectiveKind !== undefined && symbol.kind !== effectiveKind) continue; if (!nameRe.test(symbol.name)) continue; hits.push({ symbol: symbol.name, kind: symbol.kind, file: file.path, line: symbol.line, signature: symbol.signature, ...(symbol.docComment !== undefined ? { docComment: symbol.docComment } : {}), }); } } hits.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); return hits; } /** refs tool backend: callers / callees / importers. */ async queryRefs( symbol: string, direction: "callers" | "callees" | "importers", ): Promise { await this.ensureIndexed(); if (direction === "importers") return this.#importers(symbol); if (direction === "callees") return this.#callees(symbol); return this.#callers(symbol); } /** Skeleton of a file: signatures + doc comments — ~5-15% of the tokens of the full file (v2 §3). */ async skeleton(relPath: string): Promise { await this.ensureIndexed(); const file = this.#files.get(relPath.split(path.sep).join("/")); if (file === undefined || file.symbols.length === 0) return null; const lines = file.symbols.map((symbol) => { const doc = symbol.docComment !== undefined ? ` // ${symbol.docComment}` : ""; return `${String(symbol.line).padStart(5)} | ${symbol.signature}${doc}`; }); return `${file.path} — skeleton (${file.symbols.length} symbols; re-read the file if you need bodies)\n${lines.join("\n")}`; } #definitions(symbol: string): { file: IndexedFile; symbol: ExtractedSymbol }[] { const out: { file: IndexedFile; symbol: ExtractedSymbol }[] = []; for (const file of this.#files.values()) { for (const sym of file.symbols) { if (sym.name === symbol) out.push({ file, symbol: sym }); } } return out; } #importers(symbol: string): RefQueryHit[] { const hits: RefQueryHit[] = []; for (const file of this.#files.values()) { for (const imp of file.imports) { if (imp.names.includes(symbol)) { hits.push({ file: file.path, line: imp.line, context: `import { ${symbol} } from "${imp.spec}"` }); } } } return hits; } async #callers(symbol: string): Promise { const definitionFiles = new Set(this.#definitions(symbol).map((d) => d.file.path)); const re = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); const hits: RefQueryHit[] = []; const cwd = this.#workspace.cwd(); let scanned = 0; for (const file of this.#files.values()) { if (scanned >= MAX_REF_FILES) break; scanned += 1; let content: string; try { content = await this.#workspace.readFile(path.join(cwd, file.path)); } catch { continue; } const lines = content.split("\n"); for (let i = 0; i < lines.length; i += 1) { const line = lines[i] as string; if (!re.test(line)) continue; // Skip the definition lines themselves. if (definitionFiles.has(file.path) && file.symbols.some((s) => s.name === symbol && s.line === i + 1)) { continue; } if (/^\s*import\b/.test(line)) continue; // importers direction covers these hits.push({ file: file.path, line: i + 1, context: line.trim().slice(0, 200) }); } } return hits; } async #callees(symbol: string): Promise { // Heuristic: identifiers referenced inside the defining symbol's region // (its line to the next top-level symbol) that are known symbols elsewhere. const definitions = this.#definitions(symbol); if (definitions.length === 0) return []; const known = new Map(); for (const file of this.#files.values()) { for (const sym of file.symbols) { if (sym.name !== symbol && (sym.kind === "function" || sym.kind === "class" || sym.kind === "method")) { if (!known.has(sym.name)) known.set(sym.name, { file: file.path, line: sym.line }); } } } const cwd = this.#workspace.cwd(); const hits: RefQueryHit[] = []; const seen = new Set(); for (const def of definitions) { let content: string; try { content = await this.#workspace.readFile(path.join(cwd, def.file.path)); } catch { continue; } const lines = content.split("\n"); const sorted = [...def.file.symbols].sort((a, b) => a.line - b.line); const next = sorted.find((s) => s.line > def.symbol.line && s.kind !== "method"); const end = next !== undefined ? next.line - 1 : lines.length; for (let i = def.symbol.line; i < end; i += 1) { const line = lines[i] as string; for (const match of line.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) { const name = match[1] as string; const target = known.get(name); if (target === undefined || seen.has(name)) continue; seen.add(name); hits.push({ file: target.file, line: target.line, context: `${name}(…) called from ${def.file.path}:${i + 1}`, }); } } } return hits; } } function safeGlob(pattern: string): RegExp | null { try { return globToRegExp(pattern); } catch { return null; } }