claude-dev-env 2.31.0 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. package/scripts/test_shared_tree_paths.py +173 -0
@@ -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
- """Helper to test the hook via subprocess."""
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({"tool_name": tool_name, "tool_input": tool_input})
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({"tool_name": 123, "tool_input": {"file_path": "src/main.py"}})
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": {"file_path": "src/main.py", "content": VIOLATION_INSTEAD_OF_COMMENT},
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/gh_pr_author_restore.py",
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
+ )