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%
7.8 KB · 257 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/workspace/walk.ts4 * Description: Filesystem walking, .gitignore/.khaelorignore parsing, and glob matching for grep/glob tools.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import * as fsp from "node:fs/promises";11import * as path from "node:path";1213/** One file discovered by the walker. */14export interface WalkedFile {15  /** Absolute path. */16  path: string;17  mtimeMs: number;18}1920export interface WalkOptions {21  /**22   * Built-in directory ignores applied in addition to ignore files.23   * Default: node_modules, .git, dist (TOOL_PROTOCOL §6.2).24   */25  builtinIgnores?: string[];26  /** Safety cap on the number of files returned. Default 50000. */27  maxFiles?: number;28  /** Abort signal — the walk stops early when aborted. */29  signal?: AbortSignal;30}3132const DEFAULT_BUILTIN_IGNORES = ["node_modules", ".git", "dist"];33const DEFAULT_MAX_FILES = 50_000;3435// ─────────────────────────── glob translation ───────────────────────────3637function escapeRegExpChar(ch: string): string {38  return /[.+^${}()|\\]/.test(ch) ? `\\${ch}` : ch;39}4041/**42 * Translate a gitignore-style glob into a RegExp over a `/`-separated43 * relative path. Supports `**`, `*`, `?`, `[...]`. A pattern without a44 * slash matches at any depth (matchBase); a leading `/` anchors it.45 */46export function globToRegExp(pattern: string): RegExp {47  let glob = pattern;48  const dirOnly = glob.endsWith("/");49  if (dirOnly) glob = glob.slice(0, -1);50  if (glob.startsWith("/")) {51    glob = glob.slice(1);52  } else if (!glob.includes("/")) {53    // No slash → match the basename at any depth.54    glob = `**/${glob}`;55  }5657  let out = "";58  let i = 0;59  while (i < glob.length) {60    const ch = glob[i] as string;61    if (ch === "*") {62      const isDouble = glob[i + 1] === "*";63      if (isDouble) {64        const next = glob[i + 2];65        if (next === "/") {66          // `**/` — zero or more whole segments.67          out += "(?:[^/]+/)*";68          i += 3;69        } else {70          out += ".*";71          i += 2;72        }73      } else {74        out += "[^/]*";75        i += 1;76      }77    } else if (ch === "?") {78      out += "[^/]";79      i += 1;80    } else if (ch === "[") {81      const close = glob.indexOf("]", i + 1);82      if (close === -1) {83        out += "\\[";84        i += 1;85      } else {86        let cls = glob.slice(i + 1, close);87        if (cls.startsWith("!")) cls = `^${cls.slice(1)}`;88        out += `[${cls}]`;89        i = close + 1;90      }91    } else {92      out += escapeRegExpChar(ch);93      i += 1;94    }95  }96  return new RegExp(`^${out}$`);97}9899// ─────────────────────────── ignore matching ───────────────────────────100101interface IgnoreRule {102  negated: boolean;103  dirOnly: boolean;104  regex: RegExp;105}106107/** Ordered ignore rules; last matching rule wins (gitignore semantics, simple parse). */108export class IgnoreMatcher {109  private readonly rules: IgnoreRule[] = [];110111  addPattern(raw: string): void {112    let line = raw.replace(/\r$/, "");113    if (line.trim().length === 0) return;114    if (line.startsWith("#")) return;115    let negated = false;116    if (line.startsWith("!")) {117      negated = true;118      line = line.slice(1);119    }120    line = line.trim();121    if (line.length === 0) return;122    const dirOnly = line.endsWith("/");123    this.rules.push({ negated, dirOnly, regex: globToRegExp(line) });124  }125126  addFileContent(content: string): void {127    for (const line of content.split("\n")) this.addPattern(line);128  }129130  /** Is `relPath` (posix separators, no leading slash) ignored? */131  ignores(relPath: string, isDirectory: boolean): boolean {132    let ignored = false;133    for (const rule of this.rules) {134      if (rule.dirOnly && !isDirectory) continue;135      if (rule.regex.test(relPath)) ignored = !rule.negated;136    }137    return ignored;138  }139}140141async function readIfExists(file: string): Promise<string | undefined> {142  try {143    return await fsp.readFile(file, "utf8");144  } catch {145    return undefined;146  }147}148149/** Build the ignore matcher for a walk root: built-ins + .gitignore + .khaelorignore. */150export async function loadIgnoreMatcher(151  root: string,152  builtinIgnores: string[] = DEFAULT_BUILTIN_IGNORES,153): Promise<IgnoreMatcher> {154  const matcher = new IgnoreMatcher();155  for (const name of builtinIgnores) matcher.addPattern(`${name}/`);156  const gitignore = await readIfExists(path.join(root, ".gitignore"));157  if (gitignore !== undefined) matcher.addFileContent(gitignore);158  const khaelorignore = await readIfExists(path.join(root, ".khaelorignore"));159  if (khaelorignore !== undefined) matcher.addFileContent(khaelorignore);160  return matcher;161}162163/**164 * Recursively list files under `root`, honoring .gitignore/.khaelorignore165 * (root-level, simple parse) plus built-in noise directories. Hidden files166 * and directories (dot-prefixed) are skipped; symlinks are not followed.167 */168export async function walkFiles(root: string, options: WalkOptions = {}): Promise<WalkedFile[]> {169  const absRoot = path.resolve(root);170  const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;171  const matcher = await loadIgnoreMatcher(absRoot, options.builtinIgnores);172  const out: WalkedFile[] = [];173  const stack: string[] = [absRoot];174175  while (stack.length > 0) {176    if (options.signal?.aborted === true) break;177    const dir = stack.pop() as string;178    let entries;179    try {180      entries = await fsp.readdir(dir, { withFileTypes: true });181    } catch {182      continue;183    }184    for (const entry of entries) {185      if (out.length >= maxFiles) return out;186      if (entry.name.startsWith(".")) continue;187      const abs = path.join(dir, entry.name);188      const rel = path.relative(absRoot, abs).split(path.sep).join("/");189      if (entry.isDirectory()) {190        if (!matcher.ignores(rel, true)) stack.push(abs);191      } else if (entry.isFile()) {192        if (matcher.ignores(rel, false)) continue;193        try {194          const stat = await fsp.stat(abs);195          out.push({ path: abs, mtimeMs: stat.mtimeMs });196        } catch {197          // File vanished mid-walk.198        }199      }200      // Symlinks and special files are skipped.201    }202  }203  return out;204}205206/** A directory listing for the read tool (TOOL_PROTOCOL §2.2 — 2 levels, hidden counts). */207export interface DirectoryListing {208  lines: string[];209  hiddenCount: number;210  truncated: boolean;211}212213const LISTING_MAX_LINES = 200;214const OPAQUE_DIRS = new Set(["node_modules", ".git", "dist"]);215216/** List a directory two levels deep: entries sorted directories-first, hidden entries counted. */217export async function listDirectory(dir: string, depth = 2): Promise<DirectoryListing> {218  const lines: string[] = [];219  let hiddenCount = 0;220  let truncated = false;221222  async function visit(current: string, level: number): Promise<void> {223    let entries;224    try {225      entries = await fsp.readdir(current, { withFileTypes: true });226    } catch {227      return;228    }229    entries.sort((a, b) => {230      if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;231      return a.name.localeCompare(b.name);232    });233    for (const entry of entries) {234      if (entry.name.startsWith(".")) {235        hiddenCount += 1;236        continue;237      }238      if (lines.length >= LISTING_MAX_LINES) {239        truncated = true;240        return;241      }242      const indent = "  ".repeat(level);243      if (entry.isDirectory()) {244        lines.push(`${indent}${entry.name}/`);245        if (level + 1 < depth && !OPAQUE_DIRS.has(entry.name)) {246          await visit(path.join(current, entry.name), level + 1);247        }248      } else {249        lines.push(`${indent}${entry.name}`);250      }251    }252  }253254  await visit(path.resolve(dir), 0);255  return { lines, hiddenCount, truncated };256}257