bcbd1bdb22
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
480 lines
15 KiB
Python
480 lines
15 KiB
Python
# ========= 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 enum import StrEnum
|
|
|
|
import tree_sitter
|
|
|
|
from mirage.shell.types import NodeType as NT
|
|
from mirage.shell.types import Redirect, RedirectKind
|
|
|
|
|
|
class ProcessSubDirection(StrEnum):
|
|
INPUT = "input"
|
|
OUTPUT = "output"
|
|
|
|
|
|
def get_text(node: tree_sitter.Node) -> str:
|
|
"""Get the text content of a node."""
|
|
return node.text.decode()
|
|
|
|
|
|
def get_command_name(node: tree_sitter.Node) -> str:
|
|
"""Get the command name string."""
|
|
for c in node.named_children:
|
|
if c.type == NT.COMMAND_NAME:
|
|
return c.text.decode()
|
|
return ""
|
|
|
|
|
|
def get_parts(node: tree_sitter.Node) -> list[tree_sitter.Node]:
|
|
"""Get command parts as child nodes.
|
|
|
|
Preserves expansion nodes for later processing.
|
|
"""
|
|
_SKIP = frozenset({NT.FILE_REDIRECT, NT.HERESTRING_REDIRECT})
|
|
return [c for c in node.named_children if c.type not in _SKIP]
|
|
|
|
|
|
def has_command_substitution(node: tree_sitter.Node) -> bool:
|
|
"""Whether the node contains a command or process substitution.
|
|
|
|
The provision planner suppresses substitution execution, so any
|
|
word carrying one expands to empty during a plan walk and the
|
|
affected estimate must degrade to UNKNOWN instead of trusting the
|
|
incomplete expansion.
|
|
"""
|
|
if node.type in (NT.COMMAND_SUBSTITUTION, NT.PROCESS_SUBSTITUTION):
|
|
return True
|
|
return any(has_command_substitution(c) for c in node.named_children)
|
|
|
|
|
|
def split_env_prefix(
|
|
parts: list[tree_sitter.Node],
|
|
) -> tuple[list[tree_sitter.Node], list[tree_sitter.Node]]:
|
|
"""Split FOO=1 BAR=2 cmd parts into (assignments, remaining).
|
|
|
|
The single structural rule for env-prefixed commands, shared by the
|
|
executor (which expands and applies the assignments) and the
|
|
provision planner (which only needs the command parts).
|
|
"""
|
|
assignments: list[tree_sitter.Node] = []
|
|
remaining: list[tree_sitter.Node] = []
|
|
saw_command_name = False
|
|
for p in parts:
|
|
if not saw_command_name and p.type == NT.VARIABLE_ASSIGNMENT:
|
|
assignments.append(p)
|
|
continue
|
|
if p.type == NT.COMMAND_NAME:
|
|
saw_command_name = True
|
|
remaining.append(p)
|
|
return assignments, remaining
|
|
|
|
|
|
def get_pipeline_commands(
|
|
node: tree_sitter.Node,
|
|
) -> tuple[list[tree_sitter.Node], list[bool]]: # noqa: E125,E501
|
|
"""Get (commands, stderr_flags) from pipeline.
|
|
|
|
Uses node.children for pipe token detection.
|
|
"""
|
|
commands: list[tree_sitter.Node] = []
|
|
stderr_flags: list[bool] = []
|
|
for c in node.children:
|
|
if c.is_named:
|
|
commands.append(c)
|
|
elif c.type in (NT.PIPE, NT.PIPE_STDERR):
|
|
stderr_flags.append(c.type == NT.PIPE_STDERR)
|
|
return commands, stderr_flags
|
|
|
|
|
|
def get_while_parts(
|
|
node: tree_sitter.Node,
|
|
) -> tuple[tree_sitter.Node, list[tree_sitter.Node]]:
|
|
"""Get (condition, body_commands) from while/until.
|
|
|
|
Returns the do_group's children list so multi-statement
|
|
bodies are preserved.
|
|
"""
|
|
nc = node.named_children
|
|
condition = nc[0]
|
|
body = list(nc[1].named_children)
|
|
return condition, body
|
|
|
|
|
|
def get_for_parts(
|
|
node: tree_sitter.Node,
|
|
) -> tuple[str, list[tree_sitter.Node], list[tree_sitter.Node]]:
|
|
"""Get (variable, values, body_commands) from for/select.
|
|
|
|
Returns the do_group's children list so multi-statement
|
|
bodies are preserved.
|
|
"""
|
|
nc = node.named_children
|
|
variable = get_text(nc[0])
|
|
values = [c for c in nc[1:] if c.type not in (NT.DO_GROUP, "ERROR")]
|
|
body = list(nc[-1].named_children)
|
|
return variable, values, body
|
|
|
|
|
|
def get_subshell_body(node: tree_sitter.Node) -> list[tree_sitter.Node]:
|
|
"""Get body commands from subshell."""
|
|
return list(node.named_children)
|
|
|
|
|
|
def get_redirect_parts(
|
|
node: tree_sitter.Node,
|
|
) -> tuple[tree_sitter.Node, str, bool, RedirectKind]: # noqa: E125,E501
|
|
"""Get (command, target, append, stream) for the first redirect.
|
|
|
|
Legacy helper — use get_redirects for full redirect support.
|
|
"""
|
|
command, redirects = get_redirects(node)
|
|
if not redirects:
|
|
return command, "", False, RedirectKind.STDOUT
|
|
r = redirects[0]
|
|
target = r.target if isinstance(r.target, str) else ""
|
|
return command, target, r.append, r.kind
|
|
|
|
|
|
def get_redirects(
|
|
node: tree_sitter.Node, # noqa: E125
|
|
) -> tuple[tree_sitter.Node, list[Redirect]]:
|
|
"""Parse all redirects from a redirected_statement."""
|
|
nc = node.named_children
|
|
command = nc[0]
|
|
redirects: list[Redirect] = []
|
|
|
|
for child in nc[1:]:
|
|
if child.type == NT.HEREDOC_REDIRECT:
|
|
body, _, quoted = get_heredoc_meta(child)
|
|
pipe_node = None
|
|
for hc in child.named_children:
|
|
if hc.type in (NT.PIPELINE, NT.COMMAND):
|
|
pipe_node = hc
|
|
break
|
|
redirects.append(
|
|
Redirect(fd=0,
|
|
target=body,
|
|
kind=RedirectKind.HEREDOC,
|
|
pipeline=pipe_node,
|
|
expand_vars=not quoted))
|
|
continue
|
|
|
|
if child.type == NT.HERESTRING_REDIRECT:
|
|
content = ""
|
|
for sc in child.children:
|
|
if sc.is_named and sc.type != "<<<":
|
|
content = get_text(sc)
|
|
break
|
|
redirects.append(
|
|
Redirect(fd=0, target=content, kind=RedirectKind.HERESTRING))
|
|
continue
|
|
|
|
if child.type != NT.FILE_REDIRECT:
|
|
continue
|
|
|
|
fd = 1
|
|
target = ""
|
|
target_node = None
|
|
kind = RedirectKind.STDOUT
|
|
append = False
|
|
dup_fd = None
|
|
|
|
for c in child.children:
|
|
if c.type == NT.FILE_DESCRIPTOR:
|
|
fd = int(get_text(c))
|
|
elif c.type == NT.REDIRECT_OUT:
|
|
pass
|
|
elif c.type == NT.REDIRECT_APPEND:
|
|
append = True
|
|
elif c.type == NT.REDIRECT_IN:
|
|
kind = RedirectKind.STDIN
|
|
fd = 0
|
|
elif c.type == NT.REDIRECT_STDERR:
|
|
kind = RedirectKind.STDERR_TO_STDOUT
|
|
elif c.type == NT.REDIRECT_BOTH:
|
|
kind = RedirectKind.STDOUT
|
|
fd = -1
|
|
elif c.type == NT.REDIRECT_BOTH_APPEND:
|
|
kind = RedirectKind.STDOUT
|
|
fd = -1
|
|
append = True
|
|
elif c.type == NT.NUMBER:
|
|
dup_fd = int(get_text(c))
|
|
|
|
_TARGET_TYPES = frozenset({
|
|
NT.WORD,
|
|
NT.CONCATENATION,
|
|
NT.SIMPLE_EXPANSION,
|
|
NT.EXPANSION,
|
|
NT.COMMAND_SUBSTITUTION,
|
|
NT.STRING,
|
|
})
|
|
for c in child.named_children:
|
|
if c.type in _TARGET_TYPES:
|
|
target = get_text(c)
|
|
target_node = c
|
|
break
|
|
|
|
if dup_fd is not None and kind == RedirectKind.STDERR_TO_STDOUT:
|
|
if fd == 2 and dup_fd == 1:
|
|
kind = RedirectKind.STDERR_TO_STDOUT
|
|
target = dup_fd
|
|
elif fd == 1 and dup_fd == 2:
|
|
kind = RedirectKind.STDOUT
|
|
fd = 1
|
|
target = 2
|
|
else:
|
|
target = dup_fd
|
|
|
|
if fd == -1:
|
|
kind = RedirectKind.STDOUT
|
|
redirects.append(
|
|
Redirect(fd=-1,
|
|
target=target,
|
|
target_node=target_node,
|
|
kind=kind,
|
|
append=append))
|
|
continue
|
|
|
|
if fd == 2 and kind != RedirectKind.STDERR_TO_STDOUT:
|
|
kind = RedirectKind.STDERR
|
|
|
|
redirects.append(
|
|
Redirect(fd=fd,
|
|
target=target,
|
|
target_node=target_node,
|
|
kind=kind,
|
|
append=append))
|
|
|
|
return command, redirects
|
|
|
|
|
|
def get_redirect_target_node(
|
|
node: tree_sitter.Node) -> tree_sitter.Node | None:
|
|
"""Get the expandable target node from the first redirect."""
|
|
_, redirects = get_redirects(node)
|
|
if not redirects:
|
|
return None
|
|
return redirects[0].target_node
|
|
|
|
|
|
def get_list_parts(
|
|
node: tree_sitter.Node,
|
|
) -> tuple[tree_sitter.Node, str, tree_sitter.Node]:
|
|
"""Get (left, op, right) from list node."""
|
|
left = node.named_children[0]
|
|
right = node.named_children[1]
|
|
op = None
|
|
for c in node.children:
|
|
if c.type in (NT.AND, NT.OR, NT.SEMI):
|
|
op = c.type
|
|
break
|
|
return left, op, right
|
|
|
|
|
|
def get_if_branches(
|
|
node: tree_sitter.Node,
|
|
) -> tuple[list[tuple[tree_sitter.Node, list[tree_sitter.Node]]],
|
|
list[tree_sitter.Node] | None]:
|
|
"""Get (branches, else_body) from if_statement.
|
|
|
|
Each branch is (condition, body_commands) where
|
|
body_commands is a list of tree-sitter nodes.
|
|
else_body is also a list of nodes, or None.
|
|
"""
|
|
nc = node.named_children
|
|
condition = nc[0]
|
|
body: list[tree_sitter.Node] = []
|
|
branches: list[tuple[tree_sitter.Node, list[tree_sitter.Node]]] = []
|
|
else_body = None
|
|
|
|
for c in nc[1:]:
|
|
if c.type == NT.ELIF_CLAUSE:
|
|
if condition is not None:
|
|
branches.append((condition, body))
|
|
ec = c.named_children
|
|
condition = ec[0]
|
|
body = list(ec[1:])
|
|
elif c.type == NT.ELSE_CLAUSE:
|
|
if condition is not None:
|
|
branches.append((condition, body))
|
|
condition = None
|
|
else_body = list(c.named_children)
|
|
else:
|
|
body.append(c)
|
|
|
|
if condition is not None:
|
|
branches.append((condition, body))
|
|
|
|
return branches, else_body
|
|
|
|
|
|
def get_case_word(node: tree_sitter.Node) -> tree_sitter.Node:
|
|
"""Get the word being matched in case."""
|
|
return node.named_children[0]
|
|
|
|
|
|
def get_case_items(
|
|
node: tree_sitter.Node,
|
|
) -> list[tuple[list[str], list[tree_sitter.Node]]]: # noqa: E125,E501
|
|
"""Get (patterns, body_statements) pairs from case.
|
|
|
|
An arm's body is every statement up to its ;; terminator, so
|
|
multi-statement arms (x) cmd1; cmd2;;) keep all commands.
|
|
"""
|
|
items: list[tuple[list[str], list[tree_sitter.Node]]] = []
|
|
for c in node.named_children:
|
|
if c.type == NT.CASE_ITEM:
|
|
patterns = []
|
|
body: list[tree_sitter.Node] = []
|
|
for child in c.children:
|
|
if not body and child.type in (NT.EXTGLOB_PATTERN, NT.WORD,
|
|
NT.CONCATENATION, NT.STRING):
|
|
patterns.append(get_text(child))
|
|
elif child.is_named and child.type != "|":
|
|
body.append(child)
|
|
if not patterns:
|
|
patterns = [get_text(c.named_children[0])]
|
|
items.append((patterns, body))
|
|
return items
|
|
|
|
|
|
def get_declaration_assignments(node: tree_sitter.Node) -> list[str]:
|
|
"""Get assignment strings from declaration_command.
|
|
|
|
Works for export, local, declare.
|
|
"""
|
|
return [
|
|
get_text(c) for c in node.named_children
|
|
if c.type == NT.VARIABLE_ASSIGNMENT
|
|
]
|
|
|
|
|
|
def get_declaration_keyword(node: tree_sitter.Node) -> str:
|
|
"""Get keyword (export/local/declare) from declaration."""
|
|
return node.children[0].type
|
|
|
|
|
|
def get_unset_names(node: tree_sitter.Node) -> list[str]:
|
|
"""Get variable names from unset_command."""
|
|
return [
|
|
get_text(c) for c in node.named_children if c.type == NT.VARIABLE_NAME
|
|
]
|
|
|
|
|
|
def get_test_argv(node: tree_sitter.Node) -> list[str]:
|
|
"""Get test arguments from test_command ([ ] or [[ ]]).
|
|
|
|
Returns the inner expression text.
|
|
"""
|
|
return [get_text(c) for c in node.named_children]
|
|
|
|
|
|
def get_command_assignments(node: tree_sitter.Node) -> list[str]:
|
|
"""Get prefix assignments from a command (A=1 B=2 cmd).
|
|
|
|
Returns list of assignment strings.
|
|
"""
|
|
return [
|
|
get_text(c) for c in node.named_children
|
|
if c.type == NT.VARIABLE_ASSIGNMENT
|
|
]
|
|
|
|
|
|
def get_negated_command(node: tree_sitter.Node) -> tree_sitter.Node:
|
|
"""Get inner command from negated_command (! cmd)."""
|
|
return node.named_children[0]
|
|
|
|
|
|
def get_heredoc_parts(redirect_node: tree_sitter.Node) -> tuple[str, str]:
|
|
"""Get (delimiter, body) from heredoc_redirect."""
|
|
delimiter = ""
|
|
body = ""
|
|
for c in redirect_node.named_children:
|
|
if c.type == NT.HEREDOC_START:
|
|
delimiter = get_text(c)
|
|
elif c.type == NT.HEREDOC_BODY:
|
|
body = get_text(c)
|
|
return delimiter, body
|
|
|
|
|
|
def get_heredoc_meta(
|
|
redirect_node: tree_sitter.Node) -> tuple[str, bool, bool]:
|
|
"""Get (body, dash, quoted) from heredoc_redirect.
|
|
|
|
- dash: True if operator was `<<-` (strip leading tabs from body lines)
|
|
- quoted: True if delimiter was wrapped in quotes (no var expansion)
|
|
"""
|
|
delimiter, body = get_heredoc_parts(redirect_node)
|
|
quoted = (delimiter.startswith("'")
|
|
and delimiter.endswith("'")) or (delimiter.startswith('"')
|
|
and delimiter.endswith('"'))
|
|
dash = False
|
|
for c in redirect_node.children:
|
|
if c.type == "<<-":
|
|
dash = True
|
|
break
|
|
if dash and body:
|
|
body = "\n".join(line.lstrip("\t") for line in body.split("\n"))
|
|
return body, dash, quoted
|
|
|
|
|
|
def get_herestring_content(node: tree_sitter.Node) -> str:
|
|
"""Get content from herestring_redirect (<<<)."""
|
|
for c in node.named_children:
|
|
if c.type == NT.HERESTRING_REDIRECT:
|
|
return get_text(c.named_children[0])
|
|
return ""
|
|
|
|
|
|
def get_process_sub_command(node: tree_sitter.Node) -> tree_sitter.Node:
|
|
"""Get inner command from process_substitution."""
|
|
return node.named_children[0]
|
|
|
|
|
|
def get_process_sub_direction(
|
|
node: tree_sitter.Node) -> ProcessSubDirection | None:
|
|
"""Return the direction marker on a process_substitution node.
|
|
|
|
`<(cmd)` is INPUT (inner stdout feeds our stdin), `>(cmd)` is OUTPUT
|
|
(our stdout feeds inner stdin). Returns None if the open token is missing.
|
|
"""
|
|
if not node.children:
|
|
return None
|
|
open_token = node.children[0].type
|
|
if open_token == "<(":
|
|
return ProcessSubDirection.INPUT
|
|
if open_token == ">(":
|
|
return ProcessSubDirection.OUTPUT
|
|
return None
|
|
|
|
|
|
def get_function_name(node: tree_sitter.Node) -> str:
|
|
"""Get function name."""
|
|
return get_text(node.named_children[0])
|
|
|
|
|
|
def get_function_body(node: tree_sitter.Node) -> list[tree_sitter.Node] | None:
|
|
"""Get function body commands.
|
|
|
|
Returns the compound_statement's children list so
|
|
multi-statement bodies are preserved.
|
|
"""
|
|
for c in node.named_children:
|
|
if c.type == NT.COMPOUND_STATEMENT:
|
|
return list(c.named_children)
|
|
return None
|