claude-dev-env 2.31.1 → 2.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +1 -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 +13 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +3 -1
- 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_same_file_inline_duplicate.py +8 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +56 -71
- package/hooks/blocking/test_tdd_enforcer.py +67 -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/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- 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/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +87 -33
- package/hooks/validators/system_temporary_roots.py +1 -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_reporting.py +66 -0
- 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 +9 -5
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/shared_tree_paths.py +59 -25
- package/scripts/test_shared_tree_paths.py +30 -0
|
@@ -29,31 +29,16 @@ from state_description_blocker import ( # noqa: E402
|
|
|
29
29
|
)
|
|
30
30
|
from state_description_blocker import evaluate as _evaluate_without_opt_in # noqa: E402
|
|
31
31
|
|
|
32
|
+
from code_rules_enforcer_test_support import (
|
|
33
|
+
repository_file_path,
|
|
34
|
+
repository_root,
|
|
35
|
+
)
|
|
36
|
+
|
|
32
37
|
HOOK_SCRIPT_PATH = os.path.join(
|
|
33
38
|
os.path.dirname(__file__), "state_description_blocker.py"
|
|
34
39
|
)
|
|
35
40
|
|
|
36
|
-
_REPOSITORY_ROOT = str(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _repository_file_path(relative_path: str) -> str:
|
|
40
|
-
"""Return the absolute path of a repository-relative file in this checkout.
|
|
41
|
-
|
|
42
|
-
The hook resolves a bare relative path against the process working
|
|
43
|
-
directory, so a payload carrying one answers to the directory pytest was
|
|
44
|
-
invoked from. An absolute path under the repository root, paired with a
|
|
45
|
-
payload ``cwd`` naming that root, keeps the decision stable for a
|
|
46
|
-
checkout under a temporary root as well.
|
|
47
|
-
|
|
48
|
-
Args:
|
|
49
|
-
relative_path: The path of the target file relative to the repository
|
|
50
|
-
root.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
The absolute path of that file inside this repository.
|
|
54
|
-
"""
|
|
55
|
-
return str(Path(_REPOSITORY_ROOT) / relative_path)
|
|
56
|
-
|
|
41
|
+
_REPOSITORY_ROOT = str(repository_root())
|
|
57
42
|
|
|
58
43
|
CLEAN_PYTHON = "x = 1 # Uses a default timeout"
|
|
59
44
|
CLEAN_MD = "# Config\n\nThe API uses port 8080."
|
|
@@ -116,7 +101,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
|
|
|
116
101
|
result = _run_hook(
|
|
117
102
|
"Write",
|
|
118
103
|
{
|
|
119
|
-
"file_path":
|
|
104
|
+
"file_path": repository_file_path("src/main.py"),
|
|
120
105
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
121
106
|
},
|
|
122
107
|
is_prose_style_enabled=False,
|
|
@@ -127,7 +112,7 @@ def test_historical_phrase_scan_runs_with_the_env_flag_unset(
|
|
|
127
112
|
{
|
|
128
113
|
"tool_name": "Write",
|
|
129
114
|
"tool_input": {
|
|
130
|
-
"file_path":
|
|
115
|
+
"file_path": repository_file_path("src/main.py"),
|
|
131
116
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
132
117
|
},
|
|
133
118
|
"cwd": _REPOSITORY_ROOT,
|
|
@@ -141,7 +126,7 @@ def test_block_clean_python_comment_passes():
|
|
|
141
126
|
result = _run_hook(
|
|
142
127
|
"Write",
|
|
143
128
|
{
|
|
144
|
-
"file_path":
|
|
129
|
+
"file_path": repository_file_path("src/main.py"),
|
|
145
130
|
"content": CLEAN_PYTHON,
|
|
146
131
|
},
|
|
147
132
|
)
|
|
@@ -153,7 +138,7 @@ def test_block_clean_markdown_passes():
|
|
|
153
138
|
result = _run_hook(
|
|
154
139
|
"Write",
|
|
155
140
|
{
|
|
156
|
-
"file_path":
|
|
141
|
+
"file_path": repository_file_path("docs/README.md"),
|
|
157
142
|
"content": CLEAN_MD,
|
|
158
143
|
},
|
|
159
144
|
)
|
|
@@ -165,7 +150,7 @@ def test_block_clean_comment_passes():
|
|
|
165
150
|
result = _run_hook(
|
|
166
151
|
"Write",
|
|
167
152
|
{
|
|
168
|
-
"file_path":
|
|
153
|
+
"file_path": repository_file_path("src/config.py"),
|
|
169
154
|
"content": CLEAN_COMMENT,
|
|
170
155
|
},
|
|
171
156
|
)
|
|
@@ -177,7 +162,7 @@ def test_block_irrelevant_file_passes():
|
|
|
177
162
|
result = _run_hook(
|
|
178
163
|
"Write",
|
|
179
164
|
{
|
|
180
|
-
"file_path":
|
|
165
|
+
"file_path": repository_file_path("data.txt"),
|
|
181
166
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
182
167
|
},
|
|
183
168
|
)
|
|
@@ -189,7 +174,7 @@ def test_block_empty_content_passes():
|
|
|
189
174
|
result = _run_hook(
|
|
190
175
|
"Write",
|
|
191
176
|
{
|
|
192
|
-
"file_path":
|
|
177
|
+
"file_path": repository_file_path("src/main.py"),
|
|
193
178
|
"content": "",
|
|
194
179
|
},
|
|
195
180
|
)
|
|
@@ -213,7 +198,7 @@ def test_detects_instead_of_in_comment():
|
|
|
213
198
|
result = _run_hook(
|
|
214
199
|
"Write",
|
|
215
200
|
{
|
|
216
|
-
"file_path":
|
|
201
|
+
"file_path": repository_file_path("src/main.py"),
|
|
217
202
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
218
203
|
},
|
|
219
204
|
)
|
|
@@ -227,7 +212,7 @@ def test_detects_previously_in_comment():
|
|
|
227
212
|
result = _run_hook(
|
|
228
213
|
"Write",
|
|
229
214
|
{
|
|
230
|
-
"file_path":
|
|
215
|
+
"file_path": repository_file_path("src/config.py"),
|
|
231
216
|
"content": VIOLATION_PREVIOUSLY_COMMENT,
|
|
232
217
|
},
|
|
233
218
|
)
|
|
@@ -241,7 +226,7 @@ def test_detects_now_uses_in_comment():
|
|
|
241
226
|
result = _run_hook(
|
|
242
227
|
"Write",
|
|
243
228
|
{
|
|
244
|
-
"file_path":
|
|
229
|
+
"file_path": repository_file_path("src/client.py"),
|
|
245
230
|
"content": VIOLATION_NOW_USES_COMMENT,
|
|
246
231
|
},
|
|
247
232
|
)
|
|
@@ -255,7 +240,7 @@ def test_detects_instead_of_in_markdown():
|
|
|
255
240
|
result = _run_hook(
|
|
256
241
|
"Write",
|
|
257
242
|
{
|
|
258
|
-
"file_path":
|
|
243
|
+
"file_path": repository_file_path("docs/api.md"),
|
|
259
244
|
"content": VIOLATION_MD_INSTEAD,
|
|
260
245
|
},
|
|
261
246
|
)
|
|
@@ -268,7 +253,7 @@ def test_detects_previously_in_markdown():
|
|
|
268
253
|
result = _run_hook(
|
|
269
254
|
"Write",
|
|
270
255
|
{
|
|
271
|
-
"file_path":
|
|
256
|
+
"file_path": repository_file_path("docs/config.md"),
|
|
272
257
|
"content": VIOLATION_MD_PREVIOUSLY,
|
|
273
258
|
},
|
|
274
259
|
)
|
|
@@ -281,7 +266,7 @@ def test_detects_now_uses_in_markdown():
|
|
|
281
266
|
result = _run_hook(
|
|
282
267
|
"Write",
|
|
283
268
|
{
|
|
284
|
-
"file_path":
|
|
269
|
+
"file_path": repository_file_path("docs/auth.md"),
|
|
285
270
|
"content": VIOLATION_MD_NOW_USES,
|
|
286
271
|
},
|
|
287
272
|
)
|
|
@@ -307,7 +292,7 @@ def test_detects_edit_new_string():
|
|
|
307
292
|
result = _run_hook(
|
|
308
293
|
"Edit",
|
|
309
294
|
{
|
|
310
|
-
"file_path":
|
|
295
|
+
"file_path": repository_file_path("src/main.py"),
|
|
311
296
|
"old_string": "old_comment",
|
|
312
297
|
"new_string": VIOLATION_PREVIOUSLY_COMMENT,
|
|
313
298
|
},
|
|
@@ -322,7 +307,7 @@ def test_clean_edit_passes():
|
|
|
322
307
|
result = _run_hook(
|
|
323
308
|
"Edit",
|
|
324
309
|
{
|
|
325
|
-
"file_path":
|
|
310
|
+
"file_path": repository_file_path("src/main.py"),
|
|
326
311
|
"old_string": "x = 1",
|
|
327
312
|
"new_string": CLEAN_PYTHON,
|
|
328
313
|
},
|
|
@@ -336,7 +321,7 @@ def test_detects_multi_edit_second_new_string() -> None:
|
|
|
336
321
|
result = _run_hook(
|
|
337
322
|
"MultiEdit",
|
|
338
323
|
{
|
|
339
|
-
"file_path":
|
|
324
|
+
"file_path": repository_file_path("src/main.py"),
|
|
340
325
|
"edits": [
|
|
341
326
|
{"old_string": "a = 1", "new_string": CLEAN_PYTHON},
|
|
342
327
|
{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
|
|
@@ -353,7 +338,7 @@ def test_clean_multi_edit_passes() -> None:
|
|
|
353
338
|
result = _run_hook(
|
|
354
339
|
"MultiEdit",
|
|
355
340
|
{
|
|
356
|
-
"file_path":
|
|
341
|
+
"file_path": repository_file_path("src/main.py"),
|
|
357
342
|
"edits": [
|
|
358
343
|
{"old_string": "a = 1", "new_string": CLEAN_PYTHON},
|
|
359
344
|
{"old_string": "b = 2", "new_string": CLEAN_COMMENT},
|
|
@@ -375,7 +360,7 @@ def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
|
|
|
375
360
|
result = _run_hook(
|
|
376
361
|
"MultiEdit",
|
|
377
362
|
{
|
|
378
|
-
"file_path":
|
|
363
|
+
"file_path": repository_file_path("src/main.py"),
|
|
379
364
|
"edits": [
|
|
380
365
|
{"old_string": "a = 1", "new_string": "# describes the previous"},
|
|
381
366
|
{"old_string": "b = 2", "new_string": "ly implemented step."},
|
|
@@ -390,7 +375,7 @@ def test_multi_edit_with_a_single_edit_still_denies() -> None:
|
|
|
390
375
|
result = _run_hook(
|
|
391
376
|
"MultiEdit",
|
|
392
377
|
{
|
|
393
|
-
"file_path":
|
|
378
|
+
"file_path": repository_file_path("src/main.py"),
|
|
394
379
|
"edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
|
|
395
380
|
},
|
|
396
381
|
)
|
|
@@ -403,7 +388,7 @@ def test_system_message_and_suppress_output():
|
|
|
403
388
|
result = _run_hook(
|
|
404
389
|
"Write",
|
|
405
390
|
{
|
|
406
|
-
"file_path":
|
|
391
|
+
"file_path": repository_file_path("src/main.py"),
|
|
407
392
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
408
393
|
},
|
|
409
394
|
)
|
|
@@ -418,7 +403,7 @@ def test_detects_no_longer_in_comment():
|
|
|
418
403
|
result = _run_hook(
|
|
419
404
|
"Write",
|
|
420
405
|
{
|
|
421
|
-
"file_path":
|
|
406
|
+
"file_path": repository_file_path("src/config.py"),
|
|
422
407
|
"content": "# No longer supports legacy mode",
|
|
423
408
|
},
|
|
424
409
|
)
|
|
@@ -432,7 +417,7 @@ def test_detects_used_to_in_comment():
|
|
|
432
417
|
result = _run_hook(
|
|
433
418
|
"Write",
|
|
434
419
|
{
|
|
435
|
-
"file_path":
|
|
420
|
+
"file_path": repository_file_path("src/config.py"),
|
|
436
421
|
"content": "# Used to be hardcoded",
|
|
437
422
|
},
|
|
438
423
|
)
|
|
@@ -446,7 +431,7 @@ def test_detects_switched_to_in_comment():
|
|
|
446
431
|
result = _run_hook(
|
|
447
432
|
"Write",
|
|
448
433
|
{
|
|
449
|
-
"file_path":
|
|
434
|
+
"file_path": repository_file_path("src/config.py"),
|
|
450
435
|
"content": "# Switched to async processing",
|
|
451
436
|
},
|
|
452
437
|
)
|
|
@@ -464,7 +449,7 @@ def test_detects_comment_with_close_block_token():
|
|
|
464
449
|
result = _run_hook(
|
|
465
450
|
"Write",
|
|
466
451
|
{
|
|
467
|
-
"file_path":
|
|
452
|
+
"file_path": repository_file_path("src/config.py"),
|
|
468
453
|
"content": VIOLATION_COMMENT_WITH_CLOSE_BLOCK,
|
|
469
454
|
},
|
|
470
455
|
)
|
|
@@ -481,7 +466,7 @@ def test_detects_inline_trailing_comment():
|
|
|
481
466
|
result = _run_hook(
|
|
482
467
|
"Write",
|
|
483
468
|
{
|
|
484
|
-
"file_path":
|
|
469
|
+
"file_path": repository_file_path("src/main.py"),
|
|
485
470
|
"content": "max_retries = 3 # No longer needed since async retry handles it",
|
|
486
471
|
},
|
|
487
472
|
)
|
|
@@ -497,7 +482,7 @@ def test_ignores_c_preprocessor_directive():
|
|
|
497
482
|
result = _run_hook(
|
|
498
483
|
"Write",
|
|
499
484
|
{
|
|
500
|
-
"file_path":
|
|
485
|
+
"file_path": repository_file_path("src/config.h"),
|
|
501
486
|
"content": '#error "This code previously used replaced API"',
|
|
502
487
|
},
|
|
503
488
|
)
|
|
@@ -513,7 +498,7 @@ def test_ignores_hash_in_javascript_inline():
|
|
|
513
498
|
result = _run_hook(
|
|
514
499
|
"Write",
|
|
515
500
|
{
|
|
516
|
-
"file_path":
|
|
501
|
+
"file_path": repository_file_path("src/main.js"),
|
|
517
502
|
"content": 'const selector = "#originally-dark" // Use dark as default',
|
|
518
503
|
},
|
|
519
504
|
)
|
|
@@ -529,7 +514,7 @@ def test_ignores_double_slash_in_js_url():
|
|
|
529
514
|
result = _run_hook(
|
|
530
515
|
"Write",
|
|
531
516
|
{
|
|
532
|
-
"file_path":
|
|
517
|
+
"file_path": repository_file_path("src/fetch.ts"),
|
|
533
518
|
"content": 'fetch("https://api.example.com/replaces")',
|
|
534
519
|
},
|
|
535
520
|
)
|
|
@@ -545,7 +530,7 @@ def test_block_comment_continuation_with_nested_glob():
|
|
|
545
530
|
result = _run_hook(
|
|
546
531
|
"Write",
|
|
547
532
|
{
|
|
548
|
-
"file_path":
|
|
533
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
549
534
|
"content": content,
|
|
550
535
|
},
|
|
551
536
|
)
|
|
@@ -562,7 +547,7 @@ def test_detects_inline_after_url_on_same_line():
|
|
|
562
547
|
result = _run_hook(
|
|
563
548
|
"Write",
|
|
564
549
|
{
|
|
565
|
-
"file_path":
|
|
550
|
+
"file_path": repository_file_path("src/fetch.ts"),
|
|
566
551
|
"content": 'const url = "https://api.com"; // no longer used',
|
|
567
552
|
},
|
|
568
553
|
)
|
|
@@ -579,7 +564,7 @@ def test_ignores_code_before_block_comment():
|
|
|
579
564
|
result = _run_hook(
|
|
580
565
|
"Write",
|
|
581
566
|
{
|
|
582
|
-
"file_path":
|
|
567
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
583
568
|
"content": "cache.replaces(old); /* Use fresh cache */",
|
|
584
569
|
},
|
|
585
570
|
)
|
|
@@ -595,7 +580,7 @@ def test_ignores_url_with_double_slash_in_python():
|
|
|
595
580
|
result = _run_hook(
|
|
596
581
|
"Write",
|
|
597
582
|
{
|
|
598
|
-
"file_path":
|
|
583
|
+
"file_path": repository_file_path("src/main.py"),
|
|
599
584
|
"content": 'url = "https://api.example.com/replaces/v1"',
|
|
600
585
|
},
|
|
601
586
|
)
|
|
@@ -610,7 +595,7 @@ def test_ignores_instead_of_in_code_string_with_inline_comment():
|
|
|
610
595
|
result = _run_hook(
|
|
611
596
|
"Write",
|
|
612
597
|
{
|
|
613
|
-
"file_path":
|
|
598
|
+
"file_path": repository_file_path("src/main.py"),
|
|
614
599
|
"content": "msg = 'instead of' # Use the default timeout",
|
|
615
600
|
},
|
|
616
601
|
)
|
|
@@ -626,7 +611,7 @@ def test_ignores_glob_pattern_with_star_in_python():
|
|
|
626
611
|
result = _run_hook(
|
|
627
612
|
"Write",
|
|
628
613
|
{
|
|
629
|
-
"file_path":
|
|
614
|
+
"file_path": repository_file_path("src/main.py"),
|
|
630
615
|
"content": content,
|
|
631
616
|
},
|
|
632
617
|
)
|
|
@@ -642,7 +627,7 @@ def test_ignores_comment_with_glob_pattern():
|
|
|
642
627
|
result = _run_hook(
|
|
643
628
|
"Write",
|
|
644
629
|
{
|
|
645
|
-
"file_path":
|
|
630
|
+
"file_path": repository_file_path("src/main.py"),
|
|
646
631
|
"content": content,
|
|
647
632
|
},
|
|
648
633
|
)
|
|
@@ -657,7 +642,7 @@ def test_single_line_block_comment_closes():
|
|
|
657
642
|
result = _run_hook(
|
|
658
643
|
"Write",
|
|
659
644
|
{
|
|
660
|
-
"file_path":
|
|
645
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
661
646
|
"content": content,
|
|
662
647
|
},
|
|
663
648
|
)
|
|
@@ -675,7 +660,7 @@ def test_block_comment_with_url_closes_correctly():
|
|
|
675
660
|
result = _run_hook(
|
|
676
661
|
"Write",
|
|
677
662
|
{
|
|
678
|
-
"file_path":
|
|
663
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
679
664
|
"content": content,
|
|
680
665
|
},
|
|
681
666
|
)
|
|
@@ -692,7 +677,7 @@ def test_same_line_block_comment_with_trailing_inline():
|
|
|
692
677
|
result = _run_hook(
|
|
693
678
|
"Write",
|
|
694
679
|
{
|
|
695
|
-
"file_path":
|
|
680
|
+
"file_path": repository_file_path("src/fetch.ts"),
|
|
696
681
|
"content": content,
|
|
697
682
|
},
|
|
698
683
|
)
|
|
@@ -710,7 +695,7 @@ def test_multi_line_block_comment_close_with_trailing_inline():
|
|
|
710
695
|
result = _run_hook(
|
|
711
696
|
"Write",
|
|
712
697
|
{
|
|
713
|
-
"file_path":
|
|
698
|
+
"file_path": repository_file_path("src/cache.ts"),
|
|
714
699
|
"content": content,
|
|
715
700
|
},
|
|
716
701
|
)
|
|
@@ -727,7 +712,7 @@ def test_detects_earliest_inline_marker_in_php():
|
|
|
727
712
|
result = _run_hook(
|
|
728
713
|
"Write",
|
|
729
714
|
{
|
|
730
|
-
"file_path":
|
|
715
|
+
"file_path": repository_file_path("src/index.php"),
|
|
731
716
|
"content": 'echo $x; // previously used #tag',
|
|
732
717
|
},
|
|
733
718
|
)
|
|
@@ -741,7 +726,7 @@ def test_additional_context_contains_examples():
|
|
|
741
726
|
result = _run_hook(
|
|
742
727
|
"Write",
|
|
743
728
|
{
|
|
744
|
-
"file_path":
|
|
729
|
+
"file_path": repository_file_path("src/main.py"),
|
|
745
730
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
746
731
|
},
|
|
747
732
|
)
|
|
@@ -789,7 +774,7 @@ def test_handles_non_string_tool_name():
|
|
|
789
774
|
payload = json.dumps(
|
|
790
775
|
{
|
|
791
776
|
"tool_name": 123,
|
|
792
|
-
"tool_input": {"file_path":
|
|
777
|
+
"tool_input": {"file_path": repository_file_path("src/main.py")},
|
|
793
778
|
"cwd": _REPOSITORY_ROOT,
|
|
794
779
|
}
|
|
795
780
|
)
|
|
@@ -816,7 +801,7 @@ def test_detects_used_to_in_python_module_docstring() -> None:
|
|
|
816
801
|
result = _run_hook(
|
|
817
802
|
"Write",
|
|
818
803
|
{
|
|
819
|
-
"file_path":
|
|
804
|
+
"file_path": repository_file_path("tests/test_exports.py"),
|
|
820
805
|
"content": written_source,
|
|
821
806
|
},
|
|
822
807
|
)
|
|
@@ -837,7 +822,7 @@ def test_detects_previously_in_python_function_docstring() -> None:
|
|
|
837
822
|
result = _run_hook(
|
|
838
823
|
"Write",
|
|
839
824
|
{
|
|
840
|
-
"file_path":
|
|
825
|
+
"file_path": repository_file_path("src/configuration.py"),
|
|
841
826
|
"content": written_source,
|
|
842
827
|
},
|
|
843
828
|
)
|
|
@@ -854,7 +839,7 @@ def test_ignores_transitional_phrase_in_regular_python_string() -> None:
|
|
|
854
839
|
result = _run_hook(
|
|
855
840
|
"Write",
|
|
856
841
|
{
|
|
857
|
-
"file_path":
|
|
842
|
+
"file_path": repository_file_path("src/messages.py"),
|
|
858
843
|
"content": 'PADDING_NOTE = "used to pad with a flat sleep"\n',
|
|
859
844
|
},
|
|
860
845
|
)
|
|
@@ -873,7 +858,7 @@ def test_ignores_quoted_mention_inside_python_docstring() -> None:
|
|
|
873
858
|
result = _run_hook(
|
|
874
859
|
"Write",
|
|
875
860
|
{
|
|
876
|
-
"file_path":
|
|
861
|
+
"file_path": repository_file_path("src/scanner.py"),
|
|
877
862
|
"content": written_source,
|
|
878
863
|
},
|
|
879
864
|
)
|
|
@@ -889,7 +874,7 @@ def test_detects_docstring_at_start_of_unparseable_edit_fragment() -> None:
|
|
|
889
874
|
result = _run_hook(
|
|
890
875
|
"Edit",
|
|
891
876
|
{
|
|
892
|
-
"file_path":
|
|
877
|
+
"file_path": repository_file_path("src/exports.py"),
|
|
893
878
|
"old_string": "placeholder",
|
|
894
879
|
"new_string": fragment,
|
|
895
880
|
},
|
|
@@ -911,7 +896,7 @@ def test_detects_docstring_after_def_header_in_unparseable_fragment() -> None:
|
|
|
911
896
|
result = _run_hook(
|
|
912
897
|
"Edit",
|
|
913
898
|
{
|
|
914
|
-
"file_path":
|
|
899
|
+
"file_path": repository_file_path("src/reporting.py"),
|
|
915
900
|
"old_string": "placeholder",
|
|
916
901
|
"new_string": fragment,
|
|
917
902
|
},
|
|
@@ -930,7 +915,7 @@ def test_ignores_assigned_triple_quoted_string_in_unparseable_fragment() -> None
|
|
|
930
915
|
result = _run_hook(
|
|
931
916
|
"Edit",
|
|
932
917
|
{
|
|
933
|
-
"file_path":
|
|
918
|
+
"file_path": repository_file_path("src/fixtures.py"),
|
|
934
919
|
"old_string": "placeholder",
|
|
935
920
|
"new_string": fragment,
|
|
936
921
|
},
|
|
@@ -950,7 +935,7 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
|
950
935
|
deny_payload = {
|
|
951
936
|
"tool_name": "Write",
|
|
952
937
|
"tool_input": {
|
|
953
|
-
"file_path":
|
|
938
|
+
"file_path": repository_file_path("src/main.py"),
|
|
954
939
|
"content": VIOLATION_INSTEAD_OF_COMMENT,
|
|
955
940
|
},
|
|
956
941
|
"cwd": _REPOSITORY_ROOT,
|
|
@@ -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:
|
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
|
+
)
|
|
@@ -13,16 +13,27 @@ blocking the tool call.
|
|
|
13
13
|
run = run_hook_capturing_output("/hooks/blocking/gate.py", '{"tool_name": "Bash"}')
|
|
14
14
|
ok: run.captured_stdout carries the gate's deny JSON, run.did_crash is False
|
|
15
15
|
flag: run.did_crash is True when the gate raised, run.captured_stdout is ""
|
|
16
|
+
|
|
17
|
+
``run_dispatcher_main`` holds the entry-point shell a hosted-hook dispatcher's
|
|
18
|
+
own ``main`` needs: read one stdin payload, resolve its tool_name, and hand
|
|
19
|
+
both to the dispatcher's own ``dispatch`` callback. Every dispatcher's
|
|
20
|
+
``main`` reduces to one call into this shared shell, so the read-resolve-exit
|
|
21
|
+
boilerplate lives in one place rather than once per dispatcher.
|
|
16
22
|
"""
|
|
17
23
|
|
|
18
24
|
from __future__ import annotations
|
|
19
25
|
|
|
20
26
|
import io
|
|
27
|
+
import json
|
|
21
28
|
import runpy
|
|
22
29
|
import sys
|
|
30
|
+
from pathlib import Path
|
|
23
31
|
import traceback
|
|
32
|
+
from collections.abc import Callable
|
|
24
33
|
from dataclasses import dataclass
|
|
25
34
|
|
|
35
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
36
|
+
|
|
26
37
|
|
|
27
38
|
@dataclass
|
|
28
39
|
class HostedHookRun:
|
|
@@ -71,3 +82,45 @@ def run_hook_capturing_output(hook_script_path: str, payload_text: str) -> Hoste
|
|
|
71
82
|
finally:
|
|
72
83
|
sys.stdin, sys.stdout, sys.argv = original_stdin, original_stdout, original_argv
|
|
73
84
|
return HostedHookRun(captured_stdout=captured_output.getvalue(), did_crash=did_crash)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolved_hook_script_path(relative_path: str) -> str:
|
|
88
|
+
"""Resolve a hooks/-relative path to the absolute script path to run.
|
|
89
|
+
|
|
90
|
+
::
|
|
91
|
+
|
|
92
|
+
"observability/test_failure_recorder.py" -> <hooks>/observability/...
|
|
93
|
+
|
|
94
|
+
The root comes from this module's own location, so every dispatcher
|
|
95
|
+
resolves against the same directory however deep it sits.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
relative_path: A path relative to the hooks directory.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The absolute path of that script.
|
|
102
|
+
"""
|
|
103
|
+
hooks_root = Path(__file__).resolve().parent.parent
|
|
104
|
+
return str(hooks_root / relative_path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_dispatcher_main(dispatch: Callable[[str, str], None]) -> None:
|
|
108
|
+
"""Read one stdin payload and hand its JSON text and tool_name to dispatch.
|
|
109
|
+
|
|
110
|
+
Every hosted-hook dispatcher's own ``main`` reduces to this one call.
|
|
111
|
+
Exits 0 before ever calling dispatch when stdin is empty, malformed, or
|
|
112
|
+
names no tool_name string; always exits 0 afterward, since a dispatcher
|
|
113
|
+
signals its own outcome, if any, through what dispatch itself writes.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
dispatch: The dispatcher's own ``dispatch(payload_text, tool_name)``.
|
|
117
|
+
"""
|
|
118
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
119
|
+
if payload_dictionary is None:
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
payload_text = json.dumps(payload_dictionary)
|
|
122
|
+
tool_name = payload_dictionary.get("tool_name", "")
|
|
123
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
dispatch(payload_text, tool_name)
|
|
126
|
+
sys.exit(0)
|