claude-dev-env 2.30.0 → 2.31.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 (128) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/.agents/skills/e-simplify/SKILL.md +20 -8
  3. package/AGENTS.md +6 -0
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  5. package/bin/install.prune.test.mjs +4 -2
  6. package/docs/CODE_RULES.md +11 -0
  7. package/hooks/advisory/migration_safety_advisor.py +25 -1
  8. package/hooks/advisory/refactor_guard.py +85 -18
  9. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  10. package/hooks/advisory/test_refactor_guard.py +87 -0
  11. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  12. package/hooks/blocking/code_rules_constants_config.py +7 -6
  13. package/hooks/blocking/code_rules_enforcer.py +59 -36
  14. package/hooks/blocking/codex_apply_patch.py +136 -21
  15. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  16. package/hooks/blocking/conftest.py +70 -0
  17. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  18. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  19. package/hooks/blocking/pii_payload_scan.py +61 -0
  20. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  21. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  23. package/hooks/blocking/sensitive_file_protector.py +18 -3
  24. package/hooks/blocking/shell_substitution_blocker.py +62 -8
  25. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  26. package/hooks/blocking/state_description_blocker.py +39 -26
  27. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  28. package/hooks/blocking/tdd_enforcer.py +57 -14
  29. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  30. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  31. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  32. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  33. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  37. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
  38. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  39. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  40. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  41. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  42. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  43. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  46. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  49. package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
  50. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  51. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  52. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  53. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  54. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  55. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  56. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  57. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  58. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  59. package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
  60. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  61. package/hooks/blocking/test_state_description_blocker.py +68 -0
  62. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  63. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  64. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  65. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  66. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  67. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  68. package/hooks/conftest.py +47 -0
  69. package/hooks/hooks.json +8 -13
  70. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  71. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
  72. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  73. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  74. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  75. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  76. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  77. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  78. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  79. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  80. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
  81. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  82. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  83. package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -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 -9
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -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 +70 -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 +121 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/session/task_tool_prompt.py +37 -0
  96. package/hooks/session/test_task_tool_prompt.py +65 -0
  97. package/hooks/validation/hook_format_validator.py +23 -9
  98. package/hooks/validation/test_hook_format_validator.py +58 -0
  99. package/hooks/validators/README.md +1 -1
  100. package/hooks/validators/abbreviation_checks.py +3 -5
  101. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  102. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  103. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  104. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  105. package/hooks/validators/fast_save_validators.py +332 -0
  106. package/hooks/validators/hook_timing_harness.py +8 -4
  107. package/hooks/validators/python_style_checks.py +34 -60
  108. package/hooks/validators/python_style_helpers.py +16 -2
  109. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  110. package/hooks/validators/run_all_validators.py +42 -41
  111. package/hooks/validators/test_abbreviation_checks.py +20 -0
  112. package/hooks/validators/test_fast_save_validators.py +172 -0
  113. package/hooks/validators/test_python_style_checks.py +9 -0
  114. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  115. package/hooks/validators/test_python_style_helpers.py +35 -0
  116. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  117. package/hooks/validators/test_run_all_validators.py +46 -0
  118. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  119. package/package.json +1 -1
  120. package/rules/docstring-prose-matches-implementation.md +1 -1
  121. package/rules/git-workflow.md +2 -0
  122. package/rules/testing.md +4 -0
  123. package/scripts/codex_compat_materializer.py +5 -0
  124. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  125. package/hooks/blocking/tests/conftest.py +0 -10
  126. package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
  127. package/hooks/session/task_list_loop_starter.py +0 -45
  128. package/hooks/session/test_task_list_loop_starter.py +0 -63
@@ -149,18 +149,19 @@ def _scan_function_body_constants(content: str) -> list[str]:
149
149
  return advisory_issues
150
150
 
151
151
 
152
- def check_constants_outside_config_advisory(content: str, file_path: str) -> list[str]:
153
- """Return advisory entries for UPPER_SNAKE assignments inside function bodies.
152
+ def check_constants_outside_config_advisory(content: str, file_path: str) -> None:
153
+ """Emit stderr advisories for UPPER_SNAKE assignments inside function bodies.
154
154
 
155
155
  Module-level UPPER_SNAKE outside config/ is blocking (see
156
156
  check_constants_outside_config). Function-local UPPER_SNAKE is a softer
157
157
  smell — it belongs in config/ but does not block the write. This function
158
- surfaces those as advisory so callers can route them to stderr rather than
159
- to the blocking deny payload.
158
+ writes each finding straight to stderr, matching the other advisory-only
159
+ checks, so the write proceeds regardless of what it finds.
160
160
  """
161
161
  if _is_exempt_for_advisory_scan(file_path):
162
- return []
163
- return _scan_function_body_constants(content)
162
+ return
163
+ for each_issue in _scan_function_body_constants(content):
164
+ sys.stderr.write(f"[CODE_RULES advisory] {file_path}: {each_issue}\n")
164
165
 
165
166
 
166
167
  def _is_upper_snake_constant_name(name: str) -> bool:
@@ -16,7 +16,6 @@ concern focused. The separate ``tdd_enforcer.py`` hook accepts any
16
16
  ``code_rules_*`` module family, so the suffix files satisfy its gate.
17
17
  """
18
18
  import json
19
- import os
20
19
  import sys
21
20
  from collections import Counter
22
21
  from collections.abc import Callable
@@ -186,11 +185,42 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
186
185
  PRECHECK_USAGE_MESSAGE,
187
186
  UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
188
187
  VIOLATION_SEPARATOR,
188
+ apply_edits,
189
+ edits_for_tool,
189
190
  )
190
191
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
191
192
  from hooks_constants.setup_project_paths_constants import ( # noqa: E402
192
193
  UTF8_BYTE_ORDER_MARK,
193
194
  )
195
+ from hooks_constants.subprocess_budget_completeness_content import ( # noqa: E402
196
+ existing_file_content,
197
+ )
198
+
199
+
200
+ def _multiedit_post_edit_view(
201
+ file_path: str, all_tool_input: dict[str, object]
202
+ ) -> tuple[str, str] | None:
203
+ """Return the prior and reconstructed post-edit content for a MultiEdit payload.
204
+
205
+ Reads ``file_path`` once and applies every edit in the payload's ``edits``
206
+ list in order, so a violation introduced by any edit — not only the
207
+ first — is judged against the file's real prior and post-edit state.
208
+
209
+ Args:
210
+ file_path: The destination path the MultiEdit targets.
211
+ all_tool_input: The MultiEdit payload's input mapping.
212
+
213
+ Returns:
214
+ A ``(prior_content, post_edit_content)`` pair, or None when the target
215
+ file cannot be read.
216
+ """
217
+ existing_content = existing_file_content(file_path)
218
+ if existing_content is None:
219
+ return None
220
+ post_edit_content = apply_edits(
221
+ existing_content, edits_for_tool("MultiEdit", all_tool_input)
222
+ )
223
+ return existing_content, post_edit_content
194
224
 
195
225
 
196
226
  class _ValidationContext(NamedTuple):
@@ -263,9 +293,8 @@ def _report_codex_patch_payload(
263
293
  deny_stream,
264
294
  )
265
295
  return
266
- working_directory = all_pretooluse_payload.get("cwd")
267
- if not isinstance(working_directory, str):
268
- working_directory = os.getcwd()
296
+ raw_working_directory = all_pretooluse_payload.get("cwd")
297
+ working_directory = raw_working_directory if isinstance(raw_working_directory, str) else None
269
298
  try:
270
299
  all_patch_files = parse_codex_apply_patch(patch_command, working_directory)
271
300
  except CodexPatchError as error:
@@ -325,7 +354,7 @@ def _python_magic_value_and_constant_issues(context: _ValidationContext) -> list
325
354
  )
326
355
  all_issues.extend(check_fstring_structural_literals(content, file_path))
327
356
  all_issues.extend(check_constants_outside_config(content, file_path))
328
- all_issues.extend(check_constants_outside_config_advisory(content, file_path))
357
+ check_constants_outside_config_advisory(content, file_path)
329
358
  all_issues.extend(check_file_global_constants_use_count(content, file_path))
330
359
  return all_issues
331
360
 
@@ -678,15 +707,6 @@ def validate_content_for_full_gate(
678
707
  )
679
708
 
680
709
 
681
- def _read_existing_file_content(file_path: str) -> str | None:
682
- """Return the on-disk content of *file_path*, or None when it cannot be read."""
683
- try:
684
- with open(file_path, "r", encoding="utf-8") as existing_file:
685
- return existing_file.read()
686
- except (FileNotFoundError, OSError, UnicodeDecodeError):
687
- return None
688
-
689
-
690
710
  def prior_and_post_edit_content(
691
711
  file_path: str, old_string: str, new_string: str,
692
712
  ) -> tuple[str | None, str | None]:
@@ -716,7 +736,7 @@ def prior_and_post_edit_content(
716
736
  """
717
737
  if not old_string:
718
738
  return None, None
719
- existing_content = _read_existing_file_content(file_path)
739
+ existing_content = existing_file_content(file_path)
720
740
  if existing_content is None:
721
741
  return None, None
722
742
  if old_string not in existing_content:
@@ -799,11 +819,8 @@ def _hook_infrastructure_blocking_issues(
799
819
  Returns:
800
820
  The in-scope violations for the target at the given phase.
801
821
  """
802
- effective_content = content if full_file_content is None else full_file_content
803
- all_changed_lines = (
804
- changed_line_numbers(prior_full_file_content, full_file_content)
805
- if full_file_content is not None
806
- else None
822
+ effective_content, all_changed_lines = _effective_content_and_changed_lines(
823
+ content, full_file_content, prior_full_file_content
807
824
  )
808
825
  all_issues: list[str] = []
809
826
  if phase == FULL_GATE_PHASE:
@@ -1051,13 +1068,13 @@ def _run_precheck(
1051
1068
  runs_hook_duplicate_body = _is_hook_infrastructure_python_target(target_path)
1052
1069
  if not runs_full_verdict and not runs_hook_duplicate_body:
1053
1070
  return 0
1054
- candidate_content = _read_existing_file_content(candidate_path)
1071
+ candidate_content = existing_file_content(candidate_path)
1055
1072
  if candidate_content is None:
1056
1073
  error_stream.write(f"error: cannot read candidate file: {candidate_path}\n")
1057
1074
  return 1
1058
1075
  candidate_content = candidate_content.lstrip(UTF8_BYTE_ORDER_MARK)
1059
1076
  if runs_full_verdict:
1060
- old_content = _read_existing_file_content(target_path) or ""
1077
+ old_content = existing_file_content(target_path) or ""
1061
1078
  all_issues = validate_content_for_full_gate(candidate_content, target_path, old_content)
1062
1079
  else:
1063
1080
  all_issues = _hook_infrastructure_blocking_issues(
@@ -1130,18 +1147,16 @@ def _run_precheck_command(
1130
1147
 
1131
1148
  def _contents_for_validation(
1132
1149
  tool_name: str,
1133
- new_string: str,
1134
- old_string: str,
1135
- written_content: str,
1150
+ all_tool_input: dict[str, object],
1136
1151
  file_path: str,
1137
1152
  ) -> tuple[str, str, str | None, str] | None:
1138
1153
  """Resolve the content views the verdict needs for the given tool payload.
1139
1154
 
1140
1155
  Args:
1141
1156
  tool_name: The tool named in the PreToolUse payload.
1142
- new_string: The Edit payload's replacement fragment.
1143
- old_string: The Edit payload's fragment to replace.
1144
- written_content: The Write payload's whole file body.
1157
+ all_tool_input: The PreToolUse payload's tool_input mapping, carrying
1158
+ ``new_string``/``old_string`` for Edit, ``content`` for Write, and
1159
+ the ``edits`` list for MultiEdit.
1145
1160
  file_path: The destination path of the write or edit.
1146
1161
 
1147
1162
  Returns:
@@ -1149,17 +1164,26 @@ def _contents_for_validation(
1149
1164
  prior_full_file_content)`` tuple, or None when no validatable view
1150
1165
  exists — an unreadable edit target, or a write over an existing file.
1151
1166
  """
1167
+ new_string = str(all_tool_input.get("new_string", "") or "")
1168
+ old_string = str(all_tool_input.get("old_string", "") or "")
1169
+ written_content = str(all_tool_input.get("content", "") or "")
1152
1170
  if tool_name == "Edit":
1153
1171
  prior_content, full_file_content_after_edit = prior_and_post_edit_content(
1154
1172
  file_path, old_string, new_string,
1155
1173
  )
1156
1174
  if full_file_content_after_edit is None:
1157
- full_file_content_after_edit = _read_existing_file_content(file_path)
1175
+ full_file_content_after_edit = existing_file_content(file_path)
1158
1176
  if full_file_content_after_edit is None:
1159
1177
  return None
1160
1178
  return new_string, old_string, full_file_content_after_edit, prior_content or ""
1179
+ if tool_name == "MultiEdit":
1180
+ multiedit_view = _multiedit_post_edit_view(file_path, all_tool_input or {})
1181
+ if multiedit_view is None:
1182
+ return None
1183
+ prior_content, post_edit_content = multiedit_view
1184
+ return post_edit_content, prior_content, post_edit_content, prior_content
1161
1185
  content = written_content or new_string
1162
- old_content = _read_existing_file_content(file_path) or ""
1186
+ old_content = existing_file_content(file_path) or ""
1163
1187
  if old_content:
1164
1188
  return None
1165
1189
  return content, old_content, None, ""
@@ -1179,7 +1203,7 @@ def _deny_reason_for_issues(
1179
1203
  tool_name: The tool named in the PreToolUse payload.
1180
1204
  file_path: The destination path used for forecast classification.
1181
1205
  full_file_content_after_edit: The whole post-edit file content when the
1182
- edit reconstructs one, used to run the full-file forecast.
1206
+ edit reconstructs one, for the full-file forecast.
1183
1207
  prior_full_file_content: The whole file content before the edit applied.
1184
1208
  Empty when the edit's old_string is absent and no reliable prior
1185
1209
  exists; the forecast is skipped in that case so a comment diff
@@ -1195,7 +1219,7 @@ def _deny_reason_for_issues(
1195
1219
  )
1196
1220
  has_reconstructed_prior = bool(prior_full_file_content)
1197
1221
  if (
1198
- tool_name == "Edit"
1222
+ tool_name in ("Edit", "MultiEdit")
1199
1223
  and full_file_content_after_edit is not None
1200
1224
  and has_reconstructed_prior
1201
1225
  ):
@@ -1253,7 +1277,8 @@ def _report_blocking_violations(
1253
1277
  content: The fragment or whole-file body under validation.
1254
1278
  tool_name: The tool named in the PreToolUse payload.
1255
1279
  file_path: The destination path of the write or edit.
1256
- old_content: The fragment the edit replaces, or empty for a write.
1280
+ old_content: The fragment named by the edit's old_string, or empty for
1281
+ a write.
1257
1282
  full_file_content_after_edit: The reconstructed post-edit file body,
1258
1283
  or None when the payload is not an Edit.
1259
1284
  prior_full_file_content: The on-disk content before the edit.
@@ -1353,9 +1378,7 @@ def main(all_arguments: list[str]) -> None:
1353
1378
 
1354
1379
  validation_contents = _contents_for_validation(
1355
1380
  tool_name,
1356
- tool_input.get("new_string", ""),
1357
- tool_input.get("old_string", ""),
1358
- tool_input.get("content", ""),
1381
+ tool_input,
1359
1382
  file_path,
1360
1383
  )
1361
1384
  if validation_contents is None:
@@ -2,9 +2,21 @@
2
2
 
3
3
  import os
4
4
  import re
5
+ import sys
5
6
  from dataclasses import dataclass
7
+ from functools import cache
6
8
  from pathlib import Path
7
9
 
10
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
11
+ if _hooks_dir not in sys.path:
12
+ sys.path.insert(0, _hooks_dir)
13
+
14
+ from blocking.config.codex_apply_patch_constants import ( # noqa: E402
15
+ CODEX_ADD_OPERATION,
16
+ CODEX_DELETE_OPERATION,
17
+ CODEX_UPDATE_OPERATION,
18
+ )
19
+
8
20
  _codex_patch_begin_marker = "*** Begin Patch"
9
21
  _codex_patch_end_marker = "*** End Patch"
10
22
  _codex_update_marker = "*** Update File:"
@@ -14,9 +26,6 @@ _codex_hunk_marker = "@@"
14
26
  _codex_end_of_file_marker = "*** End of File"
15
27
  _codex_no_newline_marker = "\"
16
28
  _codex_minimum_patch_line_count = 2
17
- _codex_update_operation = "update"
18
- _codex_add_operation = "add"
19
- _codex_delete_operation = "delete"
20
29
 
21
30
 
22
31
  class CodexPatchError(ValueError):
@@ -70,6 +79,11 @@ def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
70
79
  raise CodexPatchError("patch requires a begin marker")
71
80
  if _codex_marker_text(all_lines[-1]) != _codex_patch_end_marker:
72
81
  raise CodexPatchError("patch requires an end marker")
82
+ all_marker_by_operation = {
83
+ CODEX_UPDATE_OPERATION: _codex_update_marker,
84
+ CODEX_ADD_OPERATION: _codex_add_marker,
85
+ CODEX_DELETE_OPERATION: _codex_delete_marker,
86
+ }
73
87
  all_sections: list[tuple[str, str, list[str]]] = []
74
88
  current_section: tuple[str, str, list[str]] | None = None
75
89
  for each_line in all_lines[1:-1]:
@@ -77,11 +91,7 @@ def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
77
91
  operation = next(
78
92
  (
79
93
  each_operation
80
- for each_operation, each_marker in (
81
- (_codex_update_operation, _codex_update_marker),
82
- (_codex_add_operation, _codex_add_marker),
83
- (_codex_delete_operation, _codex_delete_marker),
84
- )
94
+ for each_operation, each_marker in all_marker_by_operation.items()
85
95
  if marker_text.startswith(each_marker)
86
96
  ),
87
97
  None,
@@ -89,12 +99,7 @@ def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
89
99
  if operation is not None:
90
100
  if current_section is not None:
91
101
  all_sections.append(current_section)
92
- marker_by_operation = {
93
- _codex_update_operation: _codex_update_marker,
94
- _codex_add_operation: _codex_add_marker,
95
- _codex_delete_operation: _codex_delete_marker,
96
- }
97
- path_text = marker_text[len(marker_by_operation[operation]) :].strip()
102
+ path_text = marker_text[len(all_marker_by_operation[operation]) :].strip()
98
103
  if not path_text:
99
104
  raise CodexPatchError("patch operation requires a path")
100
105
  current_section = (operation, path_text, [])
@@ -190,15 +195,15 @@ def _codex_read_patch_file(
190
195
  try:
191
196
  prior_content = target_path.read_text(encoding="utf-8")
192
197
  except (FileNotFoundError, IsADirectoryError, OSError, UnicodeDecodeError, ValueError) as error:
193
- if operation == _codex_add_operation and isinstance(error, FileNotFoundError):
198
+ if operation == CODEX_ADD_OPERATION and isinstance(error, FileNotFoundError):
194
199
  prior_content = ""
195
200
  else:
196
201
  raise CodexPatchError("patch target requires readable UTF-8 content") from error
197
- if operation == _codex_add_operation:
202
+ if operation == CODEX_ADD_OPERATION:
198
203
  if target_path.exists():
199
204
  raise CodexPatchError("add target requires a new path")
200
205
  post_content = _codex_add_content(all_section_lines)
201
- elif operation == _codex_update_operation:
206
+ elif operation == CODEX_UPDATE_OPERATION:
202
207
  post_content = _codex_apply_update(prior_content, all_section_lines)
203
208
  else:
204
209
  if any(
@@ -210,15 +215,125 @@ def _codex_read_patch_file(
210
215
  return CodexPatchFile(str(target_path), prior_content, post_content, operation)
211
216
 
212
217
 
213
- def parse_codex_apply_patch(
214
- command: str, working_directory: str | None = None
215
- ) -> tuple[CodexPatchFile, ...]:
216
- """Return pre-edit and post-edit views for every Codex patch path."""
218
+ def payload_patch_command(hook_payload: dict, tool_input: dict) -> tuple[str, str | None]:
219
+ """Pull the patch text and its working directory out of an apply_patch payload.
220
+
221
+ ::
222
+
223
+ hook_payload = {"cwd": "/repo"}
224
+ tool_input = {"command": "*** Begin Patch ..."}
225
+ payload_patch_command(...) -> ("*** Begin Patch ...", "/repo")
226
+
227
+ A payload whose command or cwd is missing, or carries a non-string value,
228
+ yields an empty command and a None directory. An empty command tells the
229
+ caller there is no patch to parse.
230
+
231
+ Args:
232
+ hook_payload: The whole PreToolUse payload, carrying the cwd.
233
+ tool_input: The tool input mapping, carrying the command text.
234
+
235
+ Returns:
236
+ The command text and the working directory to resolve paths against.
237
+ """
238
+ raw_command = (tool_input or {}).get("command", "")
239
+ command = raw_command if isinstance(raw_command, str) else ""
240
+ raw_working_directory = (hook_payload or {}).get("cwd")
241
+ working_directory = raw_working_directory if isinstance(raw_working_directory, str) else None
242
+ return command, working_directory
243
+
244
+
245
+ def _codex_working_directory(command: str, working_directory: str | None) -> Path:
246
+ """Validate the patch command text and working directory, then resolve it.
247
+
248
+ Args:
249
+ command: The raw Codex apply_patch command text.
250
+ working_directory: The directory patch paths resolve against, or None
251
+ to use the current working directory.
252
+
253
+ Returns:
254
+ The resolved, existing working directory.
255
+ """
217
256
  if not isinstance(command, str) or not command.strip():
218
257
  raise CodexPatchError("patch command requires text")
219
258
  resolved_working_directory = Path(working_directory or os.getcwd()).expanduser().resolve()
220
259
  if not resolved_working_directory.is_dir():
221
260
  raise CodexPatchError("patch working directory requires an existing directory")
261
+ return resolved_working_directory
262
+
263
+
264
+ @cache
265
+ def codex_patch_operation_targets(
266
+ command: str, working_directory: str | None = None
267
+ ) -> tuple[tuple[str, str], ...]:
268
+ """Return each (operation, resolved path) pair a Codex patch command names.
269
+
270
+ Resolves every section's target path the same way ``parse_codex_apply_patch``
271
+ does, without reading any file's prior content, so a caller that only needs
272
+ each target path (a sensitive-path check, an already-exists check) is not
273
+ tripped up by a prior file it never needs to read.
274
+
275
+ Args:
276
+ command: The raw Codex apply_patch command text.
277
+ working_directory: The directory patch paths resolve against, or None
278
+ to use the current working directory.
279
+
280
+ Returns:
281
+ The ordered ``(operation, resolved_path)`` pairs the patch names.
282
+ """
283
+ resolved_working_directory = _codex_working_directory(command, working_directory)
284
+ return tuple(
285
+ (each_operation, _codex_resolve_patch_path(each_relative_path, resolved_working_directory))
286
+ for each_operation, each_relative_path, _each_section_lines in _codex_patch_sections(command)
287
+ )
288
+
289
+
290
+ def _target_paths_for_command(command: str, working_directory: str | None) -> tuple[str, ...]:
291
+ """Return each resolved target path one patch command names.
292
+
293
+ An empty command, and a command this parser rejects, both yield an empty
294
+ tuple, so the caller reads one result shape either way.
295
+
296
+ Args:
297
+ command: The raw Codex apply_patch command text, or an empty string.
298
+ working_directory: The directory patch paths resolve against, or None.
299
+
300
+ Returns:
301
+ The ordered resolved target paths the patch names.
302
+ """
303
+ if not command:
304
+ return ()
305
+ try:
306
+ all_operation_targets = codex_patch_operation_targets(command, working_directory)
307
+ except CodexPatchError:
308
+ return ()
309
+ return tuple(each_path for _each_operation, each_path in all_operation_targets)
310
+
311
+
312
+ def payload_patch_target_paths(hook_payload: dict, tool_input: dict) -> tuple[str, ...]:
313
+ """Return every resolved target path an apply_patch payload names.
314
+
315
+ The wrapper a hook wants when it needs target paths and nothing else. It
316
+ reads the command out of the payload, then resolves paths without reading
317
+ any target file's prior content.
318
+
319
+ Args:
320
+ hook_payload: The whole hook payload, carrying the cwd.
321
+ tool_input: The tool input mapping, carrying the command text.
322
+
323
+ Returns:
324
+ The ordered resolved target paths the patch names, empty when the
325
+ payload names no patch or the patch will not parse.
326
+ """
327
+ command, working_directory = payload_patch_command(hook_payload, tool_input)
328
+ return _target_paths_for_command(command, working_directory)
329
+
330
+
331
+ @cache
332
+ def parse_codex_apply_patch(
333
+ command: str, working_directory: str | None = None
334
+ ) -> tuple[CodexPatchFile, ...]:
335
+ """Return pre-edit and post-edit views for every Codex patch path."""
336
+ resolved_working_directory = _codex_working_directory(command, working_directory)
222
337
  all_patch_files: list[CodexPatchFile] = []
223
338
  seen_paths: set[str] = set()
224
339
  for each_operation, each_relative_path, each_section_lines in _codex_patch_sections(command):
@@ -0,0 +1,13 @@
1
+ """Shared operation-name constants for codex_apply_patch.py.
2
+
3
+ A Codex apply_patch command names three operations — add, update, and
4
+ delete — by a literal marker line. These three constants are the single
5
+ source of truth for those spellings, so every caller that branches on one
6
+ compares against one value rather than a second local copy that could drift.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ CODEX_ADD_OPERATION = "add"
12
+ CODEX_UPDATE_OPERATION = "update"
13
+ CODEX_DELETE_OPERATION = "delete"
@@ -0,0 +1,70 @@
1
+ """Add hooks/blocking and hooks/ to sys.path for every test collected under this directory."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ import pytest
10
+
11
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
12
+ _HOOKS_DIRECTORY = str(Path(_BLOCKING_DIRECTORY).parent)
13
+ for each_directory in (_BLOCKING_DIRECTORY, _HOOKS_DIRECTORY):
14
+ if each_directory not in sys.path:
15
+ sys.path.insert(0, each_directory)
16
+
17
+
18
+ @pytest.fixture
19
+ def three_operation_patch() -> str:
20
+ """Build a patch naming one update, one add, and one delete section."""
21
+ return (
22
+ "*** Begin Patch\n"
23
+ "*** Update File: updated.py\n"
24
+ "@@\n"
25
+ "-before\n"
26
+ "+after\n"
27
+ " keep\n"
28
+ "*** Add File: added.py\n"
29
+ "+new\n"
30
+ "*** Delete File: deleted.py\n"
31
+ "*** End of File\n"
32
+ "*** End Patch"
33
+ )
34
+
35
+
36
+ @pytest.fixture
37
+ def init_bare_git_repo() -> Callable[[Path], None]:
38
+ """Return a helper that initializes an empty git repository at a given path.
39
+
40
+ The PII scanner reads an apply_patch payload's ``cwd`` as a git
41
+ repository root, so a dispatcher-level apply_patch test needs a real
42
+ repository there rather than a bare temp directory.
43
+ """
44
+
45
+ def _init_bare_git_repo(repository_root: Path) -> None:
46
+ repository_root.mkdir(parents=True, exist_ok=True)
47
+ subprocess.run(["git", "init"], cwd=repository_root, check=True, capture_output=True)
48
+
49
+ return _init_bare_git_repo
50
+
51
+
52
+ @pytest.fixture
53
+ def synthetic_github_token() -> str:
54
+ """Return a fixture-only GitHub token shape, safe to embed in test payloads."""
55
+ return "ghp_" + ("C" * 36)
56
+
57
+
58
+ @pytest.fixture
59
+ def multi_edit_payload() -> Callable[[str, list[dict[str, str]]], str]:
60
+ """Return a helper that builds a MultiEdit tool payload JSON string."""
61
+
62
+ def _multi_edit_payload(file_path: str, all_edits: list[dict[str, str]]) -> str:
63
+ return json.dumps(
64
+ {
65
+ "tool_name": "MultiEdit",
66
+ "tool_input": {"file_path": file_path, "edits": all_edits},
67
+ }
68
+ )
69
+
70
+ return _multi_edit_payload
@@ -27,20 +27,28 @@ import json
27
27
  import sys
28
28
  from pathlib import Path
29
29
 
30
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
31
- if _hooks_dir not in sys.path:
32
- sys.path.insert(0, _hooks_dir)
33
-
34
- from hooks_constants.duplicate_rmtree_helper_blocker_constants import ( # noqa: E402
35
- ALL_EXEMPT_PATH_FRAGMENTS,
36
- ALL_EXEMPT_TEST_FILE_PREFIXES,
37
- ALL_EXEMPT_TEST_FILE_SUFFIXES,
38
- HELPER_DEFINITION_PATTERN,
39
- PYTHON_FILE_EXTENSION,
40
- TRIPLE_QUOTED_STRING_PATTERN,
41
- )
42
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
43
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
30
+ try:
31
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
32
+ if _hooks_dir not in sys.path:
33
+ sys.path.insert(0, _hooks_dir)
34
+
35
+ from hooks_constants.duplicate_rmtree_helper_blocker_constants import (
36
+ ALL_APPLICABLE_TOOL_NAMES,
37
+ ALL_EXEMPT_PATH_FRAGMENTS,
38
+ ALL_EXEMPT_TEST_FILE_PREFIXES,
39
+ ALL_EXEMPT_TEST_FILE_SUFFIXES,
40
+ HELPER_DEFINITION_PATTERN,
41
+ PYTHON_FILE_EXTENSION,
42
+ TRIPLE_QUOTED_STRING_PATTERN,
43
+ )
44
+ from hooks_constants.hook_block_logger import log_hook_block
45
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
46
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
47
+ except ImportError as import_error:
48
+ raise ImportError(
49
+ "duplicate_rmtree_helper_blocker: cannot import its sibling modules; "
50
+ "ensure the hooks directory is importable."
51
+ ) from import_error
44
52
 
45
53
 
46
54
  def payload_defines_sanctioned_helper(payload_text: str) -> bool:
@@ -87,7 +95,7 @@ def path_is_exempt(file_path: str) -> bool:
87
95
 
88
96
 
89
97
  def extract_payload_text(tool_name: str, tool_input: dict) -> tuple[str, str]:
90
- """Return the (file_path, scanned_text) pair for a Write/Edit to a Python file.
98
+ """Return the (file_path, scanned_text) pair for a Write/Edit/MultiEdit to a Python file.
91
99
 
92
100
  Args:
93
101
  tool_name: The PreToolUse tool name.
@@ -97,11 +105,13 @@ def extract_payload_text(tool_name: str, tool_input: dict) -> tuple[str, str]:
97
105
  A pair of the target path and the text to scan. The text is empty for an
98
106
  unrelated tool or a non-Python target, so the caller exits without blocking.
99
107
  """
100
- if tool_name not in {"Write", "Edit"}:
108
+ if tool_name not in ALL_APPLICABLE_TOOL_NAMES:
101
109
  return "", ""
102
110
  file_path = tool_input.get("file_path", "") or ""
103
111
  if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
104
112
  return file_path, ""
113
+ if tool_name == "MultiEdit":
114
+ return file_path, joined_new_strings(tool_input)
105
115
  scanned_text = tool_input.get("content", "") or tool_input.get("new_string", "") or ""
106
116
  return file_path, scanned_text
107
117