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.
- package/.agents/agents/pr-description-writer.md +1 -1
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +59 -36
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +62 -8
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- 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) ->
|
|
153
|
-
"""
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
if
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
803
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ==
|
|
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 ==
|
|
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 ==
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
sys.path
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|