claude-dev-env 2.31.1 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +1 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +13 -7
  6. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  7. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  8. package/hooks/blocking/stop_dispatcher.py +6 -9
  9. package/hooks/blocking/tdd_enforcer.py +3 -1
  10. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  11. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  12. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  13. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  14. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
  15. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
  16. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  17. package/hooks/blocking/test_state_description_blocker.py +56 -71
  18. package/hooks/blocking/test_tdd_enforcer.py +67 -1
  19. package/hooks/hooks.json +2 -2
  20. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  21. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  22. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  23. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  24. package/hooks/hooks_constants/state_file_lock.py +76 -0
  25. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  26. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  27. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  28. package/hooks/observability/session_file_edit_tracker.py +2 -55
  29. package/hooks/observability/test_failure_recorder.py +219 -0
  30. package/hooks/observability/test_test_failure_recorder.py +173 -0
  31. package/hooks/validators/config/directory_exemption_constants.py +0 -9
  32. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  33. package/hooks/validators/mypy_integration.py +10 -36
  34. package/hooks/validators/project_roots.py +58 -0
  35. package/hooks/validators/run_all_validators.py +87 -33
  36. package/hooks/validators/system_temporary_roots.py +1 -1
  37. package/hooks/validators/test_run_all_validators.py +22 -0
  38. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  39. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  40. package/package.json +1 -1
  41. package/rules/shell-invocation.md +1 -1
  42. package/rules/testing.md +4 -0
  43. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
  44. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  45. package/scripts/shared_tree_paths.py +59 -25
  46. package/scripts/test_shared_tree_paths.py +30 -0
@@ -91,7 +91,9 @@ bare, quoted, or backslash-escaped (``<<\\EOF`` quotes the delimiter the way
91
91
  ``<<'EOF'`` does). ``<<<`` is a here-string rather than a heredoc, so the
92
92
  pattern refuses to read one as an opener and leave the lines below it unread.
93
93
  The opener also reports its own ``-``, because ``<<`` closes on a line spelling
94
- the delimiter exactly while ``<<-`` closes on one carrying leading tabs.
94
+ the delimiter exactly while ``<<-`` closes on one carrying leading tabs. It
95
+ reports the quote and the backslash separately from the delimiter word, because
96
+ either one tells a caller that bash expands nothing inside the body below.
95
97
 
96
98
  The string-executing shell basenames and command flags start from the shared
97
99
  ``unscoped_search_blocker_constants.py`` sets and add the Windows command shell
@@ -167,8 +169,10 @@ __all__ = [
167
169
  "HEREDOC_OPENER_PATTERN",
168
170
  "HEREDOC_TAB_STRIP_GROUP",
169
171
  "HEREDOC_QUOTE_GROUP",
172
+ "HEREDOC_ESCAPE_GROUP",
170
173
  "HEREDOC_TERMINATOR_GROUP",
171
174
  "HEREDOC_TAB_STRIP_MARKER",
175
+ "HEREDOC_UNQUOTED_MARKER",
172
176
  "HEREDOC_STRIPPED_INDENT_CHARACTERS",
173
177
  "NO_FOLLOWING_OPERATOR",
174
178
  "ALL_QUOTE_CHARACTERS",
@@ -340,13 +344,16 @@ CLOSED_GROUP_DEPTH = 0
340
344
  _HEREDOC_DELIMITER_CHARACTER_CLASS = r"[^\s'\"<>|&;()`$\\]"
341
345
  HEREDOC_TAB_STRIP_GROUP = "heredoc_tab_strip"
342
346
  HEREDOC_QUOTE_GROUP = "heredoc_quote"
347
+ HEREDOC_ESCAPE_GROUP = "heredoc_escape"
343
348
  HEREDOC_TERMINATOR_GROUP = "heredoc_terminator"
344
349
  HEREDOC_OPENER_PATTERN = re.compile(
345
- rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)\\?"
350
+ rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)"
351
+ rf"(?P<{HEREDOC_ESCAPE_GROUP}>\\?)"
346
352
  rf"(?P<{HEREDOC_TERMINATOR_GROUP}>{_HEREDOC_DELIMITER_CHARACTER_CLASS}+)"
347
353
  rf"(?P={HEREDOC_QUOTE_GROUP})"
348
354
  )
349
355
  HEREDOC_TAB_STRIP_MARKER = "-"
356
+ HEREDOC_UNQUOTED_MARKER = ""
350
357
  HEREDOC_STRIPPED_INDENT_CHARACTERS = "\t"
351
358
  NO_FOLLOWING_OPERATOR = ""
352
359
  ALL_QUOTE_CHARACTERS = "\"'"
@@ -25,6 +25,11 @@ __all__ = [
25
25
  "PROCESS_SUBSTITUTION_PATTERN",
26
26
  "SINGLE_QUOTED_RUN_PATTERN",
27
27
  "STRIPPED_RUN_REPLACEMENT",
28
+ "COMMAND_LINE_SEPARATOR",
29
+ "BACKSLASH_CHARACTER",
30
+ "SINGLE_QUOTE_CHARACTER",
31
+ "DOUBLE_QUOTE_CHARACTER",
32
+ "NO_TERMINATOR_INDEX",
28
33
  "CORRECTIVE_MESSAGE",
29
34
  ]
30
35
 
@@ -49,6 +54,11 @@ EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
49
54
  PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
50
55
  SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
51
56
  STRIPPED_RUN_REPLACEMENT = ""
57
+ COMMAND_LINE_SEPARATOR = "\n"
58
+ BACKSLASH_CHARACTER = "\\"
59
+ SINGLE_QUOTE_CHARACTER = "'"
60
+ DOUBLE_QUOTE_CHARACTER = '"'
61
+ NO_TERMINATOR_INDEX = -1
52
62
 
53
63
  CORRECTIVE_MESSAGE = (
54
64
  "BLOCKED [shell-substitution]: command contains $(...), backtick, or "
@@ -0,0 +1,76 @@
1
+ """Hold a best-effort exclusive lock across one read-modify-write of a state file.
2
+
3
+ Two hooks can reach the same state file in one turn. The file is written
4
+ through a temporary-plus-replace, so it is never torn; what this closes is
5
+ the separate lost-update race, where one writer reads before the other
6
+ writes and then overwrites it.
7
+
8
+ ::
9
+
10
+ with hold_state_file_lock(state_file):
11
+ all_stored = read(state_file)
12
+ write(state_file, updated(all_stored))
13
+
14
+ The lock is best effort. A caller that cannot take it inside the timeout
15
+ proceeds without it rather than stalling the tool call that triggered it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import os
22
+ import sys
23
+ import time
24
+ from collections.abc import Iterator
25
+ from pathlib import Path
26
+
27
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
28
+ if _hooks_directory not in sys.path:
29
+ sys.path.insert(0, _hooks_directory)
30
+
31
+ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
32
+ LOCK_ACQUIRE_RETRY_SECONDS,
33
+ LOCK_ACQUIRE_TIMEOUT_SECONDS,
34
+ SESSION_EDIT_LOCK_FILE_SUFFIX,
35
+ )
36
+
37
+
38
+ def _acquire_lock_descriptor(lock_file: Path) -> int | None:
39
+ """Grab the exclusive lock, spinning until it frees or the timeout passes.
40
+
41
+ Args:
42
+ lock_file: Path to the lock file beside the state file.
43
+
44
+ Returns:
45
+ An open descriptor for the held lock, or None when it stayed held
46
+ past the acquire timeout.
47
+ """
48
+ lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
49
+ while True:
50
+ try:
51
+ return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
52
+ except FileExistsError:
53
+ if time.monotonic() >= lock_acquire_deadline:
54
+ return None
55
+ time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
56
+
57
+
58
+ @contextlib.contextmanager
59
+ def hold_state_file_lock(state_file: Path) -> Iterator[None]:
60
+ """Hold the lock beside one state file for the body of the with-block.
61
+
62
+ Args:
63
+ state_file: The state file whose read-modify-write is being guarded.
64
+
65
+ Yields:
66
+ Control to the caller while the lock is held.
67
+ """
68
+ lock_file = state_file.with_name(state_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
69
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
70
+ lock_descriptor = _acquire_lock_descriptor(lock_file)
71
+ try:
72
+ yield
73
+ finally:
74
+ if lock_descriptor is not None:
75
+ os.close(lock_descriptor)
76
+ lock_file.unlink(missing_ok=True)
@@ -0,0 +1,27 @@
1
+ """Behavioral tests for the Bash PostToolUse dispatcher's hosted-hook roster."""
2
+
3
+ from hooks_constants.bash_post_call_dispatcher_constants import (
4
+ ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
5
+ )
6
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
7
+
8
+
9
+ def test_roster_names_the_gh_pr_author_restore_hook() -> None:
10
+ all_script_paths = [
11
+ each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
12
+ ]
13
+ assert "blocking/gh_pr_author_restore.py" in all_script_paths
14
+
15
+
16
+ def test_roster_names_the_test_failure_recorder_hook() -> None:
17
+ all_script_paths = [
18
+ each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
19
+ ]
20
+ assert "observability/test_failure_recorder.py" in all_script_paths
21
+
22
+
23
+ def test_every_roster_entry_applies_to_bash() -> None:
24
+ assert all(
25
+ BASH_TOOL_NAME in each_entry.applicable_tool_names
26
+ for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
27
+ )
@@ -0,0 +1,35 @@
1
+ """Constants for the failing-test-run PostToolUse recorder hook.
2
+
3
+ Holds the cheap substring check the hook runs before any shell parsing, the
4
+ harness's own exit-code error prefix, the chaining and redirection operator
5
+ tokens that keep the recognizer to one unchained command, the pytest program
6
+ and Python interpreter basenames, and the flag-token marker the path
7
+ extractor uses to skip an option rather than an existing test file argument.
8
+
9
+ ``BASH_TOOL_NAME`` lives in ``gh_pr_author_swap_constants`` already; this
10
+ module reuses that name rather than a second literal. The pytest-program and
11
+ interpreter recognition here is deliberately its own small, stdlib-only
12
+ check rather than a reuse of ``piped_pytest_blocker_constants`` /
13
+ ``pytest_invocation``: those solve the harder pipe-and-wrapper problem and
14
+ pull in a much larger transitive import for every Bash call regardless of
15
+ content, working against the point of the recognizer's own cheap prefilter.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ ALL_PYTEST_MENTION_SUBSTRINGS: tuple[str, ...] = ("pytest", "py.test")
23
+ EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
24
+ OPTION_TOKEN_PREFIX: str = "-"
25
+ NODE_ID_FILE_PATH_SEPARATOR: str = "::"
26
+ ALL_CHAINING_OPERATOR_SUBSTRINGS: tuple[str, ...] = ("&&", "||", ";", "|", "\n")
27
+ ALL_REDIRECTION_TOKENS: frozenset[str] = frozenset(
28
+ {">", ">>", "<", "<<", "2>&1", "1>&2", "2>", "1>"}
29
+ )
30
+ ALL_PYTEST_PROGRAM_BASENAMES: frozenset[str] = frozenset({"pytest", "py.test"})
31
+ MODULE_RUN_FLAG: str = "-m"
32
+ PYTEST_MODULE_NAME: str = "pytest"
33
+ PYTHON_INTERPRETER_BASENAME_PATTERN: re.Pattern[str] = re.compile(
34
+ r"^(?:python|py|pypy)[0-9.]*(?:\.exe)?$"
35
+ )
@@ -7,15 +7,22 @@ and the restoration of the interpreter's stdin, stdout, and argv.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import io
10
11
  import json
11
12
  import sys
12
13
  from pathlib import Path
13
14
 
15
+ import pytest
16
+
14
17
  _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
15
18
  if _HOOKS_DIR not in sys.path:
16
19
  sys.path.insert(0, _HOOKS_DIR)
17
20
 
18
- from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output # noqa: E402
21
+ from hooks_constants.hosted_hook_runner import ( # noqa: E402
22
+ HostedHookRun,
23
+ run_dispatcher_main,
24
+ run_hook_capturing_output,
25
+ )
19
26
 
20
27
 
21
28
  def _write_probe(tmp_path: Path, body: str) -> str:
@@ -99,3 +106,50 @@ def test_returns_a_hosted_hook_run_instance(tmp_path: Path) -> None:
99
106
  probe_path = _write_probe(tmp_path, "print('x')\n")
100
107
  hook_run = run_hook_capturing_output(probe_path, "{}")
101
108
  assert isinstance(hook_run, HostedHookRun)
109
+
110
+
111
+ def test_run_dispatcher_main_calls_dispatch_with_payload_text_and_tool_name(
112
+ monkeypatch: pytest.MonkeyPatch,
113
+ ) -> None:
114
+ """A well-formed payload reaches dispatch with its JSON text and tool_name."""
115
+ all_calls: list[tuple[str, str]] = []
116
+ monkeypatch.setattr(
117
+ sys, "stdin", io.StringIO(json.dumps({"tool_name": "Bash", "tool_input": {}}))
118
+ )
119
+
120
+ with pytest.raises(SystemExit) as raised_exit:
121
+ run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
122
+
123
+ assert int(raised_exit.value.code or 0) == 0
124
+ assert len(all_calls) == 1
125
+ recorded_payload_text, recorded_tool_name = all_calls[0]
126
+ assert recorded_tool_name == "Bash"
127
+ assert json.loads(recorded_payload_text)["tool_name"] == "Bash"
128
+
129
+
130
+ def test_run_dispatcher_main_skips_dispatch_for_malformed_stdin(
131
+ monkeypatch: pytest.MonkeyPatch,
132
+ ) -> None:
133
+ """Unparseable stdin exits zero and never calls dispatch."""
134
+ all_calls: list[tuple[str, str]] = []
135
+ monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
136
+
137
+ with pytest.raises(SystemExit) as raised_exit:
138
+ run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
139
+
140
+ assert int(raised_exit.value.code or 0) == 0
141
+ assert all_calls == []
142
+
143
+
144
+ def test_run_dispatcher_main_skips_dispatch_when_tool_name_is_missing(
145
+ monkeypatch: pytest.MonkeyPatch,
146
+ ) -> None:
147
+ """A payload with no tool_name exits zero and never calls dispatch."""
148
+ all_calls: list[tuple[str, str]] = []
149
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"tool_input": {}})))
150
+
151
+ with pytest.raises(SystemExit) as raised_exit:
152
+ run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
153
+
154
+ assert int(raised_exit.value.code or 0) == 0
155
+ assert all_calls == []
@@ -18,13 +18,10 @@ never interrupts the edit that triggered it.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- import contextlib
22
21
  import json
23
22
  import os
24
23
  import sys
25
24
  import tempfile
26
- import time
27
- from collections.abc import Iterator
28
25
  from pathlib import Path
29
26
 
30
27
  _hooks_dir = str(Path(__file__).resolve().parent.parent)
@@ -35,15 +32,13 @@ from blocking.codex_apply_patch import payload_patch_target_paths # noqa: E402
35
32
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
36
33
  read_hook_input_dictionary_from_stdin,
37
34
  )
35
+ from hooks_constants.state_file_lock import hold_state_file_lock # noqa: E402
38
36
  from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
39
37
  ALL_EDITED_FILE_PATHS_KEY,
40
38
  ALL_TRACKED_EDIT_TOOL_NAMES,
41
39
  APPLY_PATCH_TOOL_NAME,
42
- LOCK_ACQUIRE_RETRY_SECONDS,
43
- LOCK_ACQUIRE_TIMEOUT_SECONDS,
44
40
  SESSION_EDIT_FILE_PREFIX,
45
41
  SESSION_EDIT_FILE_SUFFIX,
46
- SESSION_EDIT_LOCK_FILE_SUFFIX,
47
42
  SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
48
43
  STATE_FILE_ATOMIC_WRITE_SUFFIX,
49
44
  STATE_FILE_DEFAULT_SESSION_ID,
@@ -140,54 +135,6 @@ def _atomic_write_edit_file(edit_file: Path, all_edited_file_paths: list[str]) -
140
135
  raise
141
136
 
142
137
 
143
- def _acquire_edit_file_lock(lock_file: Path) -> int | None:
144
- """Grab an exclusive per-session lock, spinning until it frees or times out.
145
-
146
- Args:
147
- lock_file: Path to this session's lock file.
148
-
149
- Returns:
150
- An open file descriptor for the held lock, or None when the lock stayed
151
- held past the acquire timeout so the caller proceeds without it rather
152
- than stalling the edit that triggered the hook.
153
- """
154
- lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
155
- while True:
156
- try:
157
- return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
158
- except FileExistsError:
159
- if time.monotonic() >= lock_acquire_deadline:
160
- return None
161
- time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
162
-
163
-
164
- @contextlib.contextmanager
165
- def _hold_edit_file_lock(edit_file: Path) -> Iterator[None]:
166
- """Hold the per-session lock across one read-modify-write of the tracker.
167
-
168
- Two PostToolUse invocations run concurrently when Claude issues parallel
169
- Write or Edit calls in one turn. Serializing the read-then-write on a shared
170
- lock file keeps each invocation's appended path from overwriting another's.
171
- The lock is best-effort: when it cannot be acquired within the timeout, the
172
- write proceeds without it rather than stalling the edit.
173
-
174
- Args:
175
- edit_file: This session's tracker file path.
176
-
177
- Yields:
178
- Control to the caller while the lock is held.
179
- """
180
- lock_file = edit_file.with_name(edit_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
181
- lock_file.parent.mkdir(parents=True, exist_ok=True)
182
- lock_descriptor = _acquire_edit_file_lock(lock_file)
183
- try:
184
- yield
185
- finally:
186
- if lock_descriptor is not None:
187
- os.close(lock_descriptor)
188
- lock_file.unlink(missing_ok=True)
189
-
190
-
191
138
  def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
192
139
  """Append one edited path to this session's tracker file when it is new.
193
140
 
@@ -200,7 +147,7 @@ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
200
147
  resolved_file_path: The resolved absolute path of the edited file.
201
148
  """
202
149
  edit_file = _session_edit_file_path(session_id)
203
- with _hold_edit_file_lock(edit_file):
150
+ with hold_state_file_lock(edit_file):
204
151
  recorded_paths = _read_recorded_paths(edit_file)
205
152
  if resolved_file_path in recorded_paths:
206
153
  return
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook: record a failing single pytest run as TDD-gate evidence.
3
+
4
+ A file-content edit is not the same claim as a test that ran and failed. The
5
+ content-hash store in ``tdd_enforcer_parts/content_hash_store.py`` already
6
+ tracks whether a candidate test file's content changed; this hook adds a
7
+ stronger form of evidence -- a real RED -- by watching every Bash call finish
8
+ and, for a genuine failing pytest run, recording the command, its real exit
9
+ status, and the test file paths it named into that same store.
10
+
11
+ ::
12
+
13
+ pytest test_orders.py (exit 1) -> record test_orders.py
14
+ pytest test_orders.py -k fulfill (exit 1) -> record test_orders.py
15
+ pytest test_orders.py::test_fulfill (exit 1) -> record test_orders.py
16
+ pytest -q (exit 1) -> no path named, no record
17
+ cd repo && pytest test_orders.py (exit 1) -> chained, no record
18
+ pytest test_orders.py | tee run.log (blocked by piped_pytest_blocker
19
+ first -- this hook never sees it)
20
+ npm test (exit 1) -> not pytest, no record
21
+ pytest test_orders.py (exit 0) -> passed, no record
22
+
23
+ What this hook matches, and why
24
+ --------------------------------
25
+
26
+ Only a single, unchained Bash command whose own program is ``pytest`` /
27
+ ``py.test``, or a Python interpreter running ``-m pytest``. A chained command
28
+ (``&&``, ``;``, ``|``, ``||``, a newline) is rejected outright: this repo's own
29
+ ``piped_pytest_blocker.py`` already tells the caller to "run pytest alone" for
30
+ exactly this reason, and a chain's reported exit status may belong to a
31
+ command other than pytest, or to none of them if an earlier link short-
32
+ circuited before pytest even ran. ``npm test``, ``node --test``, and
33
+ ``vitest`` are deliberately not matched: ``npm test`` is a script indirection
34
+ whose real test runner lives in ``package.json``, not on the command line,
35
+ and the other two have no existing, tested classification helper in this
36
+ repository the way pytest does. Extending to them would mean inventing new
37
+ parsing rather than reusing anything proven, which is the over-reach this
38
+ mechanism is built to avoid.
39
+
40
+ The recognizer here is deliberately its own small, stdlib-only ``shlex``
41
+ check rather than a reuse of ``hooks_constants.pytest_invocation`` /
42
+ ``shell_command_pipeline``. Those solve the harder pipe-and-wrapper problem a
43
+ PreToolUse blocker needs, and this repository's own style rule forbids an
44
+ import inside a function body, so importing that larger machinery here would
45
+ mean paying its full transitive import cost on every single Bash call this
46
+ hook sees, pytest or not -- working directly against the point of watching
47
+ every Bash call. The narrower, single-unchained-command scope this hook
48
+ accepts does not need that machinery.
49
+
50
+ Where the exit status comes from
51
+ ---------------------------------
52
+
53
+ Not from scraping the command's output text for words like "failed" or
54
+ "error". The Bash tool's own PostToolUse ``tool_response`` takes two shapes
55
+ observed directly in this session's own transcript: a dict with ``stdout``/
56
+ ``stderr`` keys when the command's reported exit status is 0, and the literal
57
+ string ``"Error: Exit code <N>\\n<output>"`` when it is not. This hook reads
58
+ that harness-authored ``<N>`` directly; it is a real Bash mechanism doing the
59
+ work correctly, not a lookalike that only mimics one.
60
+
61
+ Where the test paths come from
62
+ --------------------------------
63
+
64
+ The command's own arguments, not its output. A bare ``pytest`` or ``pytest
65
+ -q`` names no path and is left unrecorded on purpose: recording it against
66
+ every test file pytest might have discovered would satisfy the gate for a
67
+ file this run never demonstrably covered, which is the exact bypass the
68
+ audit calls out. Only a token that is not an option flag and resolves to a
69
+ real file on disk, relative to the command's own ``cwd``, counts.
70
+
71
+ What stops a bypass
72
+ ---------------------
73
+
74
+ Three things, together:
75
+
76
+ 1. ``content_hash_store.record_test_command_failure`` keys each entry
77
+ exactly as the gate looks candidates up, so a failure never satisfies a
78
+ sibling file the same command also happened to run (see
79
+ ``test_content_hash_store.py::test_recorded_failure_does_not_satisfy_a_different_candidate``).
80
+ 2. The gate consults a recorded failure only while the file's *current*
81
+ content hash still matches what actually failed (see
82
+ ``test_recorded_failure_does_not_apply_once_content_no_longer_matches``).
83
+ Editing the test again clears it as evidence.
84
+ 3. A record ages out after the same freshness window every other form of
85
+ evidence uses (see ``test_recorded_failure_expires_after_the_freshness_window``).
86
+
87
+ The hook never blocks a tool call. A non-Bash tool, a malformed payload, a
88
+ command that does not mention "pytest", a chained command, a command with no
89
+ real path argument, or a failed record each returns quietly.
90
+ """
91
+
92
+ from __future__ import annotations
93
+
94
+ import shlex
95
+ import sys
96
+ from pathlib import Path
97
+
98
+ try:
99
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
100
+ _blocking_directory = str(Path(_hooks_root_directory) / "blocking")
101
+ for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
102
+ if each_bootstrap_directory not in sys.path:
103
+ sys.path.insert(0, each_bootstrap_directory)
104
+ from hooks_constants.gh_pr_author_swap_constants import BASH_TOOL_NAME
105
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
106
+ from hooks_constants.test_failure_recorder_constants import (
107
+ ALL_CHAINING_OPERATOR_SUBSTRINGS,
108
+ ALL_PYTEST_MENTION_SUBSTRINGS,
109
+ ALL_PYTEST_PROGRAM_BASENAMES,
110
+ ALL_REDIRECTION_TOKENS,
111
+ EXIT_CODE_ERROR_PREFIX,
112
+ MODULE_RUN_FLAG,
113
+ NODE_ID_FILE_PATH_SEPARATOR,
114
+ OPTION_TOKEN_PREFIX,
115
+ PYTEST_MODULE_NAME,
116
+ PYTHON_INTERPRETER_BASENAME_PATTERN,
117
+ )
118
+ from tdd_enforcer_parts import content_hash_store
119
+ except ImportError as import_error:
120
+ raise ImportError(
121
+ "test_failure_recorder: cannot import its dependencies; "
122
+ "ensure the hooks directory is importable."
123
+ ) from import_error
124
+
125
+
126
+ def _mentions_pytest(command: str) -> bool:
127
+ lowered_command = command.lower()
128
+ return any(
129
+ each_substring in lowered_command for each_substring in ALL_PYTEST_MENTION_SUBSTRINGS
130
+ )
131
+
132
+
133
+ def _harness_reported_exit_status(tool_response: object) -> int | None:
134
+ if not isinstance(tool_response, str) or not tool_response.startswith(EXIT_CODE_ERROR_PREFIX):
135
+ return None
136
+ remainder = tool_response[len(EXIT_CODE_ERROR_PREFIX) :]
137
+ digit_count = 0
138
+ while digit_count < len(remainder) and remainder[digit_count].isdigit():
139
+ digit_count += 1
140
+ return int(remainder[:digit_count]) if digit_count else None
141
+
142
+
143
+ def _is_single_unchained_command(command: str) -> bool:
144
+ return not any(each_operator in command for each_operator in ALL_CHAINING_OPERATOR_SUBSTRINGS)
145
+
146
+
147
+ def _runs_pytest_via_module_flag(all_tokens: list[str]) -> bool:
148
+ if MODULE_RUN_FLAG not in all_tokens:
149
+ return False
150
+ flag_index = all_tokens.index(MODULE_RUN_FLAG)
151
+ return flag_index + 1 < len(all_tokens) and all_tokens[flag_index + 1] == PYTEST_MODULE_NAME
152
+
153
+
154
+ def _command_tokens_run_pytest(all_tokens: list[str]) -> bool:
155
+ if not all_tokens:
156
+ return False
157
+ first_basename = Path(all_tokens[0]).name
158
+ if first_basename in ALL_PYTEST_PROGRAM_BASENAMES:
159
+ return True
160
+ if not PYTHON_INTERPRETER_BASENAME_PATTERN.match(first_basename):
161
+ return False
162
+ return _runs_pytest_via_module_flag(all_tokens)
163
+
164
+
165
+ def _existing_test_file_paths(all_tokens: list[str], cwd: str) -> list[Path]:
166
+ all_paths: list[Path] = []
167
+ for each_token in all_tokens:
168
+ if each_token.startswith(OPTION_TOKEN_PREFIX) or each_token in ALL_REDIRECTION_TOKENS:
169
+ continue
170
+ file_path_text = each_token.split(NODE_ID_FILE_PATH_SEPARATOR, 1)[0]
171
+ candidate_path = Path(cwd, file_path_text) if cwd else Path(file_path_text)
172
+ if candidate_path.is_file():
173
+ all_paths.append(candidate_path)
174
+ return all_paths
175
+
176
+
177
+ def _command_tokens(command: str) -> list[str]:
178
+ try:
179
+ return shlex.split(command)
180
+ except ValueError:
181
+ return []
182
+
183
+
184
+ def _record_pytest_failure(hook_payload: dict, command: str) -> None:
185
+ exit_status = _harness_reported_exit_status(hook_payload.get("tool_response"))
186
+ if exit_status is None or not _is_single_unchained_command(command):
187
+ return
188
+ all_tokens = _command_tokens(command)
189
+ if not _command_tokens_run_pytest(all_tokens):
190
+ return
191
+ cwd = str(hook_payload.get("cwd") or "")
192
+ all_test_file_paths = _existing_test_file_paths(all_tokens, cwd)
193
+ if not all_test_file_paths:
194
+ return
195
+ session_id = str(hook_payload.get("session_id") or "")
196
+ content_hash_store.record_test_command_failure(
197
+ all_test_file_paths, command, exit_status, session_id, cwd
198
+ )
199
+
200
+
201
+ def main() -> None:
202
+ """Record one failing pytest run's evidence from a PostToolUse Bash payload.
203
+
204
+ Reads the payload from stdin. Returns quietly on every branch that is not
205
+ a genuine, single, unchained, path-naming pytest failure, so a logging
206
+ problem never surfaces as a blocked or altered tool call.
207
+ """
208
+ hook_payload = read_hook_input_dictionary_from_stdin()
209
+ if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
210
+ return
211
+ tool_input = hook_payload.get("tool_input")
212
+ command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
213
+ if not isinstance(command, str) or not _mentions_pytest(command):
214
+ return
215
+ _record_pytest_failure(hook_payload, command)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()