UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

242 lines • 10.5 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, argumentDest } from "./compile.js"; import { flagKwargName, OPERAND, REFUSED, SPELLED } from "./constants.js"; import { compareCodePoints } from "../../utils/sort.js"; import { PathSpec } from "../../types.js"; // The tape records what the parser scanned, so its values are the parser's // own; a PATH value recovered as a PathSpec replaces the bag entry only. It // holds each option occurrence as [dest, value], each operand as [OPERAND, // word], for a program that runs its own option loop each refused option as // [REFUSED, word], and each SPELLED_WORDS word as [SPELLED, word], so it also // says which options were typed before an operand or a refusal. const occurrenceTapes = new WeakMap(); // The tape names no option declares: they mark operands, refusals and spelled // words. const TAPE_ONLY = new Set([OPERAND, REFUSED, SPELLED]); export function flagOccurrences(flags) { let tape = occurrenceTapes.get(flags); if (tape === undefined) { tape = []; occurrenceTapes.set(flags, tape); } return tape; } /** * The flags with each operand's tape entry spread over its words. * * The parse runs before a glob expands, so the tape holds an operand as it * was typed. A program that reads its operands in order (join) needs each * match where the glob stood, as its argv would hold them. A tape that does * not place exactly these operands is kept as it is. Mirrors Python's * `spread_operands`. */ export function spreadOperands(flags, groups) { const out = { ...flags }; const tape = flagOccurrences(flags); const placed = tape.filter(([name]) => name === OPERAND).length; const words = groups[Symbol.iterator](); flagOccurrences(out).push(...(placed !== groups.length ? tape : tape.flatMap(([name, value]) => name === OPERAND ? (words.next().value ?? []).map((word) => [OPERAND, word]) : [[name, value]]))); return out; } /** * Collect the kwarg names a spec's options can produce. * * One name per option: the long spelling when an option declares both, * matching the parser's canonical dest. Keeping the short spelling here * too would let a stale `fl.asBool('a')` stay legal and read false * forever after dest unification; canonical-only turns that silent miss * into a throw. Mirrors Python's `spec_flag_names`. */ export function specFlagNames(spec) { return new Set(compileSpec(spec).options.map((option) => flagKwargName(argumentDest(option)))); } /** One occurrence as typed text: a switch empty, an integer as digits. */ function typedText(value) { if (typeof value === 'boolean') return ''; if (typeof value === 'number') return Number.isInteger(value) ? BigInt(value).toString() : ''; return typeof value === 'string' ? value : ''; } /** * Typed read-only view over raw flag kwargs. * * Commands receive flags as an untyped record from the dispatcher; this * view is the one sanctioned way to read them, replacing ad-hoc * `flags.x === true` checks and typeof chains. Mirrors Python's * `FlagView`. * * When constructed with a spec, reading a name the spec does not declare * throws. A missing key is otherwise indistinguishable from "flag not * passed", so a typo in the name would silently read as false/undefined. */ export class FlagView { flags; allowed; constructor(flags, spec) { this.flags = flags ?? {}; this.allowed = spec === undefined ? null : specFlagNames(spec); } key(name) { if (this.allowed !== null && !this.allowed.has(name)) { throw new Error(`flag '${name}' is not declared by the command spec ` + `(known: ${[...this.allowed].sort(compareCodePoints).join(', ')})`); } return name; } /** * The given flag names, ordered by their recorded occurrences. * * The parser fills the bag in scan order and every hop between * (object spreads, copies) preserves string-key insertion order, so * a key's position is its last occurrence for scalars, first for accumulating options; a flag * supplied by a default or the environment lands after every typed * one. Names the line never carried are dropped. This is what an * order-sensitive option family (grep's --include/--exclude, where * the later kind overrides the earlier) reads, since the bag has no * per-occurrence positions. */ typedOrder(...names) { const wanted = new Set(names.map((n) => this.key(n))); return Object.keys(this.flags).filter((k) => wanted.has(k)); } /** * Read each typed occurrence, then defaults without a typed value. * * OPERAND among the names reads the operands too, each as [OPERAND, word] * where it was typed among the options, and REFUSED and SPELLED read the * refused options and the spelled words the same way. Flags with no tape (a * plain record) have none of them. */ occurrences(...names) { const wanted = new Set(names.map((name) => (TAPE_ONLY.has(name) ? name : this.key(name)))); const result = flagOccurrences(this.flags).filter(([name]) => wanted.has(name) && (TAPE_ONLY.has(name) || name in this.flags)); const seen = new Set(result.map(([name]) => name)); for (const name of this.typedOrder(...names.filter((name) => !TAPE_ONLY.has(name)))) { if (seen.has(name)) continue; const value = this.flags[name]; if (value === undefined) continue; for (const item of Array.isArray(value) ? value : [value]) { result.push([name, item instanceof PathSpec ? item.virtual : item]); } } return result; } /** * Each typed occurrence as the text a line would have typed: a string as * given, an integer as its digits (`asText`) and a switch that is on as * the empty text, in `occurrences` order: what a program that reads its * own options sees. A switch that is off was never typed, so it is left * out. Mirrors Python's `texts`. */ texts(...names) { return this.occurrences(...names) .filter(([, value]) => value !== false) .map(([name, value]) => [name, typedText(value)]); } asBool(name) { const value = this.flags[this.key(name)]; if (typeof value === 'boolean') return value; // A count flag holds a number; any occurrence reads as set. return typeof value === 'number' && value > 0; } asInt(name, fallback) { const value = this.flags[this.key(name)]; if (typeof value === 'number') return value; if (typeof value !== 'string') return fallback; // Python's int() is all-or-nothing: it accepts surrounding whitespace // and underscore separators and raises on anything else. parseInt would // instead take the numeric prefix of '5x' and hand back NaN for 'abc', // and NaN still satisfies `number`, so a bad value would flow onward as // a number rather than being rejected. const text = value.trim(); if (!/^[+-]?\d+(?:_\d+)*$/.test(text)) { throw new Error(`flag '${name}' expects an integer, got '${value}'`); } return Number.parseInt(text.replaceAll('_', ''), 10); } asFloat(name, fallback) { const value = this.flags[this.key(name)]; if (typeof value === 'number') return value; if (typeof value !== 'string') return fallback; // All-or-nothing like Python's float(), mirroring asInt: parseFloat // would take the numeric prefix of '2.5x' and hand back NaN for // 'abc', and NaN still satisfies `number`. const text = value.trim(); if (!/^[+-]?(?:\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\.\d+(?:_\d+)*)(?:[eE][+-]?\d+(?:_\d+)*)?$/.test(text)) { throw new Error(`flag '${name}' expects a number, got '${value}'`); } return Number.parseFloat(text.replaceAll('_', '')); } /** * The option as the text a line would have typed for it: a string as given, * an integer (a direct call's `{ c: 4 }`) as its decimal digits, undefined * when absent. A boolean is a switch, not a number, so it reads as absent. * Mirrors Python's `as_text`. */ asText(name) { const value = this.flags[this.key(name)]; if (typeof value === 'number') return Number.isInteger(value) ? BigInt(value).toString() : undefined; return typeof value === 'string' ? value : undefined; } asStr(name, fallback) { const value = this.flags[this.key(name)]; return typeof value === 'string' ? value : fallback; } asNonEmpty(name, fallback) { const value = this.asStr(name); return value === undefined || value === '' ? fallback : value; } asList(name) { const value = this.flags[this.key(name)]; if (Array.isArray(value)) { const items = value; return items.filter((item) => typeof item === 'string'); } if (typeof value === 'string') return [value]; return []; } asPath(name) { const value = this.flags[this.key(name)]; return value instanceof PathSpec ? value : undefined; } asPaths(name) { const value = this.flags[this.key(name)]; if (Array.isArray(value)) { const items = value; return items.filter((v) => v instanceof PathSpec); } if (value instanceof PathSpec) return [value]; return []; } raw(name) { return this.flags[this.key(name)]; } } //# sourceMappingURL=flag_view.js.map