UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

354 lines • 15.6 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 { specOf } from "../../spec/builtins.js"; import { FlagView } from "../../spec/flag_view.js"; import { IOResult } from "../../../io/types.js"; import { LINK_TARGET_KEY, FileType, LineFacts, PathSpec } from "../../../types.js"; import { rstripSlash, stripSlash } from "../../../utils/slash.js"; import { enoent, isMissError, isWalkError } from "../../../errors/fs.js"; import { fnmatch } from "../../../utils/fnmatch.js"; import { formatRecords } from "../utils/output.js"; import { compareCodePoints } from "../../../utils/sort.js"; import { linksOf, mountsOf } from "../utils/links.js"; import { vfsReaddirPath, vfsStatPath } from "../utils/paths.js"; import { WalkBudget } from "../utils/tree.js"; import { StreamRefusal } from "../../../rs/errors.js"; import { refused, scan } from "../../../rs/scan.js"; const UNOPENABLE_MARK = ' [error opening dir]'; // GNU tree's ASCII (C-locale) drawing set, matching `tree` in the battery's // docker oracle; the vertical/indent continuations are 4 columns wide. const BRANCH = '|-- '; const LAST = '`-- '; const VERTICAL = '| '; const INDENT = ' '; /** * tree's options, -L read by mirage-rs before any operand is resolved: the * flags, or tree's refusal of a level. Mirrors Python's `parse_flags`. */ export function parseFlags(bag) { const fl = new FlagView(bag, specOf('tree')); let level; try { level = scan('tree', fl.texts('L')).L; } catch (err) { if (!(err instanceof StreamRefusal)) throw err; return refused(err); } if (level !== null && typeof level !== 'number') { throw new Error(`tree: mirage-rs read -L as ${JSON.stringify(level)}`); } return { maxDepth: level, showHidden: fl.asBool('a'), ignorePattern: fl.asStr('args_I') ?? null, dirsOnly: fl.asBool('d'), matchPattern: fl.asStr('P') ?? null, oneFileSystem: fl.asBool('x'), }; } // The mount roots mounted directly on this directory. A mount point need // not exist in the parent backend at all, and when it does the parent // lists a directory whose contents belong to somebody else. Either way // the name has to come from the mount table, as `ls` injects it. // // Session-filtered, because a crossing entry is drawn from the mount // table alone: its row is synthesized as a directory without asking any // backend, so the dispatcher never gets the chance to refuse it and an // hidden mount's name would reach the drawing. `ls` filters the same // fact through `childMountNames`. `tree` names the boundary rather than // avoiding it, so it reads the visible list; `du` reads the other one // from the same view, because there a hidden mount still shadows the // parent's keys and its prefix has to stay. function childMounts(mounts, directory) { if (mounts === null) return []; const base = rstripSlash(directory) || '/'; return mounts.visibleDescendants(directory).filter((root) => { const parent = root.slice(0, root.lastIndexOf('/')) || '/'; return parent === base; }); } // GNU tree's ASCII (C-locale) drawing set, matching the docker oracle. A // link is drawn as `name -> target` and never walked; one leading to a // directory counts, and filters, as a directory (tree 2.2.1). async function walkTree(readdir, stat, path, prefix, lines, treeOpts, depth) { // `unopened` counts the directories in this subtree (itself included) // that could not be opened (a rule refused them below the operand): the // caller marks such a child inline the way GNU does and the run exits 2. let dirs = 0; let files = 0; let unopened = 0; // The mount table is read before the backend, not merged after it. A // directory that exists only because mounts sit under it (`/repos` when // `/repos/alpha` is mounted) has no backend to list it, so the readdir // throws and a merge below it never runs: `tree` reported the one path // whose children it could name for certain as unopenable. const nested = childMounts(treeOpts.mounts, path.virtual); const limit = treeOpts.budget?.listingLimit(path.virtual) ?? null; let entries; try { entries = await (limit === null ? readdir(path) : readdir(path, false, limit)); } catch (err) { if (!isWalkError(err)) throw err; // An absence only. A directory the backend refused (EACCES, ENOTSUP) is // there and holds data, so it stays a warning and an unopened row even // when mounts sit under it; swallowing that to draw the children would // report a readable tree that is not. if (nested.length === 0 || !isMissError(err)) { return { dirs, files, failed: true, unopened: 1 }; } entries = []; } if (nested.length > 0) entries = [...new Set([...entries, ...nested])]; entries.sort(compareCodePoints); const budget = treeOpts.budget; if (budget !== undefined) entries = entries.filter(() => budget.spend(path.virtual)); const filtered = []; for (const entry of entries) { const childPath = rstripSlash(entry); const name = childPath.slice(childPath.lastIndexOf('/') + 1); if (!treeOpts.showHidden && name.startsWith('.')) continue; if (treeOpts.ignorePattern !== null && fnmatch(name, treeOpts.ignorePattern)) continue; const sub = new PathSpec({ virtual: childPath, directory: childPath, resolved: false, vfsPath: stripSlash(childPath), }); const crossing = nested.includes(childPath) && treeOpts.crossReaddir !== null; let isDir; let link = null; if (crossing) { // The mount table already says this is a directory, and the // backend serving it may not stat its own root (an empty mount, or // a prefix store with no marker object). isDir = true; } else { let s; try { s = await stat(sub); } catch (err) { if (!isWalkError(err)) throw err; continue; } isDir = s.type === FileType.DIRECTORY; if (s.type === FileType.SYMLINK) { const target = s.extra[LINK_TARGET_KEY]; link = typeof target === 'string' ? target : ''; if (treeOpts.targetStat !== null) { // One whose target cannot be statted is drawn as a file, as a // dangling one is (tree 2.2.1). const leads = await treeOpts.targetStat(childPath).catch((err) => { if (!isWalkError(err)) throw err; console.debug(`tree: cannot stat through ${childPath}: ${String(err)}`); return null; }); isDir = leads !== null && leads.type === FileType.DIRECTORY; } } } if (treeOpts.dirsOnly && !isDir) continue; // -P spares only a real directory, a link to one included in its test // (tree 2.2.1). if (treeOpts.matchPattern !== null && (link !== null || !isDir) && !fnmatch(name, treeOpts.matchPattern)) continue; filtered.push({ spec: sub, name, isDir, crossing, link }); } for (let i = 0; i < filtered.length; i++) { const entry = filtered[i]; if (entry === undefined) continue; const last = i === filtered.length - 1; const connector = last ? LAST : BRANCH; const shown = entry.link === null ? entry.name : `${entry.name} -> ${entry.link}`; lines.push(`${prefix}${connector}${shown}`); if (entry.isDir) { dirs += 1; if (entry.link !== null) continue; if (treeOpts.maxDepth !== null && depth + 1 >= treeOpts.maxDepth) continue; if (budget !== undefined && !budget.opens(entry.spec.virtual)) continue; const nextPrefix = prefix + (last ? INDENT : VERTICAL); // Past a mount root the subtree belongs to another VFS, so the // rest of this branch reads through the dispatcher. Deeper mounts // under it need no second switch: the dispatcher already routes // every path to its owner. const subReaddir = entry.crossing ? (treeOpts.crossReaddir ?? readdir) : readdir; const subStat = entry.crossing ? (treeOpts.crossStat ?? stat) : stat; const own = lines.length - 1; const child = await walkTree(subReaddir, subStat, entry.spec, nextPrefix, lines, treeOpts, depth + 1); if (child.failed) { // GNU marks a directory it could not open inline, on the // directory's own line, and still counts it. lines[own] = `${lines[own] ?? ''}${UNOPENABLE_MARK}`; } dirs += child.dirs; files += child.files; unopened += child.unopened; } else { files += 1; } } return { dirs, files, failed: false, unopened }; } function summary(dirs, files, dirsOnly) { const dirWord = dirs === 1 ? 'directory' : 'directories'; if (dirsOnly) return `${String(dirs)} ${dirWord}`; return `${String(dirs)} ${dirWord}, ${String(files)} ${files === 1 ? 'file' : 'files'}`; } // List a mount point -x keeps the walk out of: empty, the way GNU tree draws // a directory on another filesystem. function notCrossed() { return Promise.resolve([]); } /** * Run tree over resolved operands. `unbounded` is the walk to take when no * `-L` bounds it (one the mount answers whole), else `readdir` and `stat`. * Mirrors Python's `tree_generic`; `parsed` holds the options, read before * the operands were. */ export async function treeGeneric(parsed, paths, ctx, readdir, stat, unbounded) { if (unbounded !== undefined && parsed.maxDepth === null) [readdir, stat] = unbounded; const targets = paths.length > 0 ? paths : [ new PathSpec({ virtual: ctx.cwd.virtual, directory: ctx.cwd.virtual, resolved: false, vfsPath: ctx.cwd.vfsPath, }), ]; const readdirPath = vfsReaddirPath(ctx.vfs); const statPath = vfsStatPath(ctx.vfs); const links = linksOf(ctx); const treeOpts = { showHidden: parsed.showHidden, maxDepth: parsed.maxDepth, ignorePattern: parsed.ignorePattern, dirsOnly: parsed.dirsOnly, matchPattern: parsed.matchPattern, mounts: mountsOf(ctx) ?? null, crossReaddir: readdirPath === undefined ? null : parsed.oneFileSystem ? notCrossed : (p) => readdirPath(p.virtual), crossStat: statPath === undefined ? null : async (p) => { const s = await statPath(p); // Stamped, not a bare Error: `isWalkError` keys on the code, // so an unstamped throw escapes the walk's catch and rejects // the whole run instead of skipping one vanished entry. The // python twin raises FileNotFoundError, which `WALK_ERRORS` // already covers. if (s === null) throw enoent(p); return s; }, targetStat: links === undefined ? null : (path) => links.targetStat(path), }; const lines = []; let totalDirs = 0; let totalFiles = 0; let anyError = false; const stopped = []; for (const p of targets) { const label = p.rawPath !== '' ? p.rawPath : p.virtual; const before = lines.length; lines.push(label); // What the operand is decides the whole result, so it is resolved // before the walk rather than inferred from how a backend answered // readdir on it: an object store lists a file key as an empty prefix, // lists a missing path as one too, and Graph 404s, which read as // three different trees. The probe asks both channels a backend can // answer on, so a directory that exists only as its children still // reports as one and null means nothing is there. // // GNU prints the same inline marker either way; what differs is the // count and the status. A non-directory exists, so it is counted and // the exit stays 0; a path that is not there is not counted and exits 2. if (statPath !== undefined) { const start = await statPath(p); if (start === null) { lines[before] = `${label}${UNOPENABLE_MARK}`; anyError = true; continue; } if (start.type !== FileType.DIRECTORY) { lines[before] = `${label}${UNOPENABLE_MARK}`; totalFiles += 1; continue; } } const budget = new WalkBudget(p, treeOpts.mounts); const counts = await walkTree(readdir, stat, p, '', lines, { ...treeOpts, budget }, 0); for (const line of budget.stopped) stopped.push(`tree: ${line}`); if (counts.failed && lines.length === before + 1) { // The root could not be opened (GNU marks it inline and exits 2). lines[before] = `${label}${UNOPENABLE_MARK}`; anyError = true; } else if (lines.length > before + 1) { // GNU counts the root as a directory once it has any listed entry. totalDirs += counts.dirs + 1; totalFiles += counts.files; } // A directory below the root it could not open is marked inline and // makes the run exit 2, as GNU does, with nothing on stderr. if (counts.unopened > 0) anyError = true; } lines.push('', summary(totalDirs, totalFiles, treeOpts.dirsOnly)); const out = formatRecords(lines); // A walk past walkLimit draws what it listed and says where it // stopped, exiting 2 as for a directory it could not open. return [ out, new IOResult({ exitCode: anyError || stopped.length > 0 ? 2 : 0, stderr: stopped.length > 0 ? formatRecords(stopped) : null, }), ]; } /** * A tree line reads below every path it names and walks into the mounts below * unless `-x`. Mirrors Python's `facts`. */ export function facts(fl, _operands) { return new LineFacts({ walks: !fl.asBool('x'), readsSubtree: true }); } //# sourceMappingURL=tree.js.map