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.
- package/.agents/agents/pr-description-writer.md +1 -1
- 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 +12 -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_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- 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 +256 -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 +25 -47
- 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 +63 -16
- 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 +1 -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_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -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_same_file_inline_duplicate.py +244 -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 +24 -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_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 +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -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 +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- 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 +73 -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/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -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 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -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 +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -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 +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- 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/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- 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 +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- 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/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -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):
|
|
@@ -217,11 +247,11 @@ def _validated_phase(phase: str) -> str:
|
|
|
217
247
|
return phase
|
|
218
248
|
|
|
219
249
|
|
|
220
|
-
def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
|
|
250
|
+
def _codex_patch_issues(each_patch_file: CodexPatchFile, repository_root: str = "") -> list[str]:
|
|
221
251
|
"""Run the existing code-rules verdict over one Codex patch view."""
|
|
222
252
|
if not each_patch_file.post_content and each_patch_file.operation == "delete":
|
|
223
253
|
return []
|
|
224
|
-
if _is_hook_infrastructure_python_target(each_patch_file.file_path):
|
|
254
|
+
if _is_hook_infrastructure_python_target(each_patch_file.file_path, repository_root):
|
|
225
255
|
all_issues = _hook_infrastructure_blocking_issues(
|
|
226
256
|
each_patch_file.post_content,
|
|
227
257
|
each_patch_file.file_path,
|
|
@@ -229,7 +259,7 @@ def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
|
|
|
229
259
|
each_patch_file.prior_content,
|
|
230
260
|
phase=EDIT_LANE_PHASE,
|
|
231
261
|
)
|
|
232
|
-
elif _is_validated_target(each_patch_file.file_path):
|
|
262
|
+
elif _is_validated_target(each_patch_file.file_path, repository_root):
|
|
233
263
|
all_issues = validate_content_for_edit_lane(
|
|
234
264
|
each_patch_file.post_content,
|
|
235
265
|
each_patch_file.file_path,
|
|
@@ -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:
|
|
@@ -277,7 +306,7 @@ def _report_codex_patch_payload(
|
|
|
277
306
|
all_issues = [
|
|
278
307
|
each_issue
|
|
279
308
|
for each_patch_file in all_patch_files
|
|
280
|
-
for each_issue in _codex_patch_issues(each_patch_file)
|
|
309
|
+
for each_issue in _codex_patch_issues(each_patch_file, working_directory or "")
|
|
281
310
|
]
|
|
282
311
|
if all_issues:
|
|
283
312
|
_write_deny_payload(
|
|
@@ -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,24 +707,17 @@ 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
|
-
file_path: str, old_string: str, new_string: str,
|
|
711
|
+
file_path: str, old_string: str, new_string: str, is_replace_all: bool = False,
|
|
692
712
|
) -> tuple[str | None, str | None]:
|
|
693
713
|
"""Return the pre-edit and post-edit file content from a single disk read.
|
|
694
714
|
|
|
695
715
|
Reads ``file_path`` once and derives both views from that single read so the
|
|
696
716
|
prior and the reconstruction never diverge across two independent reads.
|
|
697
|
-
The post-edit view
|
|
698
|
-
|
|
717
|
+
The post-edit view comes from apply_edits, the same reconstruction the
|
|
718
|
+
MultiEdit lane uses, so one payload never reconstructs two ways. It replaces
|
|
719
|
+
the first occurrence of ``old_string`` with ``new_string``, or every occurrence
|
|
720
|
+
when the Edit carries ``replace_all``, mirroring how the Edit tool applies a
|
|
699
721
|
replacement.
|
|
700
722
|
|
|
701
723
|
Returns ``(None, None)`` when the file cannot be read, ``old_string`` is
|
|
@@ -709,6 +731,7 @@ def prior_and_post_edit_content(
|
|
|
709
731
|
file_path: The path of the file the Edit targets.
|
|
710
732
|
old_string: The Edit's ``old_string`` fragment.
|
|
711
733
|
new_string: The Edit's ``new_string`` fragment.
|
|
734
|
+
is_replace_all: Whether the Edit carries ``replace_all``.
|
|
712
735
|
|
|
713
736
|
Returns:
|
|
714
737
|
A ``(prior_content, post_edit_content)`` pair, or ``(None, None)`` when
|
|
@@ -716,19 +739,27 @@ def prior_and_post_edit_content(
|
|
|
716
739
|
"""
|
|
717
740
|
if not old_string:
|
|
718
741
|
return None, None
|
|
719
|
-
existing_content =
|
|
742
|
+
existing_content = existing_file_content(file_path)
|
|
720
743
|
if existing_content is None:
|
|
721
744
|
return None, None
|
|
722
745
|
if old_string not in existing_content:
|
|
723
746
|
return None, None
|
|
724
|
-
|
|
747
|
+
one_edit = {
|
|
748
|
+
"old_string": old_string,
|
|
749
|
+
"new_string": new_string,
|
|
750
|
+
"replace_all": is_replace_all,
|
|
751
|
+
}
|
|
752
|
+
return existing_content, apply_edits(existing_content, [one_edit])
|
|
725
753
|
|
|
726
754
|
|
|
727
|
-
def _is_validated_target(file_path: str) -> bool:
|
|
755
|
+
def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
|
|
728
756
|
"""Return whether the path is subject to code-rules validation.
|
|
729
757
|
|
|
730
758
|
Args:
|
|
731
759
|
file_path: The destination path of the write, edit, or pre-check target.
|
|
760
|
+
repository_root: The session's working directory (the PreToolUse
|
|
761
|
+
payload's ``cwd``), so a path inside it is never exempted as
|
|
762
|
+
scratch merely because the repository sits under ``/tmp``.
|
|
732
763
|
|
|
733
764
|
Returns:
|
|
734
765
|
True when the path is non-empty, outside hook infrastructure, and
|
|
@@ -736,14 +767,14 @@ def _is_validated_target(file_path: str) -> bool:
|
|
|
736
767
|
"""
|
|
737
768
|
if not file_path:
|
|
738
769
|
return False
|
|
739
|
-
if is_ephemeral_script_path(file_path):
|
|
770
|
+
if is_ephemeral_script_path(file_path, repository_root):
|
|
740
771
|
return False
|
|
741
772
|
if is_hook_infrastructure(file_path):
|
|
742
773
|
return False
|
|
743
774
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
744
775
|
|
|
745
776
|
|
|
746
|
-
def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
777
|
+
def _is_hook_infrastructure_python_target(file_path: str, repository_root: str = "") -> bool:
|
|
747
778
|
"""Return whether the path is a hook-infrastructure Python file.
|
|
748
779
|
|
|
749
780
|
The full code-rules suite exempts hook-infrastructure files, but the
|
|
@@ -753,13 +784,16 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
|
753
784
|
|
|
754
785
|
Args:
|
|
755
786
|
file_path: The destination path of the write, edit, or pre-check target.
|
|
787
|
+
repository_root: The session's working directory (the PreToolUse
|
|
788
|
+
payload's ``cwd``), so a path inside it is never exempted as
|
|
789
|
+
scratch merely because the repository sits under ``/tmp``.
|
|
756
790
|
|
|
757
791
|
Returns:
|
|
758
792
|
True when the path names a Python file inside hook infrastructure.
|
|
759
793
|
"""
|
|
760
794
|
if not file_path:
|
|
761
795
|
return False
|
|
762
|
-
if is_ephemeral_script_path(file_path):
|
|
796
|
+
if is_ephemeral_script_path(file_path, repository_root):
|
|
763
797
|
return False
|
|
764
798
|
if not is_hook_infrastructure(file_path):
|
|
765
799
|
return False
|
|
@@ -1048,13 +1082,13 @@ def _run_precheck(
|
|
|
1048
1082
|
runs_hook_duplicate_body = _is_hook_infrastructure_python_target(target_path)
|
|
1049
1083
|
if not runs_full_verdict and not runs_hook_duplicate_body:
|
|
1050
1084
|
return 0
|
|
1051
|
-
candidate_content =
|
|
1085
|
+
candidate_content = existing_file_content(candidate_path)
|
|
1052
1086
|
if candidate_content is None:
|
|
1053
1087
|
error_stream.write(f"error: cannot read candidate file: {candidate_path}\n")
|
|
1054
1088
|
return 1
|
|
1055
1089
|
candidate_content = candidate_content.lstrip(UTF8_BYTE_ORDER_MARK)
|
|
1056
1090
|
if runs_full_verdict:
|
|
1057
|
-
old_content =
|
|
1091
|
+
old_content = existing_file_content(target_path) or ""
|
|
1058
1092
|
all_issues = validate_content_for_full_gate(candidate_content, target_path, old_content)
|
|
1059
1093
|
else:
|
|
1060
1094
|
all_issues = _hook_infrastructure_blocking_issues(
|
|
@@ -1127,18 +1161,16 @@ def _run_precheck_command(
|
|
|
1127
1161
|
|
|
1128
1162
|
def _contents_for_validation(
|
|
1129
1163
|
tool_name: str,
|
|
1130
|
-
|
|
1131
|
-
old_string: str,
|
|
1132
|
-
written_content: str,
|
|
1164
|
+
all_tool_input: dict[str, object],
|
|
1133
1165
|
file_path: str,
|
|
1134
1166
|
) -> tuple[str, str, str | None, str] | None:
|
|
1135
1167
|
"""Resolve the content views the verdict needs for the given tool payload.
|
|
1136
1168
|
|
|
1137
1169
|
Args:
|
|
1138
1170
|
tool_name: The tool named in the PreToolUse payload.
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1171
|
+
all_tool_input: The PreToolUse payload's tool_input mapping, carrying
|
|
1172
|
+
``new_string``/``old_string`` for Edit, ``content`` for Write, and
|
|
1173
|
+
the ``edits`` list for MultiEdit.
|
|
1142
1174
|
file_path: The destination path of the write or edit.
|
|
1143
1175
|
|
|
1144
1176
|
Returns:
|
|
@@ -1146,17 +1178,27 @@ def _contents_for_validation(
|
|
|
1146
1178
|
prior_full_file_content)`` tuple, or None when no validatable view
|
|
1147
1179
|
exists — an unreadable edit target, or a write over an existing file.
|
|
1148
1180
|
"""
|
|
1181
|
+
new_string = str(all_tool_input.get("new_string", "") or "")
|
|
1182
|
+
old_string = str(all_tool_input.get("old_string", "") or "")
|
|
1183
|
+
written_content = str(all_tool_input.get("content", "") or "")
|
|
1149
1184
|
if tool_name == "Edit":
|
|
1150
1185
|
prior_content, full_file_content_after_edit = prior_and_post_edit_content(
|
|
1151
1186
|
file_path, old_string, new_string,
|
|
1187
|
+
is_replace_all=all_tool_input.get("replace_all") is True,
|
|
1152
1188
|
)
|
|
1153
1189
|
if full_file_content_after_edit is None:
|
|
1154
|
-
full_file_content_after_edit =
|
|
1190
|
+
full_file_content_after_edit = existing_file_content(file_path)
|
|
1155
1191
|
if full_file_content_after_edit is None:
|
|
1156
1192
|
return None
|
|
1157
1193
|
return new_string, old_string, full_file_content_after_edit, prior_content or ""
|
|
1194
|
+
if tool_name == "MultiEdit":
|
|
1195
|
+
multiedit_view = _multiedit_post_edit_view(file_path, all_tool_input or {})
|
|
1196
|
+
if multiedit_view is None:
|
|
1197
|
+
return None
|
|
1198
|
+
prior_content, post_edit_content = multiedit_view
|
|
1199
|
+
return post_edit_content, prior_content, post_edit_content, prior_content
|
|
1158
1200
|
content = written_content or new_string
|
|
1159
|
-
old_content =
|
|
1201
|
+
old_content = existing_file_content(file_path) or ""
|
|
1160
1202
|
if old_content:
|
|
1161
1203
|
return None
|
|
1162
1204
|
return content, old_content, None, ""
|
|
@@ -1176,7 +1218,7 @@ def _deny_reason_for_issues(
|
|
|
1176
1218
|
tool_name: The tool named in the PreToolUse payload.
|
|
1177
1219
|
file_path: The destination path used for forecast classification.
|
|
1178
1220
|
full_file_content_after_edit: The whole post-edit file content when the
|
|
1179
|
-
edit reconstructs one,
|
|
1221
|
+
edit reconstructs one, for the full-file forecast.
|
|
1180
1222
|
prior_full_file_content: The whole file content before the edit applied.
|
|
1181
1223
|
Empty when the edit's old_string is absent and no reliable prior
|
|
1182
1224
|
exists; the forecast is skipped in that case so a comment diff
|
|
@@ -1192,7 +1234,7 @@ def _deny_reason_for_issues(
|
|
|
1192
1234
|
)
|
|
1193
1235
|
has_reconstructed_prior = bool(prior_full_file_content)
|
|
1194
1236
|
if (
|
|
1195
|
-
tool_name
|
|
1237
|
+
tool_name in ("Edit", "MultiEdit")
|
|
1196
1238
|
and full_file_content_after_edit is not None
|
|
1197
1239
|
and has_reconstructed_prior
|
|
1198
1240
|
):
|
|
@@ -1250,7 +1292,8 @@ def _report_blocking_violations(
|
|
|
1250
1292
|
content: The fragment or whole-file body under validation.
|
|
1251
1293
|
tool_name: The tool named in the PreToolUse payload.
|
|
1252
1294
|
file_path: The destination path of the write or edit.
|
|
1253
|
-
old_content: The fragment the edit
|
|
1295
|
+
old_content: The fragment named by the edit's old_string, or empty for
|
|
1296
|
+
a write.
|
|
1254
1297
|
full_file_content_after_edit: The reconstructed post-edit file body,
|
|
1255
1298
|
or None when the payload is not an Edit.
|
|
1256
1299
|
prior_full_file_content: The on-disk content before the edit.
|
|
@@ -1344,15 +1387,16 @@ def main(all_arguments: list[str]) -> None:
|
|
|
1344
1387
|
if is_under_session_scratchpad(file_path, pretooluse_payload):
|
|
1345
1388
|
sys.exit(0)
|
|
1346
1389
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1390
|
+
repository_root = str(pretooluse_payload.get("cwd") or "")
|
|
1391
|
+
runs_full_verdict = _is_validated_target(file_path, repository_root)
|
|
1392
|
+
if not runs_full_verdict and not _is_hook_infrastructure_python_target(
|
|
1393
|
+
file_path, repository_root
|
|
1394
|
+
):
|
|
1349
1395
|
sys.exit(0)
|
|
1350
1396
|
|
|
1351
1397
|
validation_contents = _contents_for_validation(
|
|
1352
1398
|
tool_name,
|
|
1353
|
-
tool_input
|
|
1354
|
-
tool_input.get("old_string", ""),
|
|
1355
|
-
tool_input.get("content", ""),
|
|
1399
|
+
tool_input,
|
|
1356
1400
|
file_path,
|
|
1357
1401
|
)
|
|
1358
1402
|
if validation_contents is None:
|
|
@@ -251,10 +251,50 @@ def _extract_fstring_literal_parts(
|
|
|
251
251
|
return "".join(display_segments), "".join(shape_segments)
|
|
252
252
|
|
|
253
253
|
|
|
254
|
-
def
|
|
254
|
+
def _normalized_absolute_comparison_path(raw_path: str) -> str:
|
|
255
|
+
"""Return raw_path as an absolute, lowercase, forward-slash path for root comparison.
|
|
256
|
+
|
|
257
|
+
Strips a leading Windows drive letter after normalizing, so a POSIX root
|
|
258
|
+
check and a Windows root check compare on the same shape.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
raw_path: The path to normalize.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The absolute, lowercase, forward-slash path with any leading drive
|
|
265
|
+
letter removed.
|
|
266
|
+
"""
|
|
267
|
+
return LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(raw_path).replace("\\", "/").lower())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _is_at_or_under_root(normalized_candidate: str, normalized_root: str) -> bool:
|
|
271
|
+
"""Return whether normalized_candidate sits at or under normalized_root.
|
|
272
|
+
|
|
273
|
+
Requires a path-segment boundary (an exact match or a ``/`` before the
|
|
274
|
+
remainder), so ``/tmp/repo-sibling`` never counts as under ``/tmp/repo``.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
normalized_candidate: A path already run through
|
|
278
|
+
``_normalized_absolute_comparison_path``.
|
|
279
|
+
normalized_root: A root path already run through
|
|
280
|
+
``_normalized_absolute_comparison_path``.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True when normalized_candidate equals normalized_root or names a
|
|
284
|
+
descendant of it.
|
|
285
|
+
"""
|
|
286
|
+
return normalized_candidate == normalized_root or normalized_candidate.startswith(normalized_root + "/")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def is_ephemeral_script_path(file_path: str, repository_root: str = "") -> bool:
|
|
255
290
|
"""Return True when the path is rooted at a throwaway scratch directory.
|
|
256
291
|
|
|
257
292
|
Checks these sources in order:
|
|
293
|
+
- ``repository_root`` — when given, a path inside it is never ephemeral,
|
|
294
|
+
whatever directory the repository itself is checked out under. This
|
|
295
|
+
keeps a repository placed under ``/tmp`` fully gated: the exemption
|
|
296
|
+
below is for scratch outside every repository, not for the repository's
|
|
297
|
+
own files.
|
|
258
298
|
- ``$CLAUDE_JOB_DIR/tmp`` — only when ``CLAUDE_JOB_DIR`` is set.
|
|
259
299
|
- Root-anchored ``/tmp`` and ``/temp`` (drive-letter tolerant).
|
|
260
300
|
|
|
@@ -262,20 +302,28 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
262
302
|
its sandbox fixtures there, so matching it would exempt the suite's own
|
|
263
303
|
targets. Returns False when ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT``
|
|
264
304
|
is truthy, when ``file_path`` is empty, and when no root matches. Path
|
|
265
|
-
classification is string-only;
|
|
305
|
+
classification is string-only; neither path need exist.
|
|
266
306
|
|
|
267
307
|
Args:
|
|
268
308
|
file_path: The candidate path to classify.
|
|
309
|
+
repository_root: The session's working directory — typically the
|
|
310
|
+
PreToolUse payload's ``cwd`` — or empty when no such directory is
|
|
311
|
+
known. A path outside this directory is unaffected by it.
|
|
269
312
|
|
|
270
313
|
Returns:
|
|
271
|
-
True when the path is rooted at a recognized ephemeral scratch
|
|
314
|
+
True when the path is rooted at a recognized ephemeral scratch
|
|
315
|
+
directory and does not sit inside ``repository_root``.
|
|
272
316
|
"""
|
|
273
317
|
if not file_path:
|
|
274
318
|
return False
|
|
275
319
|
disable_value = os.environ.get(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "").strip().lower()
|
|
276
320
|
if disable_value in ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES:
|
|
277
321
|
return False
|
|
278
|
-
normalized =
|
|
322
|
+
normalized = _normalized_absolute_comparison_path(file_path)
|
|
323
|
+
if repository_root and _is_at_or_under_root(
|
|
324
|
+
normalized, _normalized_absolute_comparison_path(repository_root)
|
|
325
|
+
):
|
|
326
|
+
return False
|
|
279
327
|
all_temp_roots: list[str] = []
|
|
280
328
|
job_dir = os.environ.get(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME)
|
|
281
329
|
if job_dir:
|
|
@@ -286,7 +334,7 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
286
334
|
for each_root in ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES:
|
|
287
335
|
all_temp_roots.append(each_root)
|
|
288
336
|
for each_temp_root in all_temp_roots:
|
|
289
|
-
if normalized
|
|
337
|
+
if _is_at_or_under_root(normalized, each_temp_root):
|
|
290
338
|
return True
|
|
291
339
|
return False
|
|
292
340
|
|
|
@@ -426,23 +474,25 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
426
474
|
Combines the path families a repo gate skips: the root-anchored ephemeral
|
|
427
475
|
scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``), the harness
|
|
428
476
|
session scratchpad, and a coding agent's own home-directory tooling such as
|
|
429
|
-
``~/.grok/runs/``.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
477
|
+
``~/.grok/runs/``. A path inside the payload's own working directory
|
|
478
|
+
(``cwd``) is never treated as scratch, so a repository checked out under
|
|
479
|
+
``/tmp`` still receives full enforcement. The scratchpad match reads the
|
|
480
|
+
session id from the payload when supplied, else the harness environment
|
|
481
|
+
variable. The code-rules and TDD gates call the underlying predicates
|
|
482
|
+
``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
|
|
435
483
|
``is_under_session_scratchpad`` directly.
|
|
436
484
|
|
|
437
485
|
Args:
|
|
438
486
|
file_path: The candidate path to classify.
|
|
439
|
-
hook_payload: The PreToolUse payload carrying the session id
|
|
440
|
-
read the session id from
|
|
487
|
+
hook_payload: The PreToolUse payload carrying the session id and the
|
|
488
|
+
working directory (``cwd``), or None to read the session id from
|
|
489
|
+
the environment alone.
|
|
441
490
|
|
|
442
491
|
Returns:
|
|
443
492
|
True when the path is scratch, agent tooling, or the session scratchpad.
|
|
444
493
|
"""
|
|
445
|
-
|
|
494
|
+
repository_root = str((hook_payload or {}).get("cwd") or "")
|
|
495
|
+
if is_ephemeral_script_path(file_path, repository_root):
|
|
446
496
|
return True
|
|
447
497
|
if is_agent_home_tooling(file_path):
|
|
448
498
|
return True
|
|
@@ -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):
|