From 26cb34c67e5662e6d4a734ea0320e11232d67996 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 21:09:37 +0000 Subject: [PATCH] fix(hooks): parse Stryker commands instead of scanning for the tool name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both C# mutation PreToolUse gates decided "is this a Stryker run?" with a bare regex search over the whole command string, with no notion of argument position or quoting — any command merely mentioning the tool name in prose (a --body, a grep, an echo, a Python comment) was treated as an invocation and blocked. Extract a shared hooks/lib/stryker_invocation.py predicate: tokenize with shlex (punctuation_chars mode, so &&/||/;/| are recognised as their own tokens while quoted text stays one token), split into operator-separated segments, and match the tool only in program position of a segment. Heredoc bodies are stripped before tokenizing. Falls back to the old permissive regex only when shlex can't tokenize the command at all, so a malformed command still fails closed. Both mutation_testing_smoke_gate.py and stryker_xunit_shim_guard.py now delegate to the shared predicate instead of keeping their own drifting copy, with the old regex kept only as each hook's degraded-import fallback. Closes #2185 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016uBnw1i52qEik2k9LSHa4n --- .../dev-team/hooks/lib/stryker_invocation.py | 102 ++++++++++++++++++ .../hooks/mutation_testing_smoke_gate.py | 23 +++- .../hooks/stryker_xunit_shim_guard.py | 20 ++-- .../hooks/test_mutation_testing_smoke_gate.py | 16 +++ .../tests/hooks/test_stryker_invocation.py | 78 ++++++++++++++ .../hooks/test_stryker_xunit_shim_guard.py | 19 ++++ 6 files changed, 247 insertions(+), 11 deletions(-) create mode 100755 plugins/dev-team/hooks/lib/stryker_invocation.py create mode 100644 plugins/dev-team/tests/hooks/test_stryker_invocation.py diff --git a/plugins/dev-team/hooks/lib/stryker_invocation.py b/plugins/dev-team/hooks/lib/stryker_invocation.py new file mode 100755 index 000000000..1f7044289 --- /dev/null +++ b/plugins/dev-team/hooks/lib/stryker_invocation.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Shared predicate: does a shell command actually RUN Stryker.NET? (#2185) + +Both C# mutation PreToolUse gates (`mutation_testing_smoke_gate.py` and +`stryker_xunit_shim_guard.py`) used to decide "is this a Stryker run?" with a +bare `re.search` over the entire command string — no notion of argument +position, quoting, or comments. Any command that merely *mentioned* the tool +name in prose (a `gh issue create --body "..."`, a `grep`, an `echo`, a +Python comment) was treated as an invocation and blocked. + +This module parses the command instead of scanning it: tokenize with +`shlex.shlex(..., punctuation_chars=True)` (posix quoting rules, `&&`/`||`/ +`;`/`|` recognised as their own tokens), split the token stream into +per-operator segments, and match the tool only in **program position** of a +segment — `dotnet` with `stryker` as its very next token, a bare +`dotnet-stryker` token, or the wrapper script (however it's invoked: directly, +or via an interpreter like `bash ` — matched by exact basename, not +substring, so a quoted prose mention that happens to contain the wrapper's +name cannot collide with a real path token). + +Heredoc bodies are stripped before tokenizing (`<`/`<` +#: (redirects) deliberately excluded — they don't start a new program. +_SEGMENT_OPERATORS = {"&&", "||", ";", "|"} + +#: A `<<[-]DELIM ... DELIM` heredoc body, DELIM optionally quoted. +_HEREDOC_RE = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?\n.*?\n\1\b", re.DOTALL) + +#: The plugin's Stryker.NET wrapper script, matched against a token's +#: basename — with or without hyphen/underscore separators, with or without +#: the `.sh` extension (mirrors the two gates' previously-divergent forms). +_WRAPPER_NAME_RE = re.compile(r"^csharp[_-]stryker[_-]net[_-]wrapper(\.sh)?$") + + +def _strip_heredocs(command: str) -> str: + return _HEREDOC_RE.sub("", command) + + +def _segments(tokens: list[str]) -> list[list[str]]: + segments: list[list[str]] = [[]] + for tok in tokens: + if tok in _SEGMENT_OPERATORS: + segments.append([]) + else: + segments[-1].append(tok) + return [seg for seg in segments if seg] + + +def _segment_is_stryker(segment: list[str]) -> bool: + if not segment: + return False + first = segment[0] + if first == "dotnet" and len(segment) > 1 and segment[1] == "stryker": + return True + if first == "dotnet-stryker": + return True + # The wrapper, invoked directly or via an interpreter (`bash `, + # `sh `) — only the program and its immediate first argument can + # be the wrapper path, so later arguments are never checked. + for tok in segment[:2]: + if _WRAPPER_NAME_RE.match(Path(tok).name): + return True + return False + + +def is_stryker_invocation(command: str) -> bool: + """True only when `command` actually runs Stryker.NET — `dotnet stryker`, + `dotnet-stryker`, or the plugin's wrapper script, in program position of + one of the command's operator-separated segments. A mention of the tool + name in a --body/grep/echo/comment argument does not count.""" + if not command: + return False + stripped = _strip_heredocs(command) + try: + lexer = shlex.shlex(stripped, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) + except ValueError: + return bool(_FALLBACK_TRIGGER.search(command)) + return any(_segment_is_stryker(seg) for seg in _segments(tokens)) diff --git a/plugins/dev-team/hooks/mutation_testing_smoke_gate.py b/plugins/dev-team/hooks/mutation_testing_smoke_gate.py index 1f8d87e9b..d684861ec 100755 --- a/plugins/dev-team/hooks/mutation_testing_smoke_gate.py +++ b/plugins/dev-team/hooks/mutation_testing_smoke_gate.py @@ -82,17 +82,30 @@ def emit_boundary_event(*args, **kwargs) -> None: # Trigger detection # --------------------------------------------------------------------------- +try: + from stryker_invocation import ( # type: ignore[import-not-found] + is_stryker_invocation as _is_stryker_invocation, + ) +except ImportError: # pragma: no cover - degraded fallback, hooks/lib unreachable + _STRYKER_TRIGGER_FALLBACK = re.compile( + r"(?:^|[^a-zA-Z0-9])dotnet[ \t]+stryker(?:\b|$)|csharp-stryker-net-wrapper\.sh" + ) -_STRYKER_TRIGGER = re.compile( - r"(?:^|[^a-zA-Z0-9])dotnet[ \t]+stryker(?:\b|$)|csharp-stryker-net-wrapper\.sh" -) + def _is_stryker_invocation(cmd: str) -> bool: # type: ignore[misc] + return bool(cmd) and bool(_STRYKER_TRIGGER_FALLBACK.search(cmd)) def is_stryker_command(cmd: str) -> bool: - """True when `cmd` invokes dotnet stryker OR the wrapper script.""" + """True when `cmd` invokes dotnet stryker OR the wrapper script. + + Delegates to the shared `hooks/lib/stryker_invocation` predicate (#2185): + parsed via `shlex` in program position, not scanned with a bare regex — + so a mention of the tool name in a --body/grep/echo/comment argument is + never mistaken for a real invocation. + """ if not cmd: return False - return bool(_STRYKER_TRIGGER.search(cmd)) + return _is_stryker_invocation(cmd) def extract_mutate_value(cmd: str) -> str: diff --git a/plugins/dev-team/hooks/stryker_xunit_shim_guard.py b/plugins/dev-team/hooks/stryker_xunit_shim_guard.py index e507374e2..fea2955bf 100755 --- a/plugins/dev-team/hooks/stryker_xunit_shim_guard.py +++ b/plugins/dev-team/hooks/stryker_xunit_shim_guard.py @@ -70,11 +70,19 @@ def read_stdin_json() -> dict | None: # type: ignore[misc] return None -# `dotnet stryker`, `dotnet-stryker`, or the plugin's wrapper — mirror the -# smoke-gate trigger so both gates recognise the same invocations. -_STRYKER_TRIGGER = re.compile( - r"(?:^|[^a-zA-Z0-9])dotnet[ \t-]+stryker(?:\b|$)|csharp[_-]stryker[_-]net[_-]wrapper" -) +try: + from stryker_invocation import ( # type: ignore[import-not-found] + is_stryker_invocation as _is_stryker_invocation, + ) +except ImportError: # pragma: no cover - degraded fallback, hooks/lib unreachable + # `dotnet stryker`, `dotnet-stryker`, or the plugin's wrapper — mirror the + # smoke-gate's pre-#2185 fallback so both gates degrade identically. + _STRYKER_TRIGGER_FALLBACK = re.compile( + r"(?:^|[^a-zA-Z0-9])dotnet[ \t-]+stryker(?:\b|$)|csharp[_-]stryker[_-]net[_-]wrapper" + ) + + def _is_stryker_invocation(cmd: str) -> bool: # type: ignore[misc] + return bool(cmd) and bool(_STRYKER_TRIGGER_FALLBACK.search(cmd)) _GENERATOR = _PLUGIN_DIR / "skills" / "stryker-xunit-v2-shim" / "scripts" / "generate_shim.py" _PY_SH = _HOOK_DIR / "py.sh" @@ -435,7 +443,7 @@ def main() -> int: if event.get("tool_name") != "Bash": return 0 command = (event.get("tool_input") or {}).get("command", "") or "" - if not _STRYKER_TRIGGER.search(command): + if not _is_stryker_invocation(command): return 0 # Sanctioned no-shim floor (#1156/#1159): an explicit MTP-runner run drives diff --git a/plugins/dev-team/tests/hooks/test_mutation_testing_smoke_gate.py b/plugins/dev-team/tests/hooks/test_mutation_testing_smoke_gate.py index 64021e86a..f5bb78f1c 100644 --- a/plugins/dev-team/tests/hooks/test_mutation_testing_smoke_gate.py +++ b/plugins/dev-team/tests/hooks/test_mutation_testing_smoke_gate.py @@ -50,6 +50,22 @@ def test_is_stryker_command_ignores_non_stryker(cmd: str) -> None: assert gate.is_stryker_command(cmd) is False +@pytest.mark.parametrize( + "cmd", + [ + 'gh issue create --body "the docs say to run dotnet stryker -t mtp here"', + "grep -rn 'dotnet stryker' docs/", + 'echo "never run dotnet stryker on main"', + 'python3 -c "# dotnet stryker"', + ], +) +def test_is_stryker_command_ignores_tool_name_in_prose(cmd: str) -> None: + """#2185: mentioning the tool name in a --body/grep/echo/comment argument + is not an invocation — only `dotnet stryker`/`dotnet-stryker`/the wrapper + in program position of a command segment is.""" + assert gate.is_stryker_command(cmd) is False + + # --- --mutate extraction --------------------------------------------------- diff --git a/plugins/dev-team/tests/hooks/test_stryker_invocation.py b/plugins/dev-team/tests/hooks/test_stryker_invocation.py new file mode 100644 index 000000000..c7b460412 --- /dev/null +++ b/plugins/dev-team/tests/hooks/test_stryker_invocation.py @@ -0,0 +1,78 @@ +"""Unit tests for hooks/lib/stryker_invocation.py (#2185). + +The shared predicate both C# mutation gates use to decide "does this command +actually RUN Stryker.NET" — parsed, not scanned, so a mention of the tool +name in a --body/grep/echo/comment argument is never mistaken for an +invocation. +""" + +from __future__ import annotations + +import sys + +import pytest + +from _repo_root import REPO_ROOT as _REPO_ROOT + +_LIB = _REPO_ROOT / "plugins" / "dev-team" / "hooks" / "lib" +sys.path.insert(0, str(_LIB)) + +import stryker_invocation as si + + +@pytest.mark.parametrize( + "cmd", + [ + "dotnet stryker", + "dotnet stryker --mutate src/Foo.cs", + " dotnet stryker ", + "dotnet-stryker", + "dotnet-stryker --reporter json", + "cd repo && dotnet stryker --config-file config.json", + "cd tests/Acme.Widgets.Tests.Mutation && dotnet-stryker", + "true && dotnet stryker --mutate x.cs", + "bash plugins/dev-team/hooks/mutation-adapters/csharp-stryker-net-wrapper.sh --arg", + "/abs/path/to/csharp-stryker-net-wrapper.sh", + "csharp_stryker_net_wrapper", + ], +) +def test_matches_real_invocations(cmd: str) -> None: + assert si.is_stryker_invocation(cmd) is True + + +@pytest.mark.parametrize( + "cmd", + [ + "", + "echo hi", + "dotnet build", + "dotnetstryker", # no boundary + "pip install stryker", + 'gh issue create --body "the docs say to run dotnet stryker -t mtp here"', + "grep -rn 'dotnet stryker' docs/", + 'echo "never run dotnet stryker on main"', + 'python3 -c "# dotnet stryker"', + ], +) +def test_ignores_non_invocations(cmd: str) -> None: + assert si.is_stryker_invocation(cmd) is False + + +def test_heredoc_body_mentioning_tool_name_does_not_trigger() -> None: + cmd = ( + "gh issue create --title x --body-file - <<'EOF'\n" + "the docs say to run dotnet stryker here\n" + "EOF" + ) + assert si.is_stryker_invocation(cmd) is False + + +def test_unbalanced_quotes_fail_closed_to_permissive_scan() -> None: + # shlex can't tokenize this (unterminated quote) — fall back to the old + # permissive regex, which still finds the real invocation, rather than + # silently letting a malformed command through unblocked. + assert si.is_stryker_invocation("dotnet stryker --config 'unclosed") is True + + +def test_unbalanced_quotes_without_the_tool_name_still_pass() -> None: + assert si.is_stryker_invocation("echo 'unclosed") is False diff --git a/plugins/dev-team/tests/hooks/test_stryker_xunit_shim_guard.py b/plugins/dev-team/tests/hooks/test_stryker_xunit_shim_guard.py index 2d5b1c8d0..e16b00a92 100644 --- a/plugins/dev-team/tests/hooks/test_stryker_xunit_shim_guard.py +++ b/plugins/dev-team/tests/hooks/test_stryker_xunit_shim_guard.py @@ -82,6 +82,25 @@ def test_non_stryker_command_passes(tmp_path): assert proc.stdout == "" +@pytest.mark.parametrize( + "command", + [ + 'gh issue create --body "the docs say to run dotnet stryker -t mtp here"', + "grep -rn 'dotnet stryker' docs/", + 'echo "never run dotnet stryker on main"', + 'python3 -c "# dotnet stryker"', + ], +) +def test_tool_name_in_prose_does_not_trigger(tmp_path, command): + """#2185: mentioning the tool name in a --body/grep/echo/comment argument + must not be treated as an invocation and blocked.""" + d = _v3_project(tmp_path) + proc = _run({"tool_name": "Bash", "cwd": str(d), + "tool_input": {"command": command}}) + assert proc.returncode == 0 + assert proc.stdout == "" + + def test_non_bash_tool_passes(tmp_path): d = _v3_project(tmp_path) proc = _run({"tool_name": "Edit", "cwd": str(d),