@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
242 lines • 10.5 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 { 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