chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# ========= 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. =========
from mirage.workspace.node.execute_node import execute_node
from mirage.workspace.node.provision_node import provision_node
from mirage.workspace.node.run_tree import run_command_tree
__all__ = ["execute_node", "provision_node", "run_command_tree"]
@@ -0,0 +1,437 @@
# ========= 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 asyncio
from typing import Any
from mirage.commands.builtin.utils.safeguard import run_with_timeout
from mirage.commands.safeguard import resolve_safeguard
from mirage.io import IOResult
from mirage.shell.types import NodeType as NT
from mirage.shell.types import ShellBuiltin as SB
from mirage.types import PathSpec
from mirage.utils.path import CycleError
from mirage.workspace.executor.command import handle_command
from mirage.workspace.executor.control import BreakSignal, ContinueSignal
from mirage.workspace.expand import expand_node
from mirage.workspace.expand.argv import Argv, expand_argv
from mirage.workspace.expand.classify import classify_bare_path
from mirage.workspace.route import NO_FOLLOW_COMMANDS, UNSUPPORTED_BUILTINS
from mirage.workspace.session.shell_dirs import home_dir
from mirage.workspace.types import ExecutionNode
from mirage.shell.helpers import ( # isort: skip
ProcessSubDirection, get_command_name, get_parts,
get_process_sub_direction, get_text, split_env_prefix)
from mirage.workspace.executor.builtins import ( # isort: skip
follow_paths, handle_bash, handle_cd, handle_echo, handle_eval,
handle_export, handle_history, handle_ln, handle_local, handle_man,
handle_printenv, handle_printf, handle_read, handle_readlink,
handle_return, handle_set, handle_shift, handle_sleep, handle_source,
handle_test, handle_timeout, handle_trap, handle_unset, handle_whoami,
handle_xargs, link_flags, prepare_mv, strip_link_operands)
_CdArgs = list[str | PathSpec]
def _split_cd_options(args: _CdArgs) -> tuple[_CdArgs, str | None, bool]:
"""Split leading ``cd`` option flags from the directory operand.
Accepts the GNU ``cd`` options ``-L -P -e -@`` (and clusters such as
``-LP``) plus a ``--`` end-of-options marker; a bare ``-`` is the
OLDPWD operand, not an option.
Args:
args: The classified arguments after the ``cd`` command name.
Returns:
``(operands, bad, physical)`` where ``operands`` are the non-option
args, ``bad`` is the first unknown option character (or ``None``),
and ``physical`` is True when ``-P`` is the effective (last-wins)
mode.
"""
operands: _CdArgs = []
parsing = True
physical = False
for arg in args:
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
if parsing:
if s == "--":
parsing = False
continue
if s != "-" and len(s) >= 2 and s.startswith("-"):
bad = next((c for c in s[1:] if c not in "LPe@"), None)
if bad is None:
for c in s[1:]:
if c == "P":
physical = True
elif c == "L":
physical = False
continue
return operands, bad, physical
parsing = False
operands.append(arg)
return operands, None, physical
async def execute_command(
recurse,
dispatch,
registry,
namespace,
execute_fn,
node,
session,
stdin,
call_stack,
job_table,
cancel: asyncio.Event | None = None,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Dispatch a command node by name."""
name = get_command_name(node)
assignment_nodes, parts = split_env_prefix(get_parts(node))
prefix_assignments: list[tuple[str, str]] = []
for p in assignment_nodes:
atext = get_text(p)
if "=" not in atext:
continue
key, _, raw_val = atext.partition("=")
val_nodes = [c for c in p.named_children if c.type != NT.VARIABLE_NAME]
if val_nodes:
v = await expand_node(val_nodes[0], session, execute_fn,
call_stack)
else:
v = raw_val
prefix_assignments.append((key, v))
for k, _ in prefix_assignments:
if k in session.readonly_vars:
err = f"bash: {k}: readonly variable\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=name or k,
exit_code=1,
stderr=err)
if prefix_assignments and not name:
for k, v in prefix_assignments:
session.env[k] = v
return None, IOResult(), ExecutionNode(command=" ".join(
f"{k}={v}" for k, v in prefix_assignments),
exit_code=0)
is_function_call = name in session.functions
saved_env_overrides: dict[str, str | None] = {}
for k, v in prefix_assignments:
if not is_function_call:
saved_env_overrides[k] = session.env.get(k)
session.env[k] = v
try:
return await _dispatch_command_body(recurse, dispatch, registry,
namespace, execute_fn, node, parts,
name, session, stdin, call_stack,
job_table, cancel)
finally:
for k, prev in saved_env_overrides.items():
if prev is None:
session.env.pop(k, None)
else:
session.env[k] = prev
async def _dispatch_command_body(
recurse,
dispatch,
registry,
namespace,
execute_fn,
node,
parts,
name,
session,
stdin,
call_stack,
job_table,
cancel: asyncio.Event | None = None,
) -> tuple[Any, IOResult, ExecutionNode]:
for child in node.named_children:
if child.type == NT.HERESTRING_REDIRECT:
for sc in child.named_children:
content = await expand_node(sc, session, execute_fn,
call_stack)
stdin = content.encode() + b"\n"
break
# Process substitution: <(cmd) feeds inner stdout as stdin.
# Output direction >(cmd) is unsupported; reject early so the
# caller sees a capability gap rather than a silent no-op.
proc_sub_parts = []
clean_parts = []
for p in parts:
if hasattr(p, "type") and p.type == NT.PROCESS_SUBSTITUTION:
if get_process_sub_direction(p) == ProcessSubDirection.OUTPUT:
err = b"mirage: unsupported: process substitution >(...)\n"
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(
command=name or "process_sub", exit_code=2, stderr=err)
inner_cmds = [c for c in p.named_children if c.type == NT.COMMAND]
if inner_cmds:
io_ps = await execute_fn(get_text(inner_cmds[0]),
session_id=session.session_id)
proc_sub_parts.append(io_ps.stdout or b"")
else:
clean_parts.append(p)
if proc_sub_parts and stdin is None:
stdin = b"".join(proc_sub_parts)
parts = clean_parts
argv = await expand_argv(parts, session, execute_fn, call_stack, registry)
# Safeguards resolve against the expanded name, so `$CMD`-style
# invocations get their real command's policy.
resolved = resolve_safeguard(argv.name) if argv.name else None
timeout = (resolved.timeout_seconds if resolved is not None else None)
body = _run_argv(recurse, dispatch, registry, namespace, execute_fn, argv,
session, stdin, call_stack, job_table, cancel)
return await run_with_timeout(body, timeout, argv.name or "?")
async def _run_argv(
recurse,
dispatch,
registry,
namespace,
execute_fn,
argv: Argv,
session,
stdin,
call_stack,
job_table,
cancel: asyncio.Event | None = None,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Route one expanded command to its builtin or mount handler."""
name = argv.name
args = list(argv.args)
operands = list(argv.operands)
# ── unsupported bash builtins ──────────────
# Constructs the parser accepts but the executor cannot honor.
# Returning a clear error lets LLMs detect a capability gap instead
# of treating it as a missing binary or a silent no-op.
if name in UNSUPPORTED_BUILTINS:
err = f"mirage: unsupported builtin: {name}\n".encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command=name,
exit_code=2,
stderr=err)
# ── shell builtins ──────────────────────────
if name == SB.PWD:
out = (session.cwd + "\n").encode()
return out, IOResult(), ExecutionNode(command="pwd", exit_code=0)
if name == SB.CD:
cd_operands, bad_opt, physical = _split_cd_options(operands)
if bad_opt is not None:
err = (f"cd: -{bad_opt}: invalid option\n"
f"cd: usage: cd [-L|[-P [-e]] [-@]] [dir]\n").encode()
return None, IOResult(exit_code=2,
stderr=err), ExecutionNode(command="cd",
exit_code=2,
stderr=err)
if len(cd_operands) > 1:
err = b"cd: too many arguments\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="cd",
exit_code=1,
stderr=err)
if not cd_operands:
home = home_dir(session)
if home is None:
err = b"cd: HOME not set\n"
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="cd",
exit_code=1,
stderr=err)
return await handle_cd(dispatch,
registry.is_mount_root,
home,
session,
links=namespace.symlink_targets(),
physical=physical)
raw = cd_operands[0]
raw_str = raw.virtual if isinstance(raw, PathSpec) else str(raw)
if raw_str == "-":
old = session.env.get("OLDPWD")
if not old:
err = b"cd: OLDPWD not set\n"
return None, IOResult(exit_code=1, stderr=err), ExecutionNode(
command="cd -", exit_code=1, stderr=err)
return await handle_cd(dispatch,
registry.is_mount_root,
old,
session,
print_path=True,
links=namespace.symlink_targets(),
physical=physical)
if isinstance(raw, PathSpec):
path = raw
cdpath_target = raw.raw_path
elif raw_str.startswith("/"):
path = raw_str
cdpath_target = raw_str
else:
path = classify_bare_path(raw_str, registry, session.cwd)
cdpath_target = raw_str
return await handle_cd(dispatch,
registry.is_mount_root,
path,
session,
cdpath_target=cdpath_target,
links=namespace.symlink_targets(),
physical=physical)
if name == SB.HISTORY:
return await handle_history(registry, args, session)
if name == SB.TRUE:
return None, IOResult(), ExecutionNode(command="true", exit_code=0)
if name == SB.FALSE:
return None, IOResult(exit_code=1), ExecutionNode(command="false",
exit_code=1)
if name in (SB.SOURCE, SB.DOT):
path = operands[0] if operands else ""
return await handle_source(dispatch, execute_fn, path, session)
if name == SB.EVAL:
return await handle_eval(execute_fn, args, session)
if name in (SB.BASH, SB.SH):
return await handle_bash(execute_fn, args, session, stdin)
if name == SB.EXPORT:
return await handle_export(args, session)
if name == SB.UNSET:
return await handle_unset(args, session)
if name == SB.LOCAL:
return await handle_local(args, session)
if name == SB.PRINTENV:
var_name = args[0] if args else None
return await handle_printenv(var_name, session)
if name == SB.WHOAMI:
return await handle_whoami(session)
if name == SB.MAN:
return await handle_man(args, session, registry)
if name == SB.READ:
return await handle_read(args, session, stdin)
if name == SB.SET:
return await handle_set(args, session, call_stack=call_stack)
if name == SB.SHIFT:
return await handle_shift(args, call_stack, session=session)
if name == SB.TRAP:
return await handle_trap(session)
if name in (SB.TEST, SB.BRACKET, SB.DOUBLE_BRACKET):
return await handle_test(dispatch, operands, session)
if name == SB.ECHO:
return await handle_echo(args)
if name == SB.PRINTF:
return await handle_printf(args)
if name == SB.SLEEP:
return await handle_sleep(args, cancel=cancel)
if name == SB.RETURN:
return await handle_return(args)
if name == SB.XARGS:
return await handle_xargs(execute_fn, args, session, stdin)
if name == SB.TIMEOUT:
return await handle_timeout(execute_fn, args, session)
if name == SB.BREAK:
raise BreakSignal()
if name == SB.CONTINUE:
raise ContinueSignal()
# ── symlinks (namespace-backed; not bash builtins, not mount
# commands: they mutate the addressing layer) ──
if name == "ln" and "s" in link_flags(operands, "sfnv"):
return handle_ln(namespace, session, operands)
if name == "readlink" and not (link_flags(operands, "fenm")
& {"f", "e", "m"}):
return handle_readlink(namespace, session, operands)
# ── symlink-aware dispatch: reads follow links (open(2)); rm/mv act
# on the link entry itself (lstat semantics) ──
post_unlink: str | None = None
if namespace.symlinks:
try:
if name == "rm":
operands, removed = strip_link_operands(namespace, operands)
if removed and not any(
isinstance(a, PathSpec) for a in operands):
return None, IOResult(), ExecutionNode(command=name,
exit_code=0)
elif name == "mv":
operands, post_unlink, early = await prepare_mv(
namespace, dispatch, operands)
if early is not None:
return early
elif name not in NO_FOLLOW_COMMANDS:
operands = follow_paths(namespace, operands)
except CycleError as exc:
err = (f"{name}: {exc}: "
f"Too many levels of symbolic links\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=name,
exit_code=1,
stderr=err)
argv = argv.with_operands(operands)
# ── mount command (default) ─────────────────
stdout, io, exec_node = await handle_command(recurse,
dispatch,
registry,
argv.words,
session,
stdin,
call_stack,
job_table=job_table,
namespace=namespace)
if io.exit_code == 0 and namespace.symlinks:
if name == "rm":
for item in operands:
if isinstance(item, PathSpec):
namespace.purge_under(item.virtual)
if post_unlink is not None:
namespace.unlink(post_unlink)
return stdout, io, exec_node
@@ -0,0 +1,392 @@
# ========= 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 asyncio
from functools import partial
from typing import Any, Callable
from mirage.io import IOResult
from mirage.io.stream import async_chain
from mirage.shell.arith import evaluate_arith
from mirage.shell.call_stack import CallStack
from mirage.shell.errors import ArithError
from mirage.shell.job_table import JobTable
from mirage.shell.node_kind import NodeKind, node_kind
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.shell.types import NodeType as NT
from mirage.workspace.abort import MirageAbortError
from mirage.workspace.executor.control import (handle_case, handle_for,
handle_if, handle_select,
handle_until, handle_while)
from mirage.workspace.executor.pipes import (handle_connection, handle_pipe,
handle_subshell)
from mirage.workspace.executor.redirect import handle_redirect
from mirage.workspace.expand import (expand_and_classify, expand_node,
expand_redirects)
from mirage.workspace.expand.globs import resolve_globs
from mirage.workspace.expand.node import expand_arith
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.node.command_dispatch import execute_command
from mirage.workspace.node.program import execute_program
from mirage.workspace.node.test_expr import expand_test_expr
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
from mirage.shell.helpers import ( # isort: skip
get_case_items, get_case_word, get_declaration_keyword, get_for_parts,
get_function_body, get_function_name, get_if_branches, get_list_parts,
get_negated_command, get_pipeline_commands, get_redirects,
get_subshell_body, get_text, get_unset_names, get_while_parts)
from mirage.workspace.executor.builtins import ( # isort: skip
handle_export, handle_local, handle_readonly, handle_test, handle_unset)
async def _recurse_reassociated(
recurse: Callable,
dispatch: Callable,
execute_fn: Callable,
registry: MountRegistry,
redirects: list,
right: Any,
node: Any,
session: Session,
stdin: Any = None,
call_stack: CallStack | None = None,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Recurse wrapper for a re-associated trailing redirect.
Executes the list's last command with the hoisted redirects,
expanding targets only at that point (after the left side ran, so
cwd changes apply); every other node recurses normally.
Args:
recurse (Callable): the plain execute_node recursion.
dispatch (Callable): VFS op dispatcher.
execute_fn (Callable): recursive execute (for expansions).
registry (MountRegistry): mount registry.
redirects (list): parsed redirects hoisted off the list.
right (Any): the list's last command node.
node (Any): node being executed by handle_connection.
session (Session): shell session state.
stdin (Any): input stream.
call_stack (CallStack | None): shell call stack.
"""
if node is not right:
return await recurse(node, session, stdin, call_stack)
expanded, pipe_node = await expand_redirects(redirects, session,
execute_fn, registry,
call_stack)
stdout, io, exec_node = await handle_redirect(recurse, dispatch, right,
expanded, session, stdin,
call_stack)
if pipe_node is not None and stdout is not None:
stdout, io2, exec_node2 = await recurse(pipe_node, session, stdout,
call_stack)
io = await io.merge(io2)
exec_node = exec_node2
return stdout, io, exec_node
async def execute_node(
dispatch: Callable,
registry: MountRegistry,
namespace: Namespace,
job_table: JobTable,
execute_fn: Callable,
agent_id: str,
node: Any,
session: Session,
stdin: Any = None,
call_stack: CallStack | None = None,
cancel: asyncio.Event | None = None,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Walk tree-sitter AST and dispatch each node.
Args:
dispatch (Callable): VFS op dispatcher (op, path, **kw).
registry (MountRegistry): mount registry for path resolution.
namespace (Namespace): addressing authority for symlink ops.
job_table (JobTable): background job management.
execute_fn (Callable): recursive execute (for source/eval).
agent_id (str): current agent ID for jobs.
node (Any): tree-sitter node to execute.
session (Session): shell session state.
stdin (Any): input stream.
call_stack (CallStack): shell call stack.
cancel (asyncio.Event | None): event used to abort mid-flight.
"""
if cancel is not None and cancel.is_set():
raise MirageAbortError()
cs = call_stack or CallStack()
recurse = partial(execute_node,
dispatch,
registry,
namespace,
job_table,
execute_fn,
agent_id,
cancel=cancel)
kind = node_kind(node)
if kind == NodeKind.COMMENT:
return None, IOResult(), ExecutionNode(command="", exit_code=0)
# ── program (root / semicolons) ─────────────
if kind == NodeKind.PROGRAM:
return await execute_program(recurse, node, session, stdin, cs,
job_table, agent_id)
# ── command ─────────────────────────────────
if kind == NodeKind.COMMAND:
return await execute_command(recurse,
dispatch,
registry,
namespace,
execute_fn,
node,
session,
stdin,
cs,
job_table,
cancel=cancel)
# ── pipeline ────────────────────────────────
if kind == NodeKind.PIPELINE:
commands, stderr_flags = get_pipeline_commands(node)
return await handle_pipe(recurse, commands, stderr_flags, session,
stdin, cs)
# ── list (&&, ||) ───────────────────────────
if kind == NodeKind.LIST:
left, op, right = get_list_parts(node)
return await handle_connection(recurse, left, op, right, session,
stdin, cs)
# ── redirected statement ────────────────────
if kind == NodeKind.REDIRECT:
command, redirects = get_redirects(node)
if command.type == NT.LIST:
# tree-sitter hoists a trailing redirect over the whole
# &&/|| list; bash binds it to the last command:
# redirected(list(L, op, R), r) == list(L, op, redirected(R, r))
# Re-associate and defer target expansion until R runs, so
# `cd /x && echo hi > f` writes under /x. Compound and
# subshell bodies keep the whole-body redirect (bash group
# semantics).
left, op, right = get_list_parts(command)
wrapped = partial(_recurse_reassociated, recurse, dispatch,
execute_fn, registry, redirects, right)
return await handle_connection(wrapped, left, op, right, session,
stdin, cs)
expanded_redirects, pipe_node = await expand_redirects(
redirects, session, execute_fn, registry, cs)
stdout, io, exec_node = await handle_redirect(recurse, dispatch,
command,
expanded_redirects,
session, stdin, cs)
if pipe_node is not None and stdout is not None:
stdout, io2, exec_node2 = await recurse(pipe_node, session, stdout,
cs)
io = await io.merge(io2)
exec_node = exec_node2
return stdout, io, exec_node
# ── subshell ────────────────────────────────
if kind == NodeKind.SUBSHELL:
body = get_subshell_body(node)
return await handle_subshell(recurse, body, session, stdin, cs)
# ── arithmetic command ((( ... ))) ──────────
if (kind == NodeKind.COMPOUND and node.children
and node.children[0].type == NT.ARITH_OPEN):
text = get_text(node)
expr = await expand_arith(node, session, execute_fn, cs)
try:
value, updates = evaluate_arith(expr, session.env)
except ArithError as exc:
err = f"bash: ((: {expr}: {exc}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=text,
exit_code=1,
stderr=err)
for name in updates:
if name in session.readonly_vars:
err = f"bash: {name}: readonly variable\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=text,
exit_code=1,
stderr=err)
session.env.update(updates)
code = 0 if value != 0 else 1
return None, IOResult(exit_code=code), ExecutionNode(command=text,
exit_code=code)
# ── compound statement ({ ... }) ───────────
if kind == NodeKind.COMPOUND:
all_stdout: list = []
merged_io = IOResult()
last_exec = ExecutionNode(command="{}", exit_code=0)
for child in node.named_children:
if child.type == NT.COMMENT:
continue
stdout, io, last_exec = await recurse(child, session, stdin, cs)
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and child.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
if len(all_stdout) == 1:
return all_stdout[0], merged_io, last_exec
combined = async_chain(*all_stdout) if all_stdout else None
return combined, merged_io, last_exec
# ── if ──────────────────────────────────────
if kind == NodeKind.IF:
branches, else_body = get_if_branches(node)
return await handle_if(recurse, branches, else_body, session, stdin,
cs)
# ── for / select ────────────────────────────
if kind in (NodeKind.FOR, NodeKind.SELECT):
var, values, body = get_for_parts(node)
classified = await expand_and_classify(values, session, execute_fn,
registry, session.cwd, cs)
# The loop word list is consumed by the shell (WordPolicy.SHELL):
# globs resolve to matches before iteration starts.
classified = await resolve_globs(classified, registry)
if kind == NodeKind.SELECT:
return await handle_select(recurse, var, classified, body, session,
stdin, cs)
return await handle_for(recurse, var, classified, body, session, stdin,
cs)
# ── while / until ───────────────────────────
if kind in (NodeKind.WHILE, NodeKind.UNTIL):
condition, body = get_while_parts(node)
if kind == NodeKind.UNTIL:
return await handle_until(recurse, condition, body, session, stdin,
cs)
return await handle_while(recurse, condition, body, session, stdin, cs)
# ── case ────────────────────────────────────
if kind == NodeKind.CASE:
word_node = get_case_word(node)
word = await expand_node(word_node, session, execute_fn, cs)
items = get_case_items(node)
return await handle_case(recurse, word, items, session, stdin, cs)
# ── function definition ─────────────────────
if kind == NodeKind.FUNCTION_DEF:
name = get_function_name(node)
body = get_function_body(node)
session.functions[name] = body
return None, IOResult(), ExecutionNode(command=f"function {name}",
exit_code=0)
# ── declaration (export/local/declare/readonly) ──
if kind == NodeKind.DECLARATION:
keyword = get_declaration_keyword(node)
assignments = []
flag_chars: set[str] = set()
for child in node.named_children:
if child.type == NT.VARIABLE_ASSIGNMENT:
val_nodes = [
c for c in child.named_children
if c.type != NT.VARIABLE_NAME
]
if val_nodes and val_nodes[0].type == NT.ARRAY:
key = get_text(child).partition("=")[0]
items = [
await expand_node(ac, session, execute_fn, cs)
for ac in val_nodes[0].named_children
]
session.arrays[key] = items
continue
expanded = await expand_node(child, session, execute_fn, cs)
assignments.append(expanded)
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
NT.CONCATENATION, NT.WORD):
expanded = await expand_node(child, session, execute_fn, cs)
if not expanded:
continue
if expanded.startswith("-") and len(expanded) > 1:
flag_chars.update(expanded[1:])
else:
assignments.append(expanded)
if keyword == NT.LOCAL:
return await handle_local(assignments, session)
if keyword == "readonly" or "r" in flag_chars:
return await handle_readonly(assignments, session)
return await handle_export(assignments, session)
# ── unset ───────────────────────────────────
if kind == NodeKind.UNSET:
names = get_unset_names(node)
return await handle_unset(names, session)
# ── test ([ ] or [[ ]]) ─────────────────────
if kind == NodeKind.TEST:
expanded = await expand_test_expr(node, session, execute_fn, cs,
registry)
return await handle_test(dispatch, expanded, session)
# ── negated command ─────────────────────────
if kind == NodeKind.NEGATED:
inner = get_negated_command(node)
stdout, io, exec_node = await recurse(inner, session, stdin, cs)
io = IOResult(
exit_code=0 if io.exit_code != 0 else 1,
stderr=io.stderr,
reads=io.reads,
writes=io.writes,
cache=io.cache,
)
exec_node.exit_code = io.exit_code
return stdout, io, exec_node
# ── variable assignment at top level ────────
if kind == NodeKind.VAR_ASSIGN:
text = get_text(node)
if "=" in text:
key, _, val = text.partition("=")
if key in session.readonly_vars:
err = f"bash: {key}: readonly variable\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=text,
exit_code=1,
stderr=err)
val_nodes = [
c for c in node.named_children if c.type != NT.VARIABLE_NAME
]
if val_nodes and val_nodes[0].type == NT.ARRAY:
items = []
for ac in val_nodes[0].named_children:
items.append(await expand_node(ac, session, execute_fn,
cs))
session.arrays[key] = items
session.env.pop(key, None)
return None, IOResult(), ExecutionNode(command=text,
exit_code=0)
if val_nodes:
val = await expand_node(val_nodes[0], session, execute_fn, cs)
session.env[key] = val
session.arrays.pop(key, None)
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
raise TypeError(f"unsupported tree-sitter node type: {node.type}")
+102
View File
@@ -0,0 +1,102 @@
# ========= 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. =========
from typing import Any
from mirage.io import IOResult
from mirage.io.stream import async_chain, materialize
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.shell.types import NodeType as NT
from mirage.utils.errors import format_fs_error
from mirage.workspace.executor.jobs import handle_background
from mirage.workspace.types import ExecutionNode
async def execute_program(
recurse,
node,
session,
stdin,
call_stack,
job_table,
agent_id,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Execute program node (root / semicolon-separated)."""
children = node.children
all_stdout: list = []
merged_io = IOResult()
last_exec = ExecutionNode(command="", exit_code=0)
i = 0
while i < len(children):
child = children[i]
if (not child.is_named or child.type == NT.ERROR
or child.type == NT.COMMENT):
if child.type == NT.SEMI:
i += 1
continue
i += 1
continue
# Check for background: named node followed by & token
is_bg = (i + 1 < len(children)
and children[i + 1].type == NT.BACKGROUND)
if is_bg:
stdout, io, last_exec = await handle_background(
recurse, child, None, session, job_table, agent_id, stdin,
call_stack)
i += 2
else:
stdout, io, last_exec = await recurse(child, session, stdin,
call_stack)
# Materialize stdout so lazy exit codes (e.g. from
# exit_on_empty in grep) are finalized before $? is set.
drain_err: bytes | None = None
try:
stdout = await materialize(stdout)
except OSError as exc:
# Lazy reads (head/tail opening the stream mid-pipeline) can
# fail on the first pull; format as a GNU coreutils line,
# respelling the path as typed via the operands the leaf
# node carries, mirroring the eager executor chokepoint.
cmd_name = (last_exec.command.split()[0]
if last_exec.command else "")
drain_err = format_fs_error(cmd_name, exc, last_exec.paths)
stdout = None
except Exception as exc:
drain_err = f"{exc}\n".encode()
stdout = None
io.sync_exit_code()
if drain_err is not None:
existing = await materialize(io.stderr) or b""
io.stderr = existing + drain_err
io.exit_code = 1
session.last_exit_code = io.exit_code
i += 1
if stdout is not None:
all_stdout.append(stdout)
merged_io = await merged_io.merge(io)
if (io.exit_code != 0 and session.shell_options.get("errexit")
and not is_bg and child.type not in ERREXIT_EXEMPT_TYPES):
merged_io.exit_code = io.exit_code
break
if len(all_stdout) == 1:
return all_stdout[0], merged_io, last_exec
combined = async_chain(*all_stdout) if all_stdout else None
return combined, merged_io, last_exec
@@ -0,0 +1,315 @@
# ========= 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. =========
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable
from mirage.provision import Precision, ProvisionResult
from mirage.shell.node_kind import NodeKind, node_kind
from mirage.shell.types import NodeType as NT
from mirage.shell.types import RedirectKind
from mirage.shell.types import ShellBuiltin as SB
from mirage.types import PathSpec
from mirage.workspace.expand import (classify_parts, expand_and_classify,
expand_parts, expand_redirects)
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.provision.builtins import handle_builtin_provision
from mirage.workspace.provision.command import handle_command_provision
from mirage.workspace.provision.control import (handle_for_provision,
handle_function_provision,
handle_if_provision,
handle_while_provision)
from mirage.workspace.provision.pipes import (handle_connection_provision,
handle_pipe_provision)
from mirage.workspace.provision.redirect import handle_redirect_provision
from mirage.workspace.provision.rollup import rollup_list, rollup_pipe
from mirage.workspace.session import Session
from mirage.shell.helpers import ( # isort: skip
get_case_items, get_command_name, get_for_parts, get_function_body,
get_function_name, get_if_branches, get_list_parts, get_negated_command,
get_parts, get_pipeline_commands, get_redirects, get_subshell_body,
get_text, get_while_parts, has_command_substitution, split_env_prefix)
# eval / source execute their payload, so they are NOT free builtins:
# leaving them out lets them fall through to command resolution, which
# honestly reports UNKNOWN instead of a zero-cost EXACT.
_BUILTIN_NAMES = frozenset({
SB.CD,
SB.TRUE,
SB.FALSE,
SB.EXPORT,
SB.UNSET,
SB.LOCAL,
SB.PRINTENV,
SB.READ,
SB.SET,
SB.SHIFT,
SB.TRAP,
SB.TEST,
SB.BRACKET,
SB.DOUBLE_BRACKET,
SB.WAIT,
SB.FG,
SB.KILL,
SB.JOBS,
SB.PS,
SB.ECHO,
SB.PRINTF,
SB.SLEEP,
"return",
"break",
"continue",
})
@dataclass
class PlanScope:
"""Walk-local planner state.
Function definitions seen during this plan are recorded here (not
on the session: planning must not mutate shell state), and
`planning` guards recursive functions from looping the planner.
"""
functions: dict[str, list] = field(default_factory=dict)
planning: set[str] = field(default_factory=set)
async def _provision_redirected(
recurse: Callable,
registry: MountRegistry,
namespace: Namespace | None,
execute_fn: Callable,
command: Any,
redirects: list,
session: Session,
) -> ProvisionResult:
"""Plan one redirected command: expand targets, cost, degrade.
Args:
recurse (Callable): the provision recursion.
registry (MountRegistry): mount registry.
namespace (Namespace | None): addressing authority.
execute_fn (Callable): recursive execute (for expansions).
command (Any): the redirected command node.
redirects (list): parsed redirects.
session (Session): shell session state.
"""
expanded, pipe_node = await expand_redirects(redirects, session,
execute_fn, registry)
# A cmdsub target expands empty under provision, so its
# classification is garbage; the precision degrade below keeps
# the plan honest without costing a phantom write (mirrors TS).
targets = [
(r.kind, r.target) for r in expanded
if r.kind in (RedirectKind.STDIN, RedirectKind.STDOUT) and isinstance(
r.target, PathSpec) and not r.target.virtual.startswith("/dev/")
and not (r.target_node is not None
and has_command_substitution(r.target_node))
]
result = await handle_redirect_provision(recurse, registry, command,
targets, session, namespace)
if any(r.target_node is not None
and has_command_substitution(r.target_node) for r in redirects):
# A suppressed substitution hid the real redirect target.
result.precision = Precision.UNKNOWN
if pipe_node is not None:
return rollup_pipe([result, await recurse(pipe_node, session)])
return result
async def _provision_reassociated(
recurse: Callable,
registry: MountRegistry,
namespace: Namespace | None,
execute_fn: Callable,
redirects: list,
right: Any,
node: Any,
session: Session,
) -> ProvisionResult:
"""Provision recurse wrapper for a re-associated trailing redirect.
Mirrors the executor: the list's last command carries the hoisted
redirects; every other node provisions normally.
Args:
recurse (Callable): the provision recursion.
registry (MountRegistry): mount registry.
namespace (Namespace | None): addressing authority.
execute_fn (Callable): recursive execute (for expansions).
redirects (list): parsed redirects hoisted off the list.
right (Any): the list's last command node.
node (Any): node being provisioned by the connection handler.
session (Session): shell session state.
"""
if node is not right:
return await recurse(node, session)
return await _provision_redirected(recurse, registry, namespace,
execute_fn, right, redirects, session)
async def provision_node(
registry: MountRegistry,
dispatch: Callable,
execute_fn: Callable,
namespace: Namespace | None,
node: Any,
session: Session,
scope: PlanScope | None = None,
) -> ProvisionResult:
"""Walk tree-sitter AST and estimate execution cost.
Dispatches on the same NodeKind classification as the executor
(`mirage.shell.classify`), so every construct the executor runs
has a planner branch; kinds neither walker supports fall through
to an honest UNKNOWN.
Args:
registry (MountRegistry): mount registry for path resolution.
dispatch (Callable): VFS op dispatcher (op, path, **kw).
execute_fn (Callable): recursive execute (for expansions).
node (Any): tree-sitter node to plan.
session (Session): shell session state.
scope (PlanScope | None): walk-local planner state; created at
the root and threaded through recursion.
"""
plan_scope = scope if scope is not None else PlanScope()
recurse = partial(provision_node,
registry,
dispatch,
execute_fn,
namespace,
scope=plan_scope)
kind = node_kind(node)
if kind == NodeKind.COMMENT:
return ProvisionResult(precision=Precision.EXACT)
if kind in (NodeKind.PROGRAM, NodeKind.SUBSHELL, NodeKind.COMPOUND):
if kind == NodeKind.SUBSHELL:
body = get_subshell_body(node)
else:
body = [c for c in node.named_children if c.type != NT.COMMENT]
children = []
for child in body:
children.append(await recurse(child, session))
if not children:
return ProvisionResult(precision=Precision.EXACT)
return rollup_list(";", children)
if kind == NodeKind.COMMAND:
name = get_command_name(node)
func_body = plan_scope.functions.get(name)
if func_body is None:
func_body = session.functions.get(name)
if func_body is not None:
return await handle_function_provision(recurse, name, func_body,
plan_scope.planning,
session)
if name in _BUILTIN_NAMES:
return await handle_builtin_provision()
_, parts = split_env_prefix(get_parts(node))
if not parts:
return ProvisionResult(precision=Precision.EXACT)
expanded = await expand_parts(parts, session, execute_fn)
classified = classify_parts(expanded, registry, session.cwd)
result = await handle_command_provision(registry, classified, session,
namespace)
if any(has_command_substitution(p) for p in parts):
# The plan walk suppressed the substitution, so the operand
# list is incomplete: the totals are floors, not answers.
result.precision = Precision.UNKNOWN
return result
if kind == NodeKind.PIPELINE:
commands, _ = get_pipeline_commands(node)
return await handle_pipe_provision(recurse, commands, session)
if kind == NodeKind.LIST:
left, op, right = get_list_parts(node)
return await handle_connection_provision(recurse, left, op, right,
session)
if kind == NodeKind.REDIRECT:
command, redirects = get_redirects(node)
if command.type == NT.LIST:
# Mirror the executor: a trailing redirect hoisted over an
# &&/|| list binds to the last command.
left, op, right = get_list_parts(command)
wrapped = partial(_provision_reassociated, recurse, registry,
namespace, execute_fn, redirects, right)
return await handle_connection_provision(wrapped, left, op, right,
session)
return await _provision_redirected(recurse, registry, namespace,
execute_fn, command, redirects,
session)
if kind == NodeKind.IF:
branches, else_body = get_if_branches(node)
return await handle_if_provision(recurse, branches, else_body, session)
if kind == NodeKind.FOR:
_, values, body = get_for_parts(node)
if any(has_command_substitution(v) for v in values):
# The iteration count comes from a suppressed substitution:
# plan one pass as a floor and degrade.
result = await handle_for_provision(recurse, body, 1, session)
result.precision = Precision.UNKNOWN
return result
classified = await expand_and_classify(values, session, execute_fn,
registry, session.cwd)
n = len(classified) or 1
return await handle_for_provision(recurse, body, n, session)
if kind == NodeKind.SELECT:
# select re-prompts until break: unbounded like while.
_, _, body = get_for_parts(node)
return await handle_while_provision(recurse, body, session)
if kind in (NodeKind.WHILE, NodeKind.UNTIL):
_, body = get_while_parts(node)
return await handle_while_provision(recurse, body, session)
if kind == NodeKind.CASE:
items = get_case_items(node)
children = []
for _, body in items:
if not body:
continue
stmts = [await recurse(stmt, session) for stmt in body]
children.append(stmts[0] if len(stmts) ==
1 else rollup_list(";", stmts))
if children:
return rollup_list("||", children)
return ProvisionResult(precision=Precision.EXACT)
if kind == NodeKind.FUNCTION_DEF:
name = get_function_name(node)
body = get_function_body(node)
if name and body is not None:
plan_scope.functions[name] = body
return await handle_builtin_provision()
if kind in (NodeKind.DECLARATION, NodeKind.UNSET, NodeKind.TEST,
NodeKind.VAR_ASSIGN):
return await handle_builtin_provision()
if kind == NodeKind.NEGATED:
inner = get_negated_command(node)
return await recurse(inner, session)
return ProvisionResult(command=get_text(node), precision=Precision.UNKNOWN)
+91
View File
@@ -0,0 +1,91 @@
# ========= 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 asyncio
from typing import Any, Callable
from mirage.commands.builtin.utils.safeguard import apply_safeguard
from mirage.io import IOResult
from mirage.io.types import materialize
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.job_table import JobTable
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.node.execute_node import execute_node
from mirage.workspace.session import Session
from mirage.workspace.types import ExecutionNode
async def run_command_tree(
dispatch: Callable,
registry: MountRegistry,
namespace: Namespace,
job_table: JobTable,
execute_fn: Callable,
agent_id: str,
ast: Any,
session: Session,
stdin: Any,
cancel: asyncio.Event | None,
) -> tuple[IOResult, ExecutionNode]:
"""Run a parsed command tree and finalize its output stream.
Executes the AST root, then applies the value barrier and the
command safeguard, folding the safeguard's stderr and exit code
into the result. This is the seam between the Workspace shell
(sessions, drift, recording) and the command executor: a caller
hands in a parsed tree plus its dependencies and gets back the
resolved result. Byte recording is the caller's responsibility, so
the active recorder spans the stream consumption that happens
inside the barrier here.
Args:
dispatch (Callable): VFS op dispatcher (op, path, **kw).
registry (MountRegistry): mount registry for path resolution.
namespace (Namespace): addressing authority for symlink ops.
job_table (JobTable): background job management.
execute_fn (Callable): recursive execute (for source/eval).
agent_id (str): current agent ID for jobs.
ast (Any): parsed tree-sitter root node.
session (Session): shell session state.
stdin (Any): input stream.
cancel (asyncio.Event | None): event used to abort mid-flight.
Returns:
tuple[IOResult, ExecutionNode]: the finalized result (with
``io.stdout`` set to the barrier-resolved value) and the
execution node.
"""
stdout, io, exec_node = await execute_node(
dispatch,
registry,
namespace,
job_table,
execute_fn,
agent_id,
ast,
session,
stdin,
cancel=cancel,
)
stdout = await apply_barrier(stdout, io, BarrierPolicy.VALUE)
if io.safeguard is not None:
stdout, sg_io = await apply_safeguard(stdout, io.safeguard)
if sg_io.stderr is not None:
existing = await materialize(io.stderr)
io.stderr = existing + await materialize(sg_io.stderr)
if sg_io.exit_code != 0:
io.exit_code = sg_io.exit_code
io.stdout = stdout
return io, exec_node
+68
View File
@@ -0,0 +1,68 @@
# ========= 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. =========
from mirage.shell.types import NodeType as NT
from mirage.workspace.expand import expand_node
async def expand_test_expr(node, session, execute_fn, cs, registry):
"""Expand test_command [ ... ] into a flat list of strings."""
result = []
for child in node.named_children:
if child.type == NT.BINARY_EXPRESSION:
for part in child.children:
if part.type in ("=", "!=", "=="):
result.append(part.text.decode())
elif part.is_named:
exp = await expand_node(part, session, execute_fn, cs)
result.append(exp)
elif child.type == NT.UNARY_EXPRESSION:
for part in child.children:
if part.type == NT.TEST_OPERATOR:
result.append(part.text.decode())
elif part.is_named:
exp = await expand_node(part, session, execute_fn, cs)
result.append(exp)
elif child.type == NT.NEGATION_EXPRESSION:
result.append("!")
for part in child.named_children:
sub = await _expand_inner(part, session, execute_fn, cs)
result.extend(sub)
else:
exp = await expand_node(child, session, execute_fn, cs)
result.append(exp)
return result
async def _expand_inner(node, session, execute_fn, cs):
"""Expand a single test expression node into args."""
result = []
if node.type == NT.BINARY_EXPRESSION:
for part in node.children:
if part.type in ("=", "!=", "=="):
result.append(part.text.decode())
elif part.is_named:
exp = await expand_node(part, session, execute_fn, cs)
result.append(exp)
elif node.type == NT.UNARY_EXPRESSION:
for part in node.children:
if part.type == NT.TEST_OPERATOR:
result.append(part.text.decode())
elif part.is_named:
exp = await expand_node(part, session, execute_fn, cs)
result.append(exp)
else:
exp = await expand_node(node, session, execute_fn, cs)
result.append(exp)
return result