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/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