/** * KHAELOR * File: src/repository/map.ts * Description: Incremental filesystem map — bounded, gitignore-aware repo tree with compact rendering. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { Workspace } from "../workspace/index.js"; /** One entry of the repository map. Paths are POSIX-relative to the workspace cwd. */ export interface RepoFileEntry { path: string; kind: "file" | "dir"; size: number; mtimeMs: number; /** Files only — inferred from the extension. */ language?: string; } export interface RepositoryMapOptions { /** Directory recursion cap. Default 10. */ maxDepth?: number; /** Exposure cap for `fileMap()` (the scan itself is bounded by depth + pruning). Default 4000. */ maxEntries?: number; /** Extra directory names to prune, in addition to built-ins and .gitignore-derived names. */ extraPruneNames?: string[]; /** Hard ceiling for a full scan command. Default 30 000 ms. */ scanTimeoutMs?: number; /** * Scanner flavor override (tests). "printf" needs GNU-compatible find * (GNU findutils, bfs); "stat" needs BSD stat (macOS). Auto-detected by default. */ flavor?: ScanFlavor; } export type ScanFlavor = "printf" | "stat"; /** Module-local failure type — scanning is best-effort but a total failure is loud. */ export class RepositoryScanError extends Error { constructor(message: string) { super(message); this.name = "RepositoryScanError"; } } /** Directory names never descended into (plus .gitignore/.khaelorignore-derived names). */ const BUILTIN_PRUNE_NAMES = [ ".git", "node_modules", ".hg", ".svn", "__pycache__", ".venv", ".cache", ] as const; /** File basenames excluded from the map. */ const PRUNE_FILE_NAMES = new Set([".DS_Store"]); const DEFAULT_MAX_DEPTH = 10; const DEFAULT_MAX_ENTRIES = 4000; const DEFAULT_SCAN_TIMEOUT_MS = 30_000; const DEFAULT_RENDER_BYTES = 4096; /** Field separator embedded in scan output — ASCII unit separator, absent from sane paths. */ const US = "\u001f"; const LANGUAGE_BY_EXTENSION: Record = { ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", py: "python", rs: "rust", go: "go", rb: "ruby", java: "java", c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp", cs: "csharp", swift: "swift", kt: "kotlin", sh: "shell", bash: "shell", zsh: "shell", md: "markdown", json: "json", jsonc: "jsonc", yaml: "yaml", yml: "yaml", toml: "toml", html: "html", css: "css", scss: "scss", sql: "sql", php: "php", lua: "lua", }; /** Infer a language label from a file path's extension. */ export function languageOf(filePath: string): string | undefined { const base = filePath.slice(filePath.lastIndexOf("/") + 1); const dot = base.lastIndexOf("."); if (dot <= 0) return undefined; return LANGUAGE_BY_EXTENSION[base.slice(dot + 1).toLowerCase()]; } interface ScanRow { kind: "file" | "dir"; size: number; mtimeMs: number; rel: string; } function shellQuote(s: string): string { return `'${s.replaceAll("'", "'\\''")}'`; } function depthOf(rel: string): number { return rel.length === 0 ? 0 : rel.split("/").length; } function parentOf(rel: string): string { const idx = rel.lastIndexOf("/"); return idx === -1 ? "" : rel.slice(0, idx); } function baseNameOf(rel: string): string { return rel.slice(rel.lastIndexOf("/") + 1); } /** * Parse simple ignore-file lines into prunable directory names. Only plain * names (optionally anchored `/name` or trailing `name/`) are honored; glob * and negation patterns are intentionally out of V1 scope. */ export function prunableNamesFromIgnore(content: string): string[] { const names: string[] = []; for (const rawLine of content.split("\n")) { let line = rawLine.trim(); if (line.length === 0 || line.startsWith("#") || line.startsWith("!")) continue; if (line.endsWith("/")) line = line.slice(0, -1); if (line.startsWith("/")) line = line.slice(1); if (line.length === 0) continue; if (/[*?[\]/]/.test(line)) continue; // complex pattern — skip (approximation) names.push(line); } return names; } /** * Bounded, incremental map of the project tree. Scans through the workspace * (`find`, plus BSD `stat` when `find -printf` is unavailable) so this module * never touches `node:fs` (ARCHITECTURE.md §2.1 rule 5). Refresh re-stats * directories only and re-lists just the directories whose mtime changed. */ export class RepositoryMap { private readonly workspace: Workspace; private readonly maxDepth: number; private readonly maxEntries: number; private readonly scanTimeoutMs: number; private readonly extraPruneNames: string[]; private readonly forcedFlavor: ScanFlavor | undefined; private flavor: ScanFlavor | undefined; private pruneNames: string[] = [...BUILTIN_PRUNE_NAMES]; private readonly entriesByPath = new Map(); private rootMtimeMs = 0; private built = false; private lastTruncated = false; constructor(workspace: Workspace, options: RepositoryMapOptions = {}) { this.workspace = workspace; this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; this.scanTimeoutMs = options.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS; this.extraPruneNames = options.extraPruneNames ?? []; this.forcedFlavor = options.flavor; } isBuilt(): boolean { return this.built; } /** Whether the last `fileMap()` exposure hit its entry cap. */ wasTruncated(): boolean { return this.lastTruncated; } totalEntries(): number { return this.entriesByPath.size; } // ── scanning plumbing ── private async detectFlavor(): Promise { if (this.forcedFlavor !== undefined) return this.forcedFlavor; if (this.flavor !== undefined) return this.flavor; const probe = await this.workspace.exec({ cmd: "find . -maxdepth 0 -printf 'ok\\n'", timeoutMs: 5000, }); this.flavor = probe.exitCode === 0 && probe.stdout.startsWith("ok") ? "printf" : "stat"; return this.flavor; } private pruneExpr(): string { const names = [...new Set([...this.pruneNames, ...this.extraPruneNames])]; const nameTests = names.map((n) => `-name ${shellQuote(n)}`).join(" -o "); return `\\( -type d \\( ${nameTests} \\) -prune \\)`; } private scanCommand( flavor: ScanFlavor, startPath: string, depthArgs: string, typeExpr: string, ): string { const start = shellQuote(startPath); if (flavor === "printf") { const fmt = `%y${US}%s${US}%T@${US}%p\\n`; return `find ${start} ${depthArgs} ${this.pruneExpr()} -o \\( ${typeExpr} \\) -printf '${fmt}'`; } const fmt = `%HT${US}%z${US}%m${US}%N`; return `find ${start} ${depthArgs} ${this.pruneExpr()} -o \\( ${typeExpr} \\) -print0 | xargs -0 stat -f '${fmt}'`; } private parseScan(stdout: string, flavor: ScanFlavor): ScanRow[] { const rows: ScanRow[] = []; for (const line of stdout.split("\n")) { if (line.length === 0) continue; const parts = line.split(US); if (parts.length < 4) continue; const kindField = parts[0] ?? ""; let kind: "file" | "dir"; if (flavor === "printf") { if (kindField === "f") kind = "file"; else if (kindField === "d") kind = "dir"; else continue; } else if (kindField === "Regular File") kind = "file"; else if (kindField === "Directory") kind = "dir"; else continue; const size = Number.parseInt(parts[1] ?? "0", 10); const mtimeSeconds = Number.parseFloat(parts[2] ?? "0"); let rel = parts.slice(3).join(US); if (rel.startsWith("./")) rel = rel.slice(2); if (kind === "file" && PRUNE_FILE_NAMES.has(baseNameOf(rel))) continue; rows.push({ kind, size: Number.isFinite(size) ? size : 0, mtimeMs: Math.round(mtimeSeconds * 1000), rel, }); } return rows; } private async runScan(startPath: string, depthArgs: string, typeExpr: string): Promise { const flavor = await this.detectFlavor(); const cmd = this.scanCommand(flavor, startPath, depthArgs, typeExpr); const result = await this.workspace.exec({ cmd, timeoutMs: this.scanTimeoutMs }); if (result.stdout.length === 0 && result.exitCode !== 0) { throw new RepositoryScanError( `repository scan failed (exit ${String(result.exitCode)}): ${result.stderr.trim().slice(0, 300)}`, ); } return this.parseScan(result.stdout, flavor); } private async loadIgnorePrunes(): Promise { const names = new Set(BUILTIN_PRUNE_NAMES); for (const file of [".gitignore", ".khaelorignore"]) { try { const content = await this.workspace.readFile(file); for (const name of prunableNamesFromIgnore(content)) names.add(name); } catch { // No ignore file — fine. } } this.pruneNames = [...names]; } // ── build and refresh ── /** Full scan of the tree (bounded by depth and pruning). */ async build(): Promise { await this.loadIgnorePrunes(); const rows = await this.runScan(".", `-maxdepth ${String(this.maxDepth)}`, "-type f -o -type d"); this.entriesByPath.clear(); for (const row of rows) { if (row.rel === "." || row.rel === "") { this.rootMtimeMs = row.mtimeMs; continue; } this.setEntry(row); } this.built = true; } /** * Incremental refresh: one dirs-only scan detects created/deleted/changed * directories by mtime; only the children of changed directories are * re-listed. Files inside untouched directories are not re-stated. */ async refresh(): Promise { if (!this.built) { await this.build(); return; } const dirRows = await this.runScan(".", `-maxdepth ${String(this.maxDepth)}`, "-type d"); const newDirs = new Map(); let newRootMtime = this.rootMtimeMs; for (const row of dirRows) { if (row.rel === "." || row.rel === "") newRootMtime = row.mtimeMs; else newDirs.set(row.rel, row); } const oldDirs = new Map(); for (const entry of this.entriesByPath.values()) { if (entry.kind === "dir") oldDirs.set(entry.path, entry); } // Deleted directories: drop the dir and its whole subtree. for (const [dirPath] of oldDirs) { if (!newDirs.has(dirPath)) { const prefix = `${dirPath}/`; for (const key of [...this.entriesByPath.keys()]) { if (key === dirPath || key.startsWith(prefix)) this.entriesByPath.delete(key); } } } // Changed or new directories: re-list immediate children. const changedDirs: string[] = []; for (const [dirPath, row] of newDirs) { const before = oldDirs.get(dirPath); if (before === undefined || before.mtimeMs !== row.mtimeMs) changedDirs.push(dirPath); this.setEntry(row); // keep dir metadata current } if (newRootMtime !== this.rootMtimeMs) { changedDirs.push(""); this.rootMtimeMs = newRootMtime; } for (const dirPath of changedDirs) { if (depthOf(dirPath) >= this.maxDepth) continue; const start = dirPath === "" ? "." : `./${dirPath}`; const rows = await this.runScan(start, "-mindepth 1 -maxdepth 1", "-type f -o -type d"); // Replace the immediate FILE children wholesale (dirs are authoritative above). for (const [key, entry] of [...this.entriesByPath.entries()]) { if (entry.kind === "file" && parentOf(key) === dirPath) this.entriesByPath.delete(key); } for (const row of rows) { if (row.kind === "file") this.setEntry(row); } } } private setEntry(row: ScanRow): void { const entry: RepoFileEntry = { path: row.rel, kind: row.kind, size: row.size, mtimeMs: row.mtimeMs, }; if (row.kind === "file") { const language = languageOf(row.rel); if (language !== undefined) entry.language = language; } this.entriesByPath.set(row.rel, entry); } // ── exposure ── /** * The bounded map (files and directories), sorted shallowest-first then by * path, capped at `maxEntries` (ARCHITECTURE.md `RepositoryIndex.fileMap`). */ async fileMap(opts?: { maxEntries?: number }): Promise { if (!this.built) await this.build(); const cap = opts?.maxEntries ?? this.maxEntries; const all = [...this.entriesByPath.values()].sort( (a, b) => depthOf(a.path) - depthOf(b.path) || a.path.localeCompare(b.path), ); this.lastTruncated = all.length > cap; return all.slice(0, cap); } /** File entries only (for fuzzy file finding). Builds on first use. */ async files(): Promise { if (!this.built) await this.build(); return [...this.entriesByPath.values()].filter((e) => e.kind === "file"); } /** * Compact textual repo map for context injection — an indented tree, * hard-capped at `maxBytes` with an explicit omission marker. */ async render(maxBytes: number = DEFAULT_RENDER_BYTES): Promise { if (!this.built) await this.build(); const children = new Map(); for (const entry of this.entriesByPath.values()) { const parent = parentOf(entry.path); const list = children.get(parent); if (list === undefined) children.set(parent, [entry]); else list.push(entry); } for (const list of children.values()) { list.sort((a, b) => a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === "dir" ? -1 : 1, ); } const trailerReserve = 48; const lines: string[] = []; let bytes = 0; let emitted = 0; let exhausted = false; const walk = (parent: string, indent: number): void => { if (exhausted) return; for (const entry of children.get(parent) ?? []) { const name = baseNameOf(entry.path) + (entry.kind === "dir" ? "/" : ""); const line = `${" ".repeat(indent)}${name}`; const lineBytes = Buffer.byteLength(line, "utf8") + 1; if (bytes + lineBytes > maxBytes - trailerReserve) { exhausted = true; return; } lines.push(line); bytes += lineBytes; emitted += 1; if (entry.kind === "dir") walk(entry.path, indent + 2); if (exhausted) return; } }; walk("", 0); const omitted = this.entriesByPath.size - emitted; if (omitted > 0) lines.push(`… ${String(omitted)} more entries not shown`); return lines.join("\n"); } }