@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
279 lines • 13.9 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 { runInCommandScope } from "../../cache/index/scope.js";
import { PolicyDenied } from "../../policy/errors.js";
import { arrayExtent, arrayGet, arraySet, arrayWith, buildAssocLiteral, buildIndexedLiteral, } from "../../shell/array.js";
import { encodeText } from "../../shell/bytes.js";
import { ArithError, DiscardSignal, ReadonlyVariableError } from "../../shell/errors.js";
import { getText } from "../../shell/helpers.js";
import { NodeType as NT } from "../../shell/types.js";
import { VarAttr } from "../../shell/variable.js";
import { traceAssignment } from "../../shell/xtrace.js";
import { wordText } from "../../types.js";
import { ok, result } from "../executor/builtins/shared.js";
import { assignmentStatus } from "../executor/statement.js";
import { globOptions, resolveGlobs } from "../expand/globs.js";
import { expandNode } from "../expand/node.js";
import { expandAndClassify } from "../expand/parts.js";
import { sessionEntry } from "../session/session.js";
import { appended, conversionScalar, deref, sessionView, subscriptIndex } from "../session/state.js";
/** `subscriptIndex` whose failure ends the line, in bash's words. */
async function fatalIndex(context, subscript, view) {
const session = context.session;
try {
return await subscriptIndex(session, subscript, view);
}
catch (err) {
if (err instanceof ArithError)
throw err.signal('', true);
throw err;
}
}
/** `buildIndexedLiteral` whose subscript failure ends the line. */
async function fatalIndexLiteral(held, items, append, indexOf) {
try {
return await buildIndexedLiteral(held, items, append, indexOf);
}
catch (err) {
if (err instanceof ArithError)
throw err.signal('', true);
throw err;
}
}
/**
* Discard the line on a write to a readonly `key` before its new value is
* built: bash refuses an array literal before expanding it, and an element
* write once its value and subscript are read, before the array would grow
* to the index (`a[2**40]=x`). The store refuses every other spelling
* (`assignVar`).
*/
function refuseReadonly(view, key) {
if (view.isReadonly(key, false)) {
throw new DiscardSignal(encodeText(`bash: ${key}: readonly variable\n`));
}
}
/**
* One assignment through the session view; denial is fatal.
*
* Every assignment spelling (scalar, array literal, subscript, append)
* computes its resulting value and stores through `view.set`, so the
* gate and the storage invariant live in the session view, not here. A
* readonly name or a denial is a fatal variable-assignment error that
* abandons the rest of the line (builtins like `export` merely fail with 1
* and continue).
*/
async function assignVar(view, key, value, assigned = null, added = null) {
try {
await view.set(key, value, true, assigned, added);
}
catch (err) {
if (err instanceof PolicyDenied)
throw new DiscardSignal(encodeText(`${err.message}\n`));
if (err instanceof ReadonlyVariableError) {
throw new DiscardSignal(encodeText(`bash: ${err.message}\n`));
}
if (err instanceof ArithError)
throw err.signal('', true);
throw err;
}
}
// Array-literal elements behave like any other shell word list: command
// substitutions word-split and globs resolve to matches
// (`a=($(cmd) /data/*.txt)`), with zero-match globs kept literal.
export async function expandArrayItems(arrayNode, context, executeFn, registry, namespace, callStack) {
const session = context.session;
// A bare assignment is no command, but its glob reads listings all the
// same, so it gets a scope of its own.
const resolved = await runInCommandScope(async () => {
const classified = await expandAndClassify(arrayNode.namedChildren, context, executeFn, registry, session.cwd, callStack, sessionView(session, registry.policies, context.frame.diagnostics));
return resolveGlobs(classified, registry, session.shellOptions.noglob === true, namespace.sessionLinks(session), globOptions(session));
});
return resolved.map((w) => wordText(w));
}
const SUBSCRIPT_LITERAL_TYPES = new Set([NT.WORD, NT.NUMBER]);
/**
* The expanded subscript text of one `name[...]=` assignment.
*
* A purely literal subscript keeps its raw spelling, spaces included
* (bash stores `m[ k ]` under the key `" k "`); anything carrying an
* expansion or quoting expands node by node so `m[$k]` and `m["a b"]`
* resolve with quote removal. The associative path uses the result as
* the key verbatim; the indexed path evaluates it as arithmetic.
*/
async function subscriptKeyText(subscriptNode, name, context, executeFn, callStack, view) {
const inner = subscriptNode.namedChildren.filter((sc) => sc.type !== NT.VARIABLE_NAME);
const raw = subscriptNode.text.slice(name.length + 1, -1);
if (inner.length === 0 || inner.every((sc) => SUBSCRIPT_LITERAL_TYPES.has(sc.type))) {
return raw;
}
const parts = [];
for (const sc of inner) {
parts.push(await expandNode(sc, context, executeFn, callStack, view));
}
return parts.join('');
}
/**
* Execute one top-level variable assignment (`a=1`, `a[i]+=v`).
*
* Every spelling — scalar, array literal, subscript, append — is
* computed with bash's own mechanics on a copy of the held value and
* then stored through the session view, which owns the admission gate
* and the scalar/array invariant.
*/
export async function executeAssignment(node, context, executeFn, registry, namespace, callStack) {
const session = context.session;
const text = getText(node);
if (!text.includes('='))
return ok(text);
const subSeq = context.frame.cmdsubSeq;
const subscriptNode = node.namedChildren.find((c) => c.type === 'subscript') ?? null;
const nameSource = subscriptNode ?? node;
const nameNode = nameSource.namedChildren.find((c) => c.type === NT.VARIABLE_NAME);
const eq = text.indexOf('=');
const spelled = nameNode !== undefined ? nameNode.text : text.slice(0, eq);
// A name reference assigns to its target, whatever the shape of the
// assignment; an unaimed one (`declare -n r; r=v`) resolves to itself
// and takes the value as the target's name. The spelling is kept for
// slicing the subscript out of the source.
const key = deref(session, spelled) || spelled;
const append = node.children.some((c) => c.type === '+=');
// `+=` on an integer adds, element by element too (`appended`).
const integer = sessionEntry(session.vars, key)?.attrs.has(VarAttr.Integer) === true;
const valNodes = node.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME && c.type !== 'subscript');
// Every branch below computes its resulting value with bash's own
// mechanics on a copy, then stores through the one session view,
// which owns the gate and the scalar/array invariant.
const view = sessionView(session, registry.policies, context.frame.diagnostics);
const firstVal = valNodes[0];
// `set -x` traces the name as typed, before the store refuses it.
const xtrace = session.shellOptions.xtrace === true;
if (firstVal?.type === NT.ARRAY) {
if (xtrace) {
// A literal traces as typed, its words one space apart.
const words = firstVal.namedChildren
.filter((c) => c.type !== NT.COMMENT)
.map(getText)
.join(' ');
context.frame.diagnostics.push(encodeText(`+ ${spelled}${append ? '+=' : '='}(${words})\n`));
}
refuseReadonly(view, key);
const items = await expandArrayItems(firstVal, context, executeFn, registry, namespace, callStack);
const heldMap = session.assocs[key];
if (heldMap !== undefined) {
const { map, badWords } = buildAssocLiteral(heldMap, items, append);
await assignVar(view, key, map);
// A plain word in a keyed literal is dropped with a warning and the
// assignment still succeeds; unlike a declaration's, this voice does
// not quote the word (pinned on 5.2.37).
return result(text, {
exitCode: assignmentStatus(context.frame, subSeq),
stderr: badWords
.map((word) => `bash: ${key}: ${word}: must use subscript when assigning associative array\n`)
.join(''),
});
}
let held = session.arrays[key] ?? null;
if (append && held === null) {
const scalar = conversionScalar(session, key);
held = scalar === undefined ? null : [scalar];
}
// `arr+=(...)` starts at the extent, so it fills the hole a
// trailing `unset arr[last]` left but skips interior ones; a
// `[i]=v` element places at i and the next plain word continues
// from there.
const base = await fatalIndexLiteral(held, items, append, (sub) => subscriptIndex(session, sub, view));
await assignVar(view, key, base);
return result(text, { exitCode: assignmentStatus(context.frame, subSeq) });
}
let val = text.slice(eq + 1);
if (firstVal !== undefined) {
val = await expandNode(firstVal, context, executeFn, callStack, view);
}
if (xtrace) {
const target = subscriptNode === null ? spelled : getText(subscriptNode);
context.frame.diagnostics.push(encodeText(`${traceAssignment(target, val, append)}\n`));
}
if (subscriptNode !== null) {
const subText = await subscriptKeyText(subscriptNode, spelled, context, executeFn, callStack, view);
const heldMap = session.assocs[key];
const rawSub = subscriptNode.text.slice(spelled.length + 1, -1);
if (rawSub.trim() === '' || (heldMap !== undefined && subText === '')) {
// bash discards the rest of the line on a bad assignment subscript
// (status 1), naming the raw spelling (`m[$e]: bad array
// subscript`). An indexed subscript that merely *expands*
// empty stays legal (arithmetic on nothing is 0), so only the
// associative kind checks the expanded text.
const nameText = text.slice(0, eq).replace(/\+$/, '');
throw new DiscardSignal(encodeText(`bash: ${nameText}: bad array subscript\n`));
}
if (heldMap !== undefined) {
// The subscript is the key: no arithmetic, `m[1+1]` writes the
// key "1+1".
const newMap = { ...heldMap };
const [slot, added] = append ? appended(heldMap[subText] ?? '', val, integer) : [val, null];
newMap[subText] = slot;
await assignVar(view, key, newMap, new Set([subText]), added);
return result(text, { exitCode: assignmentStatus(context.frame, subSeq) });
}
let arr = session.arrays[key];
if (arr === undefined) {
const scalar = conversionScalar(session, key);
arr = scalar === undefined ? [] : [scalar];
}
let idx = await fatalIndex(context, subText, view);
if (idx < 0)
idx += arrayExtent(arr);
if (idx < 0) {
// Same fatal shape as the empty subscript above.
const nameText = text.slice(0, eq).replace(/\+$/, '');
throw new DiscardSignal(encodeText(`bash: ${nameText}: bad array subscript\n`));
}
refuseReadonly(view, key);
const [slot, added] = append ? appended(arrayGet(arr, idx), val, integer) : [val, null];
await assignVar(view, key, arrayWith(arr, idx, slot), new Set([idx]), added);
return result(text, { exitCode: assignmentStatus(context.frame, subSeq) });
}
const heldMap = session.assocs[key];
const heldArr = session.arrays[key];
let [stored, added] = [val, null];
if (append) {
// `a+=x` appends onto element 0 (key "0" of a map); `n+=3` on an
// integer name adds: `declare -i n=5; n+=3` stores 8.
;
[stored, added] = appended(heldMap ?? heldArr ?? session.env[key] ?? null, val, integer);
}
if (heldMap !== undefined) {
// `m=x` on an associative array writes the literal key "0" and
// keeps every other key, as bash does.
const newMap = { ...heldMap };
newMap['0'] = stored;
await assignVar(view, key, newMap, new Set(['0']), added);
}
else if (heldArr !== undefined) {
// `a=x` writes element 0 and keeps the rest.
const newArr = [...heldArr];
arraySet(newArr, 0, stored);
await assignVar(view, key, newArr, new Set([0]), added);
}
else {
await assignVar(view, key, stored, null, added);
}
// Reassigning OPTIND (even to its current value) restarts the getopts
// scan at the first letter of the word it names, as bash 5.2 does
// (`OPTIND=1` midway through `-ab` makes getopts read `a` again).
if (key === 'OPTIND')
session.getoptsOptind = null;
return result(text, { exitCode: assignmentStatus(context.frame, subSeq) });
}
//# sourceMappingURL=assignment.js.map