claude-dev-env 2.31.0 → 2.32.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 (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. package/scripts/test_shared_tree_paths.py +173 -0
@@ -9,29 +9,51 @@ import argparse
9
9
  import ast
10
10
  import json
11
11
  import os
12
+ import re
12
13
  import subprocess
13
14
  import sys
14
15
  import tempfile
15
16
  import time
16
17
  from collections import Counter
17
18
  from dataclasses import dataclass
19
+ from functools import lru_cache
18
20
  from datetime import datetime
19
21
  from pathlib import Path
20
22
  from typing import Callable, Dict, List, Optional, Tuple
21
23
 
22
24
  from ._path_setup import hooks_directory_on_path # noqa: F401
25
+ from .config.baseline_identity_constants import (
26
+ QUALIFIED_SCOPE_NAME_SEPARATOR,
27
+ SCOPE_NAME_PLACEHOLDER,
28
+ SHAPE_NUMBER_PLACEHOLDER,
29
+ STANDALONE_NUMBER_PATTERN,
30
+ WORD_BOUNDARY_PATTERN,
31
+ )
23
32
  from .config.directory_exemption_constants import (
24
33
  ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES,
25
34
  ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS,
35
+ is_pytest_scratch_directory_segment,
26
36
  )
27
- from .fast_save_validators import FastSaveCheckOutcome, run_fast_save_validators
37
+ from .fast_save_validators import run_fast_save_validators
28
38
  from .health_check import get_system_health, get_validator_version, print_health_report
29
39
  from .mypy_integration import check_mypy_available, run_mypy_check
30
- from .system_temporary_roots import enclosing_system_temporary_root
31
40
  from .output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
41
+ from .project_roots import enclosing_project_root
32
42
  from .python_style_checks import fix_file
33
- from .ruff_integration import check_ruff_available, run_ruff_check
43
+ from .ruff_integration import run_ruff_check
44
+ from .config.validator_base_constants import SOURCE_CACHE_ENTRIES
45
+ from .validator_base import ValidatorResult
34
46
  from blocking.code_rules_shared import is_ephemeral_path
47
+ from blocking.codex_apply_patch import (
48
+ CODEX_DELETE_OPERATION,
49
+ CodexPatchError,
50
+ CodexPatchFile,
51
+ parse_codex_apply_patch,
52
+ payload_patch_command,
53
+ )
54
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
55
+ APPLY_PATCH_TOOL_NAME,
56
+ )
35
57
  from hooks_constants.hook_block_logger import log_hook_block
36
58
  from hooks_constants.multi_edit_reconstruction import (
37
59
  apply_edits,
@@ -43,35 +65,6 @@ hooks_dir = VALIDATORS_DIR.parent
43
65
  package_name = VALIDATORS_DIR.name
44
66
 
45
67
 
46
- def _gate_entry_for_fast_save_check(
47
- check_outcome: FastSaveCheckOutcome,
48
- ) -> "ValidatorResult":
49
- """Wrap one in-process check's finding into the gate's shared reporting type."""
50
- return ValidatorResult(
51
- name=check_outcome.display_name,
52
- checks=check_outcome.checks,
53
- passed=check_outcome.is_clean,
54
- output=check_outcome.violation_report,
55
- )
56
-
57
-
58
- def _run_fast_save_checks(files: List[Path]) -> List["ValidatorResult"]:
59
- """Run the save-path roster in-process and wrap each finding for the gate.
60
-
61
- Args:
62
- files: The files under validation -- one reconstructed file in gate mode.
63
-
64
- Returns:
65
- One ValidatorResult per in-process save-path check. Ruff and Mypy are
66
- not part of this roster; the caller adds Ruff separately and never
67
- adds Mypy on the save path.
68
- """
69
- return [
70
- _gate_entry_for_fast_save_check(each_outcome)
71
- for each_outcome in run_fast_save_validators(files)
72
- ]
73
-
74
-
75
68
  def _windows_non_unc_working_directory_string(
76
69
  candidate_directory_strings: list[str | None],
77
70
  ) -> str:
@@ -260,17 +253,6 @@ def build_json_output(
260
253
  }
261
254
 
262
255
 
263
- @dataclass(frozen=True)
264
- class ValidatorResult:
265
- """Result from running a validator."""
266
-
267
- name: str
268
- checks: str
269
- passed: bool
270
- output: str
271
- skipped: bool = False
272
-
273
-
274
256
  def run_with_fallback(
275
257
  validator_func: Callable[[], ValidatorResult],
276
258
  fallback_name: str,
@@ -446,15 +428,11 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
446
428
  def run_ruff_checks(
447
429
  files: List[Path], config_source_path: Optional[Path] = None
448
430
  ) -> ValidatorResult:
449
- """Run ruff for fast Python linting."""
450
- if not check_ruff_available():
451
- return ValidatorResult(
452
- name="Ruff",
453
- checks="37",
454
- passed=True,
455
- output="Ruff not installed - skipping",
456
- )
431
+ """Run ruff for fast Python linting.
457
432
 
433
+ run_ruff_check makes the same availability probe and returns the same
434
+ skip message, so probing here would spawn ruff --version twice.
435
+ """
458
436
  result = run_ruff_check(files, config_source_path)
459
437
 
460
438
  return ValidatorResult(
@@ -786,69 +764,77 @@ def _escapes_temporary_root(path_part: str) -> bool:
786
764
  return part_as_path.is_absolute() or bool(part_as_path.anchor)
787
765
 
788
766
 
789
- def _is_absolute_path_under_system_temporary_directory(file_path: str) -> bool:
790
- """Return True when *file_path* is absolute and resolves under an OS temp root.
767
+ def _directory_segment_signals_exemption(segment_lower: str) -> bool:
768
+ """Return True when a directory segment carries a path-exemption signal.
791
769
 
792
770
  ::
793
771
 
794
- ok: "<windows-temp-root>/pytest-of-x/test_foo0/a.py" -> True
795
- ok: "/home/runner/work/_temp/pytest-basetemp/.../a.py" when RUNNER_TEMP
796
- flag: "packages/demo/test_helpers/worker.py" -> False (relative)
797
- flag: "C:/repo/pkg/test_helpers/worker.py" -> False (project)
772
+ ok: "test_helpers" -> True (contains the ``test_`` fragment)
773
+ ok: "scripts" -> True (exact CLI-marker segment name)
774
+ flag: "latest" -> False (neither exact name nor a fragment)
775
+ flag: "test_edit_foo0" -> False (pytest generated it, not the project)
798
776
 
799
- Absolute paths under system temporary directories often carry pytest
800
- ``tmp_path`` segments named ``test_*``. Those segments must not be treated
801
- as project exemption signals, or a production file staged from a pytest
802
- path would inherit a false test-file exemption.
777
+ A segment is kept during staging when its name exactly matches an exemption
778
+ directory name, or when a separator-free substring pattern that
779
+ ``is_test_file`` matches anywhere in a path appears inside it — that is what
780
+ preserves ``pkg/test_helpers/``. A directory pytest generated for its own
781
+ scratch tree is excluded from the substring pass, so a ``test_*`` scratch
782
+ parent never lends a staged production file a false test exemption.
803
783
 
804
784
  Args:
805
- file_path: Destination path the write or edit targets.
785
+ segment_lower: One lowercased directory component of the target path.
806
786
 
807
787
  Returns:
808
- True when *file_path* is absolute and lives under ``tempfile.gettempdir()``
809
- or a known temporary-root environment variable (``TEMP``, ``TMP``,
810
- ``TMPDIR``, ``RUNNER_TEMP``).
788
+ True when staging must keep this segment to reproduce the exemption.
811
789
  """
812
- destination_path = Path(file_path)
813
- if not destination_path.is_absolute():
790
+ if segment_lower in ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES:
791
+ return True
792
+ if is_pytest_scratch_directory_segment(segment_lower):
814
793
  return False
815
- return enclosing_system_temporary_root(destination_path) is not None
794
+ return any(
795
+ each_pattern in segment_lower
796
+ for each_pattern in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
797
+ )
816
798
 
817
799
 
818
- def _directory_segment_signals_exemption(
819
- segment_lower: str, *, is_substring_match_enabled: bool
820
- ) -> bool:
821
- """Return True when a directory segment carries a path-exemption signal.
800
+ def _path_parts_the_project_owns(destination_path: Path) -> tuple[str, ...]:
801
+ """Return the destination's path parts from its own project root downward.
822
802
 
823
803
  ::
824
804
 
825
- ok: "test_helpers" (substring on) -> True (contains the ``test_`` fragment)
826
- ok: "scripts" -> True (exact CLI-marker segment name)
827
- flag: "latest" -> False (neither exact name nor a fragment)
828
- flag: "test_edit_foo0" (substring off) -> False (pytest-shaped; exact-only mode)
805
+ ok: "/srv/tests/proj/src/worker.py" (proj is the root) -> ("src", "worker.py")
806
+ ok: "/srv/anna/.claude/hooks/gate.py" (no root) -> (".claude", "hooks", "gate.py")
807
+ ok: "config/x.py" (relative) -> ("config", "x.py")
829
808
 
830
- A segment is kept during staging when its name exactly matches an exemption
831
- directory name. When *is_substring_match_enabled* is True, a separator-free
832
- substring pattern that ``is_test_file`` matches anywhere in a path also
833
- counts — that is what preserves ``pkg/test_helpers/``. Substring matching is
834
- disabled for absolute paths under the system temporary directory so pytest
835
- ``tmp_path`` parents named ``test_*`` do not falsely trip test exemptions.
809
+ Directory names above the project root were chosen by whoever laid out the
810
+ disk, not by the project, so a home directory named ``tests`` or a checkout
811
+ parked under ``config`` must lend the staged path no exemption signal.
812
+ Dropping those segments is strictly narrowing: it can remove a signal, never
813
+ invent one.
814
+
815
+ A relative destination is already project-relative and is returned whole,
816
+ which keeps the result independent of the process working directory. A
817
+ destination with no project root above it -- an install target such as
818
+ ``~/.claude/hooks/`` -- keeps every segment, because those segments are the
819
+ only exemption signal such a target carries.
836
820
 
837
821
  Args:
838
- segment_lower: One lowercased directory component of the target path.
839
- is_substring_match_enabled: When False, only exact exemption names count.
822
+ destination_path: Real destination path the write or edit targets.
840
823
 
841
824
  Returns:
842
- True when staging must keep this segment to reproduce the exemption.
825
+ The path parts staging walks for exemption signals, root anchor removed.
843
826
  """
844
- if segment_lower in ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES:
845
- return True
846
- if not is_substring_match_enabled:
847
- return False
848
- return any(
849
- each_pattern in segment_lower
850
- for each_pattern in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
851
- )
827
+ all_path_parts = destination_path.parts
828
+ if not destination_path.anchor:
829
+ return all_path_parts
830
+ all_path_parts = all_path_parts[1:]
831
+ project_root = enclosing_project_root(destination_path)
832
+ if project_root is None:
833
+ return all_path_parts
834
+ resolved_destination = destination_path.resolve()
835
+ if not resolved_destination.is_relative_to(project_root):
836
+ return all_path_parts
837
+ return resolved_destination.relative_to(project_root).parts
852
838
 
853
839
 
854
840
  def _temporary_path_preserving_directory_signal(
@@ -860,11 +846,12 @@ def _temporary_path_preserving_directory_signal(
860
846
  path fragments (``test_`` inside ``test_helpers``) match substrings of the
861
847
  real file path. Staging under a flat temp basename drops those segments.
862
848
  Mirroring the first exemption-signaling directory through the basename
863
- restores that signal. Substring fragment matching is skipped for absolute
864
- paths under the system temporary directory so pytest ``tmp_path`` parents
865
- that contain ``test_`` do not falsely trip test-file exemptions; exact
866
- exemption directory names (``config``, ``scripts``, ``tests``, ...) still
867
- anchor staging on those absolute temp paths.
849
+ restores that signal. A directory pytest generated for its own scratch tree
850
+ is recognized by name and skipped, so a ``tmp_path`` parent containing
851
+ ``test_`` never trips a test-file exemption. The staged path reads the same
852
+ wherever ``--basetemp`` places that scratch tree. The walk starts at the
853
+ destination's own project root, so a directory above the project lends it
854
+ no signal.
868
855
 
869
856
  Args:
870
857
  temporary_directory: Root of the ephemeral staging tree.
@@ -875,21 +862,14 @@ def _temporary_path_preserving_directory_signal(
875
862
  (when present) and the real basename.
876
863
  """
877
864
  destination_path = Path(file_path)
878
- path_parts = destination_path.parts
879
- if destination_path.anchor:
880
- path_parts = path_parts[1:]
865
+ path_parts = _path_parts_the_project_owns(destination_path)
881
866
  if not path_parts:
882
867
  path_parts = (destination_path.name,)
883
868
 
884
- is_substring_match_enabled = not _is_absolute_path_under_system_temporary_directory(
885
- file_path
886
- )
887
869
  start_index = len(path_parts) - 1
888
870
  for each_index, each_part in enumerate(path_parts[:-1]):
889
871
  part_lower = each_part.lower()
890
- if not _directory_segment_signals_exemption(
891
- part_lower, is_substring_match_enabled=is_substring_match_enabled
892
- ):
872
+ if not _directory_segment_signals_exemption(part_lower):
893
873
  continue
894
874
  start_index = each_index
895
875
  break
@@ -943,7 +923,7 @@ def validate_proposed_file(
943
923
  else Path(file_path)
944
924
  )
945
925
  return [
946
- *_run_fast_save_checks([temporary_file]),
926
+ *run_fast_save_validators([temporary_file]),
947
927
  run_ruff_checks([temporary_file], resolved_config_source_path),
948
928
  ]
949
929
 
@@ -1187,6 +1167,48 @@ def _line_identity(
1187
1167
  )
1188
1168
 
1189
1169
 
1170
+ @lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
1171
+ def _scope_agnostic_message(identity_scope: str, violation_message: str) -> str:
1172
+ """Return the message with its own function's name and shape metrics masked.
1173
+
1174
+ ::
1175
+
1176
+ "over_long_fn", "Function 'over_long_fn' is 37 lines (max 30)"
1177
+ -> "Function '<name>' is <number> lines (max <number>)"
1178
+ "compute_total", "Magic number 199 - use named constant"
1179
+ -> "Magic number 199 - use named constant"
1180
+
1181
+ A message that names its enclosing function reports that function's shape,
1182
+ so the numbers in it belong to the same shape and are masked alongside the
1183
+ name. A message that never names the function keeps its numbers, so a magic
1184
+ value and a ruff rule code still tell two violations apart.
1185
+
1186
+ Args:
1187
+ identity_scope: The enclosing function's qualified name, or the empty
1188
+ string for a module-scope violation.
1189
+ violation_message: The message text of one violation line.
1190
+
1191
+ Returns:
1192
+ The message with the enclosing function's name replaced by a fixed
1193
+ placeholder, and its metric numbers replaced too when that name was
1194
+ present; the unchanged message otherwise.
1195
+ """
1196
+ short_scope_name = identity_scope.rpartition(QUALIFIED_SCOPE_NAME_SEPARATOR)[2]
1197
+ if not short_scope_name:
1198
+ return violation_message
1199
+ scope_name_pattern = (
1200
+ WORD_BOUNDARY_PATTERN + re.escape(short_scope_name) + WORD_BOUNDARY_PATTERN
1201
+ )
1202
+ if not re.search(scope_name_pattern, violation_message):
1203
+ return violation_message
1204
+ masked_message = re.sub(
1205
+ scope_name_pattern, SCOPE_NAME_PLACEHOLDER, violation_message
1206
+ )
1207
+ return re.sub(
1208
+ STANDALONE_NUMBER_PATTERN, SHAPE_NUMBER_PLACEHOLDER, masked_message
1209
+ )
1210
+
1211
+
1190
1212
  def _violation_identities(
1191
1213
  failed_results: List[ValidatorResult], content: str
1192
1214
  ) -> Counter[ViolationIdentity]:
@@ -1195,7 +1217,8 @@ def _violation_identities(
1195
1217
  Keying on the enclosing function rather than the raw line number keeps a
1196
1218
  key stable when an edit shifts that function, and counting rather than set
1197
1219
  membership keeps a second violation of the same validator in the same
1198
- function visible as new.
1220
+ function visible as new. A rename moves that key rather than shifting it,
1221
+ which is why matching falls back to the shape key.
1199
1222
 
1200
1223
  Args:
1201
1224
  failed_results: The validator results that fired.
@@ -1228,22 +1251,40 @@ def _baseline_violation_identities(file_path: str) -> Counter[ViolationIdentity]
1228
1251
  return _violation_identities(baseline_failed, baseline_content)
1229
1252
 
1230
1253
 
1231
- def _identity_key_counts(
1232
- baseline_identities: Counter[ViolationIdentity],
1233
- ) -> Counter[tuple[str, str]]:
1234
- """Sum baseline counts down to ``(validator, enclosing function)`` keys.
1254
+ def _compatible_baseline_identity(
1255
+ line_identity: ViolationIdentity, remaining_exact: Counter[ViolationIdentity]
1256
+ ) -> Optional[ViolationIdentity]:
1257
+ """Return an unconsumed baseline identity *line_identity* may be charged to.
1258
+
1259
+ A same-validator baseline entry in the same enclosing function absorbs a
1260
+ message that drifted with the edit. A same-validator entry of the same
1261
+ shape absorbs a message whose enclosing function was renamed, split, or
1262
+ moved. The same-function entry is preferred, so a drifted message inside an
1263
+ untouched function never spends a renamed function's allowance.
1235
1264
 
1236
1265
  Args:
1237
- baseline_identities: The baseline identity multiset.
1266
+ line_identity: The proposed line's identity key.
1267
+ remaining_exact: The unconsumed baseline identity budget.
1238
1268
 
1239
1269
  Returns:
1240
- The per-key line counts with messages ignored, so a violation whose
1241
- message drifted with the edit still finds its baseline budget.
1270
+ The baseline identity to charge, or None when the budget is spent.
1242
1271
  """
1243
- key_counts: Counter[tuple[str, str]] = Counter()
1244
- for each_identity, each_count in baseline_identities.items():
1245
- key_counts[each_identity[:2]] += each_count
1246
- return key_counts
1272
+ all_available_identities = [
1273
+ each_identity
1274
+ for each_identity, each_count in remaining_exact.items()
1275
+ if each_count > 0 and each_identity[0] == line_identity[0]
1276
+ ]
1277
+ for each_identity in all_available_identities:
1278
+ if each_identity[1] == line_identity[1]:
1279
+ return each_identity
1280
+ line_shape_message = _scope_agnostic_message(line_identity[1], line_identity[2])
1281
+ for each_identity in all_available_identities:
1282
+ if (
1283
+ _scope_agnostic_message(each_identity[1], each_identity[2])
1284
+ == line_shape_message
1285
+ ):
1286
+ return each_identity
1287
+ return None
1247
1288
 
1248
1289
 
1249
1290
  def _result_with_output(
@@ -1262,7 +1303,6 @@ def _result_with_output(
1262
1303
  def _consume_exact_matches(
1263
1304
  all_line_identities: List[ViolationIdentity],
1264
1305
  remaining_exact: Counter[ViolationIdentity],
1265
- remaining_by_key: Counter[tuple[str, str]],
1266
1306
  ) -> set[int]:
1267
1307
  """Mark the lines whose full identity matches an unconsumed baseline entry.
1268
1308
 
@@ -1270,7 +1310,6 @@ def _consume_exact_matches(
1270
1310
  all_line_identities: One identity per located line, in output order.
1271
1311
  remaining_exact: The unconsumed baseline identity budget, decremented
1272
1312
  in place per match.
1273
- remaining_by_key: The unconsumed per-key budget, decremented in step.
1274
1313
 
1275
1314
  Returns:
1276
1315
  The indexes of the exactly-matched lines.
@@ -1280,7 +1319,6 @@ def _consume_exact_matches(
1280
1319
  if remaining_exact[each_identity] <= 0:
1281
1320
  continue
1282
1321
  remaining_exact[each_identity] -= 1
1283
- remaining_by_key[each_identity[:2]] -= 1
1284
1322
  all_matched_line_indexes.add(each_line_index)
1285
1323
  return all_matched_line_indexes
1286
1324
 
@@ -1289,42 +1327,45 @@ def _line_is_preexisting(
1289
1327
  line_index: int,
1290
1328
  all_line_identities: List[ViolationIdentity],
1291
1329
  all_matched_line_indexes: set[int],
1292
- remaining_by_key: Counter[tuple[str, str]],
1330
+ remaining_exact: Counter[ViolationIdentity],
1293
1331
  ) -> bool:
1294
1332
  """Return whether one located line matches the baseline budget.
1295
1333
 
1296
- An exact identity match is pre-existing. A leftover line whose
1297
- ``(validator, enclosing function)`` key still has baseline budget is
1298
- pre-existing with a drifted message. A line beyond its key's budget is new.
1334
+ An exact identity match is pre-existing. A leftover line a compatible
1335
+ unconsumed baseline entry can be charged to is pre-existing with a drifted
1336
+ message or a renamed enclosing function. Charging spends that one entry, so
1337
+ a line beyond the baseline's count for its validator is new.
1299
1338
 
1300
1339
  Args:
1301
1340
  line_index: The line's position in the located-line order.
1302
1341
  all_line_identities: One identity per located line.
1303
1342
  all_matched_line_indexes: The exactly-matched line indexes.
1304
- remaining_by_key: The unconsumed per-key budget, decremented in place.
1343
+ remaining_exact: The unconsumed baseline identity budget, decremented
1344
+ in place per charge.
1305
1345
 
1306
1346
  Returns:
1307
1347
  True when the line is pre-existing, False when it is new.
1308
1348
  """
1309
1349
  if line_index in all_matched_line_indexes:
1310
1350
  return True
1311
- each_key = all_line_identities[line_index][:2]
1312
- if remaining_by_key[each_key] > 0:
1313
- remaining_by_key[each_key] -= 1
1314
- return True
1315
- return False
1351
+ compatible_identity = _compatible_baseline_identity(
1352
+ all_line_identities[line_index], remaining_exact
1353
+ )
1354
+ if compatible_identity is None:
1355
+ return False
1356
+ remaining_exact[compatible_identity] -= 1
1357
+ return True
1316
1358
 
1317
1359
 
1318
1360
  def _partition_output_lines(
1319
1361
  each_result: ValidatorResult,
1320
1362
  name_by_line: dict[int, str],
1321
1363
  remaining_exact: Counter[ViolationIdentity],
1322
- remaining_by_key: Counter[tuple[str, str]],
1323
1364
  ) -> tuple[List[str], List[str]]:
1324
1365
  """Split one result's located lines into a (new, pre-existing) line pair.
1325
1366
 
1326
- The exact and per-key baseline budgets are consumed in place, exact
1327
- matches first, so a later result never double-spends an earlier match.
1367
+ The single baseline budget is consumed in place, exact matches first, so a
1368
+ later result never double-spends an earlier match.
1328
1369
  """
1329
1370
  located_lines = _located_violation_lines(each_result)
1330
1371
  all_line_identities = [
@@ -1332,13 +1373,13 @@ def _partition_output_lines(
1332
1373
  for each_line in located_lines
1333
1374
  ]
1334
1375
  all_matched_line_indexes = _consume_exact_matches(
1335
- all_line_identities, remaining_exact, remaining_by_key
1376
+ all_line_identities, remaining_exact
1336
1377
  )
1337
1378
  all_new_lines: List[str] = []
1338
1379
  all_preexisting_lines: List[str] = []
1339
1380
  for each_line_index, each_line in enumerate(located_lines):
1340
1381
  is_preexisting = _line_is_preexisting(
1341
- each_line_index, all_line_identities, all_matched_line_indexes, remaining_by_key
1382
+ each_line_index, all_line_identities, all_matched_line_indexes, remaining_exact
1342
1383
  )
1343
1384
  (all_preexisting_lines if is_preexisting else all_new_lines).append(each_line)
1344
1385
  return all_new_lines, all_preexisting_lines
@@ -1382,13 +1423,12 @@ def _scope_new_and_preexisting(
1382
1423
  """
1383
1424
  name_by_line = _enclosing_function_name_by_line(proposed_content)
1384
1425
  remaining_exact = Counter(baseline_identities)
1385
- remaining_by_key = _identity_key_counts(baseline_identities)
1386
1426
  all_new_results: List[ValidatorResult] = []
1387
1427
  all_preexisting_results: List[ValidatorResult] = []
1388
1428
  for each_result in all_proposed_failed_results:
1389
1429
  new_results, preexisting_results = _grouped_result_lines(
1390
1430
  each_result,
1391
- _partition_output_lines(each_result, name_by_line, remaining_exact, remaining_by_key),
1431
+ _partition_output_lines(each_result, name_by_line, remaining_exact),
1392
1432
  )
1393
1433
  all_new_results.extend(new_results)
1394
1434
  all_preexisting_results.extend(preexisting_results)
@@ -1405,26 +1445,80 @@ def _emit_pre_existing_warning(all_preexisting_results: List[ValidatorResult]) -
1405
1445
  sys.stderr.flush()
1406
1446
 
1407
1447
 
1408
- def _decide_pre_tool_use(file_path: str, proposed_content: str) -> None:
1448
+ def _decide_pre_tool_use(file_path: str, proposed_content: str) -> bool:
1409
1449
  """Deny only violations absent from the baseline; warn on the ones that persist.
1410
1450
 
1411
1451
  Args:
1412
1452
  file_path: The write's target path.
1413
1453
  proposed_content: The reconstructed post-edit content of that file.
1454
+
1455
+ Returns:
1456
+ True when this target denies, so a caller holding several targets stops
1457
+ at the first one and emits a single decision.
1414
1458
  """
1415
1459
  all_proposed_failed = _failed_results(
1416
1460
  validate_proposed_file(file_path, proposed_content)
1417
1461
  )
1418
1462
  if not all_proposed_failed:
1419
- return
1463
+ return False
1420
1464
  baseline_identities = _baseline_violation_identities(file_path)
1421
1465
  all_new_results, all_preexisting_results = _scope_new_and_preexisting(
1422
1466
  all_proposed_failed, proposed_content, baseline_identities
1423
1467
  )
1424
1468
  if all_preexisting_results:
1425
1469
  _emit_pre_existing_warning(all_preexisting_results)
1426
- if all_new_results:
1427
- _emit_pre_tool_use_deny(_proposed_content_deny_reason(all_new_results))
1470
+ if not all_new_results:
1471
+ return False
1472
+ _emit_pre_tool_use_deny(_proposed_content_deny_reason(all_new_results))
1473
+ return True
1474
+
1475
+
1476
+ def _decide_codex_patch_file(
1477
+ each_patch_file: CodexPatchFile, pre_tool_use_payload: dict
1478
+ ) -> bool:
1479
+ """Grade one path a Codex patch writes, skipping a deletion and an exemption.
1480
+
1481
+ Args:
1482
+ each_patch_file: One parsed path with its projected post-edit content.
1483
+ pre_tool_use_payload: The whole payload, read for its working directory.
1484
+
1485
+ Returns:
1486
+ True when this path denies the patch.
1487
+ """
1488
+ if each_patch_file.operation == CODEX_DELETE_OPERATION:
1489
+ return False
1490
+ if is_ephemeral_path(each_patch_file.file_path, pre_tool_use_payload):
1491
+ return False
1492
+ return _decide_pre_tool_use(each_patch_file.file_path, each_patch_file.post_content)
1493
+
1494
+
1495
+ def _evaluate_codex_apply_patch(pre_tool_use_payload: dict, tool_input: dict) -> None:
1496
+ """Grade every path a Codex apply_patch writes, stopping at the first denial.
1497
+
1498
+ ::
1499
+
1500
+ one patch adding two files, the second violating -> deny, names file two
1501
+ one patch deleting a file -> allow, nothing to grade
1502
+
1503
+ A patch carries several paths in one call, so each is graded on its own
1504
+ projected post-edit content and the first denial is the decision.
1505
+
1506
+ Args:
1507
+ pre_tool_use_payload: The whole PreToolUse payload.
1508
+ tool_input: The payload's tool input mapping.
1509
+ """
1510
+ patch_command, working_directory = payload_patch_command(
1511
+ pre_tool_use_payload, tool_input
1512
+ )
1513
+ if not patch_command:
1514
+ return
1515
+ try:
1516
+ all_patch_files = parse_codex_apply_patch(patch_command, working_directory)
1517
+ except CodexPatchError:
1518
+ return
1519
+ for each_patch_file in all_patch_files:
1520
+ if _decide_codex_patch_file(each_patch_file, pre_tool_use_payload):
1521
+ return
1428
1522
 
1429
1523
 
1430
1524
  def _evaluate_pre_tool_use_payload() -> None:
@@ -1434,10 +1528,12 @@ def _evaluate_pre_tool_use_payload() -> None:
1434
1528
  payload, so an ephemeral scratch or session scratchpad target passes without
1435
1529
  validation before any baseline-scoped decision runs. For a non-exempt target,
1436
1530
  each located violation is keyed by validator name, enclosing function, and
1437
- message, then counted against the on-disk baseline. A violation beyond the
1438
- baseline's budget for its key denies the write; one the baseline already
1439
- carries passes with a stderr advisory. Writes nothing for a clean file, an
1440
- exempt target, or an unparseable payload.
1531
+ message, then charged against the on-disk baseline. A violation the
1532
+ baseline cannot pay for denies the write, whether by an exact key, by the
1533
+ same enclosing function, or by the same message shape under a renamed
1534
+ function; one the baseline already carries passes with a stderr advisory.
1535
+ Writes nothing for a clean file, an exempt target, or an unparseable
1536
+ payload.
1441
1537
  """
1442
1538
  pre_tool_use_payload = json.load(sys.stdin)
1443
1539
  if not isinstance(pre_tool_use_payload, dict):
@@ -1446,6 +1542,9 @@ def _evaluate_pre_tool_use_payload() -> None:
1446
1542
  tool_input = pre_tool_use_payload.get("tool_input", {})
1447
1543
  if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
1448
1544
  return
1545
+ if tool_name == APPLY_PATCH_TOOL_NAME:
1546
+ _evaluate_codex_apply_patch(pre_tool_use_payload, tool_input)
1547
+ return
1449
1548
  file_path = tool_input.get("file_path", "")
1450
1549
  if not isinstance(file_path, str) or not file_path:
1451
1550
  return
@@ -12,7 +12,7 @@ import sys
12
12
  from pathlib import Path
13
13
  from typing import List
14
14
 
15
- from .validator_base import Violation
15
+ from .validator_base import Violation, source_text, syntax_tree
16
16
 
17
17
 
18
18
  SECRET_PATTERNS: frozenset[str] = frozenset({
@@ -95,12 +95,12 @@ def validate_file(file_path: Path) -> List[Violation]:
95
95
  filename = str(file_path)
96
96
 
97
97
  try:
98
- source = file_path.read_text(encoding="utf-8")
98
+ source = source_text(file_path)
99
99
  except Exception as error:
100
100
  return [Violation(filename, 0, f"Error reading file: {error}")]
101
101
 
102
102
  try:
103
- tree = ast.parse(source)
103
+ tree = syntax_tree(source)
104
104
  except SyntaxError as error:
105
105
  return [Violation(filename, error.lineno or 0, f"Syntax error: {error.msg}")]
106
106
 
@@ -22,7 +22,7 @@ _hooks_directory = str(Path(__file__).resolve().parent.parent)
22
22
  if _hooks_directory not in sys.path:
23
23
  sys.path.insert(0, _hooks_directory)
24
24
 
25
- from validators.config.directory_exemption_constants import ( # noqa: E402
25
+ from validators.config.system_temporary_roots_constants import ( # noqa: E402
26
26
  ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES,
27
27
  )
28
28