@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
547 lines • 24.4 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 { callerSignal } from "../context/session_context.js";
import { dotglobActive, extglobActive, sessionVisibility } from "../context/session_context.js";
import { pathVisible } from "./hidden.js";
import { FileType, PathSpec } from "../types.js";
import { isFsError } from "../errors/fs.js";
import { GlobLimitError } from "../errors/types.js";
import { EXTGLOB_RE, fnmatch, patternParts, QUOTED_CHARS } from "./fnmatch.js";
import { Glob } from "../rs/glob.js";
import { rekey } from "./key_prefix.js";
import { rstripSlash } from "./slash.js";
import { compareCodePoints } from "./sort.js";
/**
* Opt in by assigning a debug sink, as Python's module logger is off until
* configured: a glob that lists nothing is routine, and stdout may carry data.
*/
export const logger = {};
export const GLOB_CHARS = ['*', '?', '['];
function isoDay(year, month, date) {
const mm = String(month).padStart(2, '0');
const dd = String(date).padStart(2, '0');
return `${String(year)}-${mm}-${dd}`;
}
function isValidDate(year, month, date) {
if (month < 1 || month > 12 || date < 1)
return false;
const d = new Date(Date.UTC(year, month - 1, date));
return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === date;
}
function parseFixedInt(s, expectedLength) {
if (s?.length !== expectedLength || !/^\d+$/.test(s))
return null;
return Number.parseInt(s, 10);
}
/**
* Whether a glob names a date range a windowed lister can move to.
*
* The kit's `patternKinds` table holds one of these per kind: a glob it
* answers true for reaches the lister and bypasses the index, and any other
* glob is filtered out of the ordinary cached listing.
*/
export function hasGlobSpan(pattern) {
return globSpan(pattern) !== null;
}
/**
* Whether a glob starts with literal text a query can narrow on.
*
* The `patternKinds` twin of `hasGlobSpan` for a backend whose window is a
* row cap rather than a date range: the literal prefix becomes a prefix match
* in the query, so the cap covers the region the line named instead of the
* head of the table.
*/
export function hasGlobPrefix(pattern) {
return globPrefix(pattern) !== '';
}
/**
* The literal text a glob starts with, before its first metacharacter.
*
* A quoted glob character travels under a private mark and stands for that
* character literally, so the marks are restored here: `'*'ab*` asks for
* names starting with a real star.
*/
export function globPrefix(pattern) {
if (!pattern)
return '';
let metaIndex = -1;
for (const ch of GLOB_CHARS) {
const idx = pattern.indexOf(ch);
if (idx !== -1 && (metaIndex === -1 || idx < metaIndex))
metaIndex = idx;
}
const opener = extglobActive() ? pattern.search(EXTGLOB_RE) : -1;
if (opener !== -1 && (metaIndex === -1 || opener < metaIndex))
metaIndex = opener;
if (metaIndex === -1)
return '';
return unmarkGlobs(pattern.slice(0, metaIndex));
}
/**
* The literal prefix a glob puts on the stem of a leaf name.
*
* A leaf is a stem plus one of the renderer's suffixes, so a literal that has
* run into a suffix says nothing about the stem and the part that ran in is
* dropped: `12*.md` narrows to `12`, and `doc-1.m*` narrows to `doc-1` rather
* than asking for stems that start `doc-1.m`. Only a tail that spells the head
* of a suffix is dropped, which is what keeps a stem that contains a dot:
* `acct.2026*` narrows to `acct.2026`, where cutting at the first dot would
* narrow to `acct` and let the rows nobody asked for eat the window.
*/
export function globStemPrefix(pattern, suffixes) {
const literal = globPrefix(pattern);
let reached = 0;
for (const suffix of suffixes) {
for (let size = 1; size <= suffix.length; size += 1) {
if (literal.endsWith(suffix.slice(0, size)))
reached = Math.max(reached, size);
}
}
return literal.slice(0, literal.length - reached);
}
/**
* The half-open range of dates a date-prefixed glob asks for.
*
* The literal prefix before the first metacharacter is read as a year, a
* month or a day, so `2026-*` spans a year and `2026-01-05*` one day. This is
* what lets a windowed listing honour a glob instead of filtering its own
* window: the backend moves the window to the span the line named. Dates are
* floating `YYYY-MM-DD`, since a caller bucketing in a named time zone has to
* build its own instants from them; UTC instants would shift the window by
* the offset.
*/
export function globSpan(pattern) {
return literalSpan(globPrefix(pattern));
}
/**
* The half-open range of dates a literal year, month or day names.
*
* The reading `globSpan` applies to a glob's literal prefix, for a caller
* that cuts the prefix first; separators trailing the literal are ignored.
*/
export function literalSpan(literal) {
if (literal === '')
return null;
const parts = literal.replace(/[_-]+$/, '').split('-');
if (parts.length === 1) {
const year = parseFixedInt(parts[0], 4);
if (year === null)
return null;
return [isoDay(year, 1, 1), isoDay(year + 1, 1, 1)];
}
if (parts.length === 2) {
const year = parseFixedInt(parts[0], 4);
const month = parseFixedInt(parts[1], 2);
if (year === null || month === null)
return null;
if (!isValidDate(year, month, 1))
return null;
if (month === 12)
return [isoDay(year, month, 1), isoDay(year + 1, 1, 1)];
return [isoDay(year, month, 1), isoDay(year, month + 1, 1)];
}
if (parts.length === 3) {
const year = parseFixedInt(parts[0], 4);
const month = parseFixedInt(parts[1], 2);
const date = parseFixedInt(parts[2], 2);
if (year === null || month === null || date === null)
return null;
if (!isValidDate(year, month, date))
return null;
const next = new Date(Date.UTC(year, month - 1, date) + 86400000);
return [
isoDay(year, month, date),
isoDay(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()),
];
}
return null;
}
// A quoted glob character keeps travelling as a character, under a
// private mark, because bash tracks quoting per character and not per
// word: `'*'?.txt` still globs, on the `?` alone, and matches only a
// name starting with a literal star. A mark is one character wide, so
// every length relation between a spec's virtual, directory,
// vfsPath and rawPath keeps holding, and no mark is a glob
// character, so `hasGlob` already answers "does this word still glob".
// The marks are Unicode noncharacters, permanently unassigned and never
// valid interchange text -- the same impossible input `brace.ts` assumes
// away when it delimits its inert atoms with NUL.
const GLOB_MARKS = Object.fromEntries(Object.entries(QUOTED_CHARS).map(([mark, ch]) => [ch, mark]));
const GLOB_CHAR_OF = Object.fromEntries(Object.entries(GLOB_MARKS).map(([ch, mark]) => [mark, ch]));
// One native pass, not a per-character rebuild: every expanded word is
// marked and unmarked, so a JS-level loop made the cost quadratic in a
// loop that grows one word (`while true; do export X=$X.; done`).
const GLOB_CHAR_RE = /[*?[@+!()|]/g;
const GLOB_MARK_RE = /[\uFDD0-\uFDD8]/g;
export function hasGlob(segment, access) {
return (GLOB_CHARS.some((ch) => segment.includes(ch)) ||
(extglobActive(access) && EXTGLOB_RE.test(segment)));
}
// Quote every glob character, the way enclosing quotes would.
export function markGlobs(text) {
return text.replace(GLOB_CHAR_RE, (ch) => GLOB_MARKS[ch] ?? ch);
}
// The literal spelling: every quoted glob character as itself.
export function unmarkGlobs(text) {
return text.replace(GLOB_MARK_RE, (ch) => GLOB_CHAR_OF[ch] ?? ch);
}
// Whether text still carries a glob character quoting made literal.
function hasGlobMarks(text) {
return Object.keys(GLOB_CHAR_OF).some((mark) => text.includes(mark));
}
/**
* Mark the glob characters a backslash quotes in raw word text.
*
* Read the way bash reads an unquoted word: `\*` is a quoted star and
* `\\*` is a literal backslash followed by a live star. The backslash is
* left in place for the quote-removal pass that follows, which drops it
* and leaves the mark behind.
*/
export function markEscapedGlobs(text) {
if (!text.includes('\\'))
return text;
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i] ?? '';
if (ch === '\\' && i + 1 < text.length) {
const next = text[i + 1] ?? '';
out += ch + (GLOB_MARKS[next] ?? next);
i += 2;
continue;
}
out += ch;
i += 1;
}
return out;
}
/**
* A marked segment as the pattern fnmatch has to see.
*
* fnmatch has no escape character, so a quoted glob character is handed
* over as its own one-character class, exactly what `escapeGlob` builds
* for text that is literal throughout. Extended operators retain their
* marks until matching: turning a quoted `@` into `[@]` inside `[a"@"]`
* would create a nested bracket expression; unquoting it could create a group.
*/
export function globPattern(segment) {
return segment.replace(GLOB_MARK_RE, (ch) => GLOB_CHARS.includes(GLOB_CHAR_OF[ch] ?? '') ? `[${GLOB_CHAR_OF[ch] ?? ch}]` : ch);
}
// Drop the marks from a spec, leaving the literal path it names.
function unmarkSpec(spec) {
return new PathSpec({
virtual: unmarkGlobs(spec.virtual),
directory: unmarkGlobs(spec.directory),
vfsPath: unmarkGlobs(spec.vfsPath),
rawPath: unmarkGlobs(spec.rawPath),
pattern: spec.pattern === null ? null : unmarkGlobs(spec.pattern),
resolved: spec.resolved,
});
}
/**
* The word after quote removal, once glob resolution is over.
*
* The marks come off here, and a word still carrying a pattern is frozen
* as its literal: that pattern outlived its marks (an unmatched glob,
* `set -f`, a backend that could not resolve it), and reading the
* unmarked text as a pattern again would let a quoted metacharacter
* match -- `rm '/data/*'?.txt` would be back to reaching every name the
* live `?` alone would. A word that carried no marks is returned
* untouched.
*/
export function literalWord(item) {
if (typeof item === 'string')
return unmarkGlobs(item);
if (!hasGlobMarks(item.virtual) && !hasGlobMarks(item.pattern ?? ''))
return item;
const spec = unmarkSpec(item);
if (spec.pattern === null)
return spec;
return new PathSpec({
virtual: spec.virtual,
directory: spec.directory,
vfsPath: spec.vfsPath,
rawPath: spec.rawPath,
pattern: null,
resolved: true,
});
}
/**
* Encode text so the glob matcher reads every character literally.
*
* fnmatch has no escape character, so each special is wrapped in its own
* one-character class: `*` becomes `[*]`. A `]` needs no treatment: outside
* a class it is already literal, and no class can open because every `[`
* gets wrapped. Extended operators retain the quote marks in `globPattern`.
*/
export function escapeGlob(text) {
return globPattern(markGlobs(text));
}
// Whether a pattern spec is a typed word (not a directory listing). A
// classify-shaped word puts the pattern inside `virtual` (`/data/s*/x.txt`
// with directory `/data/s*/`); a dir-shaped spec (`PathSpec.dir`) sets
// `virtual` to the directory itself.
export function isWordShaped(p) {
return rstripSlash(p.virtual) !== rstripSlash(p.directory);
}
/** Path components under the active shell's pattern grammar. */
export function globParts(pattern, access) {
return extglobActive(access) ? patternParts(pattern) : pattern.split('/');
}
// Spell a match the way bash expansion would. Bash rewrites only the glob
// segments of the typed word; everything before the first glob segment keeps
// its typed spelling, so `../s*/x.txt` expands to `../sub/x.txt`. The walked
// tail has the same segment count in the typed word and in the match's
// virtual path, so the spelling is the typed head plus the match's last
// `walked` segments.
export function spellMatch(raw, virtual, walked, access) {
const head = globParts(rstripSlash(raw), access).slice(0, -walked);
const tail = rstripSlash(virtual).split('/').slice(-walked);
return [...head.map(unmarkGlobs), ...tail].join('/');
}
// Whether a match is a directory, the way a trailing slash asks. One stat
// per match, served from the index the readdir just filled. A link's own
// row is followed through `targetStat`, which stats what the link reaches,
// so a link to a directory is kept and a link to a file or to nothing is
// dropped, bash's own rule for `*/`. Without a stat function every match is
// kept, since nothing can tell them apart.
async function isDirectory(stat, accessor, match, index, targetStat) {
if (stat === undefined)
return true;
let row;
try {
row = await stat(accessor, match, index);
}
catch (err) {
if (isFsError(err))
return false;
throw err;
}
if (row.type === FileType.SYMLINK && targetStat !== undefined) {
const target = await targetStat(rstripSlash(match.virtual));
return target?.type === FileType.DIRECTORY;
}
return row.type === FileType.DIRECTORY;
}
export async function resolveGlobWith(readdir, accessor, paths, index, limit = -1, stat, targetStat, access, follow, signal = callerSignal() ?? null) {
const result = [];
const vis = sessionVisibility(access);
for (const p of paths) {
if (p.resolved) {
result.push(p);
continue;
}
if (p.pattern !== null && p.pattern !== '') {
// A trailing slash asks for directories only, and every match keeps
// one (`*/` -> `sub/`), the same rule the shell tier applies in
// workspace/expand/globs.ts. The slash is not part of the spelling
// to rebuild, so it comes off the word here and goes back on each
// match; the literal answer to a zero-match glob is still the word
// as typed (#1065).
const dirsOnly = p.rawPath.endsWith('/') && p.rawPath !== p.virtual;
const word = dirsOnly
? new PathSpec({
virtual: p.virtual,
directory: p.directory,
vfsPath: p.vfsPath,
pattern: p.pattern,
resolved: p.resolved,
rawPath: rstripSlash(p.rawPath),
})
: p;
// The hidden filter sits here, in the one loop every backend's
// resolveGlob runs through, because per-backend glob modules bind
// raw readdirs that never pass the command guards. It runs
// before the empty-match test so an all-hidden match set reads as
// no matches and falls back to the literal word, exactly what bash
// prints when nothing matched.
let matched = (await expandPattern(readdir, accessor, word, index, access, signal)).filter((m) => pathVisible(vis, m.virtual));
if (dirsOnly) {
const kept = [];
for (const m of matched) {
if (await isDirectory(stat, accessor, m, index, targetStat)) {
kept.push(new PathSpec({
virtual: m.virtual,
directory: m.directory,
vfsPath: m.vfsPath,
pattern: m.pattern,
resolved: m.resolved,
rawPath: `${m.rawPath}/`,
}));
}
}
matched = kept;
}
// Dir-shaped specs keep the empty result, which is what a caller
// that has to merge these matches with another source asks for.
if (matched.length === 0 && isWordShaped(p)) {
// The literal is the word after quote removal, so the marks come
// off here.
result.push(unmarkSpec(new PathSpec({
virtual: p.virtual,
directory: p.directory,
vfsPath: p.vfsPath,
pattern: null,
resolved: true,
rawPath: p.rawPath,
})));
continue;
}
if (limit >= 0 && matched.length > limit) {
throw new GlobLimitError(unmarkGlobs(p.rawPath), limit);
}
// A typed word's matches are the words bash would have put on the
// line, so a link one names resolves as the name typed would; an
// internal expansion's matches are left as listed.
result.push(...(follow !== undefined && isWordShaped(p)
? await followMatches(follow, matched)
: matched));
}
else {
result.push(p);
}
}
return result;
}
/**
* Whether one directory entry answers a pathname-expansion segment.
* `fnmatch` plus bash's leading-dot rule: a name starting with `.` is
* matched only by a pattern that also starts with `.` (so `*`, `?h` and
* `[.]h` pass over `.h`), unless the session has `shopt -s dotglob`.
* Pathname expansion's rule alone; `find -name` and `case` match through
* `fnmatch` directly.
*/
export function globNameMatches(name, pattern, access) {
return fnmatch(name, pattern, extglobActive(access), !dotglobActive(access));
}
/**
* Expand a glob PathSpec segment-by-segment via readdir.
*
* Mirrors bash globbing: every path component containing a glob character is
* matched against the entries of its (already expanded) parent directory, so
* a mid-path pattern (a `Demo_*` directory segment followed by `page.md`)
* never reaches the backend as a literal `*` path segment. An intermediate
* match that cannot be listed (a file, or a vanished entry) is skipped,
* matching bash's directories-only descent for non-final components.
*/
export async function expandPattern(readdir, accessor, path, index, access, signal = callerSignal() ?? null) {
const prefix = path.virtual.slice(0, rstripSlash(path.virtual).length - path.vfsPath.length);
const segments = path.vfsPath === '' ? [] : globParts(path.vfsPath, access);
// Two spec shapes reach resolvers: a full pattern path (classify), where
// the pattern is already the last segment, and a directory-shaped spec
// (PathSpec.dir), where the pattern applies to the directory's entries.
if (path.pattern !== null && path.pattern !== '' && segments.at(-1) !== path.pattern) {
segments.push(path.pattern);
}
let first = segments.findIndex((seg) => hasGlob(seg, access));
if (first < 0)
first = segments.length - 1;
// The head above the first glob segment is a real directory, so a glob
// character quoted inside it is part of the name to list.
const base = unmarkGlobs(rstripSlash(prefix + segments.slice(0, first).join('/')) || '/');
let level = [base];
for (const seg of segments.slice(first)) {
const nextLevel = [];
const matcher = new Glob(globPattern(seg), extglobActive(access), !dotglobActive(access), 'shell', signal ?? undefined);
try {
for (const parent of level) {
// Directory-shaped, carrying the segment as the pattern: a backend
// whose listing for this level is a bounded window moves the window to
// what the glob asks for instead of filtering its own (gcal, gdocs and
// the dated-message channels). Every other readdir reads the directory
// off the same spec and ignores the field. A literal segment carries
// none, so it keeps its warm listing.
const spec = new PathSpec({
virtual: parent,
directory: parent,
vfsPath: rekey(path.virtual, path.vfsPath, parent),
pattern: hasGlob(seg, access) ? seg : null,
});
// bash skips a directory it cannot open, so a pattern under one
// matches nothing there and the word stays as typed.
let entries;
try {
entries = await readdir(accessor, spec, index);
}
catch (err) {
const code = err.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EACCES')
throw err;
logger.debug?.(`glob: ${parent} lists nothing: ${String(err)}`);
entries = [];
}
// A cold listing marks a folder with a trailing slash (box, gdrive,
// dropbox); the marker is not part of the name.
for (const e of entries) {
const entry = rstripSlash(e);
if (await matcher.matchesAsync(entry.split('/').pop() ?? ''))
nextLevel.push(entry);
}
}
}
finally {
matcher.close();
}
// bash sorts a pathname expansion.
level = [...new Set(nextLevel)].sort(compareCodePoints);
if (level.length === 0)
return [];
}
const matches = level.map((e) => PathSpec.fromStrPath(e, rekey(path.virtual, path.vfsPath, e)));
// A typed word (raw differs from virtual) spells its matches; the
// dir-shaped specs internal expansions build (PathSpec.dir) have no typed
// form and keep the resolved virtual.
if (path.rawPath === path.virtual)
return matches;
const walked = segments.length - first;
const raw = path.rawPath;
return matches.map((m) => new PathSpec({
virtual: m.virtual,
directory: m.directory,
vfsPath: m.vfsPath,
pattern: m.pattern,
resolved: m.resolved,
rawPath: spellMatch(raw, m.virtual, walked, access),
}));
}
/**
* A resolver of glob operands over `readdir`, path-first: `stat` is what lets
* a trailing slash keep directories only, `targetStat` what a link it matched
* points at, `follow` the line's operand resolution a typed word's matches
* take, and `resolved` is told what each resolution answers, in order: the
* operands a command's reads are warmed ahead of. Mirrors Python's
* `make_resolve_glob`.
*/
export function makeResolveGlob(readdir, limit = -1, stat, targetStat, access, follow, resolved) {
return async (paths) => {
const found = await resolveGlobWith((_accessor, path) => readdir(path), null, paths, undefined, limit, stat === undefined ? undefined : (_accessor, path) => stat(path), targetStat, access, follow);
resolved?.(found);
return found;
};
}
// The line's operand resolution over a word's matches. A followed name keeps
// its spelling but takes the target's key, which the walk leaves for the
// mount to stamp.
async function followMatches(follow, matched) {
const followed = await follow(matched);
return matched.flatMap((m, i) => {
const f = followed[i];
if (!(f instanceof PathSpec))
return [];
return f.virtual === m.virtual
? [f]
: [PathSpec.fromStrPath(f, rekey(m.virtual, m.vfsPath, f.virtual))];
});
}
//# sourceMappingURL=glob_walk.js.map