Skip to content

fix(hooks): match exclude_commands against the peeled command form - #3749

Merged
aeppling merged 1 commit into
rtk-ai:developfrom
KuSh:fix/exclude-commands-wrapper-forms
Sep 1, 2026
Merged

fix(hooks): match exclude_commands against the peeled command form#3749
aeppling merged 1 commit into
rtk-ai:developfrom
KuSh:fix/exclude-commands-wrapper-forms

Conversation

@KuSh

@KuSh KuSh commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #243. Fixes #3035.

The problem

exclude_commands entries name a tool, but a command can spell that tool with a wrapper (npx, pnpm exec, pnpm dlx), an interpreter (python3 -m), or a path (vendor/bin/, ./). Those spellings are absorbed by each rule's own pattern rather than stripped beforehand, so by the time is_excluded runs the string is still npx playwright test — and the anchored ^playwright($|\s) never matches.

That produced an invisible asymmetry:

[hooks]
exclude_commands = ["playwright"]
Command before after
playwright test excluded ✅ excluded ✅
npx playwright test rewritten excluded ✅
pnpm exec playwright test rewritten excluded ✅

Same tool, opposite outcomes, no warning. README.md shipped exclude_commands = ["curl", "playwright"] as the example — and playwright is a tool almost nobody invokes bare, so the documented example silently failed for the standard invocation. That's what @nhumrich hit in #243.

The same gap covers python3 -m pytest with ["pytest"] (#3035), where the consequence is sharper: -m runs the current interpreter with CWD on sys.path, while the bare shim may be a different Python entirely, so the rewrite silently swaps interpreters — exactly what the exclusion existed to prevent.

The fix

Peel the wrapper off the command and match what remains, OR'd with the existing check on the typed command.

Peeling uses the rule's own rewrite_prefixes: take the longest prefix the command matches, then the shortest token-suffix of that prefix which is itself a prefix of the same rule. That drops npx and python3 -m while keeping a subcommand the rule treats as part of the tool, so golangci-lint run does not collapse to run.

The arguments are kept. This is what makes anchored entries keep meaning what they say — "^ls$" excludes a bare ls without swallowing ls -la.

Why not match the resolved rtk target

Substituting the rule's rtk_cmd is a one-liner, and it's wrong in three ways. Recording them because it's the obvious shortcut:

  1. It breaks anchored regexes. compile_exclude_patterns passes ^-prefixed entries through verbatim, so handing is_excluded a bare token turns an anchor written to narrow a pattern into one that widens it: ["^ls$"] starts excluding every ls, ["^cargo$"] every cargo.
  2. It misses tools whose target differs from the binary. eslint and biome both resolve to rtk lint, so ["eslint"] would still rewrite npx eslint . — the exact class of bug this PR is meant to close.
  3. It leaks across tools sharing a target. ["read"] would exclude cat, ["git"] would exclude yadm, ["rake"] would exclude rails.

Peeling has none of these. All three are covered by tests.

Verified

Built binary, isolated XDG_CONFIG_HOME. Covered forms:

["playwright"]     playwright test / npx / pnpm exec / pnpm dlx   -> all excluded
["pytest"]         python3 -m pytest tests/ -q                    -> excluded
["eslint"]         npx eslint .                                   -> excluded
["gradlew"]        ./gradlew assembleDebug                        -> excluded
["phpunit"]        vendor/bin/phpunit tests/                      -> excluded
["rspec"]          bundle exec rspec                              -> excluded
["phpunit"]        php vendor/bin/phpunit tests/                  -> excluded

Exactness preserved:

["^ls$"]           ls -la                -> rewritten   ("ls" alone -> excluded)
["go"]             golangci-lint run     -> rewritten
["py"]             python3 -m pytest     -> rewritten
["git push"]       git status            -> rewritten
["read"]           cat foo.txt           -> rewritten
["lint"]           eslint . / biome ...  -> rewritten
["git"]            yadm status           -> rewritten

Tests

Seven in src/discover/registry.rs, each mapped to a claim above:

  • test_exclude_matches_wrapper_invoked_form — the README example across all four forms
  • test_exclude_covers_interpreter_and_path_formspython -m, ./, vendor/bin/, bundle exec
  • test_exclude_covers_wrapper_when_tool_name_differs_from_target — the eslint/biome case
  • test_exclude_keeps_arguments_so_anchored_regex_still_narrows^ls$ and ^pytest
  • test_exclude_does_not_widen_across_tools_sharing_a_target — read/cat, lint/eslint, git/yadm
  • test_exclude_peeled_form_is_exact_token["go"] vs golangci-lint, and golangci-lint run kept whole
  • test_exclude_subcommand_pattern_stays_narrow["git push"] vs git status
  • test_exclude_covers_php_wrapper_formsphp vendor/bin/phpunit, php bin/phpunit, php vendor/bin/phpstan

cargo fmt --all --check, cargo clippy --all-targets and cargo test --all are clean (2657 passed).

Limits

Peeling is driven by each rule's rewrite_prefixes, so it only reaches tools RTK has a filter
for. A tool RTK sees only through a generic wrapper rule is matched as typed: with
["my-tool"], npx my-tool still rewrites to rtk npx my-tool — exclude "npx" for that.
The docs state this rather than promising blanket coverage.

Scope

Two adjacent gaps are deliberately not addressed here:

Docs

  • docs/guide/getting-started/configuration.md — documents peeling, the argument-preserving guarantee, and the exactness limits.
  • docs/usage/FEATURES.md — same, matching the section's existing French.
  • README.md — annotates the example so the npx case is stated where people copy it from.
@KuSh
KuSh force-pushed the fix/exclude-commands-wrapper-forms branch from 48d0e47 to d96a396 Compare August 29, 2026 01:12
@KuSh
KuSh force-pushed the fix/exclude-commands-wrapper-forms branch from d96a396 to 4b5f337 Compare August 30, 2026 13:17
@KuSh KuSh changed the title fix(hooks): apply exclude_commands to wrapper-invoked forms (#243) Aug 30, 2026
`exclude_commands` entries name a tool, but a command can spell that tool with a
wrapper (`npx playwright test`), an interpreter (`python3 -m pytest tests/`) or a
path (`vendor/bin/phpunit tests/`). Those spellings are absorbed by each rule's own
pattern rather than stripped beforehand, so the anchored `^playwright($|\s)` never
matched and the exclusion silently did nothing — the README shipped
`exclude_commands = ["curl", "playwright"]` as the example, and `playwright` is a
tool almost nobody invokes bare.

Peel the wrapper off the command and match what remains, alongside the existing
check on the typed command. The peeled form keeps the arguments, so an anchored
entry still narrows the way it was written: `"^ls$"` excludes a bare `ls` without
swallowing `ls -la`.

Peeling uses the rule's own `rewrite_prefixes`, taking the shortest token-suffix of
the matched prefix that is itself a prefix of that rule. That drops `npx` and
`python3 -m` while keeping a subcommand the rule treats as part of the tool, so
`golangci-lint run` does not collapse to `run`.

Peeling reuses the PHP normalization the rewrite path already applies (`php`
wrapper and ini flags, leading `./`, vendor/composer bin dir), extracted into
`php_tool_form` and shared by both, so `php vendor/bin/phpunit tests/` is excluded
by `["phpunit"]` the same way `vendor/bin/phpunit tests/` is.

The peeled check is gated on a non-empty `exclude_commands`, keeping the default
config off the `RULES` scan on the hook rewrite path.

Matching the resolved `rtk` target instead would have been shorter but wrong in
both directions: it misses tools whose target differs from the binary (`["eslint"]`
would still rewrite `npx eslint .`, since the target is `lint`), and it leaks
across tools sharing a target (`["read"]` would exclude `cat`, `["git"]` would
exclude `yadm`). Peeling has neither failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aeppling

aeppling commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Hey @KuSh , reviewed. No regressions, logic is correct as written.

Just one doc fix , non blocking

configuration.md over-promises

New line 97: "that tool is covered however it is invoked." Stated limits cover only TOML-only tools and exactness. Three invocation forms of filtered tools aren't covered:

["head"] head -20 f -> rtk read f --max-lines 20 (head f IS excluded)
["gradlew"] gradlew.bat build -> rtk gradlew build (./gradlew and gradlew both excluded)
["golangci-lint"] golangci run ./... -> rtk golangci-lint run ./... (golangci-lint run IS excluded)

Repro: put the entry in ~/.config/rtk/config.toml under [hooks], then rtk rewrite ''.

Row 1: line-range fast path returns before the exclusion check #3324.
Row 2: strip_absolute_path handles /, not \ or .bat/.exe; Windows asymmetry vs CODING_PRACTICES "Portability" #3617 closes it.
Row 3: the rule's own alias, tool_portion keeps it whole.

Suggest scoping the sentence to the wrapper/interpreter/path forms actually peeled and naming these as known gaps.

Line 97, replace:

"An entry names a tool RTK has a filter for, and that tool is covered however it is invoked."

with:

"An entry names a tool RTK has a filter for, and covers the wrapper, interpreter and path spellings of it."

@aeppling
aeppling merged commit e533c40 into rtk-ai:develop Sep 1, 2026
11 of 12 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Sep 1, 2026
@aeppling

aeppling commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Oopsi i merged, my bad, you can revert if you want to still work on this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants