SPB Git

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%
10.1 KB · 286 lines typescript
Raw Blame History
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