UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

406 lines • 17.2 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 { inMtimeWindow } from "../../utils/dates.js"; import { fnmatch } from "../../utils/fnmatch.js"; import { respellOne } from "../../utils/path.js"; import { rstripSlash, stripSlash } from "../../utils/slash.js"; import { DIR_SIZE } from "../../utils/stat_view.js"; function freshEffects() { return { acted: false, pruned: false, deferred: [] }; } // Whether the expression holds for one entry, recording what it did. // Evaluation short-circuits the way GNU's does (`-a` stops at the first // false, `-o` at the first true), so an action or a prune is reached // exactly when GNU would reach it. The tests and connectives every entry // meets are told apart first. export function evaluate(node, entry, effects) { switch (node.op) { case 'name': return node.icase ? fnmatch(entry.name.toLowerCase(), node.pattern.toLowerCase()) : fnmatch(entry.name, node.pattern); case 'path': { const shown = displayPath(node.prefix ?? '', entry.key); return fnmatch(node.root ? respellOne(shown, node.root, node.raw ?? '') : shown, node.pattern); } case 'type': return entry.kind === node.kind; case 'not': return !evaluate(node.kid, entry, effects); case 'and': { const mark = effects.deferred.length; for (const kid of node.kids) { const before = effects.deferred.length; if (!evaluate(kid, entry, effects)) { // Had an earlier factor's undecided test failed, the chain would // have ended there with this same answer, so those tests decide // nothing; the failing factor's own may (`! -mtime 1` flips with // its mtime). effects.deferred.splice(mark, before - mark); return false; } } return true; } case 'or': { const mark = effects.deferred.length; for (const kid of node.kids) { const before = effects.deferred.length; if (evaluate(kid, entry, effects)) { effects.deferred.splice(mark, before - mark); return true; } } return false; } case 'true': return true; case 'action': effects.acted = true; return true; case 'prune': // Only a directory has contents to skip; a file key that is also a // directory prefix (an object store allows both) must not drop what // sits under the directory. if (entry.kind === 'd' && effects.deferred.length > 0) { node.pending.push({ entry }); } else if (entry.kind === 'd') { node.pruned.push(entry.key); } effects.pruned = effects.pruned || entry.kind === 'd'; return true; case 'mtime': // An entry the walk fetched no time for passes here and meets the // expression's flat window afterwards; the test is recorded so a // `-prune` reached past it is only pending. if (entry.mtime === null || entry.mtime === undefined) { effects.deferred.push(node); return true; } return inMtimeWindow(entry.mtime, node.lo, node.hi); case 'empty': return entry.isEmpty === true; } } // Display path for a mount-relative key, as `find` prints it: the mount // root maps to the bare prefix, everything else joins with one slash. export function displayPath(prefix, key) { if (!prefix) return key; const rel = stripSlash(key); return rel === '' ? prefix : `${prefix}/${rel}`; } // Copy of a predicate tree bound to one start point. `-path` matches the // row as printed, but backend find ops evaluate entries by mount-relative // key; stamping the prefix and the operand's spelling onto the tree keeps // the evaluation site prefix-free (#396). Every prune node comes back with // an empty ledger, so what one start point pruned never drops rows from the // next (`find a/skip/inner a -path a/skip -prune -o -print` lists the first // operand in full, as GNU does). export function bindTree(node, prefix, root = '', raw = '') { switch (node.op) { case 'path': return { op: 'path', pattern: node.pattern, prefix, root, raw }; case 'prune': return { op: 'prune', pruned: [], pending: [] }; case 'not': return { op: 'not', kid: bindTree(node.kid, prefix, root, raw) }; case 'and': return { op: 'and', kids: node.kids.map((kid) => bindTree(kid, prefix, root, raw)) }; case 'or': return { op: 'or', kids: node.kids.map((kid) => bindTree(kid, prefix, root, raw)) }; default: return node; } } export function treeHasAction(node) { if (node.op === 'action') return true; if (node.op === 'not') return treeHasAction(node.kid); if (node.op === 'and' || node.op === 'or') return node.kids.some(treeHasAction); return false; } export function treeHasPrune(node) { if (node.op === 'prune') return true; if (node.op === 'not') return treeHasPrune(node.kid); if (node.op === 'and' || node.op === 'or') return node.kids.some(treeHasPrune); return false; } // The tree with every `-prune` made inert. GNU: `-prune` does nothing when // `-depth` is in effect, since a directory's contents are visited before // the directory itself. export function withoutPrune(node) { switch (node.op) { case 'prune': return { op: 'true' }; case 'not': return { op: 'not', kid: withoutPrune(node.kid) }; case 'and': return { op: 'and', kids: node.kids.map(withoutPrune) }; case 'or': return { op: 'or', kids: node.kids.map(withoutPrune) }; default: return node; } } // Every directory key the tree's `-prune` nodes reached. A pending prune // counts until `settlePrunes` decides it. export function prunedKeys(node) { if (node.op === 'prune') return [...node.pruned, ...node.pending.map((p) => p.entry.key)]; if (node.op === 'not') return prunedKeys(node.kid); if (node.op === 'and' || node.op === 'or') return node.kids.flatMap(prunedKeys); return []; } /** Every `-prune` still waiting on a time test. */ export function pendingPrunes(node) { if (node.op === 'prune') return [...node.pending]; if (node.op === 'not') return pendingPrunes(node.kid); if (node.op === 'and' || node.op === 'or') return node.kids.flatMap(pendingPrunes); return []; } function pruneNodes(node) { if (node.op === 'prune') return [node]; if (node.op === 'not') return pruneNodes(node.kid); if (node.op === 'and' || node.op === 'or') return node.kids.flatMap(pruneNodes); return []; } // The tree with every time test false, sharing the prune ledgers. A // directory that reports no mtime passes no time test, the way the flat // window admits no such row, so the prunes it reaches are the ones reachable // with every test false. The prune nodes are the original objects, so what // this copy records lands on the tree. function timeTestsFailing(node) { switch (node.op) { case 'mtime': return { op: 'not', kid: { op: 'true' } }; case 'not': return { op: 'not', kid: timeTestsFailing(node.kid) }; case 'and': return { op: 'and', kids: node.kids.map(timeTestsFailing) }; case 'or': return { op: 'or', kids: node.kids.map(timeTestsFailing) }; default: return node; } } // Decide the pending prunes whose directory mtime is now known. Each decided // directory leaves the pending ledgers and is evaluated again with its // mtime, so the prunes it reaches are exactly GNU's, whichever arm the tests // send it down: `find d -newermt X -prune` skips only the contents of // directories newer than X, and `( -mtime 1 -o -type d ) -prune` skips every // directory's. A key `mtimes` does not name stays pending. export function settlePrunes(node, mtimes) { const decided = new Map(); for (const prune of pruneNodes(node)) { const still = []; for (const pend of prune.pending) { if (mtimes.has(pend.entry.key)) decided.set(pend.entry.key, pend.entry); else still.push(pend); } prune.pending = still; } for (const [key, entry] of decided) { const mtime = mtimes.get(key) ?? null; const tree = mtime === null ? timeTestsFailing(node) : node; evaluate(tree, { ...entry, mtime }, freshEffects()); } } // The rows minus everything under a directory `-prune` reached. The pruned // directory itself stays, the root spelled `/` included: GNU reports it when // the rest of the expression does, and only its contents go unvisited. Rows are spelled as the // ledger's keys are, or as display paths when `prefix` is given. export function dropPruned(rows, tree, prefix = '') { const stems = prunedKeys(tree).map((key) => rstripSlash(displayPath(prefix, key)) + '/'); if (stems.length === 0) return rows; return rows.filter((row) => !stems.some((stem) => row.startsWith(stem) && row !== stem)); } // Decide every pending prune by asking for its directory's mtime. The // backend judged its entries without their mtimes, so a prune reached past // `-newermt` or `-mtime` is only pending; the caller's overlay-aware stat // answers for the directory here, and a directory the test rejects keeps // its contents (`find d -newermt X -prune` skips only the directories newer // than X, as GNU does). `mtimeOf` takes a mount-relative key and answers // null when the directory reports no mtime or is gone. export async function settlePendingPrunes(node, mtimeOf) { const mtimes = new Map(); for (const pend of pendingPrunes(node)) { mtimes.set(pend.entry.key, await mtimeOf(pend.entry.key)); } settlePrunes(node, mtimes); } export function treeHasEmpty(node) { if (node.op === 'empty') return true; if (node.op === 'not') return treeHasEmpty(node.kid); if (node.op === 'and' || node.op === 'or') return node.kids.some(treeHasEmpty); return false; } // Whether `find` reports the entry. With no action in the tree the rows are // the entries the whole expression holds for, GNU's implicit `-print`. With // one, they are the entries an action reached: `-path ./skip -prune -o -type // f -print` holds for `./skip` but never prints it. `-mindepth` applies // neither tests nor actions above its level, so a shallow directory is not // pruned either. `acts` is whether the tree holds an action, for a walk that // works it out once for every entry. export function keep(entry, tree, minDepth, acts = treeHasAction(tree)) { if (minDepth !== null && minDepth !== undefined && entry.depth < minDepth) return false; const effects = freshEffects(); const matched = evaluate(tree, entry, effects); if (entry.kind === 'd' && effects.deferred.length > 0 && !effects.pruned) { // The answer passed every -prune on the strength of an undecided test, // and the directory's mtime may send it into one (`-mtime 1 -o // -prune`), so it waits with the prunes it might reach, on the first // of them. const [first] = pruneNodes(tree); if (first !== undefined) first.pending.push({ entry }); } return acts ? effects.acted : matched; } // Basename of a find start path, as GNU prints and matches it. Single source // of truth for the start path's own name across every backend find op; reads // `path.virtual` so the name is correct whether the start is the mount root // or a nested directory. Returns '' for the bare root '/'. export function startBasename(virtual) { return rstripSlash(virtual).split('/').pop() ?? ''; } // Append the search start path to results when it matches. Shared by every // backend find op so the start path is emitted uniformly: bare `find <dir>`, // `-type d` on the root, `-maxdepth 0`, `-mindepth 0`, and `-name` against the // start's own basename all behave the same everywhere. A directory start path // contributes `DIR_SIZE` to `-size` filtering, the size `ls -l` shows for it // and GNU compares on ext4. A file start with an unknown size skips the // filter. export function emitStartPath(results, startKey, startName, opts) { if (!opts.exists) return; if (opts.maxDepth !== null && opts.maxDepth !== undefined && opts.maxDepth < 0) return; const entry = { key: startKey, name: startName, kind: opts.kind, depth: 0, isEmpty: opts.isEmpty ?? null, }; if (!keep(entry, opts.tree, opts.minDepth)) return; if ((opts.minSize !== null && opts.minSize !== undefined) || (opts.maxSize !== null && opts.maxSize !== undefined)) { // Unknown rendered sizes count as zero, like walked entries. const effective = opts.kind === 'f' ? (opts.size ?? 0) : opts.kind === 'd' ? DIR_SIZE : 0; if (opts.minSize !== null && opts.minSize !== undefined && effective < opts.minSize) return; if (opts.maxSize !== null && opts.maxSize !== undefined && effective > opts.maxSize) return; } results.push(startKey); } export function buildTree(opts) { const kids = []; if (opts.orNames !== null && opts.orNames !== undefined && opts.orNames.length > 0) { kids.push({ op: 'or', kids: opts.orNames.map((pat) => ({ op: 'name', pattern: pat, icase: false })), }); } else if (opts.name !== null && opts.name !== undefined) { kids.push({ op: 'name', pattern: opts.name, icase: false }); } if (opts.iname !== null && opts.iname !== undefined) { kids.push({ op: 'name', pattern: opts.iname, icase: true }); } if (opts.pathPattern !== null && opts.pathPattern !== undefined) { kids.push({ op: 'path', pattern: opts.pathPattern }); } if (opts.type !== null && opts.type !== undefined) { kids.push({ op: 'type', kind: opts.type }); } if (opts.nameExclude !== null && opts.nameExclude !== undefined) { kids.push({ op: 'not', kid: { op: 'name', pattern: opts.nameExclude, icase: false } }); } if (opts.empty === true) { kids.push({ op: 'empty' }); } const [first, ...rest] = kids; if (first === undefined) return { op: 'true' }; if (rest.length === 0) return first; return { op: 'and', kids }; } // The predicate tree for a FindOptions bag: the pre-built expression tree // when present, otherwise the flag-form fields. Single source of truth for // the fallback every backend find op used to hand-roll. export function argsToTree(options) { return (options.tree ?? buildTree({ name: options.name, iname: options.iname, pathPattern: options.pathPattern, type: options.type, nameExclude: options.nameExclude, orNames: options.orNames, empty: options.empty, })); } // Map one respelled display row back to its virtual path: the inverse of // respellRaw, for the stat probe. export function unrespellRaw(row, virtual, raw) { if (raw === '' || raw === virtual) return row; if (row === raw) return virtual; const stem = raw.endsWith('/') ? raw : raw + '/'; if (row.startsWith(stem)) { const base = rstripSlash(virtual); return base + '/' + row.slice(stem.length); } return row; } /** The one-letter kind a -printf %y/%Y directive renders for a stat. */ export function printfKind(st) { if (st.type === FileType.DIRECTORY) return 'd'; if (st.type === FileType.SYMLINK) return 'l'; if (st.type === FileType.CHAR_DEVICE) return 'c'; if (st.type === FileType.FIFO) return 'p'; return 'f'; } //# sourceMappingURL=find_eval.js.map