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%
8.9 KB · 251 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/repograph/extractor.ts4 * Description: Symbol/import extraction for TS/JS/Python — dependency-free heuristic parser (tree-sitter is the documented upgrade path) (v2 design §3).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export type SymbolKind = "function" | "class" | "type" | "export" | "variable" | "method";1112export interface ExtractedSymbol {13  name: string;14  kind: SymbolKind;15  /** 1-based line of the declaration. */16  line: number;17  /** The declaration line, trimmed. */18  signature: string;19  /** First line of the preceding doc comment, when present. */20  docComment?: string;21  exported: boolean;22}2324export interface ExtractedImport {25  /** Module specifier as written: "./kernel.js", "node:path", "react". */26  spec: string;27  /** Imported names ("default" for default imports, "*" for namespace). */28  names: string[];29  line: number;30}3132export interface ExtractionResult {33  symbols: ExtractedSymbol[];34  imports: ExtractedImport[];35}3637const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);3839/** Extensions the extractor understands. */40export function isIndexablePath(path: string): boolean {41  const dot = path.lastIndexOf(".");42  if (dot === -1) return false;43  const ext = path.slice(dot);44  return TS_EXTENSIONS.has(ext) || ext === ".py";45}4647const MAX_SIGNATURE = 160;4849function clip(line: string): string {50  const trimmed = line.trim();51  return trimmed.length > MAX_SIGNATURE ? `${trimmed.slice(0, MAX_SIGNATURE)}…` : trimmed;52}5354/** Extract the first sentence of a `/** … *\/` block ending just above `index`. */55function docCommentAbove(lines: readonly string[], index: number): string | undefined {56  let i = index - 1;57  while (i >= 0 && (lines[i] as string).trim().length === 0) i -= 1;58  if (i < 0) return undefined;59  const above = (lines[i] as string).trim();60  if (above.endsWith("*/")) {61    // Walk up to the /** opener collecting the first content line.62    for (let j = i; j >= 0 && j > i - 20; j -= 1) {63      const candidate = (lines[j] as string).trim();64      if (candidate.startsWith("/**")) {65        const inline = candidate.replace(/^\/\*\*\s*/, "").replace(/\s*\*\/$/, "");66        if (inline.length > 0) return clip(inline);67        const next = (lines[j + 1] as string | undefined)?.trim().replace(/^\*\s?/, "");68        return next !== undefined && next.length > 0 ? clip(next) : undefined;69      }70    }71  }72  if (above.startsWith("//")) return clip(above.replace(/^\/\/\s?/, ""));73  if (above.startsWith("#")) return clip(above.replace(/^#\s?/, ""));74  return undefined;75}7677// TS/JS declaration patterns — anchored to line starts, tolerant of export modifiers.78const TS_PATTERNS: { re: RegExp; kind: SymbolKind }[] = [79  { re: /^(export\s+)?(default\s+)?(async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/, kind: "function" },80  { re: /^(export\s+)?(default\s+)?(abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: "class" },81  { re: /^(export\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: "type" },82  { re: /^(export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/, kind: "type" },83  { re: /^(export\s+)?enum\s+([A-Za-z_$][\w$]*)/, kind: "type" },84  { re: /^(export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=/, kind: "variable" },85  { re: /^(export\s+)?let\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=/, kind: "variable" },86];8788const TS_METHOD = /^(?:public\s+|private\s+|protected\s+|static\s+|readonly\s+)*(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\([^;]*\)\s*(?::[^{;]+)?\{\s*$/;89const TS_KEYWORD_NOT_METHOD = new Set([90  "if", "for", "while", "switch", "catch", "return", "function", "constructor", "new", "typeof", "else", "do", "try",91]);9293const TS_IMPORT = /^import\s+(?:type\s+)?(.+?)\s+from\s+["']([^"']+)["']/;94const TS_SIDE_EFFECT_IMPORT = /^import\s+["']([^"']+)["']/;95const TS_EXPORT_LIST = /^export\s*\{([^}]*)\}/;9697function parseImportNames(clause: string): string[] {98  const names: string[] = [];99  const braces = /\{([^}]*)\}/.exec(clause);100  if (braces !== null) {101    for (const part of (braces[1] as string).split(",")) {102      const name = part.trim().split(/\s+as\s+/)[0]?.trim();103      if (name !== undefined && name.length > 0) names.push(name);104    }105  }106  const withoutBraces = clause.replace(/\{[^}]*\}/, "").trim();107  if (withoutBraces.startsWith("* as")) names.push("*");108  else {109    const first = withoutBraces.split(",")[0]?.trim();110    if (first !== undefined && first.length > 0 && first !== "*") names.push("default");111  }112  return names;113}114115function extractTs(lines: readonly string[]): ExtractionResult {116  const symbols: ExtractedSymbol[] = [];117  const imports: ExtractedImport[] = [];118  let braceDepth = 0;119  let inClassAtDepth = -1;120121  for (let i = 0; i < lines.length; i += 1) {122    const raw = lines[i] as string;123    const line = raw.trim();124125    const importMatch = TS_IMPORT.exec(line);126    if (importMatch !== null) {127      imports.push({128        spec: importMatch[2] as string,129        names: parseImportNames(importMatch[1] as string),130        line: i + 1,131      });132    } else {133      const sideEffect = TS_SIDE_EFFECT_IMPORT.exec(line);134      if (sideEffect !== null) {135        imports.push({ spec: sideEffect[1] as string, names: [], line: i + 1 });136      }137    }138139    if (braceDepth === 0) {140      const exportList = TS_EXPORT_LIST.exec(line);141      if (exportList !== null && !line.includes(" from ")) {142        for (const part of (exportList[1] as string).split(",")) {143          const name = part.trim().split(/\s+as\s+/)[0]?.trim();144          if (name !== undefined && name.length > 0) {145            symbols.push({ name, kind: "export", line: i + 1, signature: clip(line), exported: true });146          }147        }148      }149      for (const pattern of TS_PATTERNS) {150        const match = pattern.re.exec(line);151        if (match !== null) {152          const name = match[match.length - 1] as string;153          const doc = docCommentAbove(lines, i);154          symbols.push({155            name,156            kind: pattern.kind,157            line: i + 1,158            signature: clip(line),159            ...(doc !== undefined ? { docComment: doc } : {}),160            exported: /^export\b/.test(line),161          });162          if (pattern.kind === "class") inClassAtDepth = braceDepth;163          break;164        }165      }166    } else if (braceDepth === 1 && inClassAtDepth === 0) {167      const method = TS_METHOD.exec(line);168      if (method !== null) {169        const name = method[1] as string;170        if (!TS_KEYWORD_NOT_METHOD.has(name)) {171          const doc = docCommentAbove(lines, i);172          symbols.push({173            name,174            kind: "method",175            line: i + 1,176            signature: clip(line),177            ...(doc !== undefined ? { docComment: doc } : {}),178            exported: false,179          });180        }181      }182    }183184    // Cheap brace tracking, ignoring string/comment contents well enough for indexing.185    for (const ch of raw) {186      if (ch === "{") braceDepth += 1;187      else if (ch === "}") braceDepth = Math.max(0, braceDepth - 1);188    }189    if (braceDepth === 0) inClassAtDepth = -1;190  }191  return { symbols, imports };192}193194const PY_DEF = /^(\s*)def\s+([A-Za-z_]\w*)/;195const PY_CLASS = /^(\s*)class\s+([A-Za-z_]\w*)/;196const PY_IMPORT = /^import\s+([\w.]+)/;197const PY_FROM_IMPORT = /^from\s+([\w.]+)\s+import\s+(.+)/;198199function extractPy(lines: readonly string[]): ExtractionResult {200  const symbols: ExtractedSymbol[] = [];201  const imports: ExtractedImport[] = [];202  for (let i = 0; i < lines.length; i += 1) {203    const raw = lines[i] as string;204    const def = PY_DEF.exec(raw);205    if (def !== null) {206      const indent = (def[1] as string).length;207      const doc = docCommentAbove(lines, i);208      symbols.push({209        name: def[2] as string,210        kind: indent > 0 ? "method" : "function",211        line: i + 1,212        signature: clip(raw),213        ...(doc !== undefined ? { docComment: doc } : {}),214        exported: indent === 0 && !(def[2] as string).startsWith("_"),215      });216      continue;217    }218    const cls = PY_CLASS.exec(raw);219    if (cls !== null) {220      const doc = docCommentAbove(lines, i);221      symbols.push({222        name: cls[2] as string,223        kind: "class",224        line: i + 1,225        signature: clip(raw),226        ...(doc !== undefined ? { docComment: doc } : {}),227        exported: !(cls[2] as string).startsWith("_"),228      });229      continue;230    }231    const from = PY_FROM_IMPORT.exec(raw.trim());232    if (from !== null) {233      const names = (from[2] as string).split(",").map((n) => n.trim().split(/\s+as\s+/)[0] ?? "");234      imports.push({ spec: from[1] as string, names: names.filter((n) => n.length > 0), line: i + 1 });235      continue;236    }237    const imp = PY_IMPORT.exec(raw.trim());238    if (imp !== null) {239      imports.push({ spec: imp[1] as string, names: ["*"], line: i + 1 });240    }241  }242  return { symbols, imports };243}244245/** Extract symbols and imports from one file's content. */246export function extractFile(path: string, content: string): ExtractionResult {247  const lines = content.split("\n");248  if (path.endsWith(".py")) return extractPy(lines);249  return extractTs(lines);250}251