@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
316 lines • 12.7 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 * as constants from "./constants.js";
/**
* Whether two facade nodes name the same tree node. The parser hands out
* a fresh wrapper per lookup, so `===` alone cannot tell a node from a
* re-read of it; identity rides `id` when the facade has one.
*/
export function sameNode(a, b) {
return a === b || (a.id !== undefined && a.id === b.id);
}
function collectNames(node, out) {
if (node.type === 'function_definition')
return;
if (node.type === 'variable_name') {
if (node.text !== '')
out.add(node.text);
return;
}
if (constants.DECLARING_NODES.has(node.type)) {
for (const child of node.children) {
if (child.type !== 'variable_name')
collectNames(child, out);
}
return;
}
const field = constants.TARGET_NAME_FIELDS[node.type];
let target = field !== undefined ? (node.childForFieldName?.(field) ?? null) : null;
if (target !== null && node.children.some((c) => c.type === '+='))
target = null;
for (const child of node.children) {
if (target !== null && sameNode(child, target))
continue;
collectNames(child, out);
}
}
/** Every node under `root`, `root` included, depth first. */
export function* walkTree(root) {
const stack = [root];
for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
yield node;
// A loop, not a spread: a long script's root has more children than a
// call takes arguments.
for (const child of node.children)
stack.push(child);
}
}
/**
* Named nodes, skipping function_definition subtrees.
*
* A definition's body runs at invocation, not where it is defined, so
* a read walk that descended into one would charge the defining line
* for reads it never performs. The fill layer joins invoked bodies
* back in through its own node set (`lineNodes`). `skip` names further
* node types whose subtrees the caller walks on its own.
*/
export function* walkNamedOutsideDefs(node, skip = new Set()) {
if (node.type === 'function_definition' || skip.has(node.type))
return;
yield node;
for (const child of node.namedChildren) {
yield* walkNamedOutsideDefs(child, skip);
}
}
/**
* Every variable name a parsed program may read when it runs.
*
* A textual over-approximation over the whole tree, which is safe by
* construction: the worst a spurious name costs is one fetch. Walked
* everywhere -- command substitution bodies, redirect targets, heredoc
* bodies, arithmetic -- with two exceptions that are writes, not reads
* (an assignment's own name, unless it appends, since `+=` starts from
* the value it extends; a for loop's variable), one that runs later
* rather than now (a function definition's body, which the fill layer
* joins back in at invocation), and one the grammar gives for free: a
* single-quoted string tokenizes as `raw_string` with no children, so
* `'$X'` never reads X.
*/
export function referencedNames(node) {
const out = new Set();
collectNames(node, out);
return out;
}
/**
* The first word of every command a parsed program runs.
*
* What the whole-env scan and the CLI env-name lookup key on.
* `command_name` covers ordinary commands wherever they sit; the
* declaring builtins (`export`, `declare`, `local`, `readonly`,
* `unset`) parse as their own node types whose head word is the first
* anonymous token, so those are read directly. A function definition's
* body is skipped: those commands run at invocation, where the fill
* layer walks the stored body instead, and so is any `skip` type the
* caller walks on its own.
*/
export function commandWords(node, skip = new Set()) {
const out = new Set();
for (const current of walkNamedOutsideDefs(node, skip)) {
if (current.type === 'command_name') {
if (current.text !== '')
out.add(current.text);
}
else if (constants.DECLARING_NODES.has(current.type)) {
const head = current.children[0];
if (head !== undefined && head.text !== '')
out.add(head.text);
}
}
return out;
}
/**
* The argument's text when the parser fixed it, else null.
*
* A plain word, a number, a raw string and a double-quoted string of
* plain content each spell one literal; anything carrying an expansion
* or a substitution is dynamic and reads as null.
*/
export function literalText(node) {
if (node.type === 'word' || node.type === 'number') {
return node.text !== '' ? node.text : null;
}
if (node.type === 'raw_string') {
return node.text.slice(1, -1);
}
if (node.type === 'string') {
const named = node.namedChildren;
if (named.length === 0)
return '';
const only = named[0];
if (named.length === 1 && only?.type === 'string_content') {
return only.text;
}
}
return null;
}
export function commandArgs(node) {
const nameNode = node.childForFieldName?.('name') ?? null;
return node.namedChildren.filter((child) => (nameNode === null || !sameNode(child, nameNode)) &&
child.type !== 'variable_assignment' &&
!child.type.endsWith('_redirect'));
}
/**
* Every plain command's head word with its argument words.
*
* Head and arguments are reported as their literal text, or null for a
* word no static read can spell (an expansion, a substitution), so a
* caller matching names (the CLI env-name pruning) can tell "this word
* is not there" from "this word is unknowable". A null head is the
* stronger fact: the command that runs is not decidable before
* expansion, so the fill pass treats the line as an opaque read.
* Assignment prefixes and redirects are not arguments.
*/
export function commandInvocations(node) {
const out = [];
for (const current of walkNamedOutsideDefs(node)) {
if (current.type !== 'command')
continue;
const nameNode = current.childForFieldName?.('name') ?? null;
if (nameNode === null)
continue;
const only = nameNode.namedChildren.length === 1 ? nameNode.namedChildren[0] : undefined;
const head = only !== undefined ? literalText(only) : null;
out.push([head, commandArgs(current).map(literalText)]);
}
return out;
}
/**
* Identifier-shaped tokens in an arithmetic expression string.
*
* Bash evaluates a variable's value as an expression of its own
* (`x="TOKEN + 1"; $((x))` reads TOKEN), so a caller chasing that
* recursion needs the names a value may resolve. Over-approximates on
* purpose: `0x1f` yields `x1f`, which reads nothing real and costs
* nothing.
*/
export function identifierNames(text) {
return new Set(text.match(constants.IDENTIFIER_RE) ?? []);
}
/**
* Names read inside one arithmetic region.
*
* The grammar is inconsistent about identifiers here: `$((name))`
* holds a `variable_name` while a c-style for's `i<n` holds bare
* `word` nodes, and a subscript's index is one `word` whose text is a
* whole expression -- so words tokenize through `identifierNames`
* rather than reading as one name.
*/
function arithRegionNames(region, out) {
for (const current of walkNamedOutsideDefs(region)) {
if (current.type === 'variable_name' || current.type === 'word') {
if (current.text !== '')
for (const name of identifierNames(current.text))
out.add(name);
}
}
}
/**
* Names in a `${v:offset:length}` expansion's arithmetic part.
*
* The substring form is told apart from `${v:-d}` and friends by its
* bare `:` token; everything after the first one is offset or length,
* both evaluated as arithmetic.
*/
function substringArithNames(expansion, out) {
let seenColon = false;
for (const child of expansion.children) {
if (!seenColon) {
seenColon = child.isNamed !== true && child.text === ':';
continue;
}
if (child.isNamed === true || expansion.namedChildren.some((n) => sameNode(n, child))) {
arithRegionNames(child, out);
}
}
}
/** Names the numeric comparators of one `[[` read as arithmetic. */
function testArithNames(test, out) {
for (const current of walkNamedOutsideDefs(test)) {
if (current.type !== 'binary_expression')
continue;
const operator = current.namedChildren.find((child) => child.type === 'test_operator');
if (operator === undefined || !constants.ARITH_TEST_OPERATORS.has(operator.text))
continue;
for (const child of current.namedChildren) {
if (!sameNode(child, operator))
arithRegionNames(child, out);
}
}
}
/**
* Names the program reads in an arithmetic context.
*
* Arithmetic resolution recurses through values (`name=TOKEN;
* $((name))` reads TOKEN), so these names are the ones whose stored
* values a fill plan must chase. The contexts mirror where the
* executor calls `evaluateArith`: `$((...))` and `$[...]` expansions,
* the `((...))` command, a c-style for's header, a subscript's index,
* a `${v:offset:length}` offset, the `[[` numeric comparators, and
* `let`'s operands. `test`/`[` are absent on purpose: the flat builtin
* parses integers strictly, so a bare word there never resolves as a
* variable.
*/
export function arithReads(node) {
const out = new Set();
for (const current of walkNamedOutsideDefs(node)) {
if (current.type === 'arithmetic_expansion' || current.type === 'subscript') {
arithRegionNames(current, out);
}
else if (current.type === 'compound_statement' &&
current.children[0]?.type === constants.ARITH_OPEN_TOKEN) {
arithRegionNames(current, out);
}
else if (current.type === 'c_style_for_statement') {
for (const child of current.namedChildren) {
if (child.type !== 'do_group')
arithRegionNames(child, out);
}
}
else if (current.type === 'expansion') {
substringArithNames(current, out);
}
else if (current.type === 'test_command') {
testArithNames(current, out);
}
else if (current.type === 'command') {
const nameNode = current.childForFieldName?.('name') ?? null;
if (nameNode?.text !== 'let')
continue;
for (const arg of commandArgs(current)) {
arithRegionNames(arg, out);
const literal = literalText(arg);
if (literal !== null)
for (const name of identifierNames(literal))
out.add(name);
}
}
}
return out;
}
/**
* Every plain assignment's target with what its value may hold.
*
* Per assignment: the target name, the value's literal text (null when
* no static read can spell it, empty for a bare `X=`), and, for a
* dynamic value, the names it reads -- an arithmetic read of the
* target may recurse into whichever of those values lands
* (`n=$other; $((n))` reads what `other` holds). Subscripted targets
* are skipped: an element write never replaces the whole value.
*/
export function assignmentValues(node) {
const out = [];
for (const current of walkNamedOutsideDefs(node)) {
if (current.type !== 'variable_assignment')
continue;
const nameNode = current.childForFieldName?.('name') ?? null;
if (nameNode?.type !== 'variable_name' || nameNode.text === '')
continue;
const valueNode = current.childForFieldName?.('value') ?? null;
const literal = valueNode === null ? '' : literalText(valueNode);
const reads = literal === null ? referencedNames(current) : new Set();
out.push([nameNode.text, literal, reads]);
}
return out;
}
//# sourceMappingURL=names.js.map