Skip to content

[hooks] exclude_commands is matched against the raw command, not the resolved tool — "pytest" fails to exclude python -m pytest #3035

Description

@joeyda3rd

Environment: rtk 0.37.2 · Ubuntu 24.04 (WSL2) · Claude Code hook (rtk hook claude)
Confirmed present on master (5d32d07) by reading src/discover/registry.rs — see Cause.

Summary

[hooks] exclude_commands is matched against the raw command text, but classify_command resolves wrapper/module forms to a different tool. When the two disagree, the exclusion silently fails and the command is rewritten anyway.

The clearest case is python -m <tool>: excluding "pytest" does not exclude python3 -m pytest, even though rtk classifies that command as rtk pytest.

Reproduction

# ~/.config/rtk/config.toml
[hooks]
exclude_commands = ["pytest"]
$ rtk rewrite "pytest tests/ -q"
$ echo $?              # 1 — correctly excluded ✓

$ rtk rewrite "python3 -m pytest tests/ -q"
rtk pytest tests/ -q   # rewritten despite the exclusion ✗

Not pytest-specific — same for any module-form tool:

# exclude_commands = ["mypy"]
$ rtk rewrite "mypy --strict ."
$ echo $?                             # 1 — excluded ✓
$ rtk rewrite "python3 -m mypy --strict ."
rtk mypy --strict .                   # rewritten despite the exclusion ✗

Cause

rewrite_segment_inner (src/discover/registry.rs, Classification::Supported arm, ~L884-891):

let rtk_equivalent = match classify_command(cmd_part) {
    Classification::Supported { rtk_equivalent, .. } => {
        let stripped = ENV_PREFIX.replace(cmd_part, "");
        let cmd_clean = stripped.trim();
        if is_excluded(cmd_clean, excluded) {   // <-- raw text, not rtk_equivalent
            return None;
        }
        rtk_equivalent
    }

is_excluded prefix-matches (~L798):

ExcludePattern::Prefix(prefix) => cmd.starts_with(prefix.as_str()),

So for python3 -m pytest tests/:

  • classify_commandClassification::Supported { rtk_equivalent: "rtk pytest", .. } (asserted by the existing test test_classify_python_m_pytest, L2477)
  • is_excluded("python3 -m pytest tests/", ["pytest"])"python3 -m pytest tests/".starts_with("pytest")false
  • → not excluded → rewritten to rtk pytest tests/

The exclusion check never sees the tool the classifier actually resolved. Note the existing rewrite tests for these forms (test_rewrite_python_m_pytest L2534, test_python3_m_pytest L4260) all pass &[] as the excluded list, so this path is untested with a non-empty exclusion.

Why it bites in practice

python -m pytest and bare pytest are not interchangeable: -m runs the current interpreter and puts CWD on sys.path; bare pytest runs whichever pytest shim is first on PATH. On this machine they are different interpreters (3.10 vs 3.11).

So the rewrite silently swapped the interpreter, imports failed, and pytest exited 2 (collection error) — which the pytest summarizer reported as Pytest: No tests collected (#3032), hiding the ModuleNotFoundError:

$ python3 -m pytest tests/ -q      # hook rewrites this to `rtk pytest tests/ -q`
Pytest: No tests collected

$ rtk proxy python3 -m pytest tests/ -q
41 passed

The exclusion existed precisely to prevent this, and was bypassed. Two agents in one session read the filtered text and concluded working code was broken. ([tee] mode = "failures" did preserve the real traceback — that escape hatch worked.)

Suggested fix

Also test the resolved target, e.g. in the Supported arm:

Classification::Supported { rtk_equivalent, .. } => {
    let stripped = ENV_PREFIX.replace(cmd_part, "");
    let cmd_clean = stripped.trim();
    // exclusion should apply to the tool rtk actually resolved to, not just the raw text
    let target = rtk_equivalent.strip_prefix("rtk ").unwrap_or(rtk_equivalent);
    if is_excluded(cmd_clean, excluded) || is_excluded(target, excluded) {
        return None;
    }
    rtk_equivalent
}

That makes exclude_commands = ["pytest"] cover every form rtk maps to rtk pytest (python -m pytest, python3 -m pytest, …) without needing users to enumerate wrappers.

A regression test would be rewrite_command_no_prefixes("python3 -m pytest tests/", &[ExcludePattern::Prefix("pytest".into())])None.

Workaround

Exclude the interpreter as well as the tool:

exclude_commands = ["pytest", "python3", "python"]

This costs nothing today, since bare python3 script.py has no rewrite rule anyway (#2312, #928, #285 are open FRs to add one) — the only effect of the python -m path is to route to the wrapped tool's filter.

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions