spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/repograph/service.ts4 * Description: RepoGraphService — incremental symbol index, symbols/refs queries, file skeletons for the context engine (v2 design §3).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import * as path from "node:path";11import { globToRegExp, walkFiles } from "../workspace/walk.js";12import type { Workspace } from "../workspace/index.js";13import { extractFile, isIndexablePath } from "./extractor.js";14import type { ExtractedImport, ExtractedSymbol, SymbolKind } from "./extractor.js";1516export interface IndexedFile {17 path: string; // relative, posix separators18 mtimeMs: number;19 symbols: ExtractedSymbol[];20 imports: ExtractedImport[];21}2223export interface SymbolQueryHit {24 symbol: string;25 kind: SymbolKind;26 file: string;27 line: number;28 signature: string;29 docComment?: string;30}3132export interface RefQueryHit {33 file: string;34 line: number;35 context: string;36}3738export interface IndexStats {39 files: number;40 symbols: number;41 indexedAt: number;42 durationMs: number;43}4445const REFRESH_INTERVAL_MS = 5_000;46const MAX_REF_FILES = 4_000;4748function toWildcardRegex(query: string): RegExp {49 if (query.includes("*")) {50 const escaped = query.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));51 return new RegExp(`^${escaped.join(".*")}$`, "i");52 }53 return new RegExp(`^${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i");54}5556/**57 * The semantic repository index (v2 §3). Incremental by mtime; queries58 * refresh lazily (debounced) so results never go stale by more than the59 * refresh interval. Reference queries scan file contents at query time with60 * a word-boundary regex over the indexed file set — precise enough for61 * blast-radius work without storing every identifier.62 */63export class RepoGraphService {64 readonly #workspace: Workspace;65 readonly #files = new Map<string, IndexedFile>();66 #lastRefresh = 0;67 #stats: IndexStats = { files: 0, symbols: 0, indexedAt: 0, durationMs: 0 };68 #refreshing: Promise<void> | null = null;6970 constructor(options: { workspace: Workspace }) {71 this.#workspace = options.workspace;72 }7374 get stats(): IndexStats {75 return this.#stats;76 }7778 /** Walk the repo and (re)index changed files. Serialized; cheap when fresh. */79 async ensureIndexed(force = false): Promise<void> {80 if (!force && Date.now() - this.#lastRefresh < REFRESH_INTERVAL_MS) return;81 if (this.#refreshing !== null) return this.#refreshing;82 this.#refreshing = this.#refresh().finally(() => {83 this.#refreshing = null;84 });85 return this.#refreshing;86 }8788 async #refresh(): Promise<void> {89 const startedAt = Date.now();90 const cwd = this.#workspace.cwd();91 const walked = await walkFiles(cwd, { builtinIgnores: ["node_modules", ".git", "dist", "references"] });92 const seen = new Set<string>();93 for (const file of walked) {94 const rel = path.relative(cwd, file.path).split(path.sep).join("/");95 if (!isIndexablePath(rel)) continue;96 seen.add(rel);97 const existing = this.#files.get(rel);98 if (existing !== undefined && existing.mtimeMs === file.mtimeMs) continue;99 try {100 const content = await this.#workspace.readFile(file.path);101 const extracted = extractFile(rel, content);102 this.#files.set(rel, {103 path: rel,104 mtimeMs: file.mtimeMs,105 symbols: extracted.symbols,106 imports: extracted.imports,107 });108 } catch {109 this.#files.delete(rel); // binary/unreadable — drop from the index110 }111 }112 for (const known of [...this.#files.keys()]) {113 if (!seen.has(known)) this.#files.delete(known);114 }115 this.#lastRefresh = Date.now();116 this.#stats = {117 files: this.#files.size,118 symbols: [...this.#files.values()].reduce((sum, file) => sum + file.symbols.length, 0),119 indexedAt: this.#lastRefresh,120 durationMs: this.#lastRefresh - startedAt,121 };122 }123124 /** symbols tool backend: wildcard name match, optional kind/scope filters. */125 async querySymbols(query: string, kind?: string, scope?: string): Promise<SymbolQueryHit[]> {126 await this.ensureIndexed();127 // Support "class:*Controller" shorthand.128 let effectiveKind = kind;129 let effectiveQuery = query.trim();130 const colon = /^(function|class|type|export|variable|method):(.+)$/.exec(effectiveQuery);131 if (colon !== null) {132 effectiveKind = colon[1] as string;133 effectiveQuery = (colon[2] as string).trim();134 }135 const nameRe = toWildcardRegex(effectiveQuery);136 const scopeRe = scope !== undefined ? safeGlob(scope) : null;137138 const hits: SymbolQueryHit[] = [];139 for (const file of this.#files.values()) {140 if (scopeRe !== null && !scopeRe.test(file.path)) continue;141 for (const symbol of file.symbols) {142 if (effectiveKind !== undefined && symbol.kind !== effectiveKind) continue;143 if (!nameRe.test(symbol.name)) continue;144 hits.push({145 symbol: symbol.name,146 kind: symbol.kind,147 file: file.path,148 line: symbol.line,149 signature: symbol.signature,150 ...(symbol.docComment !== undefined ? { docComment: symbol.docComment } : {}),151 });152 }153 }154 hits.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);155 return hits;156 }157158 /** refs tool backend: callers / callees / importers. */159 async queryRefs(160 symbol: string,161 direction: "callers" | "callees" | "importers",162 ): Promise<RefQueryHit[]> {163 await this.ensureIndexed();164 if (direction === "importers") return this.#importers(symbol);165 if (direction === "callees") return this.#callees(symbol);166 return this.#callers(symbol);167 }168169 /** Skeleton of a file: signatures + doc comments — ~5-15% of the tokens of the full file (v2 §3). */170 async skeleton(relPath: string): Promise<string | null> {171 await this.ensureIndexed();172 const file = this.#files.get(relPath.split(path.sep).join("/"));173 if (file === undefined || file.symbols.length === 0) return null;174 const lines = file.symbols.map((symbol) => {175 const doc = symbol.docComment !== undefined ? ` // ${symbol.docComment}` : "";176 return `${String(symbol.line).padStart(5)} | ${symbol.signature}${doc}`;177 });178 return `${file.path} — skeleton (${file.symbols.length} symbols; re-read the file if you need bodies)\n${lines.join("\n")}`;179 }180181 #definitions(symbol: string): { file: IndexedFile; symbol: ExtractedSymbol }[] {182 const out: { file: IndexedFile; symbol: ExtractedSymbol }[] = [];183 for (const file of this.#files.values()) {184 for (const sym of file.symbols) {185 if (sym.name === symbol) out.push({ file, symbol: sym });186 }187 }188 return out;189 }190191 #importers(symbol: string): RefQueryHit[] {192 const hits: RefQueryHit[] = [];193 for (const file of this.#files.values()) {194 for (const imp of file.imports) {195 if (imp.names.includes(symbol)) {196 hits.push({ file: file.path, line: imp.line, context: `import { ${symbol} } from "${imp.spec}"` });197 }198 }199 }200 return hits;201 }202203 async #callers(symbol: string): Promise<RefQueryHit[]> {204 const definitionFiles = new Set(this.#definitions(symbol).map((d) => d.file.path));205 const re = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);206 const hits: RefQueryHit[] = [];207 const cwd = this.#workspace.cwd();208 let scanned = 0;209 for (const file of this.#files.values()) {210 if (scanned >= MAX_REF_FILES) break;211 scanned += 1;212 let content: string;213 try {214 content = await this.#workspace.readFile(path.join(cwd, file.path));215 } catch {216 continue;217 }218 const lines = content.split("\n");219 for (let i = 0; i < lines.length; i += 1) {220 const line = lines[i] as string;221 if (!re.test(line)) continue;222 // Skip the definition lines themselves.223 if (definitionFiles.has(file.path) && file.symbols.some((s) => s.name === symbol && s.line === i + 1)) {224 continue;225 }226 if (/^\s*import\b/.test(line)) continue; // importers direction covers these227 hits.push({ file: file.path, line: i + 1, context: line.trim().slice(0, 200) });228 }229 }230 return hits;231 }232233 async #callees(symbol: string): Promise<RefQueryHit[]> {234 // Heuristic: identifiers referenced inside the defining symbol's region235 // (its line to the next top-level symbol) that are known symbols elsewhere.236 const definitions = this.#definitions(symbol);237 if (definitions.length === 0) return [];238 const known = new Map<string, { file: string; line: number }>();239 for (const file of this.#files.values()) {240 for (const sym of file.symbols) {241 if (sym.name !== symbol && (sym.kind === "function" || sym.kind === "class" || sym.kind === "method")) {242 if (!known.has(sym.name)) known.set(sym.name, { file: file.path, line: sym.line });243 }244 }245 }246 const cwd = this.#workspace.cwd();247 const hits: RefQueryHit[] = [];248 const seen = new Set<string>();249 for (const def of definitions) {250 let content: string;251 try {252 content = await this.#workspace.readFile(path.join(cwd, def.file.path));253 } catch {254 continue;255 }256 const lines = content.split("\n");257 const sorted = [...def.file.symbols].sort((a, b) => a.line - b.line);258 const next = sorted.find((s) => s.line > def.symbol.line && s.kind !== "method");259 const end = next !== undefined ? next.line - 1 : lines.length;260 for (let i = def.symbol.line; i < end; i += 1) {261 const line = lines[i] as string;262 for (const match of line.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {263 const name = match[1] as string;264 const target = known.get(name);265 if (target === undefined || seen.has(name)) continue;266 seen.add(name);267 hits.push({268 file: target.file,269 line: target.line,270 context: `${name}(…) called from ${def.file.path}:${i + 1}`,271 });272 }273 }274 }275 return hits;276 }277}278279function safeGlob(pattern: string): RegExp | null {280 try {281 return globToRegExp(pattern);282 } catch {283 return null;284 }285}286