/** * KHAELOR * File: src/workspace/walk.ts * Description: Filesystem walking, .gitignore/.khaelorignore parsing, and glob matching for grep/glob tools. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import * as fsp from "node:fs/promises"; import * as path from "node:path"; /** One file discovered by the walker. */ export interface WalkedFile { /** Absolute path. */ path: string; mtimeMs: number; } export interface WalkOptions { /** * Built-in directory ignores applied in addition to ignore files. * Default: node_modules, .git, dist (TOOL_PROTOCOL §6.2). */ builtinIgnores?: string[]; /** Safety cap on the number of files returned. Default 50000. */ maxFiles?: number; /** Abort signal — the walk stops early when aborted. */ signal?: AbortSignal; } const DEFAULT_BUILTIN_IGNORES = ["node_modules", ".git", "dist"]; const DEFAULT_MAX_FILES = 50_000; // ─────────────────────────── glob translation ─────────────────────────── function escapeRegExpChar(ch: string): string { return /[.+^${}()|\\]/.test(ch) ? `\\${ch}` : ch; } /** * Translate a gitignore-style glob into a RegExp over a `/`-separated * relative path. Supports `**`, `*`, `?`, `[...]`. A pattern without a * slash matches at any depth (matchBase); a leading `/` anchors it. */ export function globToRegExp(pattern: string): RegExp { let glob = pattern; const dirOnly = glob.endsWith("/"); if (dirOnly) glob = glob.slice(0, -1); if (glob.startsWith("/")) { glob = glob.slice(1); } else if (!glob.includes("/")) { // No slash → match the basename at any depth. glob = `**/${glob}`; } let out = ""; let i = 0; while (i < glob.length) { const ch = glob[i] as string; if (ch === "*") { const isDouble = glob[i + 1] === "*"; if (isDouble) { const next = glob[i + 2]; if (next === "/") { // `**/` — zero or more whole segments. out += "(?:[^/]+/)*"; i += 3; } else { out += ".*"; i += 2; } } else { out += "[^/]*"; i += 1; } } else if (ch === "?") { out += "[^/]"; i += 1; } else if (ch === "[") { const close = glob.indexOf("]", i + 1); if (close === -1) { out += "\\["; i += 1; } else { let cls = glob.slice(i + 1, close); if (cls.startsWith("!")) cls = `^${cls.slice(1)}`; out += `[${cls}]`; i = close + 1; } } else { out += escapeRegExpChar(ch); i += 1; } } return new RegExp(`^${out}$`); } // ─────────────────────────── ignore matching ─────────────────────────── interface IgnoreRule { negated: boolean; dirOnly: boolean; regex: RegExp; } /** Ordered ignore rules; last matching rule wins (gitignore semantics, simple parse). */ export class IgnoreMatcher { private readonly rules: IgnoreRule[] = []; addPattern(raw: string): void { let line = raw.replace(/\r$/, ""); if (line.trim().length === 0) return; if (line.startsWith("#")) return; let negated = false; if (line.startsWith("!")) { negated = true; line = line.slice(1); } line = line.trim(); if (line.length === 0) return; const dirOnly = line.endsWith("/"); this.rules.push({ negated, dirOnly, regex: globToRegExp(line) }); } addFileContent(content: string): void { for (const line of content.split("\n")) this.addPattern(line); } /** Is `relPath` (posix separators, no leading slash) ignored? */ ignores(relPath: string, isDirectory: boolean): boolean { let ignored = false; for (const rule of this.rules) { if (rule.dirOnly && !isDirectory) continue; if (rule.regex.test(relPath)) ignored = !rule.negated; } return ignored; } } async function readIfExists(file: string): Promise { try { return await fsp.readFile(file, "utf8"); } catch { return undefined; } } /** Build the ignore matcher for a walk root: built-ins + .gitignore + .khaelorignore. */ export async function loadIgnoreMatcher( root: string, builtinIgnores: string[] = DEFAULT_BUILTIN_IGNORES, ): Promise { const matcher = new IgnoreMatcher(); for (const name of builtinIgnores) matcher.addPattern(`${name}/`); const gitignore = await readIfExists(path.join(root, ".gitignore")); if (gitignore !== undefined) matcher.addFileContent(gitignore); const khaelorignore = await readIfExists(path.join(root, ".khaelorignore")); if (khaelorignore !== undefined) matcher.addFileContent(khaelorignore); return matcher; } /** * Recursively list files under `root`, honoring .gitignore/.khaelorignore * (root-level, simple parse) plus built-in noise directories. Hidden files * and directories (dot-prefixed) are skipped; symlinks are not followed. */ export async function walkFiles(root: string, options: WalkOptions = {}): Promise { const absRoot = path.resolve(root); const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; const matcher = await loadIgnoreMatcher(absRoot, options.builtinIgnores); const out: WalkedFile[] = []; const stack: string[] = [absRoot]; while (stack.length > 0) { if (options.signal?.aborted === true) break; const dir = stack.pop() as string; let entries; try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (out.length >= maxFiles) return out; if (entry.name.startsWith(".")) continue; const abs = path.join(dir, entry.name); const rel = path.relative(absRoot, abs).split(path.sep).join("/"); if (entry.isDirectory()) { if (!matcher.ignores(rel, true)) stack.push(abs); } else if (entry.isFile()) { if (matcher.ignores(rel, false)) continue; try { const stat = await fsp.stat(abs); out.push({ path: abs, mtimeMs: stat.mtimeMs }); } catch { // File vanished mid-walk. } } // Symlinks and special files are skipped. } } return out; } /** A directory listing for the read tool (TOOL_PROTOCOL §2.2 — 2 levels, hidden counts). */ export interface DirectoryListing { lines: string[]; hiddenCount: number; truncated: boolean; } const LISTING_MAX_LINES = 200; const OPAQUE_DIRS = new Set(["node_modules", ".git", "dist"]); /** List a directory two levels deep: entries sorted directories-first, hidden entries counted. */ export async function listDirectory(dir: string, depth = 2): Promise { const lines: string[] = []; let hiddenCount = 0; let truncated = false; async function visit(current: string, level: number): Promise { let entries; try { entries = await fsp.readdir(current, { withFileTypes: true }); } catch { return; } entries.sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; return a.name.localeCompare(b.name); }); for (const entry of entries) { if (entry.name.startsWith(".")) { hiddenCount += 1; continue; } if (lines.length >= LISTING_MAX_LINES) { truncated = true; return; } const indent = " ".repeat(level); if (entry.isDirectory()) { lines.push(`${indent}${entry.name}/`); if (level + 1 < depth && !OPAQUE_DIRS.has(entry.name)) { await visit(path.join(current, entry.name), level + 1); } } else { lines.push(`${indent}${entry.name}`); } } } await visit(path.resolve(dir), 0); return { lines, hiddenCount, truncated }; }