claude-dev-env 2.31.0 → 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 (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -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 +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. package/scripts/test_shared_tree_paths.py +173 -0
@@ -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()
@@ -26,6 +26,10 @@ assert _TRACKER_SPEC.loader is not None
26
26
  tracker_module = importlib.util.module_from_spec(_TRACKER_SPEC)
27
27
  _TRACKER_SPEC.loader.exec_module(tracker_module)
28
28
 
29
+ _GATE_CONSTANTS = importlib.import_module(
30
+ "hooks_constants.pr_description_writer_gate_constants"
31
+ )
32
+
29
33
  _SESSION_ID = "session-abc123"
30
34
  _OTHER_SUBAGENT_TYPE = "general-purpose"
31
35
 
@@ -65,7 +69,7 @@ def _run_tracker(payload: dict[str, object], temp_directory: Path) -> None:
65
69
  """
66
70
  with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
67
71
  with mock.patch.object(
68
- tracker_module.tempfile, "gettempdir", return_value=str(temp_directory)
72
+ _GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(temp_directory)
69
73
  ):
70
74
  tracker_module.main()
71
75
 
@@ -120,7 +124,7 @@ def should_record_twice_without_error(tmp_path: Path) -> None:
120
124
  def should_ignore_a_malformed_payload(tmp_path: Path) -> None:
121
125
  """Unparseable stdin records nothing and raises nothing."""
122
126
  with mock.patch("sys.stdin", io.StringIO("this is not json")):
123
- with mock.patch.object(tracker_module.tempfile, "gettempdir", return_value=str(tmp_path)):
127
+ with mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(tmp_path)):
124
128
  tracker_module.main()
125
129
  assert list(tmp_path.iterdir()) == []
126
130
 
@@ -0,0 +1,173 @@
1
+ """Behavior tests for the test_failure_recorder PostToolUse hook.
2
+
3
+ Drives the hook's main through real stdin payloads shaped like a genuine
4
+ Bash PostToolUse call, then reads the shared content-hash store back to
5
+ confirm what -- if anything -- it recorded. The store's real state file
6
+ under the OS temp directory is read directly, the same way
7
+ test_content_hash_store.py and test_tdd_enforcer.py already do, rather than
8
+ mocking tempfile.gettempdir. ``hooks/conftest.py`` already puts the hooks
9
+ and blocking directories on sys.path for test collection, so this file needs
10
+ no bootstrap of its own before importing content_hash_store.
11
+ """
12
+
13
+ import contextlib
14
+ import importlib.util
15
+ import io
16
+ import json
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ from tdd_enforcer_parts import content_hash_store
21
+
22
+ _HOOK_DIRECTORY = Path(__file__).parent
23
+ _hook_spec = importlib.util.spec_from_file_location(
24
+ "test_failure_recorder_under_test", _HOOK_DIRECTORY / "test_failure_recorder.py"
25
+ )
26
+ assert _hook_spec is not None
27
+ assert _hook_spec.loader is not None
28
+ _hook_module = importlib.util.module_from_spec(_hook_spec)
29
+ _hook_spec.loader.exec_module(_hook_module)
30
+
31
+
32
+ def _run_hook_with_payload(payload: dict) -> None:
33
+ with (
34
+ mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
35
+ contextlib.suppress(SystemExit),
36
+ ):
37
+ _hook_module.main()
38
+
39
+
40
+ def _recorded_failure_entry(test_file: Path, session_id: str, repository_root: str) -> dict | None:
41
+ state_file = content_hash_store._state_file_path(session_id, repository_root)
42
+ if not state_file.exists():
43
+ return None
44
+ state = json.loads(state_file.read_text())
45
+ return state.get(content_hash_store._state_key_for(test_file))
46
+
47
+
48
+ def _bash_payload(command: str, tool_response: object, cwd: Path, session_id: str) -> dict:
49
+ return {
50
+ "session_id": session_id,
51
+ "cwd": str(cwd),
52
+ "tool_name": "Bash",
53
+ "tool_input": {"command": command},
54
+ "tool_response": tool_response,
55
+ }
56
+
57
+
58
+ _FAILING_OUTPUT = (
59
+ "Error: Exit code 1\n"
60
+ "F [100%]\n"
61
+ "=================================== FAILURES ===================================\n"
62
+ "FAILED test_orders.py::test_fulfill - AssertionError\n"
63
+ "1 failed in 0.02s"
64
+ )
65
+ _PASSING_OUTPUT = {"stdout": "1 passed in 0.02s", "stderr": "", "interrupted": False}
66
+
67
+
68
+ def test_records_a_failure_for_a_bare_pytest_command_naming_a_real_file(
69
+ tmp_path: Path,
70
+ ) -> None:
71
+ test_file = tmp_path / "test_orders.py"
72
+ test_file.write_text("def test_fulfill(): assert False\n")
73
+ session_id = "recorder-session-1"
74
+
75
+ _run_hook_with_payload(
76
+ _bash_payload("pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
77
+ )
78
+
79
+ entry = _recorded_failure_entry(test_file, session_id, str(tmp_path))
80
+ assert entry is not None
81
+ assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
82
+ assert entry[content_hash_store.STORED_FAILURE_COMMAND_KEY] == "pytest test_orders.py"
83
+
84
+
85
+ def test_does_not_record_for_a_passing_command(tmp_path: Path) -> None:
86
+ test_file = tmp_path / "test_orders.py"
87
+ test_file.write_text("def test_fulfill(): pass\n")
88
+ session_id = "recorder-session-2"
89
+
90
+ _run_hook_with_payload(
91
+ _bash_payload("pytest test_orders.py", _PASSING_OUTPUT, tmp_path, session_id)
92
+ )
93
+
94
+ assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
95
+
96
+
97
+ def test_does_not_record_for_a_non_pytest_command(tmp_path: Path) -> None:
98
+ test_file = tmp_path / "test_orders.py"
99
+ test_file.write_text("def test_fulfill(): assert False\n")
100
+ session_id = "recorder-session-3"
101
+
102
+ _run_hook_with_payload(
103
+ _bash_payload("npm test", "Error: Exit code 1\n1 failing", tmp_path, session_id)
104
+ )
105
+
106
+ assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
107
+
108
+
109
+ def test_does_not_record_for_a_chained_command(tmp_path: Path) -> None:
110
+ test_file = tmp_path / "test_orders.py"
111
+ test_file.write_text("def test_fulfill(): assert False\n")
112
+ session_id = "recorder-session-4"
113
+
114
+ _run_hook_with_payload(
115
+ _bash_payload("cd /repo && pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
116
+ )
117
+
118
+ assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
119
+
120
+
121
+ def test_does_not_record_for_a_bare_pytest_run_with_no_path_argument(
122
+ tmp_path: Path,
123
+ ) -> None:
124
+ test_file = tmp_path / "test_orders.py"
125
+ test_file.write_text("def test_fulfill(): assert False\n")
126
+ session_id = "recorder-session-5"
127
+
128
+ _run_hook_with_payload(_bash_payload("pytest -q", _FAILING_OUTPUT, tmp_path, session_id))
129
+
130
+ assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
131
+
132
+
133
+ def test_records_only_the_named_file_not_an_option_or_expression_token(
134
+ tmp_path: Path,
135
+ ) -> None:
136
+ test_file = tmp_path / "test_orders.py"
137
+ test_file.write_text("def test_fulfill(): assert False\n")
138
+ session_id = "recorder-session-6"
139
+
140
+ _run_hook_with_payload(
141
+ _bash_payload("pytest test_orders.py -k fulfill", _FAILING_OUTPUT, tmp_path, session_id)
142
+ )
143
+
144
+ entry = _recorded_failure_entry(test_file, session_id, str(tmp_path))
145
+ assert entry is not None
146
+ state_file = content_hash_store._state_file_path(session_id, str(tmp_path))
147
+ state = json.loads(state_file.read_text())
148
+ assert len(state) == 1
149
+
150
+
151
+ def test_ignores_a_tool_other_than_bash(tmp_path: Path) -> None:
152
+ test_file = tmp_path / "test_orders.py"
153
+ test_file.write_text("def test_fulfill(): assert False\n")
154
+ session_id = "recorder-session-7"
155
+ payload = _bash_payload("pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
156
+ payload["tool_name"] = "Write"
157
+
158
+ _run_hook_with_payload(payload)
159
+
160
+ assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
161
+
162
+
163
+ def test_does_not_record_when_the_named_file_does_not_exist_on_disk(
164
+ tmp_path: Path,
165
+ ) -> None:
166
+ session_id = "recorder-session-8"
167
+ missing_file = tmp_path / "test_missing.py"
168
+
169
+ _run_hook_with_payload(
170
+ _bash_payload("pytest test_missing.py", _FAILING_OUTPUT, tmp_path, session_id)
171
+ )
172
+
173
+ assert _recorded_failure_entry(missing_file, session_id, str(tmp_path)) is None
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import List
15
15
 
16
16
  from .config.abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
17
- from .validator_base import Violation
17
+ from .validator_base import Violation, source_text, syntax_tree
18
18
 
19
19
 
20
20
  def check_single_letter_variables(tree: ast.AST, filename: str) -> List[Violation]:
@@ -39,13 +39,13 @@ def validate_file(file_path: Path) -> List[Violation]:
39
39
  filename = str(file_path)
40
40
 
41
41
  try:
42
- source = file_path.read_text(encoding="utf-8")
42
+ source = source_text(file_path)
43
43
  except Exception as error:
44
44
  violations.append(Violation(filename, 0, f"Error reading file: {error}"))
45
45
  return violations
46
46
 
47
47
  try:
48
- tree = ast.parse(source)
48
+ tree = syntax_tree(source)
49
49
  except SyntaxError as error:
50
50
  violations.append(
51
51
  Violation(filename, error.lineno or 0, f"Syntax error: {error.msg}")
@@ -11,7 +11,7 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import List
13
13
 
14
- from .validator_base import Violation
14
+ from .validator_base import Violation, source_text, syntax_tree
15
15
 
16
16
 
17
17
  MAX_FUNCTION_LINES = 30
@@ -75,7 +75,7 @@ def check_file_length(file_path: Path) -> List[Violation]:
75
75
  filename = str(file_path)
76
76
 
77
77
  try:
78
- lines = file_path.read_text(encoding="utf-8").splitlines()
78
+ lines = source_text(file_path).splitlines()
79
79
  except Exception as error:
80
80
  return [Violation(filename, 0, f"Error reading file: {error}")]
81
81
 
@@ -98,8 +98,8 @@ def validate_file(file_path: Path) -> List[Violation]:
98
98
  violations.extend(check_file_length(file_path))
99
99
 
100
100
  try:
101
- source = file_path.read_text(encoding="utf-8")
102
- tree = ast.parse(source)
101
+ source = source_text(file_path)
102
+ tree = syntax_tree(source)
103
103
  except Exception as error:
104
104
  return violations + [Violation(filename, 0, f"Error: {error}")]
105
105
 
@@ -0,0 +1,12 @@
1
+ """Patterns and placeholders the baseline gate masks a violation message with.
2
+
3
+ Config files are exempt from the string-literal magic-value checks, so the
4
+ regex fragments and the placeholder text the scope-agnostic message shape needs
5
+ live here rather than inline in ``run_all_validators.py``.
6
+ """
7
+
8
+ QUALIFIED_SCOPE_NAME_SEPARATOR = "."
9
+ WORD_BOUNDARY_PATTERN = r"\b"
10
+ STANDALONE_NUMBER_PATTERN = r"(?<![A-Za-z0-9_])\d+(?![A-Za-z0-9_])"
11
+ SCOPE_NAME_PLACEHOLDER = "<name>"
12
+ SHAPE_NUMBER_PLACEHOLDER = "<number>"
@@ -7,7 +7,9 @@ siblings). Exact names come from the authoritative path-exemption pattern sets
7
7
  and CLI markers, unioned with the config-directory allowlist. Substring
8
8
  patterns come from the separator-free members of those same pattern sets so a
9
9
  directory such as ``pkg/test_helpers/`` stages with the same test exemption as
10
- the real path.
10
+ the real path. Directories pytest generates for its own scratch tree are
11
+ recognized by name and contribute no signal, whatever ``--basetemp`` they sit
12
+ under.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -27,13 +29,13 @@ from ..exempt_paths import (
27
29
  POSIX_DIRECTORY_SEPARATOR = "/"
28
30
  WINDOWS_DIRECTORY_SEPARATOR = "\\"
29
31
 
30
- ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
31
- {
32
- "TEMP",
33
- "TMP",
34
- "TMPDIR",
35
- "RUNNER_TEMP",
36
- }
32
+ PYTEST_TEMPORARY_ROOT_NAME_PREFIX = "pytest-"
33
+ DECIMAL_DIGIT_CHARACTERS = "0123456789"
34
+ LOWERCASE_ASCII_LETTER_CHARACTERS = "abcdefghijklmnopqrstuvwxyz"
35
+ UNDERSCORE_CHARACTER = "_"
36
+ PYTEST_SANITIZED_TEST_NAME_LENGTH_LIMIT = 30
37
+ ALL_PYTEST_SANITIZED_TEST_NAME_CHARACTERS: frozenset[str] = frozenset(
38
+ LOWERCASE_ASCII_LETTER_CHARACTERS + DECIMAL_DIGIT_CHARACTERS + UNDERSCORE_CHARACTER
37
39
  )
38
40
 
39
41
  ALL_AUTHORITATIVE_DIRECTORY_PATH_PATTERN_SETS: tuple[Iterable[str], ...] = (
@@ -68,6 +70,38 @@ def is_filename_like_path_segment(path_segment: str) -> bool:
68
70
  return "." in path_segment
69
71
 
70
72
 
73
+ def is_pytest_scratch_directory_segment(segment_lower: str) -> bool:
74
+ """Return True when *segment_lower* is a directory pytest itself created.
75
+
76
+ ::
77
+
78
+ is_pytest_scratch_directory_segment("test_drops_leading_anchor0") # True
79
+ is_pytest_scratch_directory_segment("pytest-of-root") # True
80
+ is_pytest_scratch_directory_segment("test_helpers") # False
81
+
82
+ ``TempPathFactory`` names its roots ``pytest-of-<user>`` and ``pytest-<n>``, and
83
+ each per-test directory as a length-capped test-name stem with a number appended.
84
+ Those generated names carry ``test_`` without naming a real project directory.
85
+
86
+ Args:
87
+ segment_lower: One lowercased directory component of a target path.
88
+
89
+ Returns:
90
+ True when pytest generated the segment rather than the project.
91
+ """
92
+ if segment_lower.startswith(PYTEST_TEMPORARY_ROOT_NAME_PREFIX):
93
+ return True
94
+ sanitized_test_name = segment_lower.rstrip(DECIMAL_DIGIT_CHARACTERS)
95
+ if not sanitized_test_name or sanitized_test_name == segment_lower:
96
+ return False
97
+ if len(sanitized_test_name) > PYTEST_SANITIZED_TEST_NAME_LENGTH_LIMIT:
98
+ return False
99
+ return all(
100
+ each_character in ALL_PYTEST_SANITIZED_TEST_NAME_CHARACTERS
101
+ for each_character in sanitized_test_name
102
+ )
103
+
104
+
71
105
  def all_directory_segments_in_path_pattern(path_pattern: str) -> list[str]:
72
106
  """Return lowercased directory tokens from one path-exemption pattern.
73
107
 
@@ -0,0 +1,15 @@
1
+ """Constants naming where the system keeps temporary directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # A run reaches its temp root through any of these, so a walk that stops at
6
+ # that boundary checks all four rather than gettempdir() alone. RUNNER_TEMP
7
+ # is the hosted-CI case: pytest writes there while gettempdir() stays /tmp.
8
+ ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
9
+ {
10
+ "TEMP",
11
+ "TMP",
12
+ "TMPDIR",
13
+ "RUNNER_TEMP",
14
+ }
15
+ )
@@ -0,0 +1,9 @@
1
+ """Constants for the shared source reader and parser in validator_base."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # One save validates one file, so a handful of entries covers a run and keeps
6
+ # a long-lived CLI pass from holding every file it ever read.
7
+ SOURCE_CACHE_ENTRIES: int = 8
8
+
9
+ SOURCE_ENCODING: str = "utf-8"