UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

291 lines 11 kB
/** * Read-only filesystem adapter over Node's `fs` APIs. * Audit checks and fact extractors target this interface so tests can swap in mock filesystems without touching extraction logic. */ import { readFileSync, statSync, readdirSync, accessSync, constants, } from "node:fs"; import { resolve, relative, join } from "node:path"; /** Read directory entries; swallows readdir errors so glob walkers treat missing trees as no matches. */ function readDirEntries(path) { try { return readdirSync(path, { withFileTypes: true }); } catch { return []; } } /** Convert one glob segment into the regex used by the filesystem walker. */ function buildGlobRegex(part) { return new RegExp("^" + part.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$"); } /** Walk a glob pattern recursively from the current directory segment. */ function walkGlob(root, resolvePath, parts, dir, patternIndex, results) { if (patternIndex >= parts.length) return; const part = parts[patternIndex]; if (part === undefined) return; if (part === "**") { walkGlobStar(root, resolvePath, parts, dir, patternIndex, results); return; } walkGlobSegment(root, resolvePath, parts, dir, patternIndex, results, part); } /** Handle the recursive `**` segment in the custom glob walker. */ function walkGlobStar(root, resolvePath, parts, dir, patternIndex, results) { if (patternIndex + 1 < parts.length) { walkGlob(root, resolvePath, parts, dir, patternIndex + 1, results); } for (const entry of readDirEntries(resolvePath(dir))) { if (entry.isDirectory() && isIgnoredDir(entry.name) === false) { walkGlob(root, resolvePath, parts, join(dir, entry.name), patternIndex, results); } } } /** Handle a normal glob segment and descend when matching directories remain. */ function walkGlobSegment(root, resolvePath, parts, dir, patternIndex, results, part) { const isLast = patternIndex === parts.length - 1; const regex = buildGlobRegex(part); for (const entry of readDirEntries(resolvePath(dir))) { if (!regex.test(entry.name)) continue; const fullPath = join(dir, entry.name); if (isLast) { // Glob callers and renderers expect POSIX-shape relative paths so the // same patterns work on Windows and POSIX. results.push(relative(root, resolvePath(fullPath)).replace(/\\/g, "/")); continue; } if (entry.isDirectory()) { walkGlob(root, resolvePath, parts, fullPath, patternIndex + 1, results); } } } /** Walk a glob pattern until the first match is found. */ function walkGlobExists(root, resolvePath, parts, dir, patternIndex) { if (patternIndex >= parts.length) return false; const part = parts[patternIndex]; if (part === undefined) return false; if (part === "**") { return walkGlobStarExists(root, resolvePath, parts, dir, patternIndex); } return walkGlobSegmentExists(root, resolvePath, parts, dir, patternIndex, part); } /** Handle a recursive `**` segment for first-match glob checks. */ function walkGlobStarExists(root, resolvePath, parts, dir, patternIndex) { if (patternIndex + 1 < parts.length && walkGlobExists(root, resolvePath, parts, dir, patternIndex + 1)) { return true; } for (const entry of readDirEntries(resolvePath(dir))) { if (entry.isDirectory() && isIgnoredDir(entry.name) === false && walkGlobExists(root, resolvePath, parts, join(dir, entry.name), patternIndex)) { return true; } } return false; } /** Handle a normal glob segment for first-match glob checks. */ function walkGlobSegmentExists(root, resolvePath, parts, dir, patternIndex, part) { const isLast = patternIndex === parts.length - 1; const regex = buildGlobRegex(part); for (const entry of readDirEntries(resolvePath(dir))) { if (!regex.test(entry.name)) continue; const fullPath = join(dir, entry.name); if (isLast) { return true; } if (entry.isDirectory() && walkGlobExists(root, resolvePath, parts, fullPath, patternIndex + 1)) { return true; } } return false; } /** Build the resolver that anchors all fact reads under the selected project root. */ function createPathResolver(root) { /** Resolve one caller-supplied relative path under the adapter root. */ function resolveProjectPath(relativePath) { return resolve(root, relativePath); } return resolveProjectPath; } /** Cache UTF-8 file reads; swallows read errors as null for missing or unreadable files. */ function createCachedReadFile(resolvePath) { const contentCache = new Map(); /** Read one UTF-8 file through the per-adapter cache; swallows read errors as a cached null fallback. */ function readCachedFile(path) { const resolved = resolvePath(path); const cached = contentCache.get(resolved); if (cached !== undefined) return cached; try { const content = readFileSync(resolved, "utf-8"); contentCache.set(resolved, content); return content; } catch { contentCache.set(resolved, null); return null; } } return readCachedFile; } /** Cache path existence; swallows stat errors and reports inaccessible paths as false. */ function createExistsChecker(resolvePath) { const existsCache = new Map(); /** Check one path through the per-adapter existence cache; swallows stat errors as a cached false fallback. */ function cachedExists(path) { const resolved = resolvePath(path); const cached = existsCache.get(resolved); if (cached !== undefined) return cached; try { statSync(resolved); existsCache.set(resolved, true); return true; } catch { existsCache.set(resolved, false); return false; } } return cachedExists; } /** Count lines from cached content so repeated audit checks do not reread the same file. */ function countCachedLines(readFile, path) { const content = readFile(path); if (content === null) return 0; return content.split("\n").length - (content.endsWith("\n") ? 1 : 0); } /** Parse JSON defensively; missing or malformed files recover to null. */ function readCachedJson(readFile, path) { const content = readFile(path); if (content === null) return null; try { return JSON.parse(content); } catch { return null; } } /** Cache directory listings; swallows readdir errors as [] by design. */ function createDirectoryLister(resolvePath) { const listDirCache = new Map(); /** List one directory through the per-adapter directory cache; swallows readdir errors as a cached [] fallback. */ function cachedListDir(path) { const resolved = resolvePath(path); const cached = listDirCache.get(resolved); if (cached !== undefined) return cached; try { const entries = readdirSync(resolved, { withFileTypes: true }).map((entry) => entry.name); listDirCache.set(resolved, entries); return entries; } catch { const empty = []; listDirCache.set(resolved, empty); return empty; } } return cachedListDir; } /** Check executability; swallows access errors and falls back to shebang detection on Windows. */ function isExecutablePath(resolvePath, readFile, path) { try { accessSync(resolvePath(path), constants.X_OK); return true; } catch { if (process.platform !== "win32") return false; const content = readFile(path); return content !== null && content.startsWith("#!"); } } /** Create cache-backed glob helpers for the filesystem adapter. */ function createGlobHelpers(root, resolvePath) { const globCache = new Map(); return { /** Expand the custom glob syntax and return a copy so callers cannot mutate the cache. */ glob(pattern) { const cached = globCache.get(pattern); if (cached !== undefined) return [...cached]; const results = []; const parts = pattern.split("/"); walkGlob(root, resolvePath, parts, ".", 0, results); globCache.set(pattern, results); return [...results]; }, /** Check whether a glob has any match without materializing results when nothing is cached. */ existsGlob(pattern) { const cached = globCache.get(pattern); if (cached !== undefined) return cached.length > 0; const parts = pattern.split("/"); return walkGlobExists(root, resolvePath, parts, ".", 0); }, }; } /** * Create a read-only filesystem abstraction rooted at the given path. * The adapter centralizes defensive filesystem handling because audit callers need stable null, * false, or empty-list results instead of platform-specific errno throws. * * @param rootPath Directory that relative fact reads resolve against. * @returns Cached, non-mutating filesystem helpers for audit and fact extraction. */ export function createFS(rootPath) { const root = resolve(rootPath); const resolvePath = createPathResolver(root); const readFile = createCachedReadFile(resolvePath); const exists = createExistsChecker(resolvePath); const listDir = createDirectoryLister(resolvePath); const globHelpers = createGlobHelpers(root, resolvePath); return { exists, /** Read a UTF-8 file, returning null when the file is missing or unreadable. */ readFile, /** Count lines from cached content so repeated audit checks do not reread the same file. */ lineCount(path) { return countCachedLines(readFile, path); }, /** Parse JSON defensively; missing or malformed files recover to null. */ readJson(path) { return readCachedJson(readFile, path); }, listDir, /** Check executability; swallows access errors and falls back to shebang detection on Windows. */ isExecutable(path) { return isExecutablePath(resolvePath, readFile, path); }, ...globHelpers, }; } /** Directory names to skip during recursive glob traversal */ const IGNORED_DIRS = new Set([ ".git", "node_modules", "dist", "build", "coverage", ".next", ".turbo", "vendor", ".venv", "__pycache__", ".idea", ".vscode", ]); /** Skip heavyweight or generated directories during recursive glob walking. */ function isIgnoredDir(name) { return IGNORED_DIRS.has(name); } //# sourceMappingURL=fs.js.map