UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

279 lines • 13.9 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 { 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