* feat(tree-sitter-bash): scaffold pure-TypeScript bash parser package
Add packages/tree-sitter-bash with the SyntaxNode tree model (UTF-16
code-unit offsets, tree-sitter-bash named-node type names), a parse
budget (deadline + node cap) with an aborted ParseResult variant, and
a placeholder parse() to be replaced by the real lexer/parser.
* feat(tree-sitter-bash): add lexer and core recursive-descent parser
Cover the permission-analysis grammar subset: lists, pipelines,
commands, words, quotes, expansions, command/process substitution,
subshells, the full redirect operator set, heredocs and comments,
with tree-sitter-bash named-node type names. Long scan loops check
the parse deadline via budget.progress() so large literals cannot
exhaust the node cap; parse depth is bounded on all recursion
chains.
* feat(tree-sitter-bash): support the full bash grammar
Add compound commands (if/while/until/for/c-style-for/select/case/
function/compound/do_group), test commands with reference-exact
extglob/regex right-hand-side rules, a Pratt expression engine for
arithmetic expansion shared across arith/c-for/test modes, arrays
and subscripts, declaration/unset commands, ansi-c/translated
strings and brace expressions. Reserved words are recognized only
in statement position. Case-aware balanced scanning keeps command
substitutions intact around case items, expression leftovers are
kept as ERROR nodes instead of dropped, and recursion depth is
bounded per chain (substitution 150, parse 500, lexer scan 1024).
* test(tree-sitter-bash): add differential fixtures, corpus, fuzz and perf suites
Turn the ad-hoc wasm comparison work into permanent infrastructure:
a differential helper pinning reference-equivalent and
known-difference samples against the real tree-sitter-bash wasm
(478 match / 79 known-diff fixtures plus the official v0.25.0
corpus), a three-way consistency check between fixtures, the
known-difference registry and the README, deterministic seeded
fuzz with tree-integrity assertions, and performance smoke tests.
Converges 15 further divergence groups found by systematic probing
and documents the rest; the full suite is 853 tests in ~4s.
* feat(agent-core-v2): add App-scope bashParser service
Wrap the pure @moonshot-ai/tree-sitter-bash package as
IBashParserService (L1, no dependencies): parse(source, options)
returns a wire-safe BashParseResult whose nodes drop the cyclic
parent link so trees can cross the RPC boundary. Budget exhaustion
surfaces as { ok: false, reason: 'aborted' }, malformed input as
hasError — never a throw.
* chore: add changeset for the bash parser service
* chore: update pnpmDeps hash after adding tree-sitter-bash package
* fix(agent-core-v2): register bashParser service with ScopeActivation
The registration was written against the removed InstantiationType API
(#/_base/di/extensions); switch to ScopeActivation.OnDemand from
#/_base/di/scope so the package typechecks and the service registers.
* feat(kimi-inspect): add Bash Parser view
Add a fourth icon-rail tab that exercises the App-scope bash parser
service over the debug RPC surface: a source textarea with a parse
budget (timeoutMs / maxNodes), a dropdown of curated examples adapted
from the tree-sitter-bash differential fixtures, and an expandable
syntax tree with per-node type, UTF-16 range and leaf text, plus
hasError / aborted / node-count badges.
* fix(agent-core-v2): snapshot bash syntax trees iteratively
A long left-associative chain (e.g. an arithmetic expression with a
few thousand operands) parses into a tree thousands of levels deep
while still within budget; the recursive DTO conversion then overflowed
the JS call stack and made parse throw RangeError, breaking the
never-throws contract. Convert the tree with an explicit stack, the
same approach as the parser's own materialize.
* feat(kimi-inspect): add a deep-arithmetic example to the Bash Parser view
A thousand-operand left-associative chain fills the textarea with a
thousand-level binary_expression tree — the shape that once overflowed
the DTO conversion. Deeper chains still parse in-process but cannot
cross the JSON RPC transport (V8 call-stack limit in serialization),
so the example stays within the wire limit.
* chore: update pnpmDeps hash for the rebased lockfile
The rebase onto main merged pnpm-lock.yaml, invalidating the recorded
fetchPnpmDeps hash; use the hash CI computed for the merged lockfile.
|
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
| vitest.config.ts | ||
@moonshot-ai/tree-sitter-bash
A pure-TypeScript bash parser that produces a syntax tree whose named node types match tree-sitter-bash 0.25.0 one-to-one, built for agent-side command permission analysis.
- No native addons; offsets are UTF-16 code units (
node.textis always a directsource.slice(startIndex, endIndex)). - Parsing runs under a hard budget and never throws: budget exhaustion
returns
{ ok: false, reason: 'aborted' }, malformed input returns a degraded tree withhasError: true. - Correctness is enforced by a differential test suite that parses hundreds
of samples — plus the complete official tree-sitter-bash 0.25.0 corpus —
with both this parser and the real tree-sitter-bash (wasm) and compares
the trees byte-for-byte (see
test/fixtures/). The only remaining deviations are the documented ones listed under Known differences below.
API
import { parse } from '@moonshot-ai/tree-sitter-bash';
const result = parse('git status && rm -rf /');
if (result.ok) {
// result.rootNode: program → list → command …
}
parse(source: string, options?: ParseOptions): ParseResultParseResultis one of:{ ok: true; rootNode: SyntaxNode; hasError: boolean }{ ok: false; reason: 'aborted' }
ParseOptions:{ timeoutMs?: number; maxNodes?: number }(defaults 50 ms / 50 000 nodes;timeoutMs: Infinitydisables the time check).SyntaxNode:{ type, text, startIndex, endIndex, isNamed, parent, children, namedChildren }. Named node types come from tree-sitter-bash'snode-types.json;childrenincludes the anonymous (punctuation / keyword token) nodes as well,namedChildrenonly the named ones, anddescendantsOfType(root, ...types)walks named descendants.- Depth caps (pathological nesting degrades locally — ERROR nodes,
hasError: true— instead of overflowing the stack):MAX_SUBSTITUTION_DEPTH = 150for the $( … ) /…/ <( … ) sub-parser chain (the deepest per level, ~13 frames — measured stack overflow at ~380–500 levels on a default Node stack, so the cap keeps a ≥2.5× margin);MAX_PARSE_DEPTH = 500for subshell/compound/${…}/expression nesting (verified safe at those caps);MAX_SCAN_DEPTH = 1024for the lexer's own scan recursion.
Budget semantics
The budget caps total work, not input size: a multi-hundred-KB string or
heredoc body parses fine (it produces only a handful of nodes), but a
source whose tree would exceed 50 000 nodes — a megabyte-long case
statement, tens of thousands of arithmetic expressions — is aborted under
the defaults. Every created node ticks the budget; long character-level
scan loops re-check the deadline periodically. When either limit is hit,
parse returns { ok: false, reason: 'aborted' } and the abort is prompt
(see Performance). Pass timeoutMs / maxNodes to raise the limits.
Error recovery
Malformed input never throws either. Unterminated constructs (quotes,
expansions, substitutions, heredocs, compound commands) are kept as
partial nodes and flagged with hasError: true; tokens that cannot start
or continue a statement (stray ), a leading &&, …) are wrapped in
ERROR nodes and parsing continues. Newlines never appear in the tree:
statement-terminating \ns produce no nodes at all (matching
tree-sitter-bash, whose scanner only emits \n tokens in heredoc
position), while ; / & terminators are anonymous children. As a
last-resort guard against parser bugs, any unexpected internal exception
degrades to a program root with a single ERROR child spanning the
source and hasError: true — callers still get a usable tree.
Known differences from tree-sitter-bash
Named node types always come from tree-sitter-bash's node-types.json, but
for the following constructs the tree shape deliberately deviates from what
tree-sitter-bash 0.25.0 produces (each verified against the real parser,
and pinned by a stored expectation in test/fixtures/ — see
test/helpers/known-differences.ts):
<>(read-write redirect) is parsed as a normalfile_redirectoperator; tree-sitter-bash 0.25.0 fails to parse it.- Several heredocs on one line (
cat <<A <<B) cannot be represented as an overlap-free tree (their bodies interleave with the redirect nodes). tree-sitter-bash errors; this parser degrades the second<<to anERRORnode, skips its body registration, and parses the "body" lines as ordinary commands, mirroring the reference's recovery shape. The same applies to a<<inside a heredoc's line tail. - Statements after a heredoc on the same line (
cat <<EOF; echo x, also&) are absorbed into theheredoc_redirectnode so the tree stays overlap-free once the body (which textually follows the whole line) is attached. tree-sitter-bash errors on the whole line. - Inside an unquoted
heredoc_body, every plain text chunk — including the leading one — becomes aheredoc_contentnode, and backtick command substitutions are parsed ascommand_substitutionchildren. tree-sitter-bash's scanner hides the leading chunk and swallows backticks into the content. - A
$'sequence inside an unquoted heredoc body is plain text here (bash does not expand ANSI-C strings in heredocs; the reference does the same mid-line). Quirk: when a body line STARTS with$'…', the reference's scanner errors and drops the wholeheredoc_redirect. - A heredoc redirect at statement start (
<<EOF cat) parses normally; tree-sitter-bash errors. - Unterminated or invalid constructs keep their partial nodes with
hasError: true(see above); tree-sitter-bash degrades them toERRORnodes, and the exact recovery shape differs. This covers invalid compound commands — a fallthrough terminator on the last case item (a) x ;;& esac), a missing separator before a compound keyword (if a; then b fi,{ ls }— both flaggedhasError: truehere), a heredoc inside an if/while condition,foo () ls, a regex with an unquoted space after=~— and small recovery details:- An unterminated test command always keeps its partial
test_commandwith a zero-width closer here. The reference only inserts that zero-width closer for an unterminated[or for a[[ending in a complete unary test ([[ -f file); other unterminated[[forms ([[ $a,[[ a == b) become anERRORnode with no closer there. (An empty[[ ]]sets the error flag in BOTH parsers — the reference recovers by inserting the missing closer as a zero-width token — and this parser matches its tree exactly: a concatenation of two]word pieces plus a zero-width]].) - An unterminated compound command gets no synthetic keyword here
(
if a; then b;ends after the last statement withhasError: true); the reference inserts zero-width keyword nodes (a zero-widthfi). ${x@}(transformation operator with no letter) keeps anexpansionwithhasError: truehere; the reference splits it into anERRORnode and a}command.
- An unterminated test command always keeps its partial
- A trailing connector (
ls &&,ls |) yields a single-childlist/pipelinewithhasError: true; tree-sitter-bash inserts a zero-widthcommandrecovery node. - An empty backtick substitution (
), or a pair containing only whitespace, is a `command_substitution` with no statements; tree-sitter-bash treats the two backticks as a singletoken that is only valid inside a concatenation and errors in argument position. - An empty command substitution (
$( )) is a cleancommand_substitutionwith no statements here; the reference inserts a zero-widthcommandrecovery node (and flagshasError). - In arithmetic, a hex literal is a
number($((0x1F))→ number "0x1F"); the reference's arithmetic number token does not cover hex and produces avariable_name"0x1F" instead (a reference quirk — bash does evaluate hex in arithmetic). [[ ((a) == x) && y ]](parenthesized test group followed by&&) parses cleanly here as nestedparenthesized_expressions; the reference mis-reads it as anarithmetic_expansionwith an embeddedERRORnode (a reference quirk — the valid[[ ((a)) == x ]]form matches exactly).- An extglob group the reference rejects — one that directly follows a
pure literal or a dot (
x@(y|z)w,*.@(a|b),_@(y),*@(y),.@(y)) — is an error there (anERRORnode inside the comparison); this parser ends the pattern at the group and recovers with a different error shape. Groups the reference ACCEPTS — at the start of the right side (+(a|b)) or after glob characters (*/@(default).vim,*_@(LIB|SYMLINK),@(LLD|GNU\ ld)*,*([0-9])([0-9]),\"+(?)\") — match exactly, as do right sides split around one substitution or quote (*${var}*,*/${a}*,*"7f 45"*). - An extglob group the reference rejects in a case pattern (
x@(y)), and the same mechanism for.((x.(y)),*.(a|b))—.is not a sigil, but the reference rejects it identically): the reference reparses the group as the start of a new case item; this parser degrades the whole pattern to anERRORnode. (Accepted groups —*(a|b)),*([0-9])([0-9]))— match exactly.) - A single-dash short-option case pattern (
-o)) is aword— the form this parser always produces — but the reference's classification flips with leftover scanner state (a first case item right after theinline's newline parses it as anextglob_pattern). - A case pattern directly after a line continuation (
| \<newline>) is awordin the reference even when its content would make it a glob (cmake_modules— a scanner-state quirk); this parser classifies patterns by content, so such a pattern is anextglob_patternhere. A continuation INSIDE a pattern (cont\<newline>ued)) is an error in the reference (word "cont" +ERROR"ued",hasError: true), and a continuation fused with a digit (a\<newline>1)) is anextglob_patternthere; this parser keeps the continuation and parses the whole pattern cleanly as oneword— which is also how bash itself reads it. - A comparison right side with TWO substitutions or quotes
(
*${x}*${y},*"s"*"t") does not fit the reference's one-construct pattern rule either; it recovers with a nestedbinary_expressionthere, while this parser keeps a singleconcatenationright side. - A few
[[ … ]]comparison right-hand sides deviate:- A negative decimal operand (
[[ $n == -0.5 ]]) is aunary_expression(-over word "0.5") in the reference; this parser produces a singleextglob_pattern"-0.5" (the unary-minus reading only covers integers here). - A test operator fused with an expansion (
[[ -x$f && … ]]) is aunary_expression(-over aconcatenationofxand$f) in the reference; this parser produces a flatconcatenationof-xand$fwith no unary wrapper. - An escaped
|inside a pattern ([[ $a == foo\|bar ]]) splits the expression in the reference —binary_expression(==, extglob "foo\")then|then word "bar" (a reference quirk); this parser keeps oneextglob_pattern"foo|bar".
- A negative decimal operand (
${!# }and${!## }(and${!##/}) are pathological expansion forms: the reference recovers with zero-width operator tokens, this parser with a flagged partialexpansion; the shapes differ. (The sane forms${#},${!#},${!##},${#!}all match exactly.)- A base prefix fused with an expansion (
10#${x}) is onenumbernode spanning the expansion in the reference (a quirk); this parser produces aconcatenationof word "10#" and the expansion. ${v:-(default)}(a parenthesized default value) is a plainwordhere; the reference parses it as anarraynode (a quirk).${=1}(zsh-style word splitting flag) produces avariable_name"1" in the reference; this parser produces aword.- An escaped space or tab between arguments is dropped by the reference
as invisible extras (its tree has gaps:
echo 1 \ 2produces number "1" and number "2", andecho a\<TAB>bsplits into word "a" and word "b"); this parser keeps the escape inside the word. (An escaped space INSIDE a word,a\ b, is kept by both parsers and matches.) - A
$directly fused with a backtick substitution ($`echo x`) is onecommand_substitutionwith a$+backtick token in the reference; this parser produces a($)token followed by the substitution. - A non-ASCII “identifier” in assignment position (
变量=值,é=1) is avariable_assignmentflaggedhasErrorin the reference; this parser keeps it a plain command word — which is also how bash itself treats it (variable names are ASCII-only, so the text runs as a command). - A negative literal after a compound assignment in a c-style for header
(
for (( … j *= -1, … ))) is folded into anumber"-1" by the reference; this parser produces aunary_expression(-over number "1") — matching what the reference itself produces everywhere else. - A double backslash in a replacement value (
${x// /\\|}) loses its first backslash in the reference's tree (a quirk — thewordstarts one character later); this parser keeps both. string_contentis not split at newlines (tree-sitter-bash's scanner splits it).
For completeness, a few constructs that LOOK like deviations but are not — the reference behaves the same way (verified):
coprochas no grammar support in tree-sitter-bash 0.25.0 and parses as an ordinary command; so does this parser.selectis parsed as afor_statementwith aselectkeyword token — that is exactly the reference's tree (there is noselect_statementnode type).$"…"(translated string): in command-argument position it is an anonymous$argument followed by thestringargument; everywhere else (assignment values, redirect destinations, for-loop values, …) it is atranslated_stringnode. Both match the reference exactly.$'…'is a single-child-freeansi_c_stringnode, also matching.- Brace expansion: only
{N..M}(unsigned digits) is abrace_expression;{a..z},{1..10..2}and{-5..5}are plain word concatenations in the reference and here. - Statement-position
((word++…))(inner text starting with a bare identifier plus a postfix++/--) is atest_commandin the reference — its statement-position arithmetic grammar does not accept that form — while((x = 1)),((b + a++)),((a[i]++))and argument-position((a++))arearithmetic_expansion. This parser matches all of these, including after!. - Command-prefix redirects take exactly one destination (
> a cmd xmakescmdthecommand_name), while redirects after the command name consume every following word (cmd > out argputs both words in the redirect) — this matches tree-sitter-bash's actual disambiguation.
Performance
Measured on an Apple-silicon MacBook (Node 24, default 50 ms / 50 000-node budget):
- A typical one-line command (
git status && rm -rf /): ~4 µs per parse. - A 100 KB realistic deployment script (functions, loops, redirects,
expansions, heredocs): tens of milliseconds per parse (~20–60 ms across
runs and machines) — the same order of magnitude as the default 50 ms
budget, so such inputs may return
ok: trueor a prompt abort depending on the machine; raisetimeoutMswhen parsing whole scripts. - A 500 KB heredoc body: parses within the default budget (the tree has only a handful of nodes).
- The abort path is prompt: a 400 KB node-budget bomb aborts in ~20 ms (hard guarantee asserted by the test suite: < 100 ms).
These numbers are smoke-tested as orders of magnitude in
test/performance.test.ts to guard against accidental quadratic
complexity; absolute timings vary by machine.