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.
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +2 -1
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_duplicate_body.py +280 -102
- package/hooks/blocking/code_rules_enforcer.py +32 -14
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/pr_description_writer_gate.py +18 -25
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +11 -5
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
- package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +84 -53
- package/hooks/blocking/test_tdd_enforcer.py +131 -1
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
- package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/abbreviation_checks.py +3 -3
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +42 -8
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +23 -80
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +3 -2
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +247 -148
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +16 -16
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +104 -0
- package/scripts/test_shared_tree_paths.py +173 -0
|
@@ -12,11 +12,14 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import sys
|
|
15
|
-
from pathlib import Path
|
|
16
15
|
|
|
17
16
|
import _path_setup # noqa: F401
|
|
18
17
|
|
|
19
|
-
from hooks_constants.hosted_hook_runner import
|
|
18
|
+
from hooks_constants.hosted_hook_runner import (
|
|
19
|
+
run_hook_capturing_output,
|
|
20
|
+
HostedHookRun,
|
|
21
|
+
resolved_hook_script_path,
|
|
22
|
+
)
|
|
20
23
|
from hooks_constants.stop_dispatcher_constants import (
|
|
21
24
|
ALL_STOP_HOSTED_HOOK_PATHS,
|
|
22
25
|
BLOCK_DECISION,
|
|
@@ -24,12 +27,6 @@ from hooks_constants.stop_dispatcher_constants import (
|
|
|
24
27
|
)
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
28
|
-
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
29
|
-
hooks_root = Path(__file__).resolve().parent.parent
|
|
30
|
-
return str(hooks_root / relative_path)
|
|
31
|
-
|
|
32
|
-
|
|
33
30
|
def _is_block_decision(stdout_text: str) -> bool:
|
|
34
31
|
"""Return True when stdout carries a Stop decision of block."""
|
|
35
32
|
stripped_text = stdout_text.strip()
|
|
@@ -58,7 +55,7 @@ def dispatch(payload_text: str) -> None:
|
|
|
58
55
|
"""Run every Stop hosted hook and emit the first block decision if any."""
|
|
59
56
|
all_runs: list[HostedHookRun] = []
|
|
60
57
|
for each_relative_path in ALL_STOP_HOSTED_HOOK_PATHS:
|
|
61
|
-
script_path =
|
|
58
|
+
script_path = resolved_hook_script_path(each_relative_path)
|
|
62
59
|
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
63
60
|
all_runs.append(hook_run)
|
|
64
61
|
block_stdout = select_first_block_stdout(all_runs)
|
|
@@ -4,7 +4,9 @@ Blocks a write to a production source file when no matching test was modified
|
|
|
4
4
|
within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
|
|
5
5
|
The gate records each candidate test's content hash at every sighting and
|
|
6
6
|
compares that hash on the next write. A first sighting requires content that
|
|
7
|
-
differs from HEAD.
|
|
7
|
+
differs from HEAD. A recorded failing pytest run, written by
|
|
8
|
+
``hooks/observability/test_failure_recorder.py``, is read before that fallback
|
|
9
|
+
and applies while the candidate's content still matches what failed.
|
|
8
10
|
Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
|
|
9
11
|
into one PreToolUse gate and re-exports their surface for the test suite.
|
|
10
12
|
"""
|
|
@@ -80,8 +82,12 @@ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
|
|
|
80
82
|
return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
def _is_silently_skipped(
|
|
84
|
-
|
|
85
|
+
def _is_silently_skipped(
|
|
86
|
+
file_path: str, extension: str, path: Path, repository_root: str = ""
|
|
87
|
+
) -> bool:
|
|
88
|
+
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(
|
|
89
|
+
file_path, repository_root
|
|
90
|
+
):
|
|
85
91
|
return True
|
|
86
92
|
if _is_agent_home_tooling_write(file_path):
|
|
87
93
|
return True
|
|
@@ -135,13 +141,13 @@ def _decide_for_target(
|
|
|
135
141
|
return None
|
|
136
142
|
path = Path(file_path)
|
|
137
143
|
extension = path.suffix.lower()
|
|
138
|
-
|
|
144
|
+
repository_root = str(input_data.get("cwd") or "")
|
|
145
|
+
if _is_silently_skipped(file_path, extension, path, repository_root):
|
|
139
146
|
return None
|
|
140
147
|
if _write_is_exempt(tool_name, tool_input, path, extension):
|
|
141
148
|
return True, ""
|
|
142
149
|
all_candidates = candidate_test_paths_for(path)
|
|
143
150
|
session_id = str(input_data.get("session_id") or "")
|
|
144
|
-
repository_root = str(input_data.get("cwd") or "")
|
|
145
151
|
is_recorded_or_fresh = has_recorded_or_fresh_test(
|
|
146
152
|
all_candidates, session_id, repository_root, _freshness_seconds()
|
|
147
153
|
)
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
Centralizes the freshness window, the ancestor-walk limit, the git-tracking
|
|
4
4
|
and git-diff command tokens, the source-file extension sets, the join
|
|
5
5
|
separator, and the last-observed-hash store's filename shape and JSON keys.
|
|
6
|
+
The store's JSON keys also cover a recorded failing-test-run entry: the test
|
|
7
|
+
file's content hash at failure time, the failure timestamp, the failing
|
|
8
|
+
command, and its exit status.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
from __future__ import annotations
|
|
@@ -22,6 +25,10 @@ HASH_STATE_FILE_SUFFIX: str = ".json"
|
|
|
22
25
|
REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
|
|
23
26
|
STORED_CONTENT_HASH_KEY: str = "hash"
|
|
24
27
|
STORED_CHANGED_AT_KEY: str = "changed_at"
|
|
28
|
+
STORED_FAILURE_HASH_KEY: str = "failed_hash"
|
|
29
|
+
STORED_FAILED_AT_KEY: str = "failed_at"
|
|
30
|
+
STORED_FAILURE_COMMAND_KEY: str = "failed_command"
|
|
31
|
+
STORED_FAILURE_EXIT_STATUS_KEY: str = "failed_exit_status"
|
|
25
32
|
PYTHON_SOURCE_EXTENSION: str = ".py"
|
|
26
33
|
ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
|
|
27
34
|
NEWLINE_JOIN_SEPARATOR: str = "\n"
|
|
@@ -30,6 +30,7 @@ import time
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
32
|
from atomic_file_writer import write_text_atomically
|
|
33
|
+
from hooks_constants.state_file_lock import hold_state_file_lock
|
|
33
34
|
from hooks_constants.session_edit_stage_gate_constants import (
|
|
34
35
|
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
35
36
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
@@ -45,6 +46,10 @@ from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
|
45
46
|
REPOSITORY_ROOT_DIGEST_LENGTH,
|
|
46
47
|
STORED_CHANGED_AT_KEY,
|
|
47
48
|
STORED_CONTENT_HASH_KEY,
|
|
49
|
+
STORED_FAILED_AT_KEY,
|
|
50
|
+
STORED_FAILURE_COMMAND_KEY,
|
|
51
|
+
STORED_FAILURE_EXIT_STATUS_KEY,
|
|
52
|
+
STORED_FAILURE_HASH_KEY,
|
|
48
53
|
)
|
|
49
54
|
|
|
50
55
|
|
|
@@ -96,6 +101,26 @@ def _recorded_entry(current_hash: str, current_time: float) -> dict[str, object]
|
|
|
96
101
|
return {STORED_CONTENT_HASH_KEY: current_hash, STORED_CHANGED_AT_KEY: current_time}
|
|
97
102
|
|
|
98
103
|
|
|
104
|
+
def _has_matching_recorded_failure(
|
|
105
|
+
stored_entry: object, current_hash: str, current_time: float, freshness_seconds: int
|
|
106
|
+
) -> bool:
|
|
107
|
+
"""Return whether a recorded test failure still evidences the candidate's content.
|
|
108
|
+
|
|
109
|
+
A failure counts as evidence only while the candidate's content is
|
|
110
|
+
exactly what failed and the record has not aged past the freshness
|
|
111
|
+
window -- editing the test again, or letting the record go stale, drops
|
|
112
|
+
it as evidence and falls back to the ordinary content-hash tracking.
|
|
113
|
+
"""
|
|
114
|
+
if not isinstance(stored_entry, dict):
|
|
115
|
+
return False
|
|
116
|
+
if stored_entry.get(STORED_FAILURE_HASH_KEY) != current_hash:
|
|
117
|
+
return False
|
|
118
|
+
failed_at = stored_entry.get(STORED_FAILED_AT_KEY)
|
|
119
|
+
if not isinstance(failed_at, (int, float)):
|
|
120
|
+
return False
|
|
121
|
+
return current_time - failed_at <= freshness_seconds
|
|
122
|
+
|
|
123
|
+
|
|
99
124
|
def _is_qualifying_first_sight(
|
|
100
125
|
candidate_path: Path, candidate_text: str, current_time: float, freshness_seconds: int
|
|
101
126
|
) -> bool:
|
|
@@ -107,6 +132,34 @@ def _is_qualifying_first_sight(
|
|
|
107
132
|
return git_tracking.has_uncommitted_change_from_head(candidate_path)
|
|
108
133
|
|
|
109
134
|
|
|
135
|
+
def _first_sight_decision(
|
|
136
|
+
candidate_path: Path,
|
|
137
|
+
candidate_text: str,
|
|
138
|
+
current_time: float,
|
|
139
|
+
freshness_seconds: int,
|
|
140
|
+
current_hash: str,
|
|
141
|
+
) -> tuple[bool, dict[str, object] | None]:
|
|
142
|
+
if not _is_qualifying_first_sight(
|
|
143
|
+
candidate_path, candidate_text, current_time, freshness_seconds
|
|
144
|
+
):
|
|
145
|
+
return False, None
|
|
146
|
+
return True, _recorded_entry(current_hash, current_time)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _content_hash_decision(
|
|
150
|
+
all_stored_entry_fields: dict[str, object],
|
|
151
|
+
current_hash: str,
|
|
152
|
+
current_time: float,
|
|
153
|
+
freshness_seconds: int,
|
|
154
|
+
) -> tuple[bool, dict[str, object] | None]:
|
|
155
|
+
if current_hash != all_stored_entry_fields.get(STORED_CONTENT_HASH_KEY):
|
|
156
|
+
return True, _recorded_entry(current_hash, current_time)
|
|
157
|
+
stored_changed_at = all_stored_entry_fields.get(STORED_CHANGED_AT_KEY)
|
|
158
|
+
if not isinstance(stored_changed_at, (int, float)):
|
|
159
|
+
return False, None
|
|
160
|
+
return current_time - stored_changed_at <= freshness_seconds, None
|
|
161
|
+
|
|
162
|
+
|
|
110
163
|
def _evaluate_candidate(
|
|
111
164
|
candidate_path: Path,
|
|
112
165
|
stored_entry: object,
|
|
@@ -117,18 +170,15 @@ def _evaluate_candidate(
|
|
|
117
170
|
if candidate_text is None:
|
|
118
171
|
return False, None
|
|
119
172
|
current_hash = _content_hash(candidate_text)
|
|
120
|
-
if
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if not isinstance(stored_changed_at, (int, float)):
|
|
130
|
-
return False, None
|
|
131
|
-
return current_time - stored_changed_at <= freshness_seconds, None
|
|
173
|
+
if _has_matching_recorded_failure(
|
|
174
|
+
stored_entry, current_hash, current_time, freshness_seconds
|
|
175
|
+
):
|
|
176
|
+
return True, None
|
|
177
|
+
if not isinstance(stored_entry, dict) or STORED_CONTENT_HASH_KEY not in stored_entry:
|
|
178
|
+
return _first_sight_decision(
|
|
179
|
+
candidate_path, candidate_text, current_time, freshness_seconds, current_hash
|
|
180
|
+
)
|
|
181
|
+
return _content_hash_decision(stored_entry, current_hash, current_time, freshness_seconds)
|
|
132
182
|
|
|
133
183
|
|
|
134
184
|
def _apply_candidate_decisions(
|
|
@@ -142,7 +192,10 @@ def _apply_candidate_decisions(
|
|
|
142
192
|
for each_candidate in all_candidates:
|
|
143
193
|
candidate_key = _state_key_for(each_candidate)
|
|
144
194
|
candidate_allowed, updated_entry = _evaluate_candidate(
|
|
145
|
-
each_candidate,
|
|
195
|
+
each_candidate,
|
|
196
|
+
all_stored_hashes.get(candidate_key),
|
|
197
|
+
current_time,
|
|
198
|
+
freshness_seconds,
|
|
146
199
|
)
|
|
147
200
|
if candidate_allowed:
|
|
148
201
|
is_allowed = True
|
|
@@ -162,19 +215,84 @@ def has_recorded_or_fresh_test(
|
|
|
162
215
|
|
|
163
216
|
Args:
|
|
164
217
|
all_candidates: Candidate test paths for the production file.
|
|
165
|
-
session_id: Raw
|
|
166
|
-
repository_root: Raw
|
|
218
|
+
session_id: Raw session_id from the hook payload.
|
|
219
|
+
repository_root: Raw cwd from the hook payload.
|
|
167
220
|
freshness_seconds: Maximum age, in seconds, a stored hash may have.
|
|
168
221
|
|
|
169
222
|
Returns:
|
|
170
|
-
True when at least one candidate
|
|
171
|
-
|
|
223
|
+
True when at least one candidate carries a matching recorded test
|
|
224
|
+
failure, is a fresh dirty first sighting, has a recorded hash that
|
|
225
|
+
now differs, or has a recorded hash still in window.
|
|
172
226
|
"""
|
|
173
227
|
state_file = _state_file_path(session_id, repository_root)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
228
|
+
with hold_state_file_lock(state_file):
|
|
229
|
+
all_stored_hashes = _load_state(state_file)
|
|
230
|
+
is_allowed, state_changed = _apply_candidate_decisions(
|
|
231
|
+
all_candidates, all_stored_hashes, time.time(), freshness_seconds
|
|
232
|
+
)
|
|
233
|
+
if state_changed:
|
|
234
|
+
_atomic_write_state(state_file, all_stored_hashes)
|
|
180
235
|
return is_allowed
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _merged_failure_entry(
|
|
239
|
+
existing_entry: object,
|
|
240
|
+
candidate_text: str,
|
|
241
|
+
command: str,
|
|
242
|
+
exit_status: int,
|
|
243
|
+
current_time: float,
|
|
244
|
+
) -> dict[str, object]:
|
|
245
|
+
merged_entry = dict(existing_entry) if isinstance(existing_entry, dict) else {}
|
|
246
|
+
merged_entry[STORED_FAILURE_HASH_KEY] = _content_hash(candidate_text)
|
|
247
|
+
merged_entry[STORED_FAILED_AT_KEY] = current_time
|
|
248
|
+
merged_entry[STORED_FAILURE_COMMAND_KEY] = command
|
|
249
|
+
merged_entry[STORED_FAILURE_EXIT_STATUS_KEY] = exit_status
|
|
250
|
+
return merged_entry
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _record_one_test_file_failure(
|
|
254
|
+
test_file_path: Path,
|
|
255
|
+
all_stored_hashes: dict[str, object],
|
|
256
|
+
command: str,
|
|
257
|
+
exit_status: int,
|
|
258
|
+
current_time: float,
|
|
259
|
+
) -> bool:
|
|
260
|
+
candidate_text = freshness._read_candidate_text(test_file_path)
|
|
261
|
+
if candidate_text is None:
|
|
262
|
+
return False
|
|
263
|
+
candidate_key = _state_key_for(test_file_path)
|
|
264
|
+
all_stored_hashes[candidate_key] = _merged_failure_entry(
|
|
265
|
+
all_stored_hashes.get(candidate_key),
|
|
266
|
+
candidate_text,
|
|
267
|
+
command,
|
|
268
|
+
exit_status,
|
|
269
|
+
current_time,
|
|
270
|
+
)
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def record_test_command_failure(
|
|
275
|
+
all_test_file_paths: list[Path],
|
|
276
|
+
command: str,
|
|
277
|
+
exit_status: int,
|
|
278
|
+
session_id: str,
|
|
279
|
+
repository_root: str,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Record a failing test command as gate evidence, one entry per test file.
|
|
282
|
+
|
|
283
|
+
Keyed exactly as the gate looks candidates up, so a failure satisfies the
|
|
284
|
+
gate only for the file it names, never a sibling the command also ran.
|
|
285
|
+
Existing content-hash fields on that entry are preserved, not overwritten.
|
|
286
|
+
"""
|
|
287
|
+
state_file = _state_file_path(session_id, repository_root)
|
|
288
|
+
current_time = time.time()
|
|
289
|
+
with hold_state_file_lock(state_file):
|
|
290
|
+
all_stored_hashes = _load_state(state_file)
|
|
291
|
+
all_recorded = [
|
|
292
|
+
_record_one_test_file_failure(
|
|
293
|
+
each_path, all_stored_hashes, command, exit_status, current_time
|
|
294
|
+
)
|
|
295
|
+
for each_path in all_test_file_paths
|
|
296
|
+
]
|
|
297
|
+
if any(all_recorded):
|
|
298
|
+
_atomic_write_state(state_file, all_stored_hashes)
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
"""Behavioral tests for the content_hash_store parts module.
|
|
1
|
+
"""Behavioral tests for the content_hash_store parts module.
|
|
2
|
+
|
|
3
|
+
Covers the recorded-test-failure evidence path: a genuinely failing test
|
|
4
|
+
command outranks a stale mtime, never satisfies a candidate it did not
|
|
5
|
+
name, expires after the freshness window, and applies only while the
|
|
6
|
+
candidate's content still matches what actually failed. Also covers the
|
|
7
|
+
private decision helpers by name, so a rename keeps its own paired test.
|
|
8
|
+
"""
|
|
2
9
|
|
|
3
10
|
import json
|
|
4
11
|
import os
|
|
@@ -141,3 +148,109 @@ def test_denies_when_no_candidate_file_exists(tmp_path: Path) -> None:
|
|
|
141
148
|
)
|
|
142
149
|
|
|
143
150
|
assert is_allowed is False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_recorded_failure_allows_a_stale_candidate(tmp_path: Path) -> None:
|
|
154
|
+
"""RED-evidence: a genuinely recorded failure outranks a stale mtime."""
|
|
155
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
156
|
+
stale_timestamp = time.time() - 700
|
|
157
|
+
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
158
|
+
session_id, repository_root = "session-failure-1", str(tmp_path)
|
|
159
|
+
content_hash_store.record_test_command_failure(
|
|
160
|
+
[candidate], "pytest test_orders.py", 1, session_id, repository_root
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
164
|
+
[candidate], session_id, repository_root, 600
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert is_allowed is True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_recorded_failure_stores_the_command_and_exit_status(tmp_path: Path) -> None:
|
|
171
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
172
|
+
session_id, repository_root = "session-failure-2", str(tmp_path)
|
|
173
|
+
|
|
174
|
+
content_hash_store.record_test_command_failure(
|
|
175
|
+
[candidate], "pytest test_orders.py -q", 1, session_id, repository_root
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
179
|
+
state = json.loads(state_file.read_text())
|
|
180
|
+
entry = state[content_hash_store._state_key_for(candidate)]
|
|
181
|
+
assert entry[content_hash_store.STORED_FAILURE_COMMAND_KEY] == "pytest test_orders.py -q"
|
|
182
|
+
assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_recorded_failure_does_not_satisfy_a_different_candidate(tmp_path: Path) -> None:
|
|
186
|
+
"""Anti-bypass: a failure recorded for one test file must not unlock a sibling."""
|
|
187
|
+
failing_candidate = tmp_path / "test_bar.py"
|
|
188
|
+
failing_candidate.write_text("def test_bar(): pass\n")
|
|
189
|
+
unrelated_candidate = tmp_path / "test_foo.py"
|
|
190
|
+
unrelated_candidate.write_text("def test_foo(): pass\n")
|
|
191
|
+
stale_timestamp = time.time() - 700
|
|
192
|
+
os.utime(unrelated_candidate, (stale_timestamp, stale_timestamp))
|
|
193
|
+
session_id, repository_root = "session-failure-3", str(tmp_path)
|
|
194
|
+
content_hash_store.record_test_command_failure(
|
|
195
|
+
[failing_candidate], "pytest test_bar.py", 1, session_id, repository_root
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
199
|
+
[unrelated_candidate], session_id, repository_root, 600
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
assert is_allowed is False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_recorded_failure_expires_after_the_freshness_window(tmp_path: Path) -> None:
|
|
206
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
207
|
+
stale_timestamp = time.time() - 700
|
|
208
|
+
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
209
|
+
session_id, repository_root = "session-failure-4", str(tmp_path)
|
|
210
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
211
|
+
candidate_key = content_hash_store._state_key_for(candidate)
|
|
212
|
+
stale_failed_at = time.time() - 700
|
|
213
|
+
state_file.write_text(
|
|
214
|
+
json.dumps(
|
|
215
|
+
{
|
|
216
|
+
candidate_key: {
|
|
217
|
+
content_hash_store.STORED_FAILURE_HASH_KEY: content_hash_store._content_hash(
|
|
218
|
+
candidate.read_text()
|
|
219
|
+
),
|
|
220
|
+
content_hash_store.STORED_FAILED_AT_KEY: stale_failed_at,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
227
|
+
[candidate], session_id, repository_root, 600
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
assert is_allowed is False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_recorded_failure_does_not_apply_once_content_no_longer_matches(
|
|
234
|
+
tmp_path: Path,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Anti-bypass: editing the test after a recorded RED clears that RED as evidence.
|
|
237
|
+
|
|
238
|
+
A bare failure-only entry (no prior content-hash tracking) must not fall
|
|
239
|
+
through to the unconditional hash-differs allow either -- it still needs a
|
|
240
|
+
real qualifying first sight (fresh mtime plus a real git-visible edit).
|
|
241
|
+
"""
|
|
242
|
+
candidate = tmp_path / "test_orders.py"
|
|
243
|
+
candidate.write_text("def test_fulfill(): pass\n")
|
|
244
|
+
session_id, repository_root = "session-failure-5", str(tmp_path)
|
|
245
|
+
content_hash_store.record_test_command_failure(
|
|
246
|
+
[candidate], "pytest test_orders.py", 1, session_id, repository_root
|
|
247
|
+
)
|
|
248
|
+
candidate.write_text("def test_fulfill(): assert True\n")
|
|
249
|
+
stale_timestamp = time.time() - 700
|
|
250
|
+
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
251
|
+
|
|
252
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
253
|
+
[candidate], session_id, repository_root, 600
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
assert is_allowed is False
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Behavior tests for the Bash PostToolUse dispatcher.
|
|
2
|
+
|
|
3
|
+
One interpreter start runs every hosted PostToolUse hook a Bash call fires.
|
|
4
|
+
The unit tests pin selection, call order, and crash isolation with a fake
|
|
5
|
+
runner; the end-to-end test drives the real hosted hooks through a real
|
|
6
|
+
payload and confirms the recorder's own real side effect lands.
|
|
7
|
+
``hooks/conftest.py`` already puts the hooks and blocking directories on
|
|
8
|
+
sys.path for test collection, so this file needs no bootstrap of its own.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from bash_post_call_dispatcher import dispatch, main, select_applicable_entries
|
|
19
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
|
|
20
|
+
from hooks_constants.hosted_hook_runner import HostedHookRun
|
|
21
|
+
from tdd_enforcer_parts import content_hash_store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_select_applicable_entries_returns_both_hosted_hooks_for_bash() -> None:
|
|
25
|
+
all_entries = select_applicable_entries(BASH_TOOL_NAME)
|
|
26
|
+
all_script_paths = [each_entry.script_relative_path for each_entry in all_entries]
|
|
27
|
+
assert all_script_paths == [
|
|
28
|
+
"blocking/gh_pr_author_restore.py",
|
|
29
|
+
"observability/test_failure_recorder.py",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_select_applicable_entries_returns_none_for_a_write_tool() -> None:
|
|
34
|
+
assert select_applicable_entries("Write") == []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_dispatch_runs_every_hosted_hook_in_roster_order(
|
|
38
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
39
|
+
) -> None:
|
|
40
|
+
all_call_paths: list[str] = []
|
|
41
|
+
|
|
42
|
+
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|
|
43
|
+
del payload_text
|
|
44
|
+
all_call_paths.append(Path(script_path).name)
|
|
45
|
+
return HostedHookRun(captured_stdout="", did_crash=False)
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr("bash_post_call_dispatcher.run_hook_capturing_output", _fake_run_hook)
|
|
48
|
+
|
|
49
|
+
dispatch('{"tool_name": "Bash"}', BASH_TOOL_NAME)
|
|
50
|
+
|
|
51
|
+
assert all_call_paths == ["gh_pr_author_restore.py", "test_failure_recorder.py"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_dispatch_runs_the_later_hook_after_an_earlier_one_crashes(
|
|
55
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
56
|
+
) -> None:
|
|
57
|
+
all_call_paths: list[str] = []
|
|
58
|
+
|
|
59
|
+
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|
|
60
|
+
del payload_text
|
|
61
|
+
script_name = Path(script_path).name
|
|
62
|
+
all_call_paths.append(script_name)
|
|
63
|
+
if script_name == "gh_pr_author_restore.py":
|
|
64
|
+
return HostedHookRun(captured_stdout="", did_crash=True)
|
|
65
|
+
return HostedHookRun(captured_stdout="", did_crash=False)
|
|
66
|
+
|
|
67
|
+
monkeypatch.setattr("bash_post_call_dispatcher.run_hook_capturing_output", _fake_run_hook)
|
|
68
|
+
|
|
69
|
+
dispatch('{"tool_name": "Bash"}', BASH_TOOL_NAME)
|
|
70
|
+
|
|
71
|
+
assert all_call_paths == ["gh_pr_author_restore.py", "test_failure_recorder.py"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_main_exits_zero_and_writes_nothing_to_stdout(
|
|
75
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
76
|
+
) -> None:
|
|
77
|
+
payload = json.dumps({"tool_name": "Write", "tool_input": {}})
|
|
78
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload))
|
|
79
|
+
|
|
80
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
81
|
+
main()
|
|
82
|
+
|
|
83
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
84
|
+
assert capsys.readouterr().out == ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_end_to_end_real_hosted_hooks_record_a_real_pytest_failure(tmp_path: Path) -> None:
|
|
88
|
+
"""Drives the real, unfaked roster: both scripts run in one process."""
|
|
89
|
+
test_file = tmp_path / "test_orders.py"
|
|
90
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
91
|
+
session_id = "post-dispatcher-session"
|
|
92
|
+
failing_output = (
|
|
93
|
+
"Error: Exit code 1\n"
|
|
94
|
+
"FAILED test_orders.py::test_fulfill - AssertionError\n"
|
|
95
|
+
"1 failed in 0.02s"
|
|
96
|
+
)
|
|
97
|
+
payload = json.dumps(
|
|
98
|
+
{
|
|
99
|
+
"session_id": session_id,
|
|
100
|
+
"cwd": str(tmp_path),
|
|
101
|
+
"tool_name": "Bash",
|
|
102
|
+
"tool_input": {"command": "pytest test_orders.py"},
|
|
103
|
+
"tool_response": failing_output,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
dispatch(payload, BASH_TOOL_NAME)
|
|
108
|
+
|
|
109
|
+
state_file = content_hash_store._state_file_path(session_id, str(tmp_path))
|
|
110
|
+
state = json.loads(state_file.read_text())
|
|
111
|
+
entry = state[content_hash_store._state_key_for(test_file)]
|
|
112
|
+
assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
import tempfile
|
|
@@ -168,8 +169,18 @@ def test_should_return_true_for_root_anchored_tmp_and_temp(
|
|
|
168
169
|
raw_path: str,
|
|
169
170
|
monkeypatch: pytest.MonkeyPatch,
|
|
170
171
|
) -> None:
|
|
171
|
-
"""B4: classifier returns True for root-anchored /tmp and /temp paths.
|
|
172
|
+
"""B4: classifier returns True for root-anchored /tmp and /temp paths.
|
|
173
|
+
|
|
174
|
+
A Windows drive-letter literal is absolute only on Windows. On POSIX,
|
|
175
|
+
os.path.abspath() joins a non-absolute path with the process cwd, so a
|
|
176
|
+
drive-letter case's pass or fail state depends on whatever directory the
|
|
177
|
+
runner happens to sit in. Skip it on a host that does not treat the
|
|
178
|
+
literal as absolute, so the assertion checks its own precondition rather
|
|
179
|
+
than the runner's ambient cwd.
|
|
180
|
+
"""
|
|
172
181
|
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
182
|
+
if not os.path.isabs(raw_path):
|
|
183
|
+
pytest.skip(f"{raw_path!r} is not absolute on this host")
|
|
173
184
|
assert is_ephemeral_script_path(raw_path) is True
|
|
174
185
|
|
|
175
186
|
|