claude-dev-env 2.30.1 → 2.31.1

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 (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -19,6 +19,7 @@ from .run_all_validators import (
19
19
  ValidatorResult,
20
20
  _escapes_temporary_root,
21
21
  _hooks_subprocess_working_directory_and_environment,
22
+ _scope_agnostic_message,
22
23
  _scope_new_and_preexisting,
23
24
  _temporary_path_preserving_directory_signal,
24
25
  _violation_line_number,
@@ -26,11 +27,16 @@ from .run_all_validators import (
26
27
  run_validators_entrypoint_subprocess,
27
28
  )
28
29
 
30
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
31
+
29
32
  CONFIG_DIR_TARGET_PATH = (
30
33
  "CDP Automations/os_update_workflow/config/submission_constants.py"
31
34
  )
32
35
  PARENT_TRAVERSAL_TARGET_PATH = "../../escape_target.py"
33
36
  RELATIVE_CONFIG_TARGET_PATH = "config/x.py"
37
+ PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH = (
38
+ "/srv/projects/demo/test_edit_introducing_new_viol0/legacy_module.py"
39
+ )
34
40
 
35
41
  CLEAN_PYTHON_SOURCE = (
36
42
  "def add_two_numbers(first_number: int, second_number: int) -> int:\n"
@@ -226,6 +232,38 @@ class TestTemporaryPathPreservingDirectorySignal:
226
232
  )
227
233
  assert staged_path == staging_root / "legacy_module.py"
228
234
 
235
+ def test_pytest_scratch_segment_stages_flat_outside_every_temporary_root(
236
+ self, tmp_path: Path
237
+ ) -> None:
238
+ staging_root = tmp_path / "staging_root"
239
+ staging_root.mkdir()
240
+ staged_path = _temporary_path_preserving_directory_signal(
241
+ staging_root, PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH
242
+ )
243
+ assert staged_path == staging_root / "legacy_module.py"
244
+
245
+ def test_test_helpers_directory_keeps_its_exemption_under_a_temporary_root(
246
+ self, tmp_path: Path
247
+ ) -> None:
248
+ helpers_target = tmp_path / "pkg" / "test_helpers" / "worker.py"
249
+ staging_root = tmp_path / "staging_root"
250
+ staging_root.mkdir()
251
+ staged_path = _temporary_path_preserving_directory_signal(
252
+ staging_root, str(helpers_target)
253
+ )
254
+ assert staged_path == staging_root / "test_helpers" / "worker.py"
255
+
256
+ def test_scripts_directory_keeps_its_exemption_under_a_temporary_root(
257
+ self, tmp_path: Path
258
+ ) -> None:
259
+ scripts_target = tmp_path / "pkg" / "scripts" / "run_job.py"
260
+ staging_root = tmp_path / "staging_root"
261
+ staging_root.mkdir()
262
+ staged_path = _temporary_path_preserving_directory_signal(
263
+ staging_root, str(scripts_target)
264
+ )
265
+ assert staged_path == staging_root / "scripts" / "run_job.py"
266
+
229
267
  def test_relative_config_path_preserves_config_under_system_temp_cwd(
230
268
  self, monkeypatch: pytest.MonkeyPatch
231
269
  ) -> None:
@@ -428,6 +466,100 @@ class TestBaselineScopedGate:
428
466
  assert '"permissionDecision": "deny"' in completed.stdout
429
467
  assert "Magic Values" in completed.stdout
430
468
 
469
+ def test_renaming_a_function_keeps_its_violation_grandfathered(
470
+ self, tmp_path: Path
471
+ ) -> None:
472
+ target_file = tmp_path / "legacy_module.py"
473
+ target_file.write_text(
474
+ _over_long_function_source("over_long_function"), encoding="utf-8"
475
+ )
476
+ completed = run_gate(
477
+ {
478
+ "tool_name": "Edit",
479
+ "tool_input": {
480
+ "file_path": str(target_file),
481
+ "old_string": "def over_long_function(",
482
+ "new_string": "def accumulate_running_total(",
483
+ },
484
+ }
485
+ )
486
+ assert completed.returncode == 0, completed.stderr
487
+ assert '"permissionDecision": "deny"' not in completed.stdout
488
+ assert "Code Quality" in completed.stderr
489
+
490
+ def test_renaming_a_function_denies_a_second_violation_of_the_same_shape(
491
+ self, tmp_path: Path
492
+ ) -> None:
493
+ target_file = tmp_path / "legacy_module.py"
494
+ target_file.write_text(
495
+ _over_long_function_source("over_long_function"), encoding="utf-8"
496
+ )
497
+ completed = run_gate(
498
+ {
499
+ "tool_name": "Edit",
500
+ "tool_input": {
501
+ "file_path": str(target_file),
502
+ "old_string": "def over_long_function(",
503
+ "new_string": (
504
+ _over_long_function_source("second_over_long_function")
505
+ + "\n\n"
506
+ + "def accumulate_running_total("
507
+ ),
508
+ },
509
+ }
510
+ )
511
+ assert completed.returncode == 0, completed.stderr
512
+ assert '"permissionDecision": "deny"' in completed.stdout
513
+ assert "Code Quality" in completed.stdout
514
+
515
+ def test_renaming_a_function_denies_a_new_violation_of_another_validator(
516
+ self, tmp_path: Path
517
+ ) -> None:
518
+ target_file = tmp_path / "legacy_module.py"
519
+ target_file.write_text(
520
+ _over_long_function_source("over_long_function"), encoding="utf-8"
521
+ )
522
+ completed = run_gate(
523
+ {
524
+ "tool_name": "Edit",
525
+ "tool_input": {
526
+ "file_path": str(target_file),
527
+ "old_string": "def over_long_function(",
528
+ "new_string": "import os\n\n\ndef accumulate_running_total(",
529
+ },
530
+ }
531
+ )
532
+ assert completed.returncode == 0, completed.stderr
533
+ assert '"permissionDecision": "deny"' in completed.stdout
534
+ assert "F401" in completed.stdout
535
+
536
+ def test_moving_a_violation_to_another_function_keeps_it_grandfathered(
537
+ self, tmp_path: Path
538
+ ) -> None:
539
+ target_file = tmp_path / "legacy_module.py"
540
+ target_file.write_text(RUFF_DIRTY_FUNCTION_SOURCE, encoding="utf-8")
541
+ completed = run_gate(
542
+ {
543
+ "tool_name": "Edit",
544
+ "tool_input": {
545
+ "file_path": str(target_file),
546
+ "old_string": RUFF_DIRTY_FUNCTION_SOURCE,
547
+ "new_string": (
548
+ "def compute_total() -> int:\n"
549
+ " return 1\n"
550
+ "\n"
551
+ "\n"
552
+ "def compute_subtotal() -> int:\n"
553
+ " unused_intermediate = 1\n"
554
+ " return 1\n"
555
+ ),
556
+ },
557
+ }
558
+ )
559
+ assert completed.returncode == 0, completed.stderr
560
+ assert '"permissionDecision": "deny"' not in completed.stdout
561
+ assert "F841" in completed.stderr
562
+
431
563
  def test_new_module_scope_ruff_violation_with_dirty_function_denies(
432
564
  self, tmp_path: Path
433
565
  ) -> None:
@@ -449,6 +581,37 @@ class TestBaselineScopedGate:
449
581
  assert "F841" not in completed.stdout
450
582
 
451
583
 
584
+ class TestScopeAgnosticMessage:
585
+ def test_masks_the_enclosing_function_name_and_its_metrics(self) -> None:
586
+ masked_message = _scope_agnostic_message(
587
+ "over_long_function", "Function 'over_long_function' is 37 lines (max 30)"
588
+ )
589
+
590
+ assert masked_message == "Function '<name>' is <number> lines (max <number>)"
591
+
592
+ def test_keeps_the_numbers_of_a_message_that_never_names_its_function(self) -> None:
593
+ magic_value_message = "Magic number 199 - use named constant"
594
+
595
+ masked_message = _scope_agnostic_message("compute_total", magic_value_message)
596
+
597
+ assert masked_message == magic_value_message
598
+
599
+ def test_keeps_a_module_scope_message_unchanged(self) -> None:
600
+ ruff_message = "F401 `os` imported but unused"
601
+
602
+ masked_message = _scope_agnostic_message("", ruff_message)
603
+
604
+ assert masked_message == ruff_message
605
+
606
+ def test_masks_only_the_innermost_name_of_a_qualified_scope(self) -> None:
607
+ masked_message = _scope_agnostic_message(
608
+ "Ledger.record_entry",
609
+ "Parameter 'entry' in 'record_entry' missing type annotation",
610
+ )
611
+
612
+ assert masked_message == "Parameter 'entry' in '<name>' missing type annotation"
613
+
614
+
452
615
  class TestScopeNewAndPreexisting:
453
616
  def test_failed_result_without_located_lines_is_treated_as_new(self) -> None:
454
617
  summary_only_result = ValidatorResult(
@@ -0,0 +1,202 @@
1
+ """Repository-root anchoring tests for the PreToolUse staging walk.
2
+
3
+ Staging mirrors the destination's exemption-signalling directories through a
4
+ temp path so the save-path checks read the same signals the real path carries.
5
+ The walk must read only the segments the project itself owns. A directory above
6
+ the project root — a home directory named ``tests``, a checkout parked under
7
+ ``config`` — belongs to whoever laid out the disk, not to the project, and must
8
+ never lend a production file a test or config exemption.
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from .run_all_validators import (
16
+ _temporary_path_preserving_directory_signal,
17
+ validate_proposed_file,
18
+ )
19
+
20
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
21
+
22
+ GIT_MARKER_DIRECTORY_NAME = ".git"
23
+ MAGIC_VALUE_CHECK_NAME = "Magic Values"
24
+ PRODUCTION_CONTENT_WITH_MAGIC_VALUE = "def retry_budget_seconds() -> int:\n return 3600 * 7\n"
25
+
26
+
27
+ def _project_root_under(parent_directory: Path) -> Path:
28
+ """Create a git-marked project directory under *parent_directory*.
29
+
30
+ Args:
31
+ parent_directory: Directory the project root is created inside.
32
+
33
+ Returns:
34
+ The created project root, holding an empty ``.git`` marker directory.
35
+ """
36
+ project_root = parent_directory / "project"
37
+ (project_root / GIT_MARKER_DIRECTORY_NAME).mkdir(parents=True)
38
+ return project_root
39
+
40
+
41
+ def _staged_relative_path(staging_directory: Path, destination_path: Path) -> str:
42
+ """Return the posix staged path *destination_path* produces, relative to staging.
43
+
44
+ Args:
45
+ staging_directory: Root of the ephemeral staging tree.
46
+ destination_path: Real destination the write or edit targets.
47
+
48
+ Returns:
49
+ The staged path relative to *staging_directory*, in posix form.
50
+ """
51
+ staged_path = _temporary_path_preserving_directory_signal(
52
+ staging_directory, str(destination_path)
53
+ )
54
+ return staged_path.relative_to(staging_directory).as_posix()
55
+
56
+
57
+ def _failed_check_names(destination_path: Path, proposed_content: str) -> list[str]:
58
+ """Return the names of every save-path check that fails for a destination.
59
+
60
+ Args:
61
+ destination_path: Real destination the write or edit targets.
62
+ proposed_content: Post-edit content the write would leave on disk.
63
+
64
+ Returns:
65
+ The failing check names, in roster order.
66
+ """
67
+ return [
68
+ each_result.name
69
+ for each_result in validate_proposed_file(str(destination_path), proposed_content)
70
+ if not each_result.passed
71
+ ]
72
+
73
+
74
+ def test_should_drop_tests_directory_above_the_project_root(tmp_path: Path) -> None:
75
+ """A home directory named ``tests`` lends no test exemption to project code."""
76
+ parent_named_tests = tmp_path / "tests"
77
+ project_root = _project_root_under(parent_named_tests)
78
+ staging_directory = tmp_path / "staging"
79
+ staging_directory.mkdir()
80
+
81
+ staged_relative = _staged_relative_path(staging_directory, project_root / "src" / "worker.py")
82
+
83
+ assert staged_relative == "worker.py", (
84
+ f"a tests directory above the project must not stage into the path, got {staged_relative}"
85
+ )
86
+
87
+
88
+ def test_should_drop_config_directory_above_the_project_root(tmp_path: Path) -> None:
89
+ """A checkout parked under ``config`` lends no config exemption to project code."""
90
+ parent_named_config = tmp_path / "config"
91
+ project_root = _project_root_under(parent_named_config)
92
+ staging_directory = tmp_path / "staging"
93
+ staging_directory.mkdir()
94
+
95
+ staged_relative = _staged_relative_path(staging_directory, project_root / "src" / "worker.py")
96
+
97
+ assert staged_relative == "worker.py", (
98
+ f"a config directory above the project must not stage into the path, got {staged_relative}"
99
+ )
100
+
101
+
102
+ def test_should_report_magic_value_for_production_file_under_a_tests_parent(
103
+ tmp_path: Path,
104
+ ) -> None:
105
+ """The magic-value check grades project code under a ``tests`` parent directory."""
106
+ project_root = _project_root_under(tmp_path / "tests")
107
+
108
+ all_failed_names = _failed_check_names(
109
+ project_root / "src" / "worker.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
110
+ )
111
+
112
+ assert MAGIC_VALUE_CHECK_NAME in all_failed_names, (
113
+ f"production code under a tests parent must still be graded, got {all_failed_names}"
114
+ )
115
+
116
+
117
+ def test_should_report_magic_value_for_production_file_under_a_config_parent(
118
+ tmp_path: Path,
119
+ ) -> None:
120
+ """The magic-value check grades project code under a ``config`` parent directory."""
121
+ project_root = _project_root_under(tmp_path / "config")
122
+
123
+ all_failed_names = _failed_check_names(
124
+ project_root / "src" / "worker.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
125
+ )
126
+
127
+ assert MAGIC_VALUE_CHECK_NAME in all_failed_names, (
128
+ f"production code under a config parent must still be graded, got {all_failed_names}"
129
+ )
130
+
131
+
132
+ def test_should_keep_test_helpers_directory_inside_the_project(tmp_path: Path) -> None:
133
+ """A ``test_helpers`` package inside the project keeps its test exemption."""
134
+ project_root = _project_root_under(tmp_path / "tests")
135
+ staging_directory = tmp_path / "staging"
136
+ staging_directory.mkdir()
137
+
138
+ staged_relative = _staged_relative_path(
139
+ staging_directory, project_root / "pkg" / "test_helpers" / "worker.py"
140
+ )
141
+
142
+ assert staged_relative == "test_helpers/worker.py", (
143
+ f"an in-project test_helpers directory must stage into the path, got {staged_relative}"
144
+ )
145
+
146
+
147
+ def test_should_keep_scripts_directory_inside_the_project(tmp_path: Path) -> None:
148
+ """A ``scripts`` directory inside the project keeps its CLI-marker signal."""
149
+ project_root = _project_root_under(tmp_path / "tests")
150
+ staging_directory = tmp_path / "staging"
151
+ staging_directory.mkdir()
152
+
153
+ staged_relative = _staged_relative_path(
154
+ staging_directory, project_root / "scripts" / "deploy.py"
155
+ )
156
+
157
+ assert staged_relative == "scripts/deploy.py", (
158
+ f"an in-project scripts directory must stage into the path, got {staged_relative}"
159
+ )
160
+
161
+
162
+ def test_should_keep_tests_directory_inside_the_project(tmp_path: Path) -> None:
163
+ """A genuine ``tests`` directory inside the project keeps its test exemption."""
164
+ project_root = _project_root_under(tmp_path / "config")
165
+ staging_directory = tmp_path / "staging"
166
+ staging_directory.mkdir()
167
+
168
+ staged_relative = _staged_relative_path(
169
+ staging_directory, project_root / "tests" / "helpers" / "data.py"
170
+ )
171
+
172
+ assert staged_relative == "tests/helpers/data.py", (
173
+ f"an in-project tests directory must stage into the path, got {staged_relative}"
174
+ )
175
+
176
+
177
+ def test_should_keep_config_directory_inside_the_project(tmp_path: Path) -> None:
178
+ """A ``config`` directory inside the project keeps its magic-value exemption."""
179
+ project_root = _project_root_under(tmp_path / "tests")
180
+
181
+ all_failed_names = _failed_check_names(
182
+ project_root / "config" / "timing.py", PRODUCTION_CONTENT_WITH_MAGIC_VALUE
183
+ )
184
+
185
+ assert MAGIC_VALUE_CHECK_NAME not in all_failed_names, (
186
+ f"an in-project config module must stay magic-value exempt, got {all_failed_names}"
187
+ )
188
+
189
+
190
+ def test_should_walk_every_segment_for_a_target_with_no_project_root(
191
+ tmp_path: Path,
192
+ ) -> None:
193
+ """An install-tree target outside every project keeps the whole-path walk."""
194
+ install_target = tmp_path / ".claude" / "hooks" / "blocking" / "gate.py"
195
+ staging_directory = tmp_path / "staging"
196
+ staging_directory.mkdir()
197
+
198
+ staged_relative = _staged_relative_path(staging_directory, install_target)
199
+
200
+ assert staged_relative == ".claude/hooks/blocking/gate.py", (
201
+ f"a target with no project root must keep its exemption tail, got {staged_relative}"
202
+ )
@@ -1,8 +1,11 @@
1
- """Tests for shared validator base types."""
1
+ """Tests for shared validator base types and its shared read and parse."""
2
+
3
+ import ast
4
+ from pathlib import Path
2
5
 
3
6
  import pytest
4
7
 
5
- from .validator_base import Violation
8
+ from .validator_base import Violation, source_text, syntax_tree
6
9
 
7
10
 
8
11
  class TestViolation:
@@ -24,3 +27,49 @@ class TestViolation:
24
27
  violation = Violation(file="test.py", line=42, message="Test message")
25
28
  violation_set = {violation}
26
29
  assert violation in violation_set
30
+
31
+ def test_source_text_reads_the_file_once_for_repeated_callers(tmp_path: Path) -> None:
32
+ """Two callers asking for the same unchanged file cause one read."""
33
+ target = tmp_path / "module.py"
34
+ target.write_text("value = 1\n", encoding="utf-8")
35
+ first = source_text(target)
36
+ second = source_text(target)
37
+ assert first == second == "value = 1\n"
38
+ assert source_text.__module__ == "validators.validator_base"
39
+
40
+
41
+ def test_source_text_reads_again_after_the_file_changes(tmp_path: Path) -> None:
42
+ """An edited file is read again rather than served from the cache.
43
+
44
+ The cache key carries the modification time and the size, so a rewrite
45
+ between two calls reaches disk a second time.
46
+ """
47
+ target = tmp_path / "module.py"
48
+ target.write_text("value = 1\n", encoding="utf-8")
49
+ assert source_text(target) == "value = 1\n"
50
+ target.write_text("value = 2\nvalue = 3\n", encoding="utf-8")
51
+ assert source_text(target) == "value = 2\nvalue = 3\n"
52
+
53
+
54
+ def test_source_text_raises_for_a_missing_file(tmp_path: Path) -> None:
55
+ """A caller's own error handling still sees the read failure."""
56
+ with pytest.raises(OSError):
57
+ source_text(tmp_path / "absent.py")
58
+
59
+
60
+ def test_syntax_tree_returns_the_same_tree_for_the_same_source() -> None:
61
+ """One parse serves every caller asking about the same text."""
62
+ source = "def handler() -> int:\n return 1\n"
63
+ assert syntax_tree(source) is syntax_tree(source)
64
+
65
+
66
+ def test_syntax_tree_parses_what_ast_parse_parses() -> None:
67
+ """The shared parse agrees with a direct ast.parse of the same text."""
68
+ source = "value = 1\n"
69
+ assert ast.dump(syntax_tree(source)) == ast.dump(ast.parse(source))
70
+
71
+
72
+ def test_syntax_tree_raises_syntax_error_for_unparseable_source() -> None:
73
+ """A caller's own SyntaxError branch still fires."""
74
+ with pytest.raises(SyntaxError):
75
+ syntax_tree("def broken(:\n")
@@ -8,7 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
  from typing import List
10
10
 
11
- from .validator_base import Violation
11
+ from .validator_base import Violation, source_text
12
12
 
13
13
 
14
14
  TODO_PATTERN = re.compile(r"#\s*(TODO|FIXME)\b(?!.*#\d+)", re.IGNORECASE)
@@ -34,7 +34,7 @@ def check_untracked_todos(source: str, filename: str) -> List[Violation]:
34
34
  def validate_file(file_path: Path) -> List[Violation]:
35
35
  filename = str(file_path)
36
36
  try:
37
- source = file_path.read_text(encoding="utf-8")
37
+ source = source_text(file_path)
38
38
  except Exception as error:
39
39
  return [Violation(filename, 0, f"Error reading file: {error}")]
40
40
 
@@ -10,7 +10,7 @@ import sys
10
10
  from pathlib import Path
11
11
  from typing import List
12
12
 
13
- from .validator_base import Violation
13
+ from .validator_base import Violation, source_text, syntax_tree
14
14
 
15
15
 
16
16
  def check_missing_type_hints(tree: ast.AST, filename: str) -> List[Violation]:
@@ -66,8 +66,8 @@ def validate_file(file_path: Path) -> List[Violation]:
66
66
  filename = str(file_path)
67
67
 
68
68
  try:
69
- source = file_path.read_text(encoding="utf-8")
70
- tree = ast.parse(source)
69
+ source = source_text(file_path)
70
+ tree = syntax_tree(source)
71
71
  except Exception as error:
72
72
  return [Violation(filename, 0, f"Error: {error}")]
73
73
 
@@ -9,7 +9,7 @@ import sys
9
9
  from pathlib import Path
10
10
  from typing import List
11
11
 
12
- from .validator_base import Violation
12
+ from .validator_base import Violation, source_text, syntax_tree
13
13
 
14
14
 
15
15
  def check_useless_tests(tree: ast.AST, filename: str) -> List[Violation]:
@@ -66,8 +66,8 @@ def validate_file(file_path: Path) -> List[Violation]:
66
66
  return []
67
67
 
68
68
  try:
69
- source = file_path.read_text(encoding="utf-8")
70
- tree = ast.parse(source)
69
+ source = source_text(file_path)
70
+ tree = syntax_tree(source)
71
71
  except Exception as error:
72
72
  return [Violation(filename, 0, f"Error: {error}")]
73
73
 
@@ -1,9 +1,15 @@
1
1
  """Base classes for validators.
2
2
 
3
- Provides shared dataclasses used across all validator modules.
3
+ Provides shared dataclasses used across all validator modules, and the one
4
+ read and the one parse each of those modules shares for a given file.
4
5
  """
5
6
 
7
+ import ast
6
8
  from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+ from .config.validator_base_constants import SOURCE_CACHE_ENTRIES, SOURCE_ENCODING
7
13
 
8
14
 
9
15
  @dataclass(frozen=True)
@@ -17,3 +23,69 @@ class Violation:
17
23
  def __str__(self) -> str:
18
24
  """Format as file:line: message."""
19
25
  return f"{self.file}:{self.line}: {self.message}"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ValidatorResult:
30
+ """Result from running a validator."""
31
+
32
+ name: str
33
+ checks: str
34
+ passed: bool
35
+ output: str
36
+ skipped: bool = False
37
+
38
+
39
+ @lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
40
+ def _source_text_for_stat(
41
+ file_path_text: str, modified_nanoseconds: int, size_in_bytes: int
42
+ ) -> str:
43
+ """Read one file, keyed on the stat that identifies this version of it."""
44
+ return Path(file_path_text).read_text(encoding=SOURCE_ENCODING)
45
+
46
+
47
+ def source_text(file_path: Path) -> str:
48
+ """Return the file's text, reading it once however many checks ask.
49
+
50
+ Every check the save-path gate runs reads the same file. The gate hands
51
+ each of them one freshly written target, so the read is repeated work.
52
+ The cache key carries the modification time and size, so an edited file
53
+ is read again.
54
+
55
+ That key has one blind spot: a rewrite that leaves the size unchanged
56
+ inside a single modification-time tick is served from the cache. A hook
57
+ process handles one payload, so it cannot happen there; a long CLI pass
58
+ over many files is where a reader needs to know.
59
+
60
+ Raises whatever ``read_text`` raises, so a caller's own error handling
61
+ is unchanged.
62
+
63
+ Args:
64
+ file_path: The file to read.
65
+
66
+ Returns:
67
+ The file's decoded text.
68
+ """
69
+ file_status = file_path.stat()
70
+ return _source_text_for_stat(
71
+ str(file_path), file_status.st_mtime_ns, file_status.st_size
72
+ )
73
+
74
+
75
+ @lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
76
+ def syntax_tree(source: str) -> ast.Module:
77
+ """Return the parsed tree for source, parsing it once however many checks ask.
78
+
79
+ No check mutates the tree; each one walks it. So one parse serves all of
80
+ them, and the tree is shared rather than copied.
81
+
82
+ Raises ``SyntaxError`` exactly as ``ast.parse`` does, so a caller's own
83
+ error handling is unchanged.
84
+
85
+ Args:
86
+ source: The file's text.
87
+
88
+ Returns:
89
+ The parsed module.
90
+ """
91
+ return ast.parse(source)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.30.1",
3
+ "version": "2.31.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@ Many deterministic shapes of this drift have Write/Edit gates in `~/.claude/hook
34
34
 
35
35
  A hook module is the sharpest case of the same rule: its docstring lead narrative and its `CORRECTIVE_MESSAGE` describe exactly the shapes the detector flags, and claim no broader trigger surface than the regex enforces.
36
36
 
37
- `hook_prose_detector_consistency` (PreToolUse on Write|Edit of hook modules and their `*_constants.py` companions) blocks prose that claims a trigger the detector never fires on, and names the fix.
37
+ `hook_prose_detector_consistency` (PreToolUse on Write|Edit|MultiEdit of hook modules and their `*_constants.py` companions) blocks prose that claims a trigger the detector never fires on, and names the fix.
38
38
 
39
39
  After writing a hook, ask: would a token matching every word of this message actually trip the detector? When the message names a shape the regex skips, rewrite the message to name only what the regex catches. The path-shape case is the common overstatement — a detector that keys off a path separator must not claim it blocks an "output-key segment". The corrective message spells the rewrite.
40
40
 
@@ -17,6 +17,8 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh` (no ex
17
17
 
18
18
  **ALWAYS create PRs as DRAFT:** Use `gh pr create --draft` for ALL PRs
19
19
 
20
+ **The pr-description-writer agent writes the body:** Spawn the `pr-description-writer` agent (Agent tool, `subagent_type` `pr-description-writer`), then publish what it returns with `--body-file`. `pr_description_writer_gate.py` (PreToolUse on Bash and PowerShell, hosted by `bash_pre_tool_use_dispatcher`) denies a `gh pr create` when the session records no such spawn. A trailing `# pr-description-skip` comment on the command opts out for a body you write yourself.
21
+
20
22
  ## Git Golden Rules (NON-NEGOTIABLE)
21
23
 
22
24
  1. **DRAFT BEFORE PUSH**: When pushing ANYTHING to a PR, it MUST be in draft state first
package/rules/testing.md CHANGED
@@ -22,3 +22,7 @@ Incomplete mocks make it impossible to distinguish "broken code" from "missing d
22
22
  ## Tests Exercise Real Behavior
23
23
 
24
24
  Tests exercise real behavior, real data, and production code paths. A test that asserts on a stand-in for the production path proves the stand-in works.
25
+
26
+ ## The File-Level TDD Gate Reads Content
27
+
28
+ `tdd_enforcer.py` records each candidate test's content hash at every sighting and compares that hash on the next write. A first sighting requires content that differs from HEAD.
@@ -64,6 +64,8 @@ codex_hook_dependency_manifest = (
64
64
  "hooks/blocking/code_rules_comments.py",
65
65
  "hooks/blocking/code_rules_constants_config.py",
66
66
  "hooks/blocking/codex_apply_patch.py",
67
+ "hooks/blocking/config/__init__.py",
68
+ "hooks/blocking/config/codex_apply_patch_constants.py",
67
69
  "hooks/blocking/code_rules_docstrings.py",
68
70
  "hooks/blocking/code_rules_duplicate_body.py",
69
71
  "hooks/blocking/code_rules_enforcer.py",
@@ -101,14 +103,17 @@ codex_hook_dependency_manifest = (
101
103
  "hooks/hooks_constants/hook_block_logger.py",
102
104
  "hooks/hooks_constants/inline_tuple_string_magic_constants.py",
103
105
  "hooks/hooks_constants/js_conventions_constants.py",
106
+ "hooks/hooks_constants/multi_edit_reconstruction.py",
104
107
  "hooks/hooks_constants/orphan_css_class_constants.py",
105
108
  "hooks/hooks_constants/paired_test_coverage_constants.py",
106
109
  "hooks/hooks_constants/setup_project_paths_constants.py",
107
110
  "hooks/hooks_constants/stuttering_check_config.py",
108
111
  "hooks/hooks_constants/stuttering_import_binding_constants.py",
112
+ "hooks/hooks_constants/subprocess_budget_completeness_content.py",
109
113
  "hooks/hooks_constants/sys_path_insert_constants.py",
110
114
  "hooks/hooks_constants/test_layout_constants.py",
111
115
  "hooks/hooks_constants/unused_module_import_constants.py",
116
+ "hooks/hooks_constants/validation_phase_constants.py",
112
117
  )
113
118
  full_prune_opt_in_flag = "--allow-prune-all"
114
119
  unreadable_source_root_message = (