@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
336 lines • 16.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 { IOResult } from "../../io/types.js";
import { PolicyDenied } from "../../policy/errors.js";
import { encodeText } from "../../shell/bytes.js";
import { DiscardSignal } from "../../shell/errors.js";
import { getDeclarationKeyword, getText } from "../../shell/helpers.js";
import { NodeType as NT } from "../../shell/types.js";
import { VarAttr, VarKind } from "../../shell/variable.js";
import { traceArray, traceCommand } from "../../shell/xtrace.js";
import { concat } from "../../utils/bytes.js";
import { compareCodePoints } from "../../utils/sort.js";
import { LISTED_ATTRIBUTES, VISIBLE_SCOPE_BUILTINS, } from "../executor/builtins/declare/constants.js";
import { declaredKind, heldValue, kindConflict, markNames, startLocal, } from "../executor/builtins/declare/declare.js";
import { flagHelp } from "../executor/builtins/help/index.js";
import { handleDeclareFunctions, handleDeclarePrint, handleLocal, noteLocalArray, } from "../executor/builtins/index.js";
import { builtinUsage, fail } from "../executor/builtins/shared.js";
import { expandNode } from "../expand/node.js";
import { sessionEntry } from "../session/session.js";
import { conversionScalar, ensureVarVisible, seedVar, sessionView } from "../session/state.js";
import { ExecutionNode } from "../types.js";
import { expandArrayItems } from "./assignment.js";
/**
* Fold kind-conversion refusals into a declaration's result.
*
* GNU reports `cannot convert indexed to associative array` per refused
* name on stderr and fails the builtin with 1 while the other operands
* still declare, so the refusals ride the handler's own result rather
* than replacing it.
*/
function mergeConversionErrors(result, errors) {
if (errors.length === 0)
return result;
const [stream, io, node] = result;
const extra = encodeText(errors.join('\n') + '\n');
const prior = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(0);
const merged = new Uint8Array(prior.length + extra.length);
merged.set(prior, 0);
merged.set(extra, prior.length);
const newIo = new IOResult({
exitCode: 1,
stderr: merged,
});
return [stream, newIo, new ExecutionNode({ command: node.command, exitCode: 1, stderr: merged })];
}
// Every letter GNU's `declare` accepts, so a typo refuses with the usage
// line instead of being silently dropped. `-a`/`-A` are kinds, not
// attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
// /`-I` are modes the handlers read. `-n` stores the reference and every
// reader and writer resolves through it (`deref` in `session/state`).
const DECLARE_LETTERS = new Set('aAfFgiIlnprtux');
// The attributes that shape a value as it stores.
const SHAPING = new Set([VarAttr.Integer, VarAttr.Lower, VarAttr.Upper]);
// `-l` displaces `-u` and vice versa; the record keeps one.
const DISPLACES = new Map([
['l', VarAttr.Upper],
['u', VarAttr.Lower],
]);
/**
* The refusal a `declare` family option cluster earns, if any.
*
* An unknown letter is GNU's `invalid option` plus the usage line, exit
* 2, and it wins over every other check because bash refuses the
* cluster before it looks at a single operand.
*/
function declareOptionRefusal(cmd, flagChars, plusChars) {
const bad = [...flagChars, ...plusChars]
.sort(compareCodePoints)
.find((c) => !DECLARE_LETTERS.has(c));
if (bad === undefined)
return null;
const sign = flagChars.has(bad) ? '-' : '+';
return fail(cmd, `bash: ${cmd}: ${sign}${bad}: invalid option\n${builtinUsage(cmd)}`, 2);
}
/**
* The attribute marks a declaration puts on each operand once it lands, in
* order, readonly last.
*
* The letters that shape a value (`-i -l -u`) are stored as attributes and
* applied by the session view on every *later* write, which is GNU's rule:
* `v=MiXeD; declare -l v` keeps `MiXeD`, and the next `v=ABC` stores `abc`.
* So this marks and never rewrites. `-l` and `-u` are exclusive: setting
* one clears the other, and a cluster naming both (`-lu`, `-ul`) sets
* neither, both pinned on 5.2.37. A `+` letter clears; `+r` is not an off
* toggle, since it is refused on a readonly name (`plusRefusal`) and a
* no-op otherwise. `r` lands last, as each operand's own last step:
* `declare -rl L=ABC L=DEF` keeps `abc` and refuses the second write.
*/
function declaredMarks(flagChars, plusChars) {
const on = new Set();
for (const c of 'iluntxr')
if (flagChars.has(c) && !plusChars.has(c))
on.add(c);
if (on.has('l') && on.has('u')) {
on.delete('l');
on.delete('u');
}
const marks = [];
for (const c of 'xiluntr') {
const attr = c;
if (on.has(c)) {
marks.push([attr, true]);
const displaced = DISPLACES.get(c);
if (displaced !== undefined)
marks.push([displaced, false]);
}
else if (plusChars.has(c) && c !== 'r') {
marks.push([attr, false]);
}
}
return marks;
}
/**
* Execute one declaration statement (export/local/declare/readonly).
*
* The executor only reads the operands: it expands them and sorts out the
* option letters, keeping the words and the staged array literals in the
* order typed, then hands them to the builtin handler that owns the
* keyword, which marks each operand with the attribute letters (`-x`,
* `-i`, `-l`) at its place, so `declare -rx X=1` keeps both marks.
*/
export async function executeDeclaration(node, context, executeFn, registry, namespace, callStack, parser) {
const session = context.session;
const keyword = getDeclarationKeyword(node);
// Array literals are staged, not stored: `readonly -a a=(y)` on an
// already-readonly name has to fail with the old value intact. They keep
// their place among the words, since bash marks each name in the order
// typed.
const operands = [];
// Option words are kept verbatim, in order, so `--` survives as an
// end-of-options marker and the handlers can name the *first* bad option
// letter the way bash does.
const flagWords = [];
const flagChars = new Set();
const plusChars = new Set();
let optsDone = false;
const traced = [];
const view = sessionView(session, registry.policies, context.frame.diagnostics);
for (const child of node.namedChildren) {
if (child.type === NT.VARIABLE_ASSIGNMENT) {
const valNodes = child.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME);
const firstVal = valNodes[0];
if (firstVal?.type === NT.ARRAY) {
const text = getText(child);
const eq = text.indexOf('=');
const key = eq >= 0 ? text.slice(0, eq) : text;
const append = key.endsWith('+');
const name = append ? key.slice(0, -1) : key;
traced.push(name);
operands.push({
name,
append,
items: await expandArrayItems(firstVal, context, executeFn, registry, namespace, callStack),
});
continue;
}
const expanded = await expandNode(child, context, executeFn, callStack, view);
operands.push(expanded);
traced.push(expanded);
}
else if (child.type === NT.SIMPLE_EXPANSION ||
child.type === NT.EXPANSION ||
child.type === NT.CONCATENATION ||
child.type === NT.WORD ||
// A bare `readonly NAME` / `export NAME` operand parses as a
// variable_name, not a word, and a quoted assignment
// (`export 'FOO=bar'`) as a plain string operand.
child.type === NT.VARIABLE_NAME ||
child.type === NT.STRING ||
child.type === NT.RAW_STRING ||
child.type === NT.ANSI_C_STRING ||
child.type === NT.TRANSLATED_STRING) {
const expanded = await expandNode(child, context, executeFn, callStack, view);
// An *unquoted* expansion that came back empty is removed by
// word splitting, so `export $UNSET` is a bare `export` and
// prints the listing. A quoted one is a real, empty operand:
// GNU answers both `export ""` and `export "$UNSET"` with
// ``export: `': not a valid identifier``, so it has to reach
// the builtin rather than vanish here.
if (expanded === '' && (child.type === NT.SIMPLE_EXPANSION || child.type === NT.EXPANSION))
continue;
traced.push(expanded);
if (!optsDone && expanded.startsWith('-') && expanded.length > 1) {
flagWords.push(expanded);
if (expanded === '--')
optsDone = true;
else
for (const ch of expanded.slice(1))
flagChars.add(ch);
}
else if (!optsDone &&
expanded.startsWith('+') &&
expanded.length > 1 &&
!VISIBLE_SCOPE_BUILTINS.has(keyword)) {
// `+attr` turns an attribute off. Only the declare family
// reads it: `export +x` and `readonly +r` are `not a valid
// identifier` in GNU, so for those two the word falls through
// as an operand and refuses there.
for (const ch of expanded.slice(1))
plusChars.add(ch);
}
else {
operands.push(expanded);
}
}
}
const cmdWord = keyword === NT.LOCAL ? 'local' : keyword;
const words = operands.filter((operand) => typeof operand === 'string');
if (session.shellOptions.xtrace === true) {
// Traced once expanded, before the builtin runs and outside its
// redirects: each array operand, then the command naming them.
const staged = operands.filter((op) => typeof op !== 'string');
context.frame.diagnostics.push(concat([
...staged.map((op) => traceArray(op.name, op.items, op.append)),
traceCommand([cmdWord, ...traced]),
]));
}
if (traced[0] === '--help') {
// bash answers `--help` as the first word with the builtin's help,
// before it reads a single option.
const answered = flagHelp(cmdWord);
if (answered !== null)
return answered;
}
if (VISIBLE_SCOPE_BUILTINS.has(keyword)) {
// Array literals travel as data: the handler stores them through
// the session view and owns both refusal voices, so the executor
// only expands and stages. The flags pass through so -p, the bare
// listing and bad options work.
const attr = keyword === 'readonly' ? VarAttr.Readonly : VarAttr.Export;
return await markNames([...flagWords, ...operands], session, view, attr, parser);
}
const refused = declareOptionRefusal(cmdWord, flagChars, plusChars);
if (refused !== null)
return refused;
if (flagChars.has('f') || flagChars.has('F')) {
// `-f`/`-F` select functions, not variables: `-rf` freezes, `-xf`
// exports, `-f NAME` prints the body, `-F NAME` prints the name, and
// a missing name is exit 1 without a word.
return handleDeclareFunctions(cmdWord, session, flagChars, words, plusChars, parser);
}
// The value-shaping marks go on or off before a value stores, the rest
// once it has (`declaredMarks`).
const marks = declaredMarks(flagChars, plusChars);
const shaping = marks.filter(([attr]) => SHAPING.has(attr));
// `-p` prints rather than declares, so it is answered before anything
// declares (`declare -ap NAME` converts nothing); with no names, an
// attribute letter lists the names carrying it, `-p` or not.
const listing = operands.length === 0 &&
[...flagChars].some((c) => LISTED_ATTRIBUTES.has(c) || c === 'a' || c === 'A');
if ((flagChars.has('p') || plusChars.has('p') || listing) &&
(keyword === 'declare' || keyword === 'typeset')) {
return handleDeclarePrint(words, session, flagChars);
}
const conversionErrors = [];
const kind = declaredKind(flagChars);
if (kind !== null) {
// `declare -a NAME` / `declare -A NAME` with no value declare an
// empty array of that kind, so ${#NAME[@]} is 0 and an element
// write leaves the other slots unassigned. GNU refuses to
// convert between the two kinds and says so per name while the
// rest of the operands still declare.
const wantAssoc = kind === VarKind.Assoc;
for (const bare of words) {
if (bare.includes('='))
continue;
// Both branches below write array storage raw (the top-level
// one migrates an existing scalar), so a hidden name refuses
// like any assignment spelling before either lands.
try {
ensureVarVisible(session, bare);
}
catch (err) {
if (!(err instanceof PolicyDenied))
throw err;
throw new DiscardSignal(encodeText(`${err.message}\n`));
}
const fresh = !flagChars.has('g') && session.localVars !== null && !session.localVars.has(bare);
const heldVar = sessionEntry(session.vars, bare);
// `handleLocal` refuses a readonly name in its voice.
if (fresh && heldVar?.attrs.has(VarAttr.Readonly) === true)
continue;
// Inside a function the name is a local of the declared kind, a new
// one starting as `startLocal` leaves it (with `-I`, the value it
// shadows); `-g` declares at global scope.
const local = !flagChars.has('g') && noteLocalArray(session, bare);
if (local && fresh)
startLocal(session, bare, flagChars.has('I'));
const held = heldValue(session, bare);
const conflict = kindConflict(held, kind);
if (conflict !== null) {
conversionErrors.push(`bash: ${cmdWord}: ${bare}: ${conflict}`);
continue;
}
const isMap = held !== null && typeof held === 'object' && !Array.isArray(held);
if (wantAssoc ? isMap : Array.isArray(held))
continue;
// A local of another kind starts empty; at top level an existing
// scalar becomes element 0, or the value at the literal key "0" (GNU
// allows scalar-to-associative conversion, unlike indexed).
const scalar = local ? undefined : conversionScalar(session, bare);
if (wantAssoc)
seedVar(session, bare, scalar === undefined ? {} : { '0': scalar });
else
seedVar(session, bare, scalar === undefined ? [] : [scalar]);
}
}
// declare/typeset scope like `local` inside a function (bash
// semantics) and assign globally at top level, which is exactly
// handleLocal's fallback when no function scope is active. `-r` rides
// the same path and lands on what each operand wrote, so
// `f() { local -r A=(x); }` freezes f's own A, not the caller's.
const result = await handleLocal(operands, session, view, {
cmd: cmdWord,
kind,
shaping,
marks,
plus: [...plusChars].sort(compareCodePoints).join(''),
nameref: flagChars.has('n') && !plusChars.has('n'),
globalScope: flagChars.has('g'),
inherit: flagChars.has('I'),
});
return mergeConversionErrors(result, conversionErrors);
}
//# sourceMappingURL=declaration.js.map