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
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
"""Tests for state_description_blocker hook.
|
|
1
|
+
"""Tests for state_description_blocker hook.
|
|
2
|
+
|
|
3
|
+
Every payload here names an absolute file under this repository and carries
|
|
4
|
+
the repository root as its ``cwd``, so each decision belongs to this
|
|
5
|
+
checkout rather than to the directory pytest runs from. The one exception is
|
|
6
|
+
the ephemeral-path test, which keeps its scratch target outside the
|
|
7
|
+
repository root on purpose.
|
|
8
|
+
"""
|
|
2
9
|
|
|
3
10
|
import json
|
|
4
11
|
import os
|
|
@@ -22,10 +29,17 @@ from state_description_blocker import ( # noqa: E402
|
|
|
22
29
|
)
|
|
23
30
|
from state_description_blocker import evaluate as _evaluate_without_opt_in # noqa: E402
|
|
24
31
|
|
|
32
|
+
from code_rules_enforcer_test_support import (
|
|
33
|
+
repository_file_path,
|
|
34
|
+
repository_root,
|
|
35
|
+
)
|
|
36
|
+
|
|
25
37
|
HOOK_SCRIPT_PATH = os.path.join(
|
|
26
38
|
os.path.dirname(__file__), "state_description_blocker.py"
|
|
27
39
|
)
|
|
28
40
|
|
|
41
|
+
_REPOSITORY_ROOT = str(repository_root())
|
|
42
|
+
|
|
29
43
|
CLEAN_PYTHON = "x = 1 # Uses a default timeout"
|
|
30
44
|
CLEAN_MD = "# Config\n\nThe API uses port 8080."
|
|
31
45
|
CLEAN_COMMENT = "# Configured with a 30-second timeout"
|
|
@@ -46,7 +60,11 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
46
60
|
|
|
47
61
|
|
|
48
62
|
class _RunHook:
|
|
49
|
-
"""
|
|
63
|
+
"""Run the hook as a subprocess on a payload anchored in this repository.
|
|
64
|
+
|
|
65
|
+
The payload names the repository root as the session working directory,
|
|
66
|
+
so the hook treats each target inside it as a real production file.
|
|
67
|
+
"""
|
|
50
68
|
|
|
51
69
|
def __call__(
|
|
52
70
|
self,
|
|
@@ -55,7 +73,9 @@ class _RunHook:
|
|
|
55
73
|
*,
|
|
56
74
|
is_prose_style_enabled: bool = True,
|
|
57
75
|
) -> subprocess.CompletedProcess:
|
|
58
|
-
payload = json.dumps(
|
|
76
|
+
payload = json.dumps(
|
|
77
|
+
{"tool_name": tool_name, "tool_input": tool_input, "cwd": _REPOSITORY_ROOT}
|
|
78
|
+
)
|
|
59
79
|
environment_by_key = os.environ.copy()
|
|
60
80
|
if is_prose_style_enabled:
|
|
61
81
|
environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
|
|
@@ -81,7 +101,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
|
|
|
81
101
|
result = _run_hook(
|
|
82
102
|
"Write",
|
|
83
103
|
{
|
|
84
|
-
"file_path": "src/main.py",
|
|
104
|
+
"file_path": repository_file_path("src/main.py"),
|
|
85
105
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
86
106
|
},
|
|
87
107
|
is_prose_style_enabled=False,
|
|
@@ -92,9 +112,10 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
|
|
|
92
112
|
{
|
|
93
113
|
"tool_name": "Write",
|
|
94
114
|
"tool_input": {
|
|
95
|
-
"file_path": "src/main.py",
|
|
115
|
+
"file_path": repository_file_path("src/main.py"),
|
|
96
116
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
97
117
|
},
|
|
118
|
+
"cwd": _REPOSITORY_ROOT,
|
|
98
119
|
}
|
|
99
120
|
)
|
|
100
121
|
assert deny_reason is not None
|
|
@@ -105,7 +126,7 @@ def test_block_clean_python_comment_passes():
|
|
|
105
126
|
result = _run_hook(
|
|
106
127
|
"Write",
|
|
107
128
|
{
|
|
108
|
-
"file_path": "src/main.py",
|
|
129
|
+
"file_path": repository_file_path("src/main.py"),
|
|
109
130
|
"content": CLEAN_PYTHON,
|
|
110
131
|
},
|
|
111
132
|
)
|
|
@@ -117,7 +138,7 @@ def test_block_clean_markdown_passes():
|
|
|
117
138
|
result = _run_hook(
|
|
118
139
|
"Write",
|
|
119
140
|
{
|
|
120
|
-
"file_path": "docs/README.md",
|
|
141
|
+
"file_path": repository_file_path("docs/README.md"),
|
|
121
142
|
"content": CLEAN_MD,
|
|
122
143
|
},
|
|
123
144
|
)
|
|
@@ -129,7 +150,7 @@ def test_block_clean_comment_passes():
|
|
|
129
150
|
result = _run_hook(
|
|
130
151
|
"Write",
|
|
131
152
|
{
|
|
132
|
-
"file_path": "src/config.py",
|
|
153
|
+
"file_path": repository_file_path("src/config.py"),
|
|
133
154
|
"content": CLEAN_COMMENT,
|
|
134
155
|
},
|
|
135
156
|
)
|
|
@@ -141,7 +162,7 @@ def test_block_irrelevant_file_passes():
|
|
|
141
162
|
result = _run_hook(
|
|
142
163
|
"Write",
|
|
143
164
|
{
|
|
144
|
-
"file_path": "data.txt",
|
|
165
|
+
"file_path": repository_file_path("data.txt"),
|
|
145
166
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
146
167
|
},
|
|
147
168
|
)
|
|
@@ -153,7 +174,7 @@ def test_block_empty_content_passes():
|
|
|
153
174
|
result = _run_hook(
|
|
154
175
|
"Write",
|
|
155
176
|
{
|
|
156
|
-
"file_path": "src/main.py",
|
|
177
|
+
"file_path": repository_file_path("src/main.py"),
|
|
157
178
|
"content": "",
|
|
158
179
|
},
|
|
159
180
|
)
|
|
@@ -177,7 +198,7 @@ def test_detects_instead_of_in_comment():
|
|
|
177
198
|
result = _run_hook(
|
|
178
199
|
"Write",
|
|
179
200
|
{
|
|
180
|
-
"file_path": "src/main.py",
|
|
201
|
+
"file_path": repository_file_path("src/main.py"),
|
|
181
202
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
182
203
|
},
|
|
183
204
|
)
|
|
@@ -191,7 +212,7 @@ def test_detects_previously_in_comment():
|
|
|
191
212
|
result = _run_hook(
|
|
192
213
|
"Write",
|
|
193
214
|
{
|
|
194
|
-
"file_path": "src/config.py",
|
|
215
|
+
"file_path": repository_file_path("src/config.py"),
|
|
195
216
|
"content": VIOLATION_PREVIOUSLY_COMMENT,
|
|
196
217
|
},
|
|
197
218
|
)
|
|
@@ -205,7 +226,7 @@ def test_detects_now_uses_in_comment():
|
|
|
205
226
|
result = _run_hook(
|
|
206
227
|
"Write",
|
|
207
228
|
{
|
|
208
|
-
"file_path": "src/client.py",
|
|
229
|
+
"file_path": repository_file_path("src/client.py"),
|
|
209
230
|
"content": VIOLATION_NOW_USES_COMMENT,
|
|
210
231
|
},
|
|
211
232
|
)
|
|
@@ -219,7 +240,7 @@ def test_detects_instead_of_in_markdown():
|
|
|
219
240
|
result = _run_hook(
|
|
220
241
|
"Write",
|
|
221
242
|
{
|
|
222
|
-
"file_path": "docs/api.md",
|
|
243
|
+
"file_path": repository_file_path("docs/api.md"),
|
|
223
244
|
"content": VIOLATION_MD_INSTEAD,
|
|
224
245
|
},
|
|
225
246
|
)
|
|
@@ -232,7 +253,7 @@ def test_detects_previously_in_markdown():
|
|
|
232
253
|
result = _run_hook(
|
|
233
254
|
"Write",
|
|
234
255
|
{
|
|
235
|
-
"file_path": "docs/config.md",
|
|
256
|
+
"file_path": repository_file_path("docs/config.md"),
|
|
236
257
|
"content": VIOLATION_MD_PREVIOUSLY,
|
|
237
258
|
},
|
|
238
259
|
)
|
|
@@ -245,7 +266,7 @@ def test_detects_now_uses_in_markdown():
|
|
|
245
266
|
result = _run_hook(
|
|
246
267
|
"Write",
|
|
247
268
|
{
|
|
248
|
-
"file_path": "docs/auth.md",
|
|
269
|
+
"file_path": repository_file_path("docs/auth.md"),
|
|
249
270
|
"content": VIOLATION_MD_NOW_USES,
|
|
250
271
|
},
|
|
251
272
|
)
|
|
@@ -271,7 +292,7 @@ def test_detects_edit_new_string():
|
|
|
271
292
|
result = _run_hook(
|
|
272
293
|
"Edit",
|
|
273
294
|
{
|
|
274
|
-
"file_path": "src/main.py",
|
|
295
|
+
"file_path": repository_file_path("src/main.py"),
|
|
275
296
|
"old_string": "old_comment",
|
|
276
297
|
"new_string": VIOLATION_PREVIOUSLY_COMMENT,
|
|
277
298
|
},
|
|
@@ -286,7 +307,7 @@ def test_clean_edit_passes():
|
|
|
286
307
|
result = _run_hook(
|
|
287
308
|
"Edit",
|
|
288
309
|
{
|
|
289
|
-
"file_path": "src/main.py",
|
|
310
|
+
"file_path": repository_file_path("src/main.py"),
|
|
290
311
|
"old_string": "x = 1",
|
|
291
312
|
"new_string": CLEAN_PYTHON,
|
|
292
313
|
},
|
|
@@ -300,7 +321,7 @@ def test_detects_multi_edit_second_new_string() -> None:
|
|
|
300
321
|
result = _run_hook(
|
|
301
322
|
"MultiEdit",
|
|
302
323
|
{
|
|
303
|
-
"file_path": "src/main.py",
|
|
324
|
+
"file_path": repository_file_path("src/main.py"),
|
|
304
325
|
"edits": [
|
|
305
326
|
{"old_string": "a = 1", "new_string": CLEAN_PYTHON},
|
|
306
327
|
{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
|
|
@@ -317,7 +338,7 @@ def test_clean_multi_edit_passes() -> None:
|
|
|
317
338
|
result = _run_hook(
|
|
318
339
|
"MultiEdit",
|
|
319
340
|
{
|
|
320
|
-
"file_path": "src/main.py",
|
|
341
|
+
"file_path": repository_file_path("src/main.py"),
|
|
321
342
|
"edits": [
|
|
322
343
|
{"old_string": "a = 1", "new_string": CLEAN_PYTHON},
|
|
323
344
|
{"old_string": "b = 2", "new_string": CLEAN_COMMENT},
|
|
@@ -339,7 +360,7 @@ def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
|
|
|
339
360
|
result = _run_hook(
|
|
340
361
|
"MultiEdit",
|
|
341
362
|
{
|
|
342
|
-
"file_path": "src/main.py",
|
|
363
|
+
"file_path": repository_file_path("src/main.py"),
|
|
343
364
|
"edits": [
|
|
344
365
|
{"old_string": "a = 1", "new_string": "# describes the previous"},
|
|
345
366
|
{"old_string": "b = 2", "new_string": "ly implemented step."},
|
|
@@ -354,7 +375,7 @@ def test_multi_edit_with_a_single_edit_still_denies() -> None:
|
|
|
354
375
|
result = _run_hook(
|
|
355
376
|
"MultiEdit",
|
|
356
377
|
{
|
|
357
|
-
"file_path": "src/main.py",
|
|
378
|
+
"file_path": repository_file_path("src/main.py"),
|
|
358
379
|
"edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
|
|
359
380
|
},
|
|
360
381
|
)
|
|
@@ -367,7 +388,7 @@ def test_system_message_and_suppress_output():
|
|
|
367
388
|
result = _run_hook(
|
|
368
389
|
"Write",
|
|
369
390
|
{
|
|
370
|
-
"file_path": "src/main.py",
|
|
391
|
+
"file_path": repository_file_path("src/main.py"),
|
|
371
392
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
372
393
|
},
|
|
373
394
|
)
|
|
@@ -382,7 +403,7 @@ def test_detects_no_longer_in_comment():
|
|
|
382
403
|
result = _run_hook(
|
|
383
404
|
"Write",
|
|
384
405
|
{
|
|
385
|
-
"file_path": "src/config.py",
|
|
406
|
+
"file_path": repository_file_path("src/config.py"),
|
|
386
407
|
"content": "# No longer supports legacy mode",
|
|
387
408
|
},
|
|
388
409
|
)
|
|
@@ -396,7 +417,7 @@ def test_detects_used_to_in_comment():
|
|
|
396
417
|
result = _run_hook(
|
|
397
418
|
"Write",
|
|
398
419
|
{
|
|
399
|
-
"file_path": "src/config.py",
|
|
420
|
+
"file_path": repository_file_path("src/config.py"),
|
|
400
421
|
"content": "# Used to be hardcoded",
|
|
401
422
|
},
|
|
402
423
|
)
|
|
@@ -410,7 +431,7 @@ def test_detects_switched_to_in_comment():
|
|
|
410
431
|
result = _run_hook(
|
|
411
432
|
"Write",
|
|
412
433
|
{
|
|
413
|
-
"file_path": "src/config.py",
|
|
434
|
+
"file_path": repository_file_path("src/config.py"),
|
|
414
435
|
"content": "# Switched to async processing",
|
|
415
436
|
},
|
|
416
437
|
)
|
|
@@ -428,7 +449,7 @@ def test_detects_comment_with_close_block_token():
|
|
|
428
449
|
result = _run_hook(
|
|
429
450
|
"Write",
|
|
430
451
|
{
|
|
431
|
-
"file_path": "src/config.py",
|
|
452
|
+
"file_path": repository_file_path("src/config.py"),
|
|
432
453
|
"content": VIOLATION_COMMENT_WITH_CLOSE_BLOCK,
|
|
433
454
|
},
|
|
434
455
|
)
|
|
@@ -445,7 +466,7 @@ def test_detects_inline_trailing_comment():
|
|
|
445
466
|
result = _run_hook(
|
|
446
467
|
"Write",
|
|
447
468
|
{
|
|
448
|
-
"file_path": "src/main.py",
|
|
469
|
+
"file_path": repository_file_path("src/main.py"),
|
|
449
470
|
"content": "max_retries = 3 # No longer needed since async retry handles it",
|
|
450
471
|
},
|
|
451
472
|
)
|
|
@@ -461,7 +482,7 @@ def test_ignores_c_preprocessor_directive():
|
|
|
461
482
|
result = _run_hook(
|
|
462
483
|
"Write",
|
|
463
484
|
{
|
|
464
|
-
"file_path": "src/config.h",
|
|
485
|
+
"file_path": repository_file_path("src/config.h"),
|
|
465
486
|
"content": '#error "This code previously used replaced API"',
|
|
466
487
|
},
|
|
467
488
|
)
|
|
@@ -477,7 +498,7 @@ def test_ignores_hash_in_javascript_inline():
|
|
|
477
498
|
result = _run_hook(
|
|
478
499
|
"Write",
|
|
479
500
|
{
|
|
480
|
-
"file_path": "src/main.js",
|
|
501
|
+
"file_path": repository_file_path("src/main.js"),
|
|
481
502
|
"content": 'const selector = "#originally-dark" // Use dark as default',
|
|
482
503
|
},
|
|
483
504
|
)
|
|
@@ -493,7 +514,7 @@ def test_ignores_double_slash_in_js_url():
|
|
|
493
514
|
result = _run_hook(
|
|
494
515
|
"Write",
|
|
495
516
|
{
|
|
496
|
-
"file_path": "src/fetch.ts",
|
|
517
|
+
"file_path": repository_file_path("src/fetch.ts"),
|
|
497
518
|
"content": 'fetch("https://api.example.com/replaces")',
|
|
498
519
|
},
|
|
499
520
|
)
|
|
@@ -509,7 +530,7 @@ def test_block_comment_continuation_with_nested_glob():
|
|
|
509
530
|
result = _run_hook(
|
|
510
531
|
"Write",
|
|
511
532
|
{
|
|
512
|
-
"file_path": "src/cache.ts",
|
|
533
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
513
534
|
"content": content,
|
|
514
535
|
},
|
|
515
536
|
)
|
|
@@ -526,7 +547,7 @@ def test_detects_inline_after_url_on_same_line():
|
|
|
526
547
|
result = _run_hook(
|
|
527
548
|
"Write",
|
|
528
549
|
{
|
|
529
|
-
"file_path": "src/fetch.ts",
|
|
550
|
+
"file_path": repository_file_path("src/fetch.ts"),
|
|
530
551
|
"content": 'const url = "https://api.com"; // no longer used',
|
|
531
552
|
},
|
|
532
553
|
)
|
|
@@ -543,7 +564,7 @@ def test_ignores_code_before_block_comment():
|
|
|
543
564
|
result = _run_hook(
|
|
544
565
|
"Write",
|
|
545
566
|
{
|
|
546
|
-
"file_path": "src/cache.ts",
|
|
567
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
547
568
|
"content": "cache.replaces(old); /* Use fresh cache */",
|
|
548
569
|
},
|
|
549
570
|
)
|
|
@@ -559,7 +580,7 @@ def test_ignores_url_with_double_slash_in_python():
|
|
|
559
580
|
result = _run_hook(
|
|
560
581
|
"Write",
|
|
561
582
|
{
|
|
562
|
-
"file_path": "src/main.py",
|
|
583
|
+
"file_path": repository_file_path("src/main.py"),
|
|
563
584
|
"content": 'url = "https://api.example.com/replaces/v1"',
|
|
564
585
|
},
|
|
565
586
|
)
|
|
@@ -574,7 +595,7 @@ def test_ignores_instead_of_in_code_string_with_inline_comment():
|
|
|
574
595
|
result = _run_hook(
|
|
575
596
|
"Write",
|
|
576
597
|
{
|
|
577
|
-
"file_path": "src/main.py",
|
|
598
|
+
"file_path": repository_file_path("src/main.py"),
|
|
578
599
|
"content": "msg = 'instead of' # Use the default timeout",
|
|
579
600
|
},
|
|
580
601
|
)
|
|
@@ -590,7 +611,7 @@ def test_ignores_glob_pattern_with_star_in_python():
|
|
|
590
611
|
result = _run_hook(
|
|
591
612
|
"Write",
|
|
592
613
|
{
|
|
593
|
-
"file_path": "src/main.py",
|
|
614
|
+
"file_path": repository_file_path("src/main.py"),
|
|
594
615
|
"content": content,
|
|
595
616
|
},
|
|
596
617
|
)
|
|
@@ -606,7 +627,7 @@ def test_ignores_comment_with_glob_pattern():
|
|
|
606
627
|
result = _run_hook(
|
|
607
628
|
"Write",
|
|
608
629
|
{
|
|
609
|
-
"file_path": "src/main.py",
|
|
630
|
+
"file_path": repository_file_path("src/main.py"),
|
|
610
631
|
"content": content,
|
|
611
632
|
},
|
|
612
633
|
)
|
|
@@ -621,7 +642,7 @@ def test_single_line_block_comment_closes():
|
|
|
621
642
|
result = _run_hook(
|
|
622
643
|
"Write",
|
|
623
644
|
{
|
|
624
|
-
"file_path": "src/cache.ts",
|
|
645
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
625
646
|
"content": content,
|
|
626
647
|
},
|
|
627
648
|
)
|
|
@@ -639,7 +660,7 @@ def test_block_comment_with_url_closes_correctly():
|
|
|
639
660
|
result = _run_hook(
|
|
640
661
|
"Write",
|
|
641
662
|
{
|
|
642
|
-
"file_path": "src/cache.ts",
|
|
663
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
643
664
|
"content": content,
|
|
644
665
|
},
|
|
645
666
|
)
|
|
@@ -656,7 +677,7 @@ def test_same_line_block_comment_with_trailing_inline():
|
|
|
656
677
|
result = _run_hook(
|
|
657
678
|
"Write",
|
|
658
679
|
{
|
|
659
|
-
"file_path": "src/fetch.ts",
|
|
680
|
+
"file_path": repository_file_path("src/fetch.ts"),
|
|
660
681
|
"content": content,
|
|
661
682
|
},
|
|
662
683
|
)
|
|
@@ -674,7 +695,7 @@ def test_multi_line_block_comment_close_with_trailing_inline():
|
|
|
674
695
|
result = _run_hook(
|
|
675
696
|
"Write",
|
|
676
697
|
{
|
|
677
|
-
"file_path": "src/cache.ts",
|
|
698
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
678
699
|
"content": content,
|
|
679
700
|
},
|
|
680
701
|
)
|
|
@@ -691,7 +712,7 @@ def test_detects_earliest_inline_marker_in_php():
|
|
|
691
712
|
result = _run_hook(
|
|
692
713
|
"Write",
|
|
693
714
|
{
|
|
694
|
-
"file_path": "src/index.php",
|
|
715
|
+
"file_path": repository_file_path("src/index.php"),
|
|
695
716
|
"content": 'echo $x; // previously used #tag',
|
|
696
717
|
},
|
|
697
718
|
)
|
|
@@ -705,7 +726,7 @@ def test_additional_context_contains_examples():
|
|
|
705
726
|
result = _run_hook(
|
|
706
727
|
"Write",
|
|
707
728
|
{
|
|
708
|
-
"file_path": "src/main.py",
|
|
729
|
+
"file_path": repository_file_path("src/main.py"),
|
|
709
730
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
710
731
|
},
|
|
711
732
|
)
|
|
@@ -750,7 +771,13 @@ def test_handles_non_dict_tool_input():
|
|
|
750
771
|
def test_handles_non_string_tool_name():
|
|
751
772
|
"""A tool_name that is not a string should exit cleanly — the hook must
|
|
752
773
|
guard against tool_name not being a string."""
|
|
753
|
-
payload = json.dumps(
|
|
774
|
+
payload = json.dumps(
|
|
775
|
+
{
|
|
776
|
+
"tool_name": 123,
|
|
777
|
+
"tool_input": {"file_path": repository_file_path("src/main.py")},
|
|
778
|
+
"cwd": _REPOSITORY_ROOT,
|
|
779
|
+
}
|
|
780
|
+
)
|
|
754
781
|
result = subprocess.run(
|
|
755
782
|
[sys.executable, HOOK_SCRIPT_PATH],
|
|
756
783
|
input=payload,
|
|
@@ -774,7 +801,7 @@ def test_detects_used_to_in_python_module_docstring() -> None:
|
|
|
774
801
|
result = _run_hook(
|
|
775
802
|
"Write",
|
|
776
803
|
{
|
|
777
|
-
"file_path": "tests/test_exports.py",
|
|
804
|
+
"file_path": repository_file_path("tests/test_exports.py"),
|
|
778
805
|
"content": written_source,
|
|
779
806
|
},
|
|
780
807
|
)
|
|
@@ -795,7 +822,7 @@ def test_detects_previously_in_python_function_docstring() -> None:
|
|
|
795
822
|
result = _run_hook(
|
|
796
823
|
"Write",
|
|
797
824
|
{
|
|
798
|
-
"file_path": "src/configuration.py",
|
|
825
|
+
"file_path": repository_file_path("src/configuration.py"),
|
|
799
826
|
"content": written_source,
|
|
800
827
|
},
|
|
801
828
|
)
|
|
@@ -812,7 +839,7 @@ def test_ignores_transitional_phrase_in_regular_python_string() -> None:
|
|
|
812
839
|
result = _run_hook(
|
|
813
840
|
"Write",
|
|
814
841
|
{
|
|
815
|
-
"file_path": "src/messages.py",
|
|
842
|
+
"file_path": repository_file_path("src/messages.py"),
|
|
816
843
|
"content": 'PADDING_NOTE = "used to pad with a flat sleep"\n',
|
|
817
844
|
},
|
|
818
845
|
)
|
|
@@ -831,7 +858,7 @@ def test_ignores_quoted_mention_inside_python_docstring() -> None:
|
|
|
831
858
|
result = _run_hook(
|
|
832
859
|
"Write",
|
|
833
860
|
{
|
|
834
|
-
"file_path": "src/scanner.py",
|
|
861
|
+
"file_path": repository_file_path("src/scanner.py"),
|
|
835
862
|
"content": written_source,
|
|
836
863
|
},
|
|
837
864
|
)
|
|
@@ -847,7 +874,7 @@ def test_detects_docstring_at_start_of_unparseable_edit_fragment() -> None:
|
|
|
847
874
|
result = _run_hook(
|
|
848
875
|
"Edit",
|
|
849
876
|
{
|
|
850
|
-
"file_path": "src/exports.py",
|
|
877
|
+
"file_path": repository_file_path("src/exports.py"),
|
|
851
878
|
"old_string": "placeholder",
|
|
852
879
|
"new_string": fragment,
|
|
853
880
|
},
|
|
@@ -869,7 +896,7 @@ def test_detects_docstring_after_def_header_in_unparseable_fragment() -> None:
|
|
|
869
896
|
result = _run_hook(
|
|
870
897
|
"Edit",
|
|
871
898
|
{
|
|
872
|
-
"file_path": "src/reporting.py",
|
|
899
|
+
"file_path": repository_file_path("src/reporting.py"),
|
|
873
900
|
"old_string": "placeholder",
|
|
874
901
|
"new_string": fragment,
|
|
875
902
|
},
|
|
@@ -888,7 +915,7 @@ def test_ignores_assigned_triple_quoted_string_in_unparseable_fragment() -> None
|
|
|
888
915
|
result = _run_hook(
|
|
889
916
|
"Edit",
|
|
890
917
|
{
|
|
891
|
-
"file_path": "src/fixtures.py",
|
|
918
|
+
"file_path": repository_file_path("src/fixtures.py"),
|
|
892
919
|
"old_string": "placeholder",
|
|
893
920
|
"new_string": fragment,
|
|
894
921
|
},
|
|
@@ -907,7 +934,11 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
|
907
934
|
"""
|
|
908
935
|
deny_payload = {
|
|
909
936
|
"tool_name": "Write",
|
|
910
|
-
"tool_input": {
|
|
937
|
+
"tool_input": {
|
|
938
|
+
"file_path": repository_file_path("src/main.py"),
|
|
939
|
+
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
940
|
+
},
|
|
941
|
+
"cwd": _REPOSITORY_ROOT,
|
|
911
942
|
}
|
|
912
943
|
native_hook = NativeHook(evaluate=evaluate, build_deny_payload=build_deny_payload)
|
|
913
944
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Tests for tdd-enforcer hook (blocking behavior).
|
|
1
|
+
"""Tests for tdd-enforcer hook (blocking behavior).
|
|
2
|
+
|
|
3
|
+
Includes the recorded-test-failure evidence path: a genuinely recorded
|
|
4
|
+
failure unlocks a production write, and never unlocks it for a test file
|
|
5
|
+
the failing command did not name.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
import importlib.util
|
|
4
9
|
import io
|
|
@@ -1352,6 +1357,67 @@ def test_should_allow_repeat_production_write_within_window_without_further_test
|
|
|
1352
1357
|
assert _decision_from(second_write) == "allow"
|
|
1353
1358
|
|
|
1354
1359
|
|
|
1360
|
+
def _seed_recorded_test_failure(
|
|
1361
|
+
test_file: Path, session_id: str, repository_root: Path, command: str, exit_status: int
|
|
1362
|
+
) -> None:
|
|
1363
|
+
"""Plant a failure record as if a PostToolUse hook had genuinely seen it fail."""
|
|
1364
|
+
content_hash_store.record_test_command_failure(
|
|
1365
|
+
[test_file], command, exit_status, session_id, str(repository_root)
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def test_should_allow_production_write_when_recorded_test_failure_matches_content(
|
|
1370
|
+
tmp_path: Path,
|
|
1371
|
+
) -> None:
|
|
1372
|
+
"""RED evidence outranks a stale mtime: a genuinely recorded failure unlocks GREEN."""
|
|
1373
|
+
sandbox = _sandbox(tmp_path)
|
|
1374
|
+
production_file = sandbox / "orders.py"
|
|
1375
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
1376
|
+
sibling_test = sandbox / "test_orders.py"
|
|
1377
|
+
sibling_test.write_text("def test_fulfill(): assert fulfill() == 1\n")
|
|
1378
|
+
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
1379
|
+
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
1380
|
+
session_id = "recorded-failure-session"
|
|
1381
|
+
_seed_recorded_test_failure(
|
|
1382
|
+
sibling_test, session_id, sandbox, "pytest test_orders.py", 1
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
completed = _run_hook_with_payload(
|
|
1386
|
+
_make_write_payload_for_session(
|
|
1387
|
+
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1388
|
+
)
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
assert _decision_from(completed) == "allow"
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def test_should_deny_production_write_when_recorded_failure_names_a_different_file(
|
|
1395
|
+
tmp_path: Path,
|
|
1396
|
+
) -> None:
|
|
1397
|
+
"""Anti-bypass: a failure recorded for one test file must not unlock a sibling's gate."""
|
|
1398
|
+
sandbox = _sandbox(tmp_path)
|
|
1399
|
+
production_file = sandbox / "orders.py"
|
|
1400
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
1401
|
+
sibling_test = sandbox / "test_orders.py"
|
|
1402
|
+
sibling_test.write_text("def test_fulfill(): assert fulfill() == 1\n")
|
|
1403
|
+
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
1404
|
+
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
1405
|
+
unrelated_test = sandbox / "test_unrelated.py"
|
|
1406
|
+
unrelated_test.write_text("def test_other(): pass\n")
|
|
1407
|
+
session_id = "recorded-failure-unrelated-session"
|
|
1408
|
+
_seed_recorded_test_failure(
|
|
1409
|
+
unrelated_test, session_id, sandbox, "pytest test_unrelated.py", 1
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
completed = _run_hook_with_payload(
|
|
1413
|
+
_make_write_payload_for_session(
|
|
1414
|
+
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1415
|
+
)
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
assert _decision_from(completed) == "deny"
|
|
1419
|
+
|
|
1420
|
+
|
|
1355
1421
|
def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_transaction(
|
|
1356
1422
|
tmp_path: Path,
|
|
1357
1423
|
) -> None:
|
|
@@ -1374,3 +1440,67 @@ def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_trans
|
|
|
1374
1440
|
completed = _run_hook_with_payload(_make_apply_patch_payload(sandbox, patch_command))
|
|
1375
1441
|
|
|
1376
1442
|
assert _decision_from(completed) == "deny"
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def test_should_stay_exempt_for_scratch_outside_repository_root(
|
|
1446
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1447
|
+
) -> None:
|
|
1448
|
+
"""Repo-root fix: a genuine scratch file outside the repository stays exempt."""
|
|
1449
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1450
|
+
repository_root = "/tmp/pr-alpha"
|
|
1451
|
+
payload = {
|
|
1452
|
+
"tool_name": "Write",
|
|
1453
|
+
"tool_input": {
|
|
1454
|
+
"file_path": "/tmp/scratch/one_off.py",
|
|
1455
|
+
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1456
|
+
},
|
|
1457
|
+
"cwd": repository_root,
|
|
1458
|
+
}
|
|
1459
|
+
completed = _run_hook_with_payload(payload)
|
|
1460
|
+
assert _decision_from(completed) != "deny", (
|
|
1461
|
+
f"TDD enforcer must not deny scratch outside the repository, got: {completed.stdout!r}"
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def test_should_stay_exempt_for_unrelated_worktree_sibling_under_tmp(
|
|
1466
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1467
|
+
) -> None:
|
|
1468
|
+
"""Repo-root fix: a sibling sharing the root's name prefix is not inside it.
|
|
1469
|
+
|
|
1470
|
+
Containment is judged by path segment, so /tmp/pr-alpha-sibling is outside
|
|
1471
|
+
/tmp/pr-alpha however much of the name the two share.
|
|
1472
|
+
"""
|
|
1473
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1474
|
+
repository_root = "/tmp/pr-alpha"
|
|
1475
|
+
payload = {
|
|
1476
|
+
"tool_name": "Write",
|
|
1477
|
+
"tool_input": {
|
|
1478
|
+
"file_path": "/tmp/pr-alpha-sibling/hooks/orders.py",
|
|
1479
|
+
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1480
|
+
},
|
|
1481
|
+
"cwd": repository_root,
|
|
1482
|
+
}
|
|
1483
|
+
completed = _run_hook_with_payload(payload)
|
|
1484
|
+
assert _decision_from(completed) != "deny", (
|
|
1485
|
+
f"TDD enforcer must not deny an unrelated sibling worktree, got: {completed.stdout!r}"
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def test_should_deny_production_file_inside_repository_root_under_tmp(
|
|
1490
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1491
|
+
) -> None:
|
|
1492
|
+
"""Repo-root fix: a production file inside a repository under /tmp still needs a test."""
|
|
1493
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1494
|
+
repository_root = "/tmp/pr-alpha"
|
|
1495
|
+
payload = {
|
|
1496
|
+
"tool_name": "Write",
|
|
1497
|
+
"tool_input": {
|
|
1498
|
+
"file_path": f"{repository_root}/hooks/blocking/orders.py",
|
|
1499
|
+
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1500
|
+
},
|
|
1501
|
+
"cwd": repository_root,
|
|
1502
|
+
}
|
|
1503
|
+
completed = _run_hook_with_payload(payload)
|
|
1504
|
+
assert _decision_from(completed) == "deny", (
|
|
1505
|
+
f"TDD enforcer must gate a repository file under /tmp, got: {completed.stdout!r}"
|
|
1506
|
+
)
|
package/hooks/hooks.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
6
|
-
"matcher": "Write|Edit",
|
|
6
|
+
"matcher": "Write|Edit|MultiEdit|apply_patch",
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
"hooks": [
|
|
265
265
|
{
|
|
266
266
|
"type": "command",
|
|
267
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/
|
|
267
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/bash_post_call_dispatcher.py",
|
|
268
268
|
"timeout": 60
|
|
269
269
|
}
|
|
270
270
|
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Constants for the Bash PostToolUse dispatcher.
|
|
2
|
+
|
|
3
|
+
Holds the ordered hosted-hook roster this dispatcher runs after a Bash call
|
|
4
|
+
finishes. Reuses ``BashHostedHookEntry`` and the Bash-only tool-name set from
|
|
5
|
+
``bash_pre_tool_use_dispatcher_constants`` -- the entry shape and the tool-name
|
|
6
|
+
membership question are identical on the PostToolUse side, so this module adds
|
|
7
|
+
no second copy of either.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
13
|
+
ALL_BASH_ONLY_TOOL_NAMES,
|
|
14
|
+
BashHostedHookEntry,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES"]
|
|
18
|
+
|
|
19
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
20
|
+
BashHostedHookEntry("blocking/gh_pr_author_restore.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
21
|
+
BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
|
|
22
|
+
)
|