fix(hooks): match exclude_commands against the peeled command form - #3749
Conversation
48d0e47 to
d96a396
Compare
d96a396 to
4b5f337
Compare
`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>
4b5f337 to
9ba5239
Compare
|
Hey @KuSh , reviewed. No regressions, logic is correct as written. Just one doc fix , non blocking configuration.md over-promisesNew 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) 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. 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." |
|
Oopsi i merged, my bad, you can revert if you want to still work on this |
Fixes #243. Fixes #3035.
The problem
exclude_commandsentries 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 timeis_excludedruns the string is stillnpx playwright test— and the anchored^playwright($|\s)never matches.That produced an invisible asymmetry:
playwright testnpx playwright testpnpm exec playwright testSame tool, opposite outcomes, no warning.
README.mdshippedexclude_commands = ["curl", "playwright"]as the example — andplaywrightis 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 pytestwith["pytest"](#3035), where the consequence is sharper:-mruns the current interpreter with CWD onsys.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 dropsnpxandpython3 -mwhile keeping a subcommand the rule treats as part of the tool, sogolangci-lint rundoes not collapse torun.The arguments are kept. This is what makes anchored entries keep meaning what they say —
"^ls$"excludes a barelswithout swallowingls -la.Why not match the resolved
rtktargetSubstituting the rule's
rtk_cmdis a one-liner, and it's wrong in three ways. Recording them because it's the obvious shortcut:compile_exclude_patternspasses^-prefixed entries through verbatim, so handingis_excludeda bare token turns an anchor written to narrow a pattern into one that widens it:["^ls$"]starts excluding everyls,["^cargo$"]everycargo.eslintandbiomeboth resolve tortk lint, so["eslint"]would still rewritenpx eslint .— the exact class of bug this PR is meant to close.["read"]would excludecat,["git"]would excludeyadm,["rake"]would excluderails.Peeling has none of these. All three are covered by tests.
Verified
Built binary, isolated
XDG_CONFIG_HOME. Covered forms:Exactness preserved:
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 formstest_exclude_covers_interpreter_and_path_forms—python -m,./,vendor/bin/,bundle exectest_exclude_covers_wrapper_when_tool_name_differs_from_target— the eslint/biome casetest_exclude_keeps_arguments_so_anchored_regex_still_narrows—^ls$and^pytesttest_exclude_does_not_widen_across_tools_sharing_a_target— read/cat, lint/eslint, git/yadmtest_exclude_peeled_form_is_exact_token—["go"]vsgolangci-lint, andgolangci-lint runkept wholetest_exclude_subcommand_pattern_stays_narrow—["git push"]vsgit statustest_exclude_covers_php_wrapper_forms—php vendor/bin/phpunit,php bin/phpunit,php vendor/bin/phpstancargo fmt --all --check,cargo clippy --all-targetsandcargo test --allare clean (2657 passed).Limits
Peeling is driven by each rule's
rewrite_prefixes, so it only reaches tools RTK has a filterfor. A tool RTK sees only through a generic wrapper rule is matched as typed: with
["my-tool"],npx my-toolstill rewrites tortk 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:
--rtk-binoverride and handler changes. Leaving it open.[hooks] exclude_commandsis bypassed forhead/tail(line-range fast path skips the exclusion check) #2823 /[hooks] exclude_commandssilently ignored forhead/tailrewrites (honored forcat) #3371 / hooks.exclude_commands does not apply to head/tail rewrites #2363 — thehead/tailline-range fast path returns before any exclusion check. Different code path; fix(hooks): honour exclude_commands for head and tail #3324 covers it.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 thenpxcase is stated where people copy it from.