@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
360 lines • 13.8 kB
JavaScript
// ========= 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 { fnmatch } from "./fnmatch.js";
import { rstripSlash, stripSlash } from "./slash.js";
export function norm(path) {
return `/${stripSlash(path)}`;
}
// Whether a glob pattern matches `path` or one of its ancestors.
// Segment-wise fnmatch, so `*` does not cross `/` (shell glob semantics,
// not fnmatch's flat matching). An ancestor match covers entries living
// under a matched directory.
export function globPrefixMatch(path, pattern) {
const patSegs = stripSlash(pattern).split('/');
const pathSegs = stripSlash(path).split('/');
if (pathSegs.length < patSegs.length)
return false;
return patSegs.every((pat, i) => fnmatch(pathSegs[i] ?? '', pat));
}
// Resolve a relative path against cwd; absolute paths are only
// normalized. Mirrors the Python utils resolve_path.
export function resolvePath(path, cwd) {
if (path.startsWith('/'))
return posixNormpath(path);
return posixNormpath(`${rstripSlash(cwd)}/${path}`);
}
// Mirror of Python's posixpath.normpath: resolves . and .. segments and
// collapses redundant slashes without touching the filesystem.
export function posixNormpath(path) {
if (path === '')
return '.';
const isAbs = path.startsWith('/');
const parts = path.split('/').filter((p) => p !== '' && p !== '.');
const stack = [];
for (const part of parts) {
if (part === '..') {
if (stack.length > 0 && stack[stack.length - 1] !== '..') {
stack.pop();
}
else if (!isAbs) {
stack.push('..');
}
}
else {
stack.push(part);
}
}
const joined = stack.join('/');
if (isAbs)
return `/${joined}`;
return joined === '' ? '.' : joined;
}
const DOTS = new Set(['.', '..']);
/**
* The absolute spelling of a typed path whose dots a walk proves.
*
* The kernel resolves `.` and `..` against the directory they sit in, so
* every component in front of one has to be a directory, while the textual
* simplification a virtual path gets lets `nope/../f` reach `f` past a
* missing `nope`. This keeps the spelling a walk needs. A trailing slash is
* kept too, since `x/` resolves as `x/.` and so names a directory. Null when
* neither follows a named component: a leading climb (`../x`) only walks up
* from `base`, a directory already, so the common `cd ..` and `cat ../f`
* cost nothing. Mirrors Python's dotted_spelling.
*/
/**
* Whether a name's last component, before any trailing slash, is `.` or
* `..`: then a trailing slash says nothing on its own, and the walk through
* the dot answers first (`missing/./` is ENOENT, `f/./` ENOTDIR, where
* `missing/` and `f/` are EISDIR).
*/
export function endsAtDot(spelling) {
const last = rstripSlash(spelling).split('/').pop();
return last === '.' || last === '..';
}
export function dottedSpelling(word, base = '/') {
const parts = word.split('/').filter((part) => part !== '');
let lead = 0;
while (lead < parts.length && DOTS.has(parts[lead] ?? ''))
lead += 1;
const rest = parts.slice(lead);
const slashed = rest.length > 0 && word.endsWith('/') && !DOTS.has(rest[rest.length - 1] ?? '');
if (!slashed && !rest.some((part) => DOTS.has(part)))
return null;
const start = resolvePath(parts.slice(0, lead).join('/') || '.', word.startsWith('/') ? '/' : base);
return `${rstripSlash(start)}/${rest.join('/')}${slashed ? '/' : ''}`;
}
/**
* The directories a walk of `dotted` has to find, in walk order.
*
* Whatever stands in front of a `.` or `..` is where it resolves, so it has
* to be a directory; each is spelled as the walk has simplified it so far,
* and the root, always one, is left out. Mirrors Python's dot_prefixes.
*/
export function dotPrefixes(dotted, follow = null) {
let current = '/';
const found = [];
for (const part of dotted.split('/').filter((p) => p !== '')) {
if (DOTS.has(part)) {
if (follow !== null)
current = follow(current);
if (current !== '/' && !found.includes(current))
found.push(current);
if (part === '..')
current = parent(current);
continue;
}
current = `${rstripSlash(current)}/${part}`;
}
return found;
}
/**
* The names a walk of `dotted` looks up in front of a dot, in walk order,
* each as the walk reached it and before a link there is followed: the
* simplified path no longer names them, so a hide has to answer for them
* here. A caller that stops at one never follows it. Mirrors Python's
* dot_lookups.
*/
export function* dotLookups(dotted, follow = null) {
let current = '/';
for (const part of dotted.split('/').filter((p) => p !== '')) {
if (DOTS.has(part)) {
if (current !== '/')
yield current;
if (follow !== null)
current = follow(current);
if (part === '..')
current = parent(current);
continue;
}
current = `${rstripSlash(current)}/${part}`;
}
}
/**
* The names a walk of `dotted` enters, each with its spelling in `raw`.
*
* What `mkdir -p` creates on the way and names when it cannot: GNU makes each
* component as it reaches it, so `mkdir -p nope/../m` leaves `nope` behind as
* well as `m`, and a plain file in the way is quoted as the operand spells it
* (`'a.txt'`, not the absolute path). Only the typed components are entered:
* the directory a relative word starts from is there already. Mirrors
* Python's walk_nodes.
*/
export function walkNodes(dotted, raw, follow = null) {
const typed = raw.split('/').filter((part) => part !== '');
let lead = 0;
while (lead < typed.length && DOTS.has(typed[lead] ?? ''))
lead += 1;
const parts = dotted.split('/').filter((part) => part !== '');
const start = parts.slice(0, parts.length - (typed.length - lead));
let current = `/${start.join('/')}`;
const head = raw.startsWith('/') ? '/' : '';
const entered = [];
for (let index = lead; index < typed.length - 1; index++) {
const part = typed[index] ?? '';
if (DOTS.has(part)) {
if (follow !== null) {
try {
current = follow(current);
}
catch (err) {
if (!(err instanceof CycleError))
throw err;
return entered;
}
}
if (part === '..')
current = parent(current);
continue;
}
current = `${rstripSlash(current)}/${part}`;
entered.push([current, head + typed.slice(0, index + 1).join('/')]);
}
return entered;
}
export function expandTilde(word, home) {
if (home === null)
return word;
if (word === '~')
return home;
if (word.startsWith('~/'))
return rstripSlash(home) + word.slice(1);
return word;
}
// Rewrite the base of walked output paths (find/grep -r results) to the
// as-typed form (`PathSpec.rawPath`); `raw` equal to `virtual` leaves the
// paths unchanged (the absolute-argument case).
export function respellRaw(paths, virtual, raw) {
if (raw === virtual)
return paths;
return paths.map((p) => respellOne(p, virtual, raw));
}
// The prefix of `path` with `count` trailing segments removed. The ancestor
// counterpart of respellOne: it names a path above another one while keeping the
// original spelling, so a relative argument stays relative. `count` is clamped
// so the result never loses every segment, which would leave an empty string
// where a path belongs. Mirrors Python's drop_trailing_segments.
export function dropTrailingSegments(path, count) {
if (count <= 0)
return path;
if (count >= path.split('/').filter((part) => part !== '').length)
return path;
let head = rstripSlash(path);
for (let i = 0; i < count; i++)
head = rstripSlash(head.slice(0, head.lastIndexOf('/')));
return head === '' ? '/' : head;
}
export function respellOne(path, virtual, raw) {
if (raw === virtual)
return path;
const base = rstripSlash(virtual);
if (path === base || (base === '' && path === '/'))
return raw === '' ? '.' : raw;
if (path.startsWith(base + '/')) {
// The empty raw is the synthetic no-operand spelling (GNU grep -r
// with no path): results render as bare names relative to the base.
if (raw === '')
return path.slice(base.length + 1);
return rstripSlash(raw) + path.slice(base.length);
}
return path;
}
export function parent(path) {
const i = path.lastIndexOf('/');
if (i <= 0)
return '/';
return path.slice(0, i);
}
// The proper ancestors of a normalized key, outermost first: "/a/b/c" ->
// ["/a", "/a/b"]; "/a" and "/" -> []. "/" is left out because every store
// treats the mount root as an existing directory, so it is never a component
// worth probing. Used by the store-backed backends (ram, redis) to walk a
// destination's parent chain the way rename(2) resolves it.
export function ancestors(path) {
const parts = stripSlash(path).split('/');
const out = [];
for (let i = 1; i < parts.length; i++)
out.push(`/${parts.slice(0, i).join('/')}`);
return out;
}
export const MAX_SYMLINK_HOPS = 40;
// Raised when symlink resolution exceeds the maximum hop count. Mirrors POSIX
// ELOOP (a loop such as `a -> b -> a` or an unbounded expansion such as
// `a -> a/x`). Command boundaries render this as the GNU strerror text
// "Too many levels of symbolic links".
export class CycleError extends Error {
path;
// The condition's name in the shared vocabulary (errors/classify.ts
// keys on codes), so a loop stops degrading to EIO at the kernel and
// guest boundaries, and the path a command line names for it.
code = 'ELOOP';
virtualPath;
constructor(path) {
super(`too many levels of symbolic links: ${path}`);
this.name = 'CycleError';
this.path = path;
this.virtualPath = path;
}
}
export function resolveSymlinks(path, links) {
return _resolveSymlinks(path, links, null);
}
export function traceSymlinks(path, links) {
const trace = [];
try {
_resolveSymlinks(path, links, trace);
}
catch (error) {
if (!(error instanceof CycleError) || trace.at(-1)?.kind !== 'hop_limit')
throw error;
}
return trace;
}
function _resolveSymlinks(path, links, trace) {
const pending = path.split('/').reverse();
const resolved = [];
let hops = 0;
while (pending.length > 0) {
const part = pending.pop() ?? '';
if (part === '' || part === '.')
continue;
if (part === '..') {
resolved.pop();
continue;
}
const candidate = '/' + [...resolved, part].join('/');
const target = links.get(candidate);
if (target === undefined) {
if (trace !== null && hops > 0) {
let next;
for (let index = pending.length - 1; index >= 0; index -= 1) {
const part = pending[index];
if (part !== '' && part !== '.') {
next = part;
break;
}
}
if (next === '..')
trace.push({ kind: 'enter', path: candidate, nextIsParent: true });
}
resolved.push(part);
continue;
}
trace?.push({ kind: 'follow', path: candidate });
if (++hops > MAX_SYMLINK_HOPS) {
trace?.push({ kind: 'hop_limit' });
throw new CycleError(path);
}
if (target.startsWith('/'))
resolved.length = 0;
for (const part of target.split('/').reverse())
pending.push(part);
}
const landing = '/' + resolved.join('/');
if (hops > 0)
trace?.push({ kind: 'landing', path: landing });
const suffix = resolved.length > 0 && path.endsWith('/') ? '/' : '';
return landing + suffix;
}
export function gnuBasename(path, suffix) {
let i = path.length;
while (i > 0 && path[i - 1] === '/')
i--;
if (i === 0)
return path.length > 0 ? '/' : '';
const j = path.lastIndexOf('/', i - 1);
let base = path.slice(j + 1, i);
if (suffix !== undefined && suffix !== '' && base !== suffix && base.endsWith(suffix)) {
base = base.slice(0, base.length - suffix.length);
}
return base;
}
export function gnuDirname(path) {
if (path === '')
return '.';
let i = path.length;
while (i > 0 && path[i - 1] === '/')
i--;
if (i === 0)
return '/';
let j = path.lastIndexOf('/', i - 1);
if (j === -1)
return '.';
while (j > 0 && path[j - 1] === '/')
j--;
if (j === 0)
return '/';
return path.slice(0, j);
}
//# sourceMappingURL=path.js.map