Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions plugins/dev-team/hooks/lib/stryker_invocation.py
Original file line number Diff line number Diff line change
@@ -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 <path>` — 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 (`<<EOF ... EOF` /
`<<'EOF' ... EOF` / `<<-EOF ... EOF`) — a command that pipes documentation
prose through a heredoc is the same false-positive shape as a quoted
argument, just harder for `shlex` to see through structurally.

Fails closed: if `shlex` raises (unbalanced quotes it cannot tokenize), fall
back to the old permissive regex over the raw command rather than silently
returning False — a malformed command still gets caught rather than sliding
through unblocked.
"""

from __future__ import annotations

import re
import shlex
from pathlib import Path

#: Matches only when shlex tokenization itself fails — the pre-#2185
#: permissive scan, kept as the fail-closed fallback for unparseable input.
_FALLBACK_TRIGGER = re.compile(
r"(?:^|[^a-zA-Z0-9])dotnet[ \t-]+stryker(?:\b|$)|csharp[_-]stryker[_-]net[_-]wrapper"
)

#: Shell operators that separate one command segment from the next. `>`/`<`
#: (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 <path>`,
# `sh <path>`) — 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))
23 changes: 18 additions & 5 deletions plugins/dev-team/hooks/mutation_testing_smoke_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 14 additions & 6 deletions plugins/dev-team/hooks/stryker_xunit_shim_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,19 @@ def read_stdin_json() -> dict | None: # type: ignore[misc]
_MTP_PER_TEST_COVERAGE_FLOOR = (5, 0, 0)


# `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"
Expand Down Expand Up @@ -499,7 +507,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
Expand Down
16 changes: 16 additions & 0 deletions plugins/dev-team/tests/hooks/test_mutation_testing_smoke_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------


Expand Down
78 changes: 78 additions & 0 deletions plugins/dev-team/tests/hooks/test_stryker_invocation.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions plugins/dev-team/tests/hooks/test_stryker_xunit_shim_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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),
Expand Down
Loading