UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

480 lines • 19.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 { compileSpec } from "../../../spec/compile.js"; import { CommandSpec } from "../../../spec/types.js"; import { HEAD } from "./constants.js"; import { IOResult } from "../../../../io/types.js"; import { BadConfigValueError, NotAWorkTreeError, UnrecognizedArgumentError, UsageError, } from "./errors.js"; import { HELP_SWITCH, gitOptionRefusal, gitUsage } from "../../refusal.js"; import { PathSpec } from "../../../../types.js"; const ROOT = '/'; export const STDOUT = 'stdout'; export const STDERR = 'stderr'; // The end-of-options marker, which the parser consumes. export const MARKER = '--'; const TRUE_WORDS = ['true', 'yes', 'on']; const FALSE_WORDS = ['false', 'no', 'off', '']; // A config integer as git reads one: C-locale space, a number read the way // C's strtoimax reads base 0, then at most one unit, and the product has to // fit an int. const INTEGER = /^[ \t\n\v\f\r]*([-+]?)(0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)([kKmMgG]?)$/; const UNIT_SHIFTS = { '': 0, k: 10, m: 20, g: 30 }; const INT_BITS = 31; const VALUE_ESCAPES = { '\n': '\\n', '\t': '\\t', '"': '\\"', '\\': '\\\\', }; const QUOTED_HEADER = /^\s*\[([A-Za-z0-9.-]+)\s+"((?:[^"\\\n]|\\.)*)"\s*\]/; const DOTTED_HEADER = /^\s*\[([A-Za-z0-9-]+)\.([^\]\s]*)\]/; const ENC = new TextEncoder(); /** * Where repository discovery begins for this invocation. * * `-C` changes directory before anything else happens, git's own reading of the * option. It needs no separate session-cwd fact: the option is declared with a * `'.'` default, and a PATH default lands as if typed, so an absent `-C` * resolves to the session cwd and a relative `-C build` is already absolute by * the time it arrives. * * Keep the PathSpec until the directory walk is checked. Repository-relative * pathspec matching uses its virtual spelling after that check. * * @param fl spec-validated view over the leaf's flag bag */ export function startPoint(fl) { return fl.asPath('C') ?? PathSpec.fromStrPath(ROOT); } /** * The revision operand a verb was given, or git's own default. * * @param texts positional text operands * @param fallback what an absent operand means */ export function revisionArg(texts, fallback = HEAD) { return texts[0] ?? fallback; } /** * The words a `--` on the line marked as operands, not options. * * `--` is exactly how a caller names a file whose name begins with a dash, and * git says so in every synopsis that ends `[--] [<pathspec>...]`: `git rm * -draft` is a refused switch and `git rm -- -draft` removes the file. The * parser consumes the marker, so the words themselves are what carries the fact * forward, read back off the verbatim argv the record already holds. * * A set is enough. A dash word before the marker was read as an option and * never reached the operands, so a word that is here and also spelled earlier on * the line is still the escaped one. * * @param argv the line's verbatim tokens after the head word, subcommand words * included */ export function escaped(argv) { const at = argv.indexOf(MARKER); return at === -1 ? new Set() : new Set(argv.slice(at + 1)); } /** * The operands before a `--` on the line and the ones after it. * * `git diff A B -- docs` reads what comes before the marker as revisions and * what follows as pathspecs, whatever either looks like. Every word after the * marker is an operand, so they are the tail of the operands, as many as the * verbatim argv holds past it. * * @param texts positional text operands, as typed * @param argv the line's verbatim tokens after the head word, subcommand words * included */ export function splitMarked(texts, argv) { const at = argv.indexOf(MARKER); if (at === -1) return [[...texts], []]; const cut = texts.length - (argv.length - at - 1); return [texts.slice(0, cut), texts.slice(cut)]; } /** * The first operand that is really an option this build lacks. * * A verb taking a revision accepts free text, so every flag mirage does not * declare reaches it as one. Resolving it as a revision is the wrong answer * twice over: it fails, and it fails saying the repository has no such commit, * when what happened is that mirage has no such flag. Found here, before any * object is read, so the refusal names the real problem. * * Unless the caller said otherwise. A word after `--` is an operand by the * caller's own instruction whatever it starts with, so it is never read as an * option here; see `escaped`, which is where the marker survives the parser. * * Which side of the marker an operand fell on says nothing about what it * *means* here. `diff`, `show`, `diff-tree` and the walks (`log`, `rev-list`, * `shortlog`) read what follows the marker as pathspecs (`splitMarked`). * * @param texts positional text operands, as typed * @param marked operands a `--` on the line escaped * @param known the verb's one-letter switches, which narrow a refused cluster to * its first unknown letter the way parse-options does; absent names the whole * word */ export function offending(texts, marked = new Set(), known) { for (const text of texts) { if (text.startsWith('-') && !marked.has(text)) return offendingSwitch(text, known); } return null; } /** * The first operand that is really an option, once `-h` is out. * * `-h` asks for the verb's usage block, which git prints on stdout for most * verbs and on stderr for a few (`diff`); every other word is the caller's to * refuse in the verb's own words. See `offending` for which words count. * * @param ctx the line's context, carrying its leaf * @param texts positional text operands, as typed * @param helpStream where `-h` puts the usage block * @throws UsageError the line asked for the usage block */ export function optionOperand(ctx, texts, helpStream = STDOUT) { const word = offending(texts, escaped(ctx.argv ?? [])); if (word === HELP_SWITCH) { const usage = verbUsage(ctx); if (helpStream === STDOUT) throw new UsageError(usage, ''); throw new UsageError('', usage); } return word; } /** * Where `-h` puts the usage block for a verb that takes it as help only alone. * * `rev-list`, `rev-parse` and `diff-tree` print it on stdout, exiting 0, when * `-h` is their one word, and beside anything else on stderr as a refusal, * exiting 129 (git 2.56). * * @param ctx the line's context, carrying its leaf * @param texts the verb's positional text operands */ export function loneHelp(ctx, texts) { const verb = ctx.spec?.name ?? ''; const alone = (ctx.argv ?? []).slice(-2).join(' ') === `${verb} ${HELP_SWITCH}`; return alone && texts.length === 1 && texts[0] === HELP_SWITCH ? STDOUT : STDERR; } /** * Refuse an operand that is really an option, as an unrecognized argument. * * For the verbs git words without a usage block (`log`, `show`, `reflog`), * whose refusal names the whole word (git 2.50.1). * * @param ctx the line's context, carrying its leaf * @param texts positional text operands, as typed * @param helpStream where `-h` puts the usage block */ export function checkOperands(ctx, texts, helpStream = STDOUT) { const word = optionOperand(ctx, texts, helpStream); if (word !== null) throw new UnrecognizedArgumentError(word); } /** * The verb's usage block, as parse-options prints it. * * @param ctx the line's context, carrying its leaf */ export function verbUsage(ctx) { const spec = ctx.spec ?? new CommandSpec({ name: '' }); return gitUsage(spec.name, spec); } /** * Refuse an operand that is really an option, as parse-options does. * * The verbs built on parse-options (`status`, `add`, `branch`, `commit` and * most others) name an unknown option or switch and follow it with the usage * block, refuse a boolean handed a value on one line, and print the usage block * on stdout for `-h`; see `gitOptionRefusal`. Measured on git 2.50.1. * * @param ctx the line's context, carrying its leaf * @param texts positional text operands, as typed */ export function checkSwitches(ctx, texts) { const word = offending(texts, escaped(ctx.argv ?? []), switches(ctx)); if (word !== null) { const spec = ctx.spec ?? new CommandSpec({ name: '' }); throw new UsageError(...gitOptionRefusal(word, spec.name, spec)); } } /** * The one-letter switches the leaf declares, without their dash. * * Read off the spec the line was parsed against, so the set is the verb's * own and never a copy of it; empty where no executor built the record. * * @param ctx the line's context, carrying its leaf */ export function switches(ctx) { const letters = new Set(); for (const option of ctx.spec === undefined ? [] : compileSpec(ctx.spec).options) { for (const name of option.names) if (name.length === 2) letters.add(name.slice(1)); } return letters; } /** * The part of a dash word parse-options would refuse. * * git reads a short cluster letter by letter, consumes the ones the verb * declares and stops at the first it does not, so `git mv -nx` says `x' and * `git mv -draft` says `d' (git 2.50.1); a verb that declares no switch at * all (`reset`) still names the first letter. A long option is refused as * typed, and so is a cluster for a verb that hands over no set: log, show * and diff word the whole argument. * * @param text the dash word as the user spelled it * @param known the verb's one-letter switches, absent for a verb that * refuses the word whole */ export function offendingSwitch(text, known) { if (known === undefined || text.startsWith('--')) return text; for (const letter of text.slice(1)) { if (!known.has(letter)) return `-${letter}`; } return text; } /** * The session's files, which every verb reads and writes through. Only a leaf * called directly, outside a workspace, has none; git words that the way it * words a bare repository. Mirrors Python's `require_vfs`. */ export function requireVfs(ctx) { if (ctx.vfs === undefined) throw new NotAWorkTreeError(); return ctx.vfs; } /** * Render a git error: `<prefix>: <message>`, on its own stream. * * git uses 128 for a fatal, which is neither the dispatcher's usage exit (2) nor * its generic handler-error exit (1), so leaves return the code rather than * throwing into the catch-all. A refused option carries its own prefix and code * instead, which is git's own split, and a refusal that is really a report * ("nothing to commit") carries no prefix and goes to stdout. * * * An error carrying a `report` puts that on stdout beside the stderr line, * because git writes some refusals to both streams at once: `<path>: needs * merge` is the diagnosis and "you need to resolve your current index first" is * the refusal. * * @param exc the error to render */ export function fatal(exc) { const body = exc.prefix === null ? `${exc.message}\n` : `${exc.prefix}: ${exc.message}\n`; const data = ENC.encode(body); if (exc.stream === 'stdout') return [data, new IOResult({ exitCode: exc.exitCode })]; const told = exc.report === '' ? null : ENC.encode(exc.report); return [told, new IOResult({ exitCode: exc.exitCode, stderr: data })]; } /** * A config boolean read the way git's config callbacks read one. * * Every occurrence is parsed and the last one wins, so a value git cannot read * fails even when a later line would have read fine. A value is * `true`/`yes`/`on` or `false`/`no`/`off` in any case, empty for false, or an * integer for whether it is nonzero (pinned against git 2.54). * * @param values every value the variable takes, in file order; a bare name * arrives as `true` * @param key the variable, section and name lowercased * @param fallback the answer when the variable is unset */ export function gitBool(values, key, fallback) { let answer = fallback; for (const value of values) { const parsed = maybeBool(value); if (parsed === null) throw new BadConfigValueError(value, key); answer = parsed; } return answer; } /** * A config boolean as git reads it: `true`/`yes`/`on` or `false`/`no`/`off` * in any case, empty for false, or an integer for whether it is nonzero; null * for anything else, which each caller answers in its own way. * * @param value the value as typed or as the config spells it */ export function maybeBool(value) { const word = value.toLowerCase(); if (TRUE_WORDS.includes(word)) return true; if (FALSE_WORDS.includes(word)) return false; const number = integer(value); return number === null ? null : number !== 0; } /** * One `[section "name"]` block the way git's config writer spells it. * * The subsection escapes `"` and `\`; a value escapes those plus newline and * tab, and is quoted when it starts or ends with a space or holds `;` or `#`. * A branch may be named `a"b` or `a#b`, and either one written raw reads back * as a different name (pinned against git 2.50.1). * * @param section the section, e.g. `branch` * @param name the subsection, e.g. the branch name * @param pairs variables and values, in order */ export function configSection(section, name, pairs) { const quoted = name.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); let text = `[${section} "${quoted}"]\n`; for (const [key, value] of pairs) { let body = value.replace(/[\n\t"\\]/g, (ch) => VALUE_ESCAPES[ch] ?? ch); if (value.startsWith(' ') || value.endsWith(' ') || /[;#]/.test(value)) body = `"${body}"`; text += `\t${key} = ${body}\n`; } return text; } /** * A config's text with every block for `section.name` taken out. * * `git branch -d` drops the deleted branch's settings this way, so a branch * made again under the same name starts with no upstream rather than with two. * A header names the block the way git matches it, * spelled exactly: `[branch "x"]` with its escapes, or the older `[branch.x]`. * `[Branch "x"]` and `[branch.X]` are left, as git leaves them, and a line * opening with `[` ends a block (pinned against git 2.50.1). * * With `keys` only those variables leave the blocks, and a block they leave * holding no variable leaves with them, header and all, as * `git branch --delete-merged` clears an upstream (git 2.56.0). A variable * sharing its header's line leaves the header behind. * * A value continued onto the next line by a trailing backslash is followed, so * a continuation that opens with `[` is still part of the block. git 2.50.1 * reads it as a header there and leaves the rest of the block behind, which it * then refuses as a bad config line; mirage keeps the file readable instead. * * @param text the config file's contents * @param section the section as git writes it, e.g. `branch` * @param name the subsection, e.g. the branch name * @param keys the variables to take out, lowercase; null takes the whole block */ export function withoutSection(text, section, name, keys = null) { const kept = []; let block = []; let dropping = false; let continued = false; let inside = false; let holding = false; let word = ''; for (const line of text.split(/(?<=\n)/)) { let value = line; let head = ''; if (!continued) { let rest = line; if (line.trimStart().startsWith('[')) { if (holding) kept.push(...block); block = []; holding = false; const quoted = QUOTED_HEADER.exec(line); const dotted = DOTTED_HEADER.exec(line); dropping = (quoted?.[1] === section && quoted[2]?.replace(/\\(.)/g, '$1') === name) || (dotted?.[1] === section && dotted[2] === name); const header = quoted ?? dotted; const end = header ? header[0].length : line.indexOf(']') + 1; const ending = line.endsWith('\r\n') ? '\r\n' : line.endsWith('\n') ? '\n' : ''; head = line.slice(0, end) + ending; rest = line.slice(end); } const equals = rest.indexOf('='); const comment = /^\s*[;#]/.test(rest); value = equals < 0 || comment ? '' : rest.slice(equals + 1); word = comment ? '' : (equals < 0 ? rest : rest.slice(0, equals)).trim().toLowerCase(); inside = false; } ; [continued, inside] = continues(value, inside); if (!dropping) kept.push(line); else if (keys !== null && !keys.has(word)) { block.push(line); holding = holding || word !== ''; } else if (keys !== null && head !== '') block.push(head); } if (holding) kept.push(...block); return kept.join(''); } /** * Whether a config value runs onto the next line, as git parses one. * * A backslash ending the line continues the value unless it is itself escaped * or sits in a comment; a comment starts at `;` or `#` outside double quotes. * Returns whether the value continues and whether the next line starts inside * quotes. * * @param value the rest of the line, from the value on * @param inside whether the line starts inside double quotes */ function continues(value, inside) { const body = value.replace(/\r?\n$/, ''); let quoted = inside; for (let at = 0; at < body.length; at++) { const ch = body[at]; if (ch === '\\') { if (at === body.length - 1) return [true, quoted]; at++; } else if (ch === '"') quoted = !quoted; else if (!quoted && (ch === ';' || ch === '#')) break; } return [false, false]; } /** * The integer a config value spells as git reads it, null for none. * * Hex after `0x` and octal after a leading zero, then an optional `k`, `m` or * `g`; a product outside an int is no integer. */ function integer(value) { const match = INTEGER.exec(value); if (match === null) return null; const [, sign = '', digits = '', unit = ''] = match; const hex = /^0[xX]/.test(digits); const magnitude = hex ? parseInt(digits.slice(2), 16) : parseInt(digits, digits.startsWith('0') ? 8 : 10); const number = sign === '-' ? -magnitude : magnitude; const factor = 2 ** (UNIT_SHIFTS[unit.toLowerCase()] ?? 0); const lowest = Math.floor(-(2 ** INT_BITS) / factor); const highest = Math.floor((2 ** INT_BITS - 1) / factor); if (number < lowest || number > highest) return null; return number * factor; } //# sourceMappingURL=util.js.map