@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
JavaScript
/**
* 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