claude-dev-env 2.31.0 → 2.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/docs/CODE_RULES.md +2 -1
  2. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  3. package/hooks/blocking/code_rules_enforcer.py +32 -14
  4. package/hooks/blocking/code_rules_shared.py +64 -14
  5. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  6. package/hooks/blocking/tdd_enforcer.py +8 -4
  7. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  8. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  9. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  10. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  11. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  12. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -2
  13. package/hooks/blocking/test_state_description_blocker.py +99 -53
  14. package/hooks/blocking/test_tdd_enforcer.py +64 -0
  15. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  16. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  17. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  18. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  19. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  20. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  21. package/hooks/validators/abbreviation_checks.py +3 -3
  22. package/hooks/validators/code_quality_checks.py +4 -4
  23. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  24. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  25. package/hooks/validators/config/validator_base_constants.py +9 -0
  26. package/hooks/validators/fast_save_validators.py +23 -80
  27. package/hooks/validators/pr_reference_checks.py +2 -2
  28. package/hooks/validators/python_antipattern_checks.py +3 -3
  29. package/hooks/validators/python_style_checks.py +3 -2
  30. package/hooks/validators/react_checks.py +2 -9
  31. package/hooks/validators/run_all_validators.py +190 -145
  32. package/hooks/validators/security_checks.py +3 -3
  33. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  34. package/hooks/validators/test_fast_save_validators.py +16 -16
  35. package/hooks/validators/test_hook_timing_harness.py +10 -1
  36. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  37. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  38. package/hooks/validators/test_validator_base.py +51 -2
  39. package/hooks/validators/todo_checks.py +2 -2
  40. package/hooks/validators/type_safety_checks.py +3 -3
  41. package/hooks/validators/useless_test_checks.py +3 -3
  42. package/hooks/validators/validator_base.py +73 -1
  43. package/package.json +1 -1
  44. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  45. package/scripts/grok_headless_runner.py +15 -12
  46. package/scripts/shared_tree_paths.py +70 -0
  47. package/scripts/test_shared_tree_paths.py +143 -0
@@ -126,7 +126,8 @@ Scope assigns the lane. A check that reads a file other than the target runs on
126
126
 
127
127
  Hook-infrastructure targets run three checks in the edit lane — `check_same_file_inline_duplicate_body`, `check_zero_payload_function_alias`, `check_unanchored_command_dispatch` — and the whole roster on the full gate. `ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES` holds that set.
128
128
 
129
- Two surfaces report on the roster, and each reports a specific thing:
129
+ Three surfaces report on the roster, and each reports a specific thing:
130
130
 
131
131
  - `hooks/validators/hook_timing_harness.py` builds a `Write` payload against a target that already holds content. `_contents_for_validation` returns `None` for that payload, so the harness times interpreter start and hook dispatch. Time an `Edit` payload against a real file to measure the checks.
132
132
  - `~/.claude/logs/hook-blocks.log` records the denials raised by fixtures in `test_code_rules_enforcer_*.py` and by the timing harness's default target.
133
+ - `hooks/validators/run_all_validators.py` suppresses exemption-directory substring matching for a target under a system temporary root, so pytest's `test_<name>0` parents stay scaffolding. A pytest `--basetemp` outside those roots restores the matching, and the staged copy takes a test-file exemption. Run this suite under the default temporary root.
@@ -54,6 +54,7 @@ import ast
54
54
  import copy
55
55
  import sys
56
56
  from pathlib import Path
57
+ from typing import NamedTuple
57
58
 
58
59
  _blocking_directory = str(Path(__file__).resolve().parent)
59
60
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -84,6 +85,21 @@ from hooks_constants.duplicate_function_body_constants import ( # noqa: E402
84
85
  )
85
86
 
86
87
 
88
+ _ModuleScopeFunction = ast.FunctionDef | ast.AsyncFunctionDef
89
+
90
+
91
+ class _FunctionScanProfile(NamedTuple):
92
+ """The three readings of one module-scope function the inline scan compares.
93
+
94
+ Every reading is a property of that function alone, so each is taken once per
95
+ file and read by each pairing the scan weighs.
96
+ """
97
+
98
+ reachable_statement_count: int
99
+ all_sorted_body_dumps: list[str]
100
+ all_block_dumps: list[list[str]]
101
+
102
+
87
103
  def _body_statements_without_docstring(
88
104
  function_node: ast.FunctionDef | ast.AsyncFunctionDef,
89
105
  ) -> list[ast.stmt]:
@@ -384,6 +400,37 @@ def _normalized_statement_dump(statement: ast.stmt) -> str:
384
400
  return ast.dump(canonical_statement, annotate_fields=False)
385
401
 
386
402
 
403
+ def _memoized_statement_dump(
404
+ statement: ast.stmt,
405
+ dump_by_statement: dict[ast.stmt, str],
406
+ ) -> str:
407
+ """Return the normalized dump of one statement, computing it at most once.
408
+
409
+ The inline scan reads the same statement's dump many times over: once for its
410
+ enclosing function's block window, once for that function's twin-test
411
+ multiset, and again for every helper the function is weighed against. Dumping
412
+ deep-copies the statement subtree before canonicalizing string constants, so
413
+ this memo serves the repeat reads.
414
+
415
+ An ``ast`` node defines no ``__eq__``, so the key is the node's identity and
416
+ the dict holds a strong reference to it: a key cannot be freed and a later
417
+ node cannot reuse its slot. The caller owns one memo per scanned file and
418
+ drops it when the scan returns, so no entry outlives its parse tree.
419
+
420
+ Args:
421
+ statement: The statement node to fingerprint.
422
+ dump_by_statement: The file's statement-to-dump memo, mutated in place.
423
+
424
+ Returns:
425
+ The value ``_normalized_statement_dump`` returns for this statement.
426
+ """
427
+ memoized_dump = dump_by_statement.get(statement)
428
+ if memoized_dump is None:
429
+ memoized_dump = _normalized_statement_dump(statement)
430
+ dump_by_statement[statement] = memoized_dump
431
+ return memoized_dump
432
+
433
+
387
434
  def _statement_blocks_in_function(
388
435
  function_node: ast.FunctionDef | ast.AsyncFunctionDef,
389
436
  ) -> list[list[ast.stmt]]:
@@ -443,76 +490,6 @@ def _total_reachable_statement_count(
443
490
  return total_statement_count
444
491
 
445
492
 
446
- def _normalized_body_dump_multiset(
447
- function_node: ast.FunctionDef | ast.AsyncFunctionDef,
448
- ) -> list[str]:
449
- """Return the function's docstring-stripped statement dumps, sorted.
450
-
451
- Used to test whether two functions are structural twins — same statements in
452
- any arrangement under string normalization. Sorting makes the comparison
453
- order-independent, so two peer helpers that read different inputs but share
454
- the same statement shapes compare equal and are left to the cross-file check.
455
-
456
- Args:
457
- function_node: The function whose body to fingerprint.
458
-
459
- Returns:
460
- The sorted per-statement normalized dumps of the docstring-stripped body.
461
- """
462
- body_statements = _body_statements_without_docstring(function_node)
463
- return sorted(_normalized_statement_dump(each) for each in body_statements)
464
-
465
-
466
- def _function_inlines_window(
467
- helper_node: ast.FunctionDef | ast.AsyncFunctionDef,
468
- enclosing_node: ast.FunctionDef | ast.AsyncFunctionDef,
469
- all_helper_window_dumps: list[str],
470
- ) -> bool:
471
- """Return whether a function inlines a helper window inside a larger body.
472
-
473
- Slides a window the length of ``all_helper_window_dumps`` over each reachable
474
- statement block in the enclosing function; a window whose per-statement dumps
475
- match the helper's, in order, is the inlined copy. Two guards keep the report
476
- to the genuine "helper inlined inside a larger function" shape:
477
-
478
- - The enclosing function carries more total reachable statements than the
479
- window, so a copy that fills a whole peer function is not reported here. The
480
- count spans every nested block, so a window wrapped inside one top-level
481
- compound — a ``try``/``finally`` cleanup, an ``except`` handler, or a single
482
- ``if`` guard — still clears the guard and is scanned.
483
- - The two functions are not structural twins — their docstring-stripped
484
- statement multisets differ — so two peer helpers that share a statement
485
- shape but read different inputs are left to the cross-file whole-function
486
- check ``check_duplicate_function_body_across_files`` rather than reported as
487
- an inline duplicate.
488
-
489
- Args:
490
- helper_node: The candidate helper whose window is sought.
491
- enclosing_node: The candidate enclosing function to scan.
492
- all_helper_window_dumps: The helper's substantive-block per-statement dumps.
493
-
494
- Returns:
495
- True when some block in the enclosing function contains the helper window
496
- verbatim as a contiguous run inside a strictly larger, non-twin body.
497
- """
498
- window_length = len(all_helper_window_dumps)
499
- if _total_reachable_statement_count(enclosing_node) <= window_length:
500
- return False
501
- if _normalized_body_dump_multiset(helper_node) == _normalized_body_dump_multiset(
502
- enclosing_node
503
- ):
504
- return False
505
- for each_block in _statement_blocks_in_function(enclosing_node):
506
- if len(each_block) < window_length:
507
- continue
508
- block_dumps = [_normalized_statement_dump(each) for each in each_block]
509
- for each_start_index in range(len(block_dumps) - window_length + 1):
510
- window = block_dumps[each_start_index : each_start_index + window_length]
511
- if window == all_helper_window_dumps:
512
- return True
513
- return False
514
-
515
-
516
493
  def _helper_match_window_dumps(
517
494
  function_node: ast.FunctionDef | ast.AsyncFunctionDef,
518
495
  ) -> list[str] | None:
@@ -616,40 +593,235 @@ def check_same_file_inline_duplicate_body(
616
593
  for each_node in tree.body
617
594
  if isinstance(each_node, ast.FunctionDef | ast.AsyncFunctionDef)
618
595
  ]
596
+ return _scope_violations_to_changed_lines(
597
+ _inline_duplicate_violations(all_top_level_functions, MAX_DUPLICATE_BODY_ISSUES),
598
+ all_changed_lines,
599
+ defer_scope_to_caller,
600
+ )
601
+
602
+
603
+ def _block_dumps_in_function(
604
+ function_node: _ModuleScopeFunction,
605
+ dump_by_statement: dict[ast.stmt, str],
606
+ ) -> list[list[str]]:
607
+ """Return the per-statement dumps of each reachable block in a function.
608
+
609
+ Args:
610
+ function_node: The module-scope function to read.
611
+ dump_by_statement: The file's statement-to-dump memo, mutated in place.
612
+
613
+ Returns:
614
+ One dump list per reachable block, in block-collection order.
615
+ """
616
+ return [
617
+ [
618
+ _memoized_statement_dump(each_statement, dump_by_statement)
619
+ for each_statement in each_block
620
+ ]
621
+ for each_block in _statement_blocks_in_function(function_node)
622
+ ]
623
+
624
+
625
+ def _function_scan_profile(
626
+ function_node: _ModuleScopeFunction,
627
+ dump_by_statement: dict[ast.stmt, str],
628
+ ) -> _FunctionScanProfile:
629
+ """Take the three readings the inline scan needs from one function.
630
+
631
+ The sorted body dumps carry the docstring-stripped statement multiset that
632
+ tests whether two functions are structural twins. Sorting makes that
633
+ comparison order-independent, so two peer helpers that read different inputs
634
+ but share the same statement shapes compare equal and are left to the
635
+ cross-file check.
636
+
637
+ Args:
638
+ function_node: The module-scope function to read.
639
+ dump_by_statement: The file's statement-to-dump memo, mutated in place.
640
+
641
+ Returns:
642
+ The function's reachable statement count, sorted body dumps, and
643
+ per-block statement dumps.
644
+ """
645
+ return _FunctionScanProfile(
646
+ reachable_statement_count=_total_reachable_statement_count(function_node),
647
+ all_sorted_body_dumps=sorted(
648
+ _memoized_statement_dump(each_statement, dump_by_statement)
649
+ for each_statement in _body_statements_without_docstring(function_node)
650
+ ),
651
+ all_block_dumps=_block_dumps_in_function(function_node, dump_by_statement),
652
+ )
653
+
654
+
655
+ def _block_dumps_contain_window(
656
+ all_block_dumps: list[str],
657
+ all_window_dumps: list[str],
658
+ ) -> bool:
659
+ """Return whether one block carries the window as a contiguous run.
660
+
661
+ A block shorter than the window holds no run and answers False.
662
+
663
+ Args:
664
+ all_block_dumps: One reachable block's per-statement dumps.
665
+ all_window_dumps: The helper's window dumps to seek.
666
+
667
+ Returns:
668
+ True when the window appears in the block as a contiguous run.
669
+ """
670
+ window_length = len(all_window_dumps)
671
+ last_start_index = len(all_block_dumps) - window_length
672
+ for each_start_index in range(last_start_index + 1):
673
+ window_end = each_start_index + window_length
674
+ if all_block_dumps[each_start_index:window_end] == all_window_dumps:
675
+ return True
676
+ return False
677
+
678
+
679
+ def _profile_inlines_window(
680
+ helper_profile: _FunctionScanProfile,
681
+ enclosing_profile: _FunctionScanProfile,
682
+ all_helper_window_dumps: list[str],
683
+ ) -> bool:
684
+ """Return whether a function inlines a helper window inside a larger body.
685
+
686
+ A run inside one of the enclosing function's reachable blocks whose dumps
687
+ match the helper's, in order, is the inlined copy. Two guards hold the report
688
+ to a genuine inlining: the enclosing function carries more reachable
689
+ statements than the window, and the two functions are not structural twins,
690
+ so peer helpers sharing a statement shape are left to the cross-file check.
691
+
692
+ Args:
693
+ helper_profile: The candidate helper's readings, for the twin guard.
694
+ enclosing_profile: The candidate enclosing function's readings.
695
+ all_helper_window_dumps: The helper's substantive-block statement dumps.
696
+
697
+ Returns:
698
+ True when a block carries the window inside a larger, non-twin body.
699
+ """
700
+ if enclosing_profile.reachable_statement_count <= len(all_helper_window_dumps):
701
+ return False
702
+ if helper_profile.all_sorted_body_dumps == enclosing_profile.all_sorted_body_dumps:
703
+ return False
704
+ return any(
705
+ _block_dumps_contain_window(each_block_dumps, all_helper_window_dumps)
706
+ for each_block_dumps in enclosing_profile.all_block_dumps
707
+ )
708
+
709
+
710
+ def _first_enclosing_inliner(
711
+ helper_node: _ModuleScopeFunction,
712
+ all_helper_window_dumps: list[str],
713
+ profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
714
+ ) -> _ModuleScopeFunction | None:
715
+ """Return the first other module-scope function that inlines this window.
716
+
717
+ Args:
718
+ helper_node: The candidate helper whose window is sought.
719
+ all_helper_window_dumps: The helper's substantive-block statement dumps.
720
+ profile_by_function: Each function's readings, keyed in source order.
721
+
722
+ Returns:
723
+ The first function in source order that carries the window, or None.
724
+ """
725
+ helper_profile = profile_by_function[helper_node]
726
+ for each_enclosing, each_profile in profile_by_function.items():
727
+ if each_enclosing is helper_node:
728
+ continue
729
+ if _profile_inlines_window(
730
+ helper_profile, each_profile, all_helper_window_dumps
731
+ ):
732
+ return each_enclosing
733
+ return None
734
+
735
+
736
+ def _inline_duplicate_violation(
737
+ helper_node: _ModuleScopeFunction,
738
+ enclosing_node: _ModuleScopeFunction,
739
+ ) -> tuple[frozenset[int], str]:
740
+ """Return the scoped lines and report text for one inlined-helper finding.
741
+
742
+ Args:
743
+ helper_node: The helper whose body appears inline elsewhere.
744
+ enclosing_node: The function carrying the inlined copy.
745
+
746
+ Returns:
747
+ The union of both function spans, and the message naming both.
748
+ """
749
+ helper_span = _function_definition_span(helper_node)
750
+ enclosing_span = _function_definition_span(enclosing_node)
751
+ span_suffix = SAME_FILE_INLINE_DUPLICATE_SPAN_SUFFIX_TEMPLATE.format(
752
+ helper_start=helper_span.start,
753
+ helper_length=len(helper_span),
754
+ enclosing_start=enclosing_span.start,
755
+ enclosing_length=len(enclosing_span),
756
+ )
757
+ message = (
758
+ f"Function {helper_node.name!r} duplicates an inline block in "
759
+ f"{enclosing_node.name!r} — {SAME_FILE_INLINE_DUPLICATE_GUIDANCE} "
760
+ f"{span_suffix}"
761
+ )
762
+ return (frozenset(helper_span) | frozenset(enclosing_span), message)
763
+
764
+
765
+ def _violations_for_qualifying_helpers(
766
+ window_by_function: dict[_ModuleScopeFunction, list[str] | None],
767
+ profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
768
+ maximum_issue_count: int,
769
+ ) -> list[tuple[frozenset[int], str]]:
770
+ """Walk the helpers in source order and report the first inliner of each.
771
+
772
+ Args:
773
+ window_by_function: Each function's helper match window, or None.
774
+ profile_by_function: Each function's readings, keyed in source order.
775
+ maximum_issue_count: The report ceiling the walk stops at.
776
+
777
+ Returns:
778
+ The in-scope lines and message of each report, in walk order.
779
+ """
619
780
  all_violations_in_walk_order: list[tuple[frozenset[int], str]] = []
620
- for each_helper in all_top_level_functions:
621
- helper_window_dumps = _helper_match_window_dumps(each_helper)
622
- if helper_window_dumps is None:
781
+ for each_helper, each_window_dumps in window_by_function.items():
782
+ if each_window_dumps is None:
623
783
  continue
624
- for each_enclosing in all_top_level_functions:
625
- if each_enclosing is each_helper:
626
- continue
627
- if not _function_inlines_window(
628
- each_helper, each_enclosing, helper_window_dumps
629
- ):
630
- continue
631
- helper_span = _function_definition_span(each_helper)
632
- enclosing_span = _function_definition_span(each_enclosing)
633
- in_scope_lines = frozenset(helper_span) | frozenset(enclosing_span)
634
- span_suffix = SAME_FILE_INLINE_DUPLICATE_SPAN_SUFFIX_TEMPLATE.format(
635
- helper_start=helper_span.start,
636
- helper_length=len(helper_span),
637
- enclosing_start=enclosing_span.start,
638
- enclosing_length=len(enclosing_span),
639
- )
640
- message = (
641
- f"Function {each_helper.name!r} duplicates an inline block in "
642
- f"{each_enclosing.name!r} — {SAME_FILE_INLINE_DUPLICATE_GUIDANCE} "
643
- f"{span_suffix}"
784
+ each_enclosing = _first_enclosing_inliner(
785
+ each_helper, each_window_dumps, profile_by_function
786
+ )
787
+ if each_enclosing is not None:
788
+ all_violations_in_walk_order.append(
789
+ _inline_duplicate_violation(each_helper, each_enclosing)
644
790
  )
645
- all_violations_in_walk_order.append((in_scope_lines, message))
791
+ if len(all_violations_in_walk_order) >= maximum_issue_count:
646
792
  break
647
- if len(all_violations_in_walk_order) >= MAX_DUPLICATE_BODY_ISSUES:
648
- break
649
- return _scope_violations_to_changed_lines(
650
- all_violations_in_walk_order,
651
- all_changed_lines,
652
- defer_scope_to_caller,
793
+ return all_violations_in_walk_order
794
+
795
+
796
+ def _inline_duplicate_violations(
797
+ all_top_level_functions: list[_ModuleScopeFunction],
798
+ maximum_issue_count: int,
799
+ ) -> list[tuple[frozenset[int], str]]:
800
+ """Report every module-scope helper whose body is inlined in another function.
801
+
802
+ The dump memo and the per-function readings live for this one call and are
803
+ dropped when it returns, so no reading outlives the parse tree it describes.
804
+
805
+ Args:
806
+ all_top_level_functions: The module-scope functions in source order.
807
+ maximum_issue_count: The report ceiling the walk stops at.
808
+
809
+ Returns:
810
+ The in-scope lines and message of each report, in walk order.
811
+ """
812
+ dump_by_statement: dict[ast.stmt, str] = {}
813
+ window_by_function = {
814
+ each_function: _helper_match_window_dumps(each_function)
815
+ for each_function in all_top_level_functions
816
+ }
817
+ if all(each_window is None for each_window in window_by_function.values()):
818
+ return []
819
+ profile_by_function = {
820
+ each_function: _function_scan_profile(each_function, dump_by_statement)
821
+ for each_function in all_top_level_functions
822
+ }
823
+ return _violations_for_qualifying_helpers(
824
+ window_by_function, profile_by_function, maximum_issue_count
653
825
  )
654
826
 
655
827
 
@@ -247,11 +247,11 @@ def _validated_phase(phase: str) -> str:
247
247
  return phase
248
248
 
249
249
 
250
- def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
250
+ def _codex_patch_issues(each_patch_file: CodexPatchFile, repository_root: str = "") -> list[str]:
251
251
  """Run the existing code-rules verdict over one Codex patch view."""
252
252
  if not each_patch_file.post_content and each_patch_file.operation == "delete":
253
253
  return []
254
- 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):
255
255
  all_issues = _hook_infrastructure_blocking_issues(
256
256
  each_patch_file.post_content,
257
257
  each_patch_file.file_path,
@@ -259,7 +259,7 @@ def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
259
259
  each_patch_file.prior_content,
260
260
  phase=EDIT_LANE_PHASE,
261
261
  )
262
- elif _is_validated_target(each_patch_file.file_path):
262
+ elif _is_validated_target(each_patch_file.file_path, repository_root):
263
263
  all_issues = validate_content_for_edit_lane(
264
264
  each_patch_file.post_content,
265
265
  each_patch_file.file_path,
@@ -306,7 +306,7 @@ def _report_codex_patch_payload(
306
306
  all_issues = [
307
307
  each_issue
308
308
  for each_patch_file in all_patch_files
309
- for each_issue in _codex_patch_issues(each_patch_file)
309
+ for each_issue in _codex_patch_issues(each_patch_file, working_directory or "")
310
310
  ]
311
311
  if all_issues:
312
312
  _write_deny_payload(
@@ -708,14 +708,16 @@ def validate_content_for_full_gate(
708
708
 
709
709
 
710
710
  def prior_and_post_edit_content(
711
- file_path: str, old_string: str, new_string: str,
711
+ file_path: str, old_string: str, new_string: str, is_replace_all: bool = False,
712
712
  ) -> tuple[str | None, str | None]:
713
713
  """Return the pre-edit and post-edit file content from a single disk read.
714
714
 
715
715
  Reads ``file_path`` once and derives both views from that single read so the
716
716
  prior and the reconstruction never diverge across two independent reads.
717
- The post-edit view replaces the first occurrence of ``old_string`` with
718
- ``new_string``, mirroring how the Edit tool itself applies a single
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
719
721
  replacement.
720
722
 
721
723
  Returns ``(None, None)`` when the file cannot be read, ``old_string`` is
@@ -729,6 +731,7 @@ def prior_and_post_edit_content(
729
731
  file_path: The path of the file the Edit targets.
730
732
  old_string: The Edit's ``old_string`` fragment.
731
733
  new_string: The Edit's ``new_string`` fragment.
734
+ is_replace_all: Whether the Edit carries ``replace_all``.
732
735
 
733
736
  Returns:
734
737
  A ``(prior_content, post_edit_content)`` pair, or ``(None, None)`` when
@@ -741,14 +744,22 @@ def prior_and_post_edit_content(
741
744
  return None, None
742
745
  if old_string not in existing_content:
743
746
  return None, None
744
- return existing_content, existing_content.replace(old_string, new_string, 1)
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])
745
753
 
746
754
 
747
- def _is_validated_target(file_path: str) -> bool:
755
+ def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
748
756
  """Return whether the path is subject to code-rules validation.
749
757
 
750
758
  Args:
751
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``.
752
763
 
753
764
  Returns:
754
765
  True when the path is non-empty, outside hook infrastructure, and
@@ -756,14 +767,14 @@ def _is_validated_target(file_path: str) -> bool:
756
767
  """
757
768
  if not file_path:
758
769
  return False
759
- if is_ephemeral_script_path(file_path):
770
+ if is_ephemeral_script_path(file_path, repository_root):
760
771
  return False
761
772
  if is_hook_infrastructure(file_path):
762
773
  return False
763
774
  return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
764
775
 
765
776
 
766
- def _is_hook_infrastructure_python_target(file_path: str) -> bool:
777
+ def _is_hook_infrastructure_python_target(file_path: str, repository_root: str = "") -> bool:
767
778
  """Return whether the path is a hook-infrastructure Python file.
768
779
 
769
780
  The full code-rules suite exempts hook-infrastructure files, but the
@@ -773,13 +784,16 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
773
784
 
774
785
  Args:
775
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``.
776
790
 
777
791
  Returns:
778
792
  True when the path names a Python file inside hook infrastructure.
779
793
  """
780
794
  if not file_path:
781
795
  return False
782
- if is_ephemeral_script_path(file_path):
796
+ if is_ephemeral_script_path(file_path, repository_root):
783
797
  return False
784
798
  if not is_hook_infrastructure(file_path):
785
799
  return False
@@ -1170,6 +1184,7 @@ def _contents_for_validation(
1170
1184
  if tool_name == "Edit":
1171
1185
  prior_content, full_file_content_after_edit = prior_and_post_edit_content(
1172
1186
  file_path, old_string, new_string,
1187
+ is_replace_all=all_tool_input.get("replace_all") is True,
1173
1188
  )
1174
1189
  if full_file_content_after_edit is None:
1175
1190
  full_file_content_after_edit = existing_file_content(file_path)
@@ -1372,8 +1387,11 @@ def main(all_arguments: list[str]) -> None:
1372
1387
  if is_under_session_scratchpad(file_path, pretooluse_payload):
1373
1388
  sys.exit(0)
1374
1389
 
1375
- runs_full_verdict = _is_validated_target(file_path)
1376
- if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
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
+ ):
1377
1395
  sys.exit(0)
1378
1396
 
1379
1397
  validation_contents = _contents_for_validation(