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
@@ -54,53 +54,31 @@ import json
54
54
  import sys
55
55
  from pathlib import Path
56
56
 
57
- try:
58
- from hooks_constants.shell_substitution_blocker_constants import (
59
- BASH_TOOL_NAME,
60
- CLOSE_PAREN_CHARACTER,
61
- COMMAND_KEY,
62
- CORRECTIVE_MESSAGE,
63
- DENY_DECISION,
64
- DOLLAR_PAREN_PATTERN,
65
- EVEN_BACKSLASH_BACKTICK_PATTERN,
66
- HOOK_EVENT_NAME,
67
- HOOK_EVENT_NAME_KEY,
68
- HOOK_SPECIFIC_OUTPUT_KEY,
69
- OPEN_PAREN_CHARACTER,
70
- PAREN_DEPTH_AFTER_DOUBLE_OPEN,
71
- PERMISSION_DECISION_KEY,
72
- PERMISSION_DECISION_REASON_KEY,
73
- PROCESS_SUBSTITUTION_PATTERN,
74
- SINGLE_QUOTED_RUN_PATTERN,
75
- STRIPPED_RUN_REPLACEMENT,
76
- TOOL_INPUT_KEY,
77
- TOOL_NAME_KEY,
78
- )
79
- except ModuleNotFoundError:
80
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
81
- if _hooks_dir not in sys.path:
82
- sys.path.insert(0, _hooks_dir)
83
- from hooks_constants.shell_substitution_blocker_constants import (
84
- BASH_TOOL_NAME,
85
- CLOSE_PAREN_CHARACTER,
86
- COMMAND_KEY,
87
- CORRECTIVE_MESSAGE,
88
- DENY_DECISION,
89
- DOLLAR_PAREN_PATTERN,
90
- EVEN_BACKSLASH_BACKTICK_PATTERN,
91
- HOOK_EVENT_NAME,
92
- HOOK_EVENT_NAME_KEY,
93
- HOOK_SPECIFIC_OUTPUT_KEY,
94
- OPEN_PAREN_CHARACTER,
95
- PAREN_DEPTH_AFTER_DOUBLE_OPEN,
96
- PERMISSION_DECISION_KEY,
97
- PERMISSION_DECISION_REASON_KEY,
98
- PROCESS_SUBSTITUTION_PATTERN,
99
- SINGLE_QUOTED_RUN_PATTERN,
100
- STRIPPED_RUN_REPLACEMENT,
101
- TOOL_INPUT_KEY,
102
- TOOL_NAME_KEY,
103
- )
57
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
58
+ if _hooks_dir not in sys.path:
59
+ sys.path.insert(0, _hooks_dir)
60
+
61
+ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
62
+ BASH_TOOL_NAME,
63
+ CLOSE_PAREN_CHARACTER,
64
+ COMMAND_KEY,
65
+ CORRECTIVE_MESSAGE,
66
+ DENY_DECISION,
67
+ DOLLAR_PAREN_PATTERN,
68
+ EVEN_BACKSLASH_BACKTICK_PATTERN,
69
+ HOOK_EVENT_NAME,
70
+ HOOK_EVENT_NAME_KEY,
71
+ HOOK_SPECIFIC_OUTPUT_KEY,
72
+ OPEN_PAREN_CHARACTER,
73
+ PAREN_DEPTH_AFTER_DOUBLE_OPEN,
74
+ PERMISSION_DECISION_KEY,
75
+ PERMISSION_DECISION_REASON_KEY,
76
+ PROCESS_SUBSTITUTION_PATTERN,
77
+ SINGLE_QUOTED_RUN_PATTERN,
78
+ STRIPPED_RUN_REPLACEMENT,
79
+ TOOL_INPUT_KEY,
80
+ TOOL_NAME_KEY,
81
+ )
104
82
 
105
83
 
106
84
  def _strip_single_quoted_runs(command: str) -> str:
@@ -11,28 +11,95 @@ directly above it, denying the edit when that line is a standalone ``#``
11
11
  comment naming an identifier ``old_string`` carries and ``new_string`` drops.
12
12
  """
13
13
 
14
+ import functools
14
15
  import json
15
16
  import re
16
17
  import sys
17
18
  from pathlib import Path
18
19
  from typing import TextIO
19
20
 
20
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
- if _hooks_dir not in sys.path:
22
- sys.path.insert(0, _hooks_dir)
21
+ try:
22
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
23
+ if _hooks_dir not in sys.path:
24
+ sys.path.insert(0, _hooks_dir)
23
25
 
24
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
25
- from hooks_constants.pre_tool_use_dispatcher_constants import EDIT_TOOL_NAME # noqa: E402
26
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
27
- from hooks_constants.stale_comment_reference_blocker_constants import ( # noqa: E402
28
- ALL_COMMENT_STOPWORDS,
29
- COMMENT_IDENTIFIER_PATTERN,
30
- COMMENT_LINE_PREFIX,
31
- PYTHON_FILE_SUFFIX,
32
- STALE_COMMENT_ADDITIONAL_CONTEXT,
33
- STALE_COMMENT_DENY_TEMPLATE,
34
- STALE_COMMENT_SYSTEM_MESSAGE,
35
- )
26
+ from hooks_constants.hook_block_logger import log_hook_block
27
+ from hooks_constants.multi_edit_reconstruction import edits_for_tool
28
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
29
+ EDIT_TOOL_NAME,
30
+ MULTI_EDIT_TOOL_NAME,
31
+ )
32
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
33
+ from hooks_constants.stale_comment_reference_blocker_constants import (
34
+ ALL_COMMENT_STOPWORDS,
35
+ COMMENT_IDENTIFIER_PATTERN,
36
+ COMMENT_LINE_PREFIX,
37
+ PYTHON_FILE_SUFFIX,
38
+ STALE_COMMENT_ADDITIONAL_CONTEXT,
39
+ STALE_COMMENT_DENY_TEMPLATE,
40
+ STALE_COMMENT_SYSTEM_MESSAGE,
41
+ )
42
+ except ImportError as import_error:
43
+ raise ImportError(
44
+ "stale_comment_reference_blocker: cannot import its sibling modules; "
45
+ "ensure the hooks directory is importable."
46
+ ) from import_error
47
+
48
+
49
+ def _edit_step_fields(each_edit: object) -> tuple[str, str, bool] | None:
50
+ """Return (old_string, new_string, is_replace_all) for one usable MultiEdit step."""
51
+ if not isinstance(each_edit, dict):
52
+ return None
53
+ old_string = each_edit.get("old_string", "")
54
+ new_string = each_edit.get("new_string", "")
55
+ if not isinstance(old_string, str) or not isinstance(new_string, str) or not old_string:
56
+ return None
57
+ return old_string, new_string, each_edit.get("replace_all") is True
58
+
59
+
60
+ def _evaluate_multi_edit(file_path: str, all_tool_input: dict[str, object]) -> str | None:
61
+ """Judge each MultiEdit step against the content the prior steps already left.
62
+
63
+ Args:
64
+ file_path: The destination path the MultiEdit targets.
65
+ all_tool_input: The MultiEdit payload's input mapping.
66
+
67
+ Returns:
68
+ The deny-reason text for the first step whose kept comment orphans an
69
+ identifier, or None when every step stays consistent.
70
+ """
71
+ try:
72
+ current_content = Path(file_path).read_text(encoding="utf-8")
73
+ except (OSError, UnicodeDecodeError):
74
+ return None
75
+ for each_edit in edits_for_tool(MULTI_EDIT_TOOL_NAME, all_tool_input):
76
+ edit_fields = _edit_step_fields(each_edit)
77
+ if edit_fields is None:
78
+ continue
79
+ old_string, new_string, is_replace_all = edit_fields
80
+ if old_string not in current_content:
81
+ continue
82
+ deny_reason = _find_stale_comment_reference(
83
+ current_content, old_string, new_string, is_replace_all, file_path
84
+ )
85
+ if deny_reason is not None:
86
+ return deny_reason
87
+ current_content = current_content.replace(old_string, new_string, -1 if is_replace_all else 1)
88
+ return None
89
+
90
+
91
+ @functools.lru_cache
92
+ def _bounded_identifier_pattern(identifier: str) -> re.Pattern[str]:
93
+ """Return the compiled whole-word pattern for one comment-named identifier.
94
+
95
+ Args:
96
+ identifier: The identifier the comment names.
97
+
98
+ Returns:
99
+ The compiled pattern matching identifier with no adjoining word
100
+ character on either side.
101
+ """
102
+ return re.compile("(?<![A-Za-z0-9_])" + re.escape(identifier) + "(?![A-Za-z0-9_])")
36
103
 
37
104
 
38
105
  def _first_orphaned_identifier(
@@ -57,9 +124,7 @@ def _first_orphaned_identifier(
57
124
  for each_identifier in COMMENT_IDENTIFIER_PATTERN.findall(words_in_comment):
58
125
  if each_identifier.lower() in ALL_COMMENT_STOPWORDS:
59
126
  continue
60
- bounded_pattern = re.compile(
61
- "(?<![A-Za-z0-9_])" + re.escape(each_identifier) + "(?![A-Za-z0-9_])"
62
- )
127
+ bounded_pattern = _bounded_identifier_pattern(each_identifier)
63
128
  if bounded_pattern.search(original_block_text) and not bounded_pattern.search(
64
129
  revised_block_text
65
130
  ):
@@ -159,13 +224,15 @@ def _find_stale_comment_reference(
159
224
 
160
225
 
161
226
  def evaluate(payload_by_key: dict[str, object]) -> str | None:
162
- """Decide whether an Edit payload orphans a comment above a changed line.
227
+ """Decide whether an Edit or MultiEdit payload orphans a comment above a changed line.
163
228
 
164
229
  Reads the target file from disk and checks the line directly above each
165
- occurrence the edit rewrites for a kept standalone comment whose named
166
- identifier the edit removes from the line below it. Non-Edit tools,
167
- non-Python targets, unreadable files, and an old_string absent from the
168
- file all pass.
230
+ occurrence a step rewrites for a kept standalone comment whose named
231
+ identifier the step carries and drops. A MultiEdit's steps are judged in
232
+ order against the content the prior steps already left, so a step that
233
+ only reads as stale once an earlier step in the same payload has run is
234
+ still caught. Non-Edit/MultiEdit tools, non-Python targets, unreadable
235
+ files, and an old_string absent from the file all pass.
169
236
 
170
237
  Args:
171
238
  payload_by_key: The PreToolUse payload with tool_name and tool_input.
@@ -175,7 +242,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
175
242
  """
176
243
  raw_tool_name = payload_by_key.get("tool_name", "")
177
244
  tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
178
- if tool_name != EDIT_TOOL_NAME:
245
+ if tool_name not in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
179
246
  return None
180
247
 
181
248
  raw_tool_input = payload_by_key.get("tool_input", {})
@@ -185,6 +252,9 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
185
252
  if not isinstance(file_path, str) or not file_path.endswith(PYTHON_FILE_SUFFIX):
186
253
  return None
187
254
 
255
+ if tool_name == MULTI_EDIT_TOOL_NAME:
256
+ return _evaluate_multi_edit(file_path, tool_input)
257
+
188
258
  raw_old_string = tool_input.get("old_string", "")
189
259
  raw_new_string = tool_input.get("new_string", "")
190
260
  old_string = raw_old_string if isinstance(raw_old_string, str) else ""
@@ -15,28 +15,43 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import TextIO
17
17
 
18
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
- if _hooks_dir not in sys.path:
20
- sys.path.insert(0, _hooks_dir)
21
-
22
- from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
23
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
25
- from hooks_constants.state_description_blocker_constants import ( # noqa: E402
26
- ALL_BLOCK_COMMENT_EXTENSIONS,
27
- ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
28
- ALL_COMMENT_BEARING_EXTENSIONS,
29
- ALL_COMMENT_TRANSITION_PATTERNS,
30
- ALL_DEFINITION_HEADER_PREFIXES,
31
- ALL_HASH_AND_SLASH_EXTENSIONS,
32
- ALL_HASH_ONLY_EXTENSIONS,
33
- ALL_MARKDOWN_EXTENSIONS,
34
- CODE_FENCE_PATTERN,
35
- DOUBLE_QUOTED_SPAN_PATTERN,
36
- INLINE_CODE_PATTERN,
37
- PYTHON_EXTENSION,
38
- TRIPLE_QUOTED_BLOCK_PATTERN,
39
- )
18
+ try:
19
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
20
+ if _hooks_dir not in sys.path:
21
+ sys.path.insert(0, _hooks_dir)
22
+
23
+ from blocking.code_rules_shared import is_ephemeral_path
24
+ from hooks_constants.hook_block_logger import log_hook_block
25
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
26
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
27
+ from hooks_constants.state_description_blocker_constants import (
28
+ ALL_BLOCK_COMMENT_EXTENSIONS,
29
+ ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
30
+ ALL_COMMENT_BEARING_EXTENSIONS,
31
+ ALL_COMMENT_TRANSITION_PATTERNS,
32
+ ALL_DEFINITION_HEADER_PREFIXES,
33
+ ALL_HASH_AND_SLASH_EXTENSIONS,
34
+ ALL_HASH_ONLY_EXTENSIONS,
35
+ ALL_MARKDOWN_EXTENSIONS,
36
+ CODE_FENCE_PATTERN,
37
+ DOUBLE_QUOTED_SPAN_PATTERN,
38
+ INLINE_CODE_PATTERN,
39
+ PYTHON_EXTENSION,
40
+ TRIPLE_QUOTED_BLOCK_PATTERN,
41
+ )
42
+ except ImportError as import_error:
43
+ raise ImportError(
44
+ "state_description_blocker: cannot import its sibling modules"
45
+ ) from import_error
46
+
47
+
48
+ def _content_to_check(tool_name: str, all_tool_input: dict) -> str:
49
+ """Return the text a Write, Edit, or MultiEdit payload introduces."""
50
+ if tool_name == "MultiEdit":
51
+ return joined_new_strings(all_tool_input)
52
+ content_key = "content" if tool_name == "Write" else "new_string"
53
+ raw_content = all_tool_input.get(content_key, "")
54
+ return raw_content if isinstance(raw_content, str) else ""
40
55
 
41
56
 
42
57
  def _get_file_extension(file_path: str) -> str:
@@ -289,7 +304,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
289
304
  """
290
305
  raw_tool_name = payload_by_key.get("tool_name", "")
291
306
  tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
292
- if tool_name not in ("Write", "Edit"):
307
+ if tool_name not in ("Write", "Edit", "MultiEdit"):
293
308
  return None
294
309
 
295
310
  raw_tool_input = payload_by_key.get("tool_input", {})
@@ -303,9 +318,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
303
318
  if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
304
319
  return None
305
320
 
306
- content_key = "content" if tool_name == "Write" else "new_string"
307
- raw_content = tool_input.get(content_key, "")
308
- content_to_check = raw_content if isinstance(raw_content, str) else ""
321
+ content_to_check = _content_to_check(tool_name, tool_input)
309
322
  if not content_to_check:
310
323
  return None
311
324
 
@@ -42,55 +42,29 @@ if _blocking_dir not in sys.path:
42
42
  if _hooks_dir not in sys.path:
43
43
  sys.path.insert(0, _hooks_dir)
44
44
 
45
- from code_rules_shared import is_test_file # noqa: E402
46
-
47
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
48
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
49
- read_hook_input_dictionary_from_stdin,
50
- )
51
- from hooks_constants.subprocess_budget_completeness_constants import ( # noqa: E402
52
- ALL_BUDGET_NAME_MARKERS,
53
- BUDGET_ENTRY_POINT_FUNCTION_NAME,
54
- SUBPROCESS_TIMEOUT_KEYWORD,
55
- )
56
- from hooks_constants.windows_rmtree_blocker_constants import ( # noqa: E402
57
- PYTHON_FILE_EXTENSION,
58
- )
45
+ try:
46
+ from code_rules_shared import is_test_file
47
+
48
+ from hooks_constants.hook_block_logger import log_hook_block
49
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
50
+ from hooks_constants.subprocess_budget_completeness_constants import (
51
+ ALL_BUDGET_NAME_MARKERS,
52
+ BUDGET_ENTRY_POINT_FUNCTION_NAME,
53
+ SUBPROCESS_TIMEOUT_KEYWORD,
54
+ )
55
+ from hooks_constants.subprocess_budget_completeness_content import resolved_content
56
+ from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION
57
+ except ImportError as import_error:
58
+ raise ImportError(
59
+ "subprocess_budget_completeness: cannot import its sibling modules; "
60
+ "ensure the hooks directory is importable."
61
+ ) from import_error
59
62
 
60
63
 
61
64
  def is_python_target(file_path: str) -> bool:
62
65
  return file_path.endswith(PYTHON_FILE_EXTENSION)
63
66
 
64
67
 
65
- def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
66
- written_content = all_tool_input_fields.get("content")
67
- if isinstance(written_content, str):
68
- return written_content
69
- return reconstructed_edit_content(all_tool_input_fields)
70
-
71
-
72
- def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
73
- file_path = all_tool_input_fields.get("file_path")
74
- old_string = all_tool_input_fields.get("old_string")
75
- new_string = all_tool_input_fields.get("new_string")
76
- if not isinstance(file_path, str) or not isinstance(old_string, str):
77
- return ""
78
- if not isinstance(new_string, str) or not old_string:
79
- return ""
80
- existing_content = existing_file_content(file_path)
81
- if existing_content is None or old_string not in existing_content:
82
- return ""
83
- return existing_content.replace(old_string, new_string, 1)
84
-
85
-
86
- def existing_file_content(file_path: str) -> str | None:
87
- try:
88
- with open(file_path, "r", encoding="utf-8") as existing_file:
89
- return existing_file.read()
90
- except (FileNotFoundError, OSError, UnicodeDecodeError):
91
- return None
92
-
93
-
94
68
  def integer_literal_value(node: ast.expr) -> int | None:
95
69
  if (
96
70
  isinstance(node, ast.Constant)
@@ -2,6 +2,9 @@
2
2
 
3
3
  Blocks a write to a production source file when no matching test was modified
4
4
  within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
5
+ The gate records each candidate test's content hash at every sighting and
6
+ compares that hash on the next write. A first sighting requires content that
7
+ differs from HEAD.
5
8
  Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
6
9
  into one PreToolUse gate and re-exports their surface for the test suite.
7
10
  """
@@ -21,9 +24,14 @@ try:
21
24
  is_ephemeral_script_path,
22
25
  is_under_session_scratchpad,
23
26
  )
27
+ from codex_apply_patch import (
28
+ payload_patch_target_paths,
29
+ )
30
+ from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
24
31
  from tdd_enforcer_parts import (
25
32
  candidate_paths,
26
33
  content_analysis,
34
+ content_hash_store,
27
35
  decisions,
28
36
  freshness,
29
37
  git_tracking,
@@ -56,8 +64,8 @@ candidate_test_paths_for = candidate_paths.candidate_test_paths_for
56
64
  _ancestor_tests_directories = candidate_paths._ancestor_tests_directories
57
65
  _parent_walk_limit = candidate_paths._parent_walk_limit
58
66
  _freshness_seconds = freshness._freshness_seconds
59
- has_fresh_test = freshness.has_fresh_test
60
67
  _read_candidate_text = freshness._read_candidate_text
68
+ has_recorded_or_fresh_test = content_hash_store.has_recorded_or_fresh_test
61
69
  _is_constants_only_python_content = content_analysis._is_constants_only_python_content
62
70
  _is_post_edit_import_only = content_analysis._is_post_edit_import_only
63
71
  _is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
@@ -72,8 +80,12 @@ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
72
80
  return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
73
81
 
74
82
 
75
- def _is_silently_skipped(file_path: str, extension: str, path: Path) -> bool:
76
- if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
83
+ def _is_silently_skipped(
84
+ file_path: str, extension: str, path: Path, repository_root: str = ""
85
+ ) -> bool:
86
+ if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(
87
+ file_path, repository_root
88
+ ):
77
89
  return True
78
90
  if _is_agent_home_tooling_write(file_path):
79
91
  return True
@@ -117,29 +129,64 @@ def _write_is_exempt(
117
129
  return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
118
130
 
119
131
 
132
+ def _decide_for_target(
133
+ tool_name: str, tool_input: dict, file_path: str, input_data: dict
134
+ ) -> tuple[bool, str] | None:
135
+ """Return (is_allowed, deny_reason) for one target file, or None to skip silently."""
136
+ if not file_path:
137
+ return None
138
+ if _is_session_scratchpad_write(file_path, input_data):
139
+ return None
140
+ path = Path(file_path)
141
+ extension = path.suffix.lower()
142
+ repository_root = str(input_data.get("cwd") or "")
143
+ if _is_silently_skipped(file_path, extension, path, repository_root):
144
+ return None
145
+ if _write_is_exempt(tool_name, tool_input, path, extension):
146
+ return True, ""
147
+ all_candidates = candidate_test_paths_for(path)
148
+ session_id = str(input_data.get("session_id") or "")
149
+ is_recorded_or_fresh = has_recorded_or_fresh_test(
150
+ all_candidates, session_id, repository_root, _freshness_seconds()
151
+ )
152
+ if is_recorded_or_fresh:
153
+ return True, ""
154
+ return False, build_deny_reason(path, all_candidates)
155
+
156
+
157
+ def _decide_apply_patch(input_data: dict, tool_input: dict) -> None:
158
+ """Run the TDD gate over every file a Codex apply_patch payload touches."""
159
+ for each_file_path in payload_patch_target_paths(input_data, tool_input):
160
+ decision = _decide_for_target(
161
+ APPLY_PATCH_TOOL_NAME, tool_input, each_file_path, input_data
162
+ )
163
+ if decision is None:
164
+ continue
165
+ is_allowed, deny_reason = decision
166
+ if not is_allowed:
167
+ emit_deny(deny_reason)
168
+ return
169
+ emit_allow()
170
+
171
+
120
172
  def main() -> None:
121
- """Run the TDD gate over one PreToolUse Write, Edit, or MultiEdit payload."""
173
+ """Run the TDD gate over one PreToolUse Write, Edit, MultiEdit, or apply_patch payload."""
122
174
  try:
123
175
  input_data = json.load(sys.stdin)
124
176
  except json.JSONDecodeError:
125
177
  sys.exit(0)
126
178
  tool_name, tool_input, file_path = _resolve_payload(input_data)
127
- if not file_path:
128
- sys.exit(0)
129
- if _is_session_scratchpad_write(file_path, input_data):
130
- sys.exit(0)
131
- path = Path(file_path)
132
- extension = path.suffix.lower()
133
- if _is_silently_skipped(file_path, extension, path):
179
+ if tool_name == APPLY_PATCH_TOOL_NAME:
180
+ _decide_apply_patch(input_data, tool_input)
134
181
  sys.exit(0)
135
- if _write_is_exempt(tool_name, tool_input, path, extension):
136
- emit_allow()
182
+ decision = _decide_for_target(tool_name, tool_input, file_path, input_data)
183
+ if decision is None:
137
184
  sys.exit(0)
138
- all_candidates = candidate_test_paths_for(path)
139
- if has_fresh_test(all_candidates, _freshness_seconds()):
185
+ is_allowed, deny_reason = decision
186
+ if is_allowed:
140
187
  emit_allow()
141
188
  sys.exit(0)
142
- emit_deny(build_deny_reason(path, all_candidates))
189
+ emit_deny(deny_reason)
143
190
  sys.exit(0)
144
191
 
145
192
 
@@ -1,7 +1,8 @@
1
1
  """Constants for the TDD-enforcer hook and its decomposed parts modules.
2
2
 
3
3
  Centralizes the freshness window, the ancestor-walk limit, the git-tracking
4
- command tokens, the source-file extension sets, and the join separator.
4
+ and git-diff command tokens, the source-file extension sets, the join
5
+ separator, and the last-observed-hash store's filename shape and JSON keys.
5
6
  """
6
7
 
7
8
  from __future__ import annotations
@@ -9,9 +10,18 @@ from __future__ import annotations
9
10
  FRESHNESS_WINDOW_SECONDS: int = 600
10
11
  PARENT_WALK_LIMIT: int = 10
11
12
  GIT_LS_FILES_TIMEOUT_SECONDS: int = 10
13
+ GIT_DIFF_TIMEOUT_SECONDS: int = 10
12
14
  GIT_EXECUTABLE_NAME: str = "git"
13
15
  GIT_LS_FILES_SUBCOMMAND: str = "ls-files"
16
+ GIT_DIFF_SUBCOMMAND: str = "diff"
17
+ GIT_DIFF_QUIET_FLAG: str = "--quiet"
18
+ GIT_HEAD_REVISION: str = "HEAD"
14
19
  GIT_PATHSPEC_SEPARATOR: str = "--"
20
+ HASH_STATE_FILE_PREFIX: str = "claude-tdd-test-hashes-"
21
+ HASH_STATE_FILE_SUFFIX: str = ".json"
22
+ REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
23
+ STORED_CONTENT_HASH_KEY: str = "hash"
24
+ STORED_CHANGED_AT_KEY: str = "changed_at"
15
25
  PYTHON_SOURCE_EXTENSION: str = ".py"
16
26
  ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
17
27
  NEWLINE_JOIN_SEPARATOR: str = "\n"