@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
291 lines • 14.1 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 { PolicyDenied } from "../../../../policy/errors.js";
import { ArithError } from "../../../../shell/errors.js";
import { VarAttr } from "../../../../shell/variable.js";
import { sessionEntry } from "../../../session/session.js";
import { appended, deref, envGet, evaluateInteger, inCallEnv, reachGlobal, sessionView, shadowLocal, visibleArrays, visibleAssocs, } from "../../../session/state.js";
import { fail, isValidName, readonlyLine, refusal, requireView } from "../shared.js";
import { SUBSCRIPT_RE } from "./constants.js";
import { declarationResult, dropReference, heldValue, identifierRefusal, kindConflict, localAttrs, namerefRefusal, operandParts, plusRefusal, premark, referenceRefusal, scalarValue, stampMarks, startLocal, storeStagedArrays, visibleRecord, } from "./declare.js";
/**
* Declare names in the running function's scope, or globally.
*
* bash's two passes (`storeStagedArrays`): every array literal stores
* first, then each operand in order is declared and marked before the next
* one runs, a literal's name at its own place. So `declare -r R=1 R=2`
* freezes `R` at 1 and refuses the second write, which fails the builtin
* while the later operands still declare, and `declare -r R=1 R=(2)` leaves
* `(1)`. `assignments` holds the operands in order: `NAME`, `NAME=value`,
* `NAME+=value` and staged array literals; `options` the spelling and the
* letters it carried, `local` with none by default.
*/
export async function handleLocal(assignments, session, state = null, options = {}) {
const decl = {
cmd: 'local',
kind: null,
shaping: [],
marks: [],
plus: '',
nameref: false,
globalScope: false,
inherit: false,
...options,
};
if (decl.cmd === 'local' && session.localVars === null) {
// `local` is the one spelling that needs a function scope;
// `declare`/`typeset` share this handler and are legal at top level.
// Without the check the builtin took its operands, stored them
// globally and exited 0, which is the silent-accept this whole tier
// exists to remove.
return fail('local', 'bash: local: can only be used in a function\n');
}
const view = requireView(state);
const restore = decl.globalScope
? reachGlobal(session, assignments.map((a) => (typeof a === 'string' ? operandParts(a)[0] : a.name)))
: null;
try {
return await declareOperands(assignments, session, view, decl, decl.globalScope ? null : session.localVars);
}
finally {
restore?.();
}
}
/** Run `handleLocal`'s operands in the scope it settled on. */
async function declareOperands(operands, session, view, decl, locals) {
const { cmd, kind, shaping, marks, plus, nameref, inherit } = decl;
const errors = [];
const warnings = [];
const stored = new Map();
try {
const refused = await storeStagedArrays(cmd, session, view, operands, errors, warnings, session.localVars === null, stored, kind, shaping, locals === null, inherit);
for (const [position, operand] of operands.entries()) {
let line;
if (typeof operand === 'string') {
line = refused !== null ? null : await declareOperand(session, view, operand, decl, locals);
}
else {
// A literal takes its marks at its place, against the target its
// own write cleared, even when a policy refused a later literal;
// under `-n` they go on the reference, which an array cannot
// become and a frozen one refuses, its own `-i -l -u` coming off
// unless asked for (`unshaped`).
const checked = stored.get(position);
if (checked === undefined)
continue;
const name = operand.name;
line =
(nameref ? referenceLine(session, view, cmd, name, false) : null) ??
plusRefusal(cmd, session, view, name, plus);
if (line === null) {
await stampMarks(session, view, name, checked, nameref ? unshaped(marks) : marks, !nameref);
}
}
if (line !== null)
errors.push(line);
}
if (refused !== null)
return refused;
}
catch (err) {
if (err instanceof PolicyDenied)
return refusal(cmd, err);
if (err instanceof ArithError)
throw err.signal(cmd, true);
throw err;
}
return declarationResult(cmd, errors, warnings);
}
/**
* The line a `-n` operand earns on the name it lands on: an array cannot
* become a reference (`referenceRefusal`), and a frozen one keeps every mark
* (`declare -nr r=t; declare -n r=(3)` writes `t` and refuses `r`), as
* bash's does. `bare` is an operand that gave no value.
*/
function referenceLine(session, view, cmd, name, bare) {
const line = referenceRefusal(cmd, name, visibleRecord(session, name), bare);
if (line === null && view.isReadonly(name, false))
return readonlyLine(cmd, name);
return line;
}
/**
* Declare one `NAME`, `NAME=value` or `NAME+=value` operand and mark it.
* Returns the operand's refusal line, or null when it declared; a policy
* denial or an `-i` value that does not evaluate throws. A `-n`
* declaration writes the reference itself, so a frozen reference refuses
* it (`declare -rn r=T; declare -n r=U`) even when what it points at is
* writable.
*/
async function declareOperand(session, view, assign, decl, locals) {
const { cmd, kind, plus, nameref, inherit } = decl;
// The reference's own `-i -l -u` come off unless asked for, as bash's do
// (`declare -l x=T; declare -n x=U` aims at `U`); its target's stay.
const shaping = nameref ? unshaped(decl.shaping) : decl.shaping;
const marks = nameref ? unshaped(decl.marks) : decl.marks;
const badName = identifierRefusal(cmd, assign);
if (badName !== null)
return badName;
const [key, append, given] = operandParts(assign);
const fresh = locals !== null && !locals.has(key);
if (given === null) {
if (locals !== null)
shadowLocal(session, locals, key);
if (fresh) {
const line = await freshLocal(session, view, cmd, key, inherit);
if (line !== null)
return line;
}
const line = (nameref ? referenceLine(session, view, cmd, key, true) : null) ??
plusRefusal(cmd, session, view, key, plus);
if (line !== null)
return line;
if (envGet(session, key) === null &&
!(key in visibleArrays(session)) &&
!(key in visibleAssocs(session))) {
// Declared, not assigned. `local L` leaves the name *unset*, exactly
// as `export Z` does: GNU prints `declare -- L` and `${L-d}` still
// expands to `d`. A bare declaration of an existing array re-scopes
// it, so nothing is written there. Visible reads: a hidden name
// counts as unset, so the mark is attempted and the session view refuses it.
await view.mark(key, null, true, !nameref);
}
await stampMarks(session, view, key, null, marks, !nameref);
return null;
}
if (nameref && !append && given !== '') {
// A value that names no variable refuses first, before a local is made;
// an empty one, and what `+=` builds, are judged once the name may take
// a reference (`aimReference`).
const badRef = namerefRefusal(cmd, key, given);
if (badRef !== null)
return badRef;
}
const creates = fresh && !inCallEnv(session, key);
if ((creates || !nameref) && view.isReadonly(key, !nameref))
return readonlyLine(cmd, key);
if (locals !== null)
shadowLocal(session, locals, key);
if (creates)
startLocal(session, key, inherit);
// A reference is checked on the local, which exists from here on even
// when the array it inherited cannot become a reference, as bash's does,
// so the function's later writes stay its own. A name no local replaces
// reports its array before its readonly mark.
const line = (nameref ? referenceLine(session, view, cmd, key, false) : null) ??
plusRefusal(cmd, session, view, key, plus);
if (line !== null)
return line;
if (nameref) {
return aimReference(session, view, cmd, key, append, given, shaping, marks, creates);
}
// A new local holds nothing of the caller's but what `startLocal` kept;
// otherwise the value lands as any declaration's does (`scalarValue`),
// and an array kind the variable cannot take is refused.
const held = fresh && !inherit ? null : heldValue(session, key);
const conflict = kindConflict(held, kind);
if (conflict !== null)
return `bash: ${cmd}: ${key}: ${conflict}`;
const checked = deref(session, key) || key;
await premark(view, key, shaping);
const integer = sessionEntry(session.vars, checked)?.attrs.has(VarAttr.Integer) === true;
const [slot, added] = append ? appended(held, given, integer) : [given, null];
const [value, assigned] = scalarValue(held, slot, kind);
if (kind !== null)
await dropReference(session, view, key);
await view.set(key, value, true, assigned, added);
await stampMarks(session, view, key, checked, marks);
return null;
}
/** `marks` taking off each of `-i -l -u` they do not name. */
function unshaped(marks) {
const named = new Set(marks.map(([attr]) => attr));
const off = [VarAttr.Integer, VarAttr.Lower, VarAttr.Upper]
.filter((attr) => !named.has(attr))
.map((attr) => [attr, false]);
return [...off, ...marks];
}
/**
* Aim a `declare -n NAME=VALUE` (or `NAME+=VALUE`) reference once the name
* may take one.
*
* The reference is what `+=` builds onto its own value, and under a
* declared `-i` what the arithmetic makes of it, landing the writes it does
* (`M='X=5'; declare -ni r=M` sets X) and never a name. A result that names
* no variable fails, in bash's words when the given text names none either
* (`declare -n r=''` is `` `': not a valid identifier``) and silently
* otherwise (`x=1; declare -n x+=T`). The name still takes the
* declaration's marks but `-n`, `-i -l -u` it did not ask for coming off,
* and a local it made stays declared; a name that never existed stays
* unset. Returns the refusal line ('' for a silent one), or null once aimed.
*/
async function aimReference(session, view, cmd, key, append, given, shaping, marks, creates) {
const held = append ? visibleRecord(session, key)?.value : undefined;
const old = typeof held === 'string' ? held : '';
let value = old + given;
if (shaping.some(([attr, on]) => attr === VarAttr.Integer && on)) {
await evaluateInteger(session, view, append ? old : given, append ? given : null);
value = '';
}
if (isValidName(value) || SUBSCRIPT_RE.test(value)) {
const line = namerefRefusal(cmd, key, value);
if (line !== null)
return line;
await premark(view, key, shaping, false);
await view.set(key, value, false, null);
await stampMarks(session, view, key, key, marks, false);
return null;
}
if (creates && sessionEntry(session.vars, key) === undefined) {
await view.mark(key, null, true, false);
}
if (sessionEntry(session.vars, key) !== undefined) {
const kept = marks.filter(([attr]) => attr !== VarAttr.Nameref);
await stampMarks(session, view, key, null, kept, false);
}
return isValidName(given) ? '' : `bash: ${cmd}: \`${given}': not a valid identifier`;
}
/**
* Start a new bare `local NAME` unset, as bash 5.2 does.
*
* Only a name the frame did not shadow yet: a second `local x`, or the
* fresh array `local -a x` has already put in place, keeps what the
* function holds. The caller's value and attributes stay behind except
* the export mark: GNU prints `declare -- x` for `x=1; f() { local x; }`
* and `declare -x x` for an exported one, and `local x; x+=y` stores `y`.
* With `-I` the value and attributes stay, a reference's aside. A name the
* call assigned in front is the exception and keeps that value (`x=1 f`
* where f runs `local x` reads 1). A readonly name refuses, as GNU's does,
* and the operands after it still declare: the refusal line is returned.
*/
async function freshLocal(session, view, cmd, name, inherit) {
const record = sessionEntry(session.vars, name);
if (record === undefined || inCallEnv(session, name))
return null;
if (view.isReadonly(name))
return readonlyLine(cmd, name);
if (inherit) {
if (record.attrs.has(VarAttr.Nameref))
await view.mark(name, VarAttr.Nameref, false);
return null;
}
await view.unset(name, false);
for (const attr of localAttrs(record, false))
await view.mark(name, attr, true);
return null;
}
/** The `local` arm. */
export async function localBuiltin(call) {
return handleLocal([...call.argv.args], call.context.session, sessionView(call.context.session, call.registry.policies, call.context.frame.diagnostics));
}
//# sourceMappingURL=local.js.map