UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

295 lines • 12.9 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { FileType } from "../../types.js"; import { fsStrerror, isWalkError } from "../../errors/fs.js"; import { classify } from "../../errors/classify.js"; import { posixErrno } from "../../errors/posix.js"; import { gnuBasename, respellOne } from "../../utils/path.js"; import { BINARY_EXTENSIONS, getExtension } from "../../utils/filetype.js"; import { IgnoreRules, Verdict, walkCandidate } from "./rg_glob.js"; import { rstripSlash } from "../../utils/slash.js"; /** * What ripgrep's walker keeps below a directory operand. The ignore crate's * order: a `-g` glob decides first and outranks everything after it, then * `-t`/`-T`, and a dot entry is left out unless a glob or a `-t` type kept * it or `--hidden` is on. A file is also left out past `--max-filesize`, * and for a binary extension unless `-a`/`--binary` asked for it. A name on * the line is never filtered: only walked entries are. `maxDepth` is the * deepest entry kept (1 is the operand's own children). */ export class WalkFilter { overrides; types; hidden; maxDepth; maxFilesize; binary; ignores; constructor(overrides, types, hidden, maxDepth, maxFilesize, binary, // --ignore-file's rules, applied after the -g globs and before -t types. ignores = new IgnoreRules()) { this.overrides = overrides; this.types = types; this.hidden = hidden; this.maxDepth = maxDepth; this.maxFilesize = maxFilesize; this.binary = binary; this.ignores = ignores; } // Whether a walked entry is kept (a directory: descended). `candidate` // is its path as the globs match it, `name` its file name. admits(candidate, name, isDir) { const verdict = this.overrides.verdict(candidate, isDir); if (verdict === Verdict.IGNORE) return false; if (verdict === Verdict.WHITELIST) return true; const ruled = this.ignores.verdict(candidate, isDir); if (ruled === Verdict.IGNORE) return false; const typed = this.types.verdict(name, isDir); if (typed === Verdict.IGNORE) return false; return (typed === Verdict.WHITELIST || ruled === Verdict.WHITELIST || this.hidden || !name.startsWith('.')); } // Whether a walked file is searched, its stat read when the walk has one. admitsFile(candidate, name, stat) { if (!this.admits(candidate, name, false)) return false; const size = stat?.size ?? null; if (this.maxFilesize !== null && size !== null && size > this.maxFilesize) return false; return this.binary || !BINARY_EXTENSIONS.has(getExtension(name) ?? ''); } } /** * An OS error the way ripgrep's Rust `io::Error` displays it: the strerror, * then the Linux errno it came from (`No such file or directory (os error * 2)`). A failure the vocabulary cannot number keeps its words alone. * Mirrors Python's os_error_text. */ export function osErrorText(err) { const text = fsStrerror(err) ?? (err instanceof Error ? err.message : String(err)); const condition = classify(err); return condition === null ? text : `${text} (os error ${String(posixErrno(condition))})`; } // ripgrep's line for a path its walker could not stat or list, worded by the // walker it ran. The sequential one names the path a second time; the // parallel one, which it runs for more than one path or a directory unless // -j1 or a sort holds it to one thread, names it once (ripgrep 14.1.1). // Mirrors Python's walk_error_line. export function walkErrorLine(shown, err, parallel = false) { if (parallel) return `rg: ${shown}: ${osErrorText(err)}`; return `rg: ${shown}: IO error for operation on ${shown}: ${osErrorText(err)}`; } // ripgrep's line for a link -L found leading back into the walk: the link, // then the directory above it that it names, nearest first, each as the // walker spells it (ripgrep 14.1.1). Mirrors Python's loop_error_line. export function loopErrorLine(shown, ancestor) { return `rg: File system loop found: ${shown} points to an ancestor ${ancestor}`; } // ripgrep's line for a file its searcher could not open or read: the bare // I/O error, without the walker's preamble (ripgrep 14.1.1). Mirrors // Python's open_error_line. export function openErrorLine(shown, err) { return `rg: ${shown}: ${osErrorText(err)}`; } // An entry's path without the folder mark some backends append. function entryName(entry) { return rstripSlash(entry); } function byName(a, b) { const x = entryName(a); const y = entryName(b); return x < y ? -1 : x > y ? 1 : 0; } /** * --one-file-system's test: whether a directory lies on another mount than * the operand's. A mount is mirage's filesystem boundary, which a directory * crosses by being a mount root and a link by leading onto another mount. * `path` has every link resolved. Mirrors Python's on_other_mount. */ export function onOtherMount(rootOf, home, path) { return rootOf(path) !== home; } /** * The files a walk of one directory operand searches, in walk order. `root` * is the operand's virtual path and `shownRoot` the operand as typed, which * every printed path below it starts with (empty for the implicit cwd, whose * matches print bare while the walker names `./x`); `cwd` is the root the * globs are matched from. `sortByName` is --sort path, each directory's * entries in name order rather than the backend's. `boundary` is * --one-file-system's test for a directory on another mount than the * operand's, which the walk does not enter, or null to enter everything. * `resolver` is the namespace's links and the dispatcher past them, null outside a * workspace, where no link can stand. * * A link the walk meets is skipped, as ripgrep skips one, unless `follow` * (-L) says to walk through it: then it stands for what it leads to, a * directory descended and a file searched under the link's own name, and * one that dangles, loops or leads back to a directory above it is reported * in ripgrep's words and skipped. Mirrors Python's walk_haystacks. */ export async function* walkHaystacks(readdirFn, statFn, root, shownRoot, cwd, walk, sortByName, warnings, boundary = null, resolver = null, follow = false, parallel = false) { const walker = new Walker(readdirFn, statFn, cwd, walk, sortByName, warnings, boundary, resolver, follow, shownRoot === '', parallel); yield* walker.below(root, root, shownRoot, 0, [[root, walker.named(shownRoot)]], false); } // What one operand's walk reads with and keeps, for every level. Mirrors // Python's _Walker. class Walker { readdirFn; statFn; cwd; walk; sortByName; warnings; boundary; resolver; follow; implicit; parallel; constructor(readdirFn, statFn, cwd, walk, sortByName, warnings, boundary, resolver, follow, implicit, parallel) { this.readdirFn = readdirFn; this.statFn = statFn; this.cwd = cwd; this.walk = walk; this.sortByName = sortByName; this.warnings = warnings; this.boundary = boundary; this.resolver = resolver; this.follow = follow; this.implicit = implicit; this.parallel = parallel; } // A path as the walker names it in a warning. Matches print the implicit // cwd's paths bare, but the walker's own errors name the path it walked, // which starts `./` (ripgrep 14.1.1). named(shown) { if (!this.implicit) return shown; return shown !== '' ? `./${shown}` : './'; } warn(line) { this.warnings?.push(line); } // Whether a link stands at a walked entry. isLink(virtual) { return this.resolver !== null && this.resolver.links.statAt(virtual) !== null; } /** * The files under one directory the walk lists. `here` has every link * resolved; `base` is the path its entries are spelled from (the operand, * or the link-resolved directory the walk last reached through a link) * and `shownBase` that path as printed. `chain` is `here` and every * directory above it to the operand, nearest first: what a link leading * back into the walk is caught against. `linked` says the walk reached * `here` through a link, so it reads through the dispatcher rather than the * operand's backend. */ async *below(here, base, shownBase, depth, chain, linked) { if (this.walk.maxDepth !== null && depth >= this.walk.maxDepth) return; const resolver = linked ? this.resolver : null; let entries; try { entries = resolver !== null ? await resolver.readdir(here) : await this.readdirFn(here); } catch (err) { if (!isWalkError(err)) throw err; this.warn(walkErrorLine(chain[0]?.[1] ?? here, err, this.parallel)); return; } if (this.sortByName) entries = [...entries].sort(byName); for (const entry of entries) { // box/dropbox readdir marks folders with a trailing slash. const child = entryName(entry) || entry; const shown = respellOne(child, base, shownBase); if (this.isLink(child)) { if (this.follow) yield* this.through(child, shown, depth, chain); continue; } let s; try { s = resolver !== null ? await resolver.stat(entry) : await this.statFn(entry); } catch (err) { if (!isWalkError(err)) throw err; this.warn(walkErrorLine(this.named(shown), err, this.parallel)); continue; } const name = gnuBasename(child); const candidate = walkCandidate(shown, this.cwd); if (s.type === FileType.DIRECTORY) { if (this.boundary?.(child) === true) continue; if (this.walk.admits(candidate, name, true)) { yield* this.below(child, base, shownBase, depth + 1, [[child, this.named(shown)], ...chain], linked); } } else if (s.type === FileType.FILE && this.walk.admitsFile(candidate, name, s)) { yield { virtual: child, shown, stat: s, spec: null, resolver }; } } } /** * What -L walks in place of one link. The ignore crate's order: the link * is followed first, so one that dangles or loops is reported whatever the * filters would have said of its name, then a directory it leads to is * checked against the chain above it, and only then do the filters decide. */ async *through(link, shown, depth, chain) { const resolver = this.resolver; if (resolver === null) return; const named = this.named(shown); let target; let s; try { target = resolver.target(link); s = await resolver.stat(target); } catch (err) { if (!isWalkError(err)) throw err; this.warn(walkErrorLine(named, err, this.parallel)); return; } const name = gnuBasename(link); const candidate = walkCandidate(shown, this.cwd); if (s.type === FileType.DIRECTORY) { const above = chain.find(([real]) => real === target); if (above !== undefined) { this.warn(loopErrorLine(named, above[1])); return; } if (this.boundary?.(target) === true) return; if (this.walk.admits(candidate, name, true)) { yield* this.below(target, target, shown, depth + 1, [[target, named], ...chain], true); } } else if (s.type === FileType.FILE && this.walk.admitsFile(candidate, name, s)) { yield { virtual: target, shown, stat: s, spec: null, resolver }; } } } //# sourceMappingURL=rg_scan.js.map