Skip to content

refactor(cmdline): peel transparent wrappers with a generic walker over family tables - #4402

Draft
KuSh wants to merge 2 commits into
lex/14-grammar-tablesfrom
feat/4315-1-cmdline-walker
Draft

KuSh wants to merge 2 commits into
lex/14-grammar-tablesfrom
feat/4315-1-cmdline-walker

Conversation

@KuSh

@KuSh KuSh commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

First layer of #4315. The wrappers the rewrite looks through (env assignments, uv run, timeout/time/nice/nohup, command/exec/noglob, reserved words, user transparent_prefixes) are read by one generic walker over family tables.

  • core/cmdline/family.rs and walker.rs: the walker peels the families a caller passes and names no tool. Wrapper flags are read through Grammar; nice -5 through .numeric(), and a reserved word's options through two new axis values, SingleDash::Whole (-pp is the flag pp) and Attach::Never (-p=1 is the flag p=1).
  • discover/families/: one file per family, each with its own accept/reject table test. Adding a family is one file and a line in ALL, and in MATCHING unless the rules also match it whole.
  • registry::Walk runs the walker over the segment's one lex. Every decision is still an edit on the segment's bytes.
  • rtk discover, rtk gain and rtk session are unchanged here; their counting follows in a later PR.

Behaviour

  • Wrapper options follow each tool's grammar: clustered short options (timeout -vk 5 300), GNU nice -n N/-N, and options end at the first operand (timeout 300 -s KILL cmd is left as written).
  • Reserved words where a pipeline starts are stepped over: if git diff --quiet; then git status; fi rewrites both commands. Commands inside a compound command that is piped are left as written.
  • time's options are read as bash 5.3 reads them: -p, then --, each compared as written. A redirect ends them (time -p>out -- cmd runs a command named --), and a process substitution is text of its word (time -p<(true) cmd runs -p/dev/fd/63). Where the options end inside a word, the command is left as written.
  • NAME=value is an assignment only where a simple command starts; after timeout, nice, exec or uv run it is the program's name.
  • Quoted or escaped wrapper names are recognised ('timeout' 5 cmd); path-spelled ones (/usr/bin/env) are not.
  • exclude_commands matches the command as typed and as bash runs it (quotes removed), behind any wrapper, uv run included.
  • The RTK_DISABLED= warning is printed exactly when the rewrite reaches the assignment, matched by name. Inside a quoted sh|bash -c script it warns where develop does.
  • The permission gate reads past prefixes with the same walker, over families::MATCHING: the rewrite's families but uv run, plus stdbuf and a bare xargs. time is read both as bash's reserved word and as GNU time. A quoted sh|bash -c script is read the same way, and one whose prefixes outrun the peel's cap is deferred to the host where a deny rule exists. nice +5 git push and timeout 5 -- git push run a program named +5 or --, so a deny rule on git push no longer matches them.
  • A user prefix is tried at every layer. An entry that does not lex into whole words, or starts with a reserved word, is refused with a [rtk] warning: each time the rewrite runs. Every warning the rewrite prints starts with [rtk] warning: (the RTK_DISABLED= one was [rtk] RTK_DISABLED=1 detected).

Part of #4315
Refs #3230
Refs #1606

Test plan

  • cargo fmt --all --check && cargo clippy --all-targets && cargo test --all: 5320 passing, clippy clean, at this commit
  • wincheck.sh (cfg(windows) type-check) clean
  • time option readings checked against bash 5.3 in Docker
  • Quoted scripts holding RTK_DISABLED=: rewrite and warnings identical to develop over 200 commands
  • Hook verdicts compared with the parent commit over 1.27M commands: to re-run on this head
  • Rewrite sweep against the parent: to re-run on this head
  • rtk discover, rtk gain and rtk session against the parent on a sample session corpus: to re-run on this head

Draft, part of #4315 (layer 1 of the command-line walker). Based on #4401.

🤖 Generated with Claude Code

@KuSh
KuSh added this pull request to stack #4139 October 2, 2026 16:01
@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

♻️ Type refactor
🟡 Risk medium

Summary

Refactors the command-line rewrite path to peel transparent wrappers (env assignments, uv run, timeout/time/nice/nohup, command/exec/noglob, reserved words, user transparent_prefixes) using a single generic walker driven by per-family grammar tables instead of ad-hoc tool-specific logic. Adds one file per wrapper family under discover/families/ with its own accept/reject tests, and updates core/README and hook docs to describe the new peeling behavior and exclude_commands matching semantics.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Analyzed automatically by wshm · This is an automated analysis, not a human review.

@KuSh
KuSh force-pushed the feat/4315-1-cmdline-walker branch from 78057f2 to b8dd618 Compare October 2, 2026 16:20
@KuSh
KuSh force-pushed the feat/4315-1-cmdline-walker branch from b8dd618 to 6031a95 Compare October 5, 2026 22:36
@KuSh
KuSh force-pushed the feat/4315-1-cmdline-walker branch from 6031a95 to 45dd57a Compare October 7, 2026 07:16
@KuSh
KuSh force-pushed the feat/4315-1-cmdline-walker branch from 45dd57a to e7f9971 Compare October 9, 2026 21:17
@KuSh
KuSh force-pushed the feat/4315-1-cmdline-walker branch from e7f9971 to ab24355 Compare October 15, 2026 12:03
…er family tables

Transparent wrappers are peeled by one walker that reads data: env
assignments, the reserved words bash runs a pipeline after, the routable
`uv run` wrapper, the process wrappers (timeout, time, nice, nohup), the
precommand words and a user's prefixes. Each family is a table, and the
walker knows no tool by name.

- `core/cmdline/family.rs` declares what a wrapper family is: a head, a label
  of the caller's own type, and a `Form` (a bare keyword, a word of the
  assignment run, or a wrapper with its own flag `Grammar` and operand
  count);
- `core/cmdline/walker.rs` peels a caller-supplied `&[Family]` off the words
  and tokens a caller already lexed, at a `Position` (whether bash reads an
  assignment, or a reserved word such as `time`, at that place), and returns
  word indices. It reads reserved words and `time`'s options from
  `core/cmdline/bash_grammar.rs` and flags from the `Grammar` of
  `core/arg_tokenizer.rs`, where `.numeric()` reads the `-N` flag `nice`
  takes, and two new axis values read the options of a reserved word, each
  argument one option compared as written: `SingleDash::Whole` (`-pp` is
  the short flag `pp`) and `Attach::Never` (`-p=1` is the flag `p=1`).
  `Grammar::new` refuses a value spec they could never apply: a
  solo-only value under `Whole`, and an attached-only one on a
  whole-argument spelling under `Never`;
- `discover/families/` holds the tables, one file per family with its own
  accept/reject table test: `env`, `precommand` (`command`, `builtin`,
  `exec`, `noglob`, `nocorrect`), `uv_run`, `process`, `match_only`
  (`stdbuf` and a bare `xargs`, which only the permission gate peels), and
  how a user's `transparent_prefixes` entry is read. The meaning of each label
  (`LayerKind`) is defined there. Adding a family is one file and a line in
  `ALL`, and in `MATCHING` unless the rules also match it whole.

`registry::Walk` drives the walker over the words of the one lex a segment is
cut from and records each layer as a range of words. The decision and its
fall-through search read those ranges, and everything the rewrite decides is
an edit against the segment's own bytes.

Rules the walker and the rewrite implement:
- short options cluster the way getopt reads them (`timeout -vk 5 300`,
  `timeout -vs9 300`, `/usr/bin/time -pv`), and `timeout -f` and `-p` are
  recognised;
- where a pipeline starts, `if`, `then`, `elif`, `else`, `while`, `until`,
  `do` and bash's `time` are reserved words the walk steps over, and a
  pipeline starts again behind each (`if git diff --quiet; then git status;
  fi` becomes `if rtk git diff --quiet; then rtk git status; fi`, `time -p
  time cmd` peels both). `time`'s options are read as bash 5.3 reads them:
  `-p`, then `--`, each optional, each compared whole as written, through
  the `Grammar` the reserved-word table declares for them, a word running to
  a blank, an operator, a redirect, a pipe, `&` or a bracket. A redirect
  ends the options too: `time -p>out -- cmd` runs a command named `--`; a
  substitution glued to a word is text of it (`time -p<(true) cmd` runs
  `-p/dev/fd/63`). Where the options end inside a word the walk takes (`time
  -p>out cmd`), no layer can end, so the command is left as written, as it
  is behind a redirect that stands apart (`time -p >out cmd`), and no other
  tier reads the reserved word as a program. The walk and the reading of the
  line's grammar read them through one function. The word behind them is the
  command bash runs, so `time -v cmd`, `time -o x cmd` and `time -p -p cmd`
  run a command named `-v`, `-o` or `-p` and are left as written: the
  rewrite takes no prefix, filter or rule for the GNU program there (the
  permission gate reads both, below), and `rtk` is never put inside its
  words. `coproc` runs one command and no pipeline, and is no layer. GNU
  `time` and its `-f`/`-o`/`-a`/`-v`/`-p`/`-q` grammar apply anywhere else
  and through a path;
- a compound command whose parts are command lists and that a reserved word
  closes, when it is a pipeline's stage, with a `|` or `|&` right before its
  opening word or right after its closing word and the redirections behind
  it, writes its output into that pipe or reads its input from it, so every
  command in any
  of its lists, a `case` arm's included, is left as written, the later ones
  of a list as much as the first (`for x in a; do git status; git log; done
  | wc -l`, `for x in a; do git status && git log; done | head`, `case x in
  a) git log;; esac | wc -l`, `ls | while read x; do git status; done`), as
  a subshell's commands are. A compound command or a subshell right after
  `coproc` or `coproc NAME` is the coprocess's body, whose input and output
  are pipes, and is left as written the same way (`coproc while true; do git
  status; done`, `coproc ( git status )`), a name of several tokens being
  one word (`coproc NAME${x} ( git status )`). The rule reaches across the
  lines of a block: a compound that one line opens and a later one closes
  holds the commands between. A compound no pipe joins is rewritten like
  any other list (`if true; then git status; fi; ls | head` becomes `if
  true; then rtk git status; fi; rtk ls | head`), and a pipeline inside
  one of its lists goes through the pipeline rules like any other (`case $y
  in (a|b) git status | head;; esac` becomes `case $y in (a|b) rtk git
  status | head;; esac`). `lexer::read_nesting` finds the compounds in the
  pass that reads the line's grammar;
- `nice` options follow GNU nice: `-n N`, `-N`, `--adjustment=N`; `nice +5
  cmd` is left as written, because `+5` is the program nice runs;
- a wrapper's options end at the first operand it requires: `timeout 300 -s
  KILL cmd` and `timeout 300 -- cmd` are left as written, because after the
  duration `-s` and `--` are the program timeout runs;
- a shell `NAME=value` is an assignment only at the start of a simple command
  (the front of a segment, of any pipeline stage, or of a compound command's
  list), and one run of assignments and `env` words is one layer. After
  `timeout`, `nice`, `nohup`, a precommand word (`exec`, `command`,
  `noglob`, ...) or `uv run` it is the program's name, so none of those are
  rewritten. `env`'s own operands are its own wherever it sits (`timeout 5
  env A=1 cmd` is rewritten), and what follows a user prefix may be an
  assignment;
- an unbraced `$NAME` glued to a word (`timeout 5$UNIT cmd`) is one word;
- a wrapper head is read with its quotes and escapes removed (`'timeout' 5
  cmd`, `\timeout 5 cmd`), and one that carries an expansion (`$D/timeout`,
  or a backquote inside double quotes) never matches; the words of a
  multi-word head (`uv run`) may be apart by any spaces or tabs;
- a path-spelled head (`/usr/bin/env`, `/bin/command`, `/usr/bin/uv run`) is
  the program at that path, not the word `env`, `command`, `exec`, `noglob`
  or `uv run` stands for, so no layer takes it and the command it heads is
  left as written; behind a `uv run` under `exclude_commands` it is refused,
  as what it runs is out of sight;
- a word behind a subshell's closing `)` in the same simple command, glued to
  it or not and at any depth (`(true)git status`, `(true) git status`,
  `( (true)git status )`), is left as written, and the subshell inside is
  rewritten (`( git status )word`); a case pattern's `)` is not one (`case x
  in a)git status;; esac` is rewritten);
- a command word that starts with `-` (a wrapper's own option, or `--`),
  quoted or not, is never a command, and a user prefix that would end inside
  the words of the layer it covers (`nice` inside `nice -5`, `nice timeout`
  inside `nice timeout 5`) is skipped, so `rtk` never lands inside a
  wrapper's grammar;
- the `RTK_DISABLED=` warning is printed exactly when a walk of the rewrite
  reaches the prefix and the command is refused: the first walk, behind any
  layer it peels (`timeout 5 env RTK_DISABLED=1 git status`), a retry behind
  a user prefix (`nohup RTK_DISABLED=1 git status` under the prefix `nohup`
  warns, as `docker exec c RTK_DISABLED=1 git status` under `docker exec c`
  does), or the walk of what a refused fall-through wraps. A quoted
  `sh|bash -c` script the rewrite reads, in which a command is refused this
  way, warns where the first walk reaches it and where a fall-through
  settles on its rewrite. Rewritten around the refused command it settles
  the command; left as written it settles nothing, and a wrapper's rule may
  still route the line whole (`uv run bash -c 'RTK_DISABLED=1 git status'`
  becomes `rtk uv run …` and warns). A fall-through that wins over a walk
  that stopped at the prefix draws none, and neither does a producer left
  as written before a
  consumer that does not take filtered output (`RTK_DISABLED=1 git status |
  wc -l`), which no walk reads, nor a `NAME=value` word where no simple
  command starts (`timeout 5 RTK_DISABLED=1 git status`, `uv run
  RTK_DISABLED=1 git status`), which is a program's name. The warning is
  carried on the rewrite's answer and printed from the one run of the
  rewrite, ahead of the configuration's and the filter registry's warnings,
  which are held until then (a panic inside the rewrite drops the held
  warnings with it, and so does a caller that catches the panic). The
  marker is the
  assignment `RTK_DISABLED=` by name, not a substring (`XRTK_DISABLED=1`,
  `MSG='RTK_DISABLED=1'` are not the bypass), and the append form
  `RTK_DISABLED+=1` is one only as a shell assignment, not as an `env`
  operand. An assignment run with no command behind it is a statement of its
  own, not the prefix, and draws none (`RTK_DISABLED=1; git status` rewrites
  `git status`). A line that holds `$((` is refused before any walk, so it
  draws no warning;
- the decision answers `Excluded` for a command `exclude_commands` covers,
  before anything that depends on where the command sits, a quoted shell
  script excepted, whose own commands are checked instead. It reads the
  command up to the redirections behind it, as typed and as bash runs it,
  and a pattern that matches either excludes it. As bash runs it, the words
  have their quotes and escapes removed, and are read as they are, with the
  program's absolute path left out, or as the tool a rule routes it to (read
  off the words bash runs, so `npx "playwright" test` is `playwright`; an
  empty argument is kept, so `pytest ''` is not `^pytest$`). A pattern reads
  those words with one space between two of them and a blank inside a
  word behind a backslash, every other character as it is, so a blank
  separates two words where bash separates them. Under
  `exclude_commands = ["pytest"]`, `'pytest' -x`, `\pytest -x`, `timeout 5
  'pytest' -x` and `'/opt/my tools/pytest' -x` are left as written, also
  where a project filter takes every command, and so is `'pytest' 2>&1`
  under `^pytest$`; `'pytest x'` runs a program named `pytest x`, which
  neither pattern covers;
- `uv run`'s fall-through to its own rule reads what it wraps with the
  rewrite's own walk (the same peels, user-prefix retries and depth budget),
  and refuses when that walk reaches `RTK_DISABLED=` or a command the
  decision excludes; what it wraps as written is checked the same way, by
  the same decision. A quoted shell script is decided as a whole, so a
  command excluded inside it does not refuse the fall-through (`uv run bash
  -c 'pytest'` is routed as before). Under `exclude_commands = ["pytest"]`,
  `uv run timeout 5 pytest`, `uv run noglob pytest`, `uv run
  /usr/bin/pytest`, `uv run 'pytest'`, `uv run p"ytest" -x`, `uv run
  \pytest`, `uv run '/opt/my tools/pytest' -x`, `uv run timeout 5 'pytest'
  2>&1`, `uv run uv run pytest`, `uv run time pytest`, `uv run python -m
  pytest`, `uv run nice python -m pytest`, and `uv run nice -n 19 ionice
  pytest` under the prefix `nice -n 19 ionice` are refused, as `uv run
  pytest` and `python -m pytest` are, and so is `uv run 'pytest' 2>&1` under
  `^pytest$`, while `uv run 'pytest x'` is not; `uv run timeout 5 env
  RTK_DISABLED=1 pytest` is refused, and layers deeper than the walk follows
  count as excluded, since nothing shows what they wrap. So does an option
  the walk cannot read past, which is `uv`'s own and leaves the command
  behind it unread (`uv run --with x pytest`), though a user prefix that
  starts at `uv run` is tried. A `--` right behind `uv run` is read through
  to the command, so `uv run -- pytest` is refused and `uv run -- cargo
  test` is not. A `NAME=value` word right behind `uv run` is a program's
  name, and the fall-through refuses it (`uv run RTK_DISABLED=1 git status`,
  `uv run A=1 pytest`). Without `exclude_commands` nothing is hidden, and an
  option or `--` behind `uv run` leaves the whole string to its rule (`uv
  run --with x pytest` becomes `rtk uv run --with x pytest`);
- a user-configured prefix is tried at the start of every layer whose own
  command has no rewrite, not only behind `uv run`: `transparent_prefixes =
  ["nice -n 19 ionice"]` rewrites `nice -n 19 ionice git status`; the
  built-in peel wins whenever it succeeds, and a reserved word is never a
  prefix's to claim;
- a `transparent_prefixes` entry is read without the `$IFS` bytes at either
  end, and one that does not lex into whole words (an unclosed quote or a
  trailing backslash), or whose first word is a bash reserved word
  (`coproc`, `time`, `if`, ...), which bash reads as grammar where a
  command starts, is refused, with a warning each time the rewrite runs;
- a quoted `sh|bash|zsh|fish -c` wrapper is decided wherever a walk
  reaches it, its script rewritten one wrapper deep, and a wrapper at the
  walk's depth limit is still the command (`exec … exec bash -c '…'`). The
  permission gate checks for a wrapper at the words the rewrite decides at,
  the walk's and every fall-through's, whether the segment opens a pipeline
  or is a later stage of one (`registry::shell_wrapper_positions`), so it
  looks wherever the rewrite would reach, and at a pipeline's start a little
  further;
- the permission gate reads past prefixes with the same walk over its own
  list of families (`families::MATCHING`): the rewrite's, but for `uv run`
  and the user's prefixes, which the host does not strip, plus `stdbuf` and
  a bare `xargs`. Each word of an assignment run is a reading of its own, and
  an assignment is read wherever it sits (`nohup HUSKY=0 git push` is `git
  push` to a deny rule). `time` is read both ways, as bash's reserved word
  and as the program a later pipeline stage or `sh` runs (`time -v git
  push`). A quoted wrapper's script is read the same way and, where a deny
  rule exists, one whose prefixes outrun the peel's cap is deferred to the
  host;
- every warning the rewrite prints starts with `[rtk] warning:`, as rtk's
  other warnings do: the `RTK_DISABLED=` warning (`[rtk] RTK_DISABLED=1
  detected` before), a refused `transparent_prefixes` entry, and a trivial or
  invalid `exclude_commands` pattern.

Part of #4315
Refs #3230
Refs #1606

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KuSh
KuSh force-pushed the feat/4315-1-cmdline-walker branch from ab24355 to 50663bc Compare October 19, 2026 16:38
`rtk rewrite` renders a deny as exit 2 with no output: rtk's pipeline
rules are applied by `rtk hook claude` before the rewrite flow, not by
`rtk rewrite`. The exit-code list in FEATURES.md still described a block
message on exit 2, and told integrations to read stdout there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant