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.
Files changed (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -9,6 +9,7 @@ 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
@@ -20,16 +21,29 @@ from pathlib import Path
20
21
  from typing import Callable, Dict, List, Optional, Tuple
21
22
 
22
23
  from ._path_setup import hooks_directory_on_path # noqa: F401
24
+ from .config.baseline_identity_constants import (
25
+ QUALIFIED_SCOPE_NAME_SEPARATOR,
26
+ SCOPE_NAME_PLACEHOLDER,
27
+ SHAPE_NUMBER_PLACEHOLDER,
28
+ STANDALONE_NUMBER_PATTERN,
29
+ WORD_BOUNDARY_PATTERN,
30
+ )
23
31
  from .config.directory_exemption_constants import (
24
32
  ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES,
25
33
  ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS,
34
+ is_pytest_scratch_directory_segment,
26
35
  )
36
+ from .fast_save_validators import run_fast_save_validators
27
37
  from .health_check import get_system_health, get_validator_version, print_health_report
28
- from .mypy_integration import check_mypy_available, run_mypy_check
29
- from .system_temporary_roots import enclosing_system_temporary_root
38
+ from .mypy_integration import (
39
+ check_mypy_available,
40
+ find_module_resolution_root,
41
+ run_mypy_check,
42
+ )
30
43
  from .output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
31
44
  from .python_style_checks import fix_file
32
- from .ruff_integration import check_ruff_available, run_ruff_check
45
+ from .ruff_integration import run_ruff_check
46
+ from .validator_base import ValidatorResult
33
47
  from blocking.code_rules_shared import is_ephemeral_path
34
48
  from hooks_constants.hook_block_logger import log_hook_block
35
49
  from hooks_constants.multi_edit_reconstruction import (
@@ -230,17 +244,6 @@ def build_json_output(
230
244
  }
231
245
 
232
246
 
233
- @dataclass(frozen=True)
234
- class ValidatorResult:
235
- """Result from running a validator."""
236
-
237
- name: str
238
- checks: str
239
- passed: bool
240
- output: str
241
- skipped: bool = False
242
-
243
-
244
247
  def run_with_fallback(
245
248
  validator_func: Callable[[], ValidatorResult],
246
249
  fallback_name: str,
@@ -416,15 +419,11 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
416
419
  def run_ruff_checks(
417
420
  files: List[Path], config_source_path: Optional[Path] = None
418
421
  ) -> ValidatorResult:
419
- """Run ruff for fast Python linting."""
420
- if not check_ruff_available():
421
- return ValidatorResult(
422
- name="Ruff",
423
- checks="37",
424
- passed=True,
425
- output="Ruff not installed - skipping",
426
- )
422
+ """Run ruff for fast Python linting.
427
423
 
424
+ run_ruff_check makes the same availability probe and returns the same
425
+ skip message, so probing here would spawn ruff --version twice.
426
+ """
428
427
  result = run_ruff_check(files, config_source_path)
429
428
 
430
429
  return ValidatorResult(
@@ -731,38 +730,6 @@ def reconstruct_proposed_content(
731
730
  return apply_edits(existing_content, edits_for_tool(tool_name, dict(tool_input)))
732
731
 
733
732
 
734
- def run_file_scoped_validators(
735
- all_files: List[Path], config_source_path: Optional[Path] = None
736
- ) -> List[ValidatorResult]:
737
- """Run every file-scoped validator against *all_files*.
738
-
739
- Excludes the branch-scoped File Structure and Git validators.
740
-
741
- Args:
742
- all_files: The files under validation — one reconstructed file in gate mode.
743
- config_source_path: Original path ruff and mypy resolve their config from.
744
-
745
- Returns:
746
- One ValidatorResult per file-scoped validator, in run order.
747
- """
748
- return [
749
- run_python_style_checks(all_files),
750
- run_test_safety_checks(all_files),
751
- run_react_checks(all_files),
752
- run_ruff_checks(all_files, config_source_path),
753
- run_mypy_checks(all_files, config_source_path),
754
- run_abbreviation_checks(all_files),
755
- run_pr_reference_checks(all_files),
756
- run_magic_value_checks(all_files),
757
- run_useless_test_checks(all_files),
758
- run_security_checks(all_files),
759
- run_code_quality_checks(all_files),
760
- run_python_antipattern_checks(all_files),
761
- run_todo_checks(all_files),
762
- run_type_safety_checks(all_files),
763
- ]
764
-
765
-
766
733
  def _escapes_temporary_root(path_part: str) -> bool:
767
734
  """Return True when a path part would climb out of or re-anchor the temp root.
768
735
 
@@ -788,69 +755,77 @@ def _escapes_temporary_root(path_part: str) -> bool:
788
755
  return part_as_path.is_absolute() or bool(part_as_path.anchor)
789
756
 
790
757
 
791
- def _is_absolute_path_under_system_temporary_directory(file_path: str) -> bool:
792
- """Return True when *file_path* is absolute and resolves under an OS temp root.
758
+ def _directory_segment_signals_exemption(segment_lower: str) -> bool:
759
+ """Return True when a directory segment carries a path-exemption signal.
793
760
 
794
761
  ::
795
762
 
796
- ok: "<windows-temp-root>/pytest-of-x/test_foo0/a.py" -> True
797
- ok: "/home/runner/work/_temp/pytest-basetemp/.../a.py" when RUNNER_TEMP
798
- flag: "packages/demo/test_helpers/worker.py" -> False (relative)
799
- flag: "C:/repo/pkg/test_helpers/worker.py" -> False (project)
763
+ ok: "test_helpers" -> True (contains the ``test_`` fragment)
764
+ ok: "scripts" -> True (exact CLI-marker segment name)
765
+ flag: "latest" -> False (neither exact name nor a fragment)
766
+ flag: "test_edit_foo0" -> False (pytest generated it, not the project)
800
767
 
801
- Absolute paths under system temporary directories often carry pytest
802
- ``tmp_path`` segments named ``test_*``. Those segments must not be treated
803
- as project exemption signals, or a production file staged from a pytest
804
- path would inherit a false test-file exemption.
768
+ A segment is kept during staging when its name exactly matches an exemption
769
+ directory name, or when a separator-free substring pattern that
770
+ ``is_test_file`` matches anywhere in a path appears inside it — that is what
771
+ preserves ``pkg/test_helpers/``. A directory pytest generated for its own
772
+ scratch tree is excluded from the substring pass, so a ``test_*`` scratch
773
+ parent never lends a staged production file a false test exemption.
805
774
 
806
775
  Args:
807
- file_path: Destination path the write or edit targets.
776
+ segment_lower: One lowercased directory component of the target path.
808
777
 
809
778
  Returns:
810
- True when *file_path* is absolute and lives under ``tempfile.gettempdir()``
811
- or a known temporary-root environment variable (``TEMP``, ``TMP``,
812
- ``TMPDIR``, ``RUNNER_TEMP``).
779
+ True when staging must keep this segment to reproduce the exemption.
813
780
  """
814
- destination_path = Path(file_path)
815
- if not destination_path.is_absolute():
781
+ if segment_lower in ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES:
782
+ return True
783
+ if is_pytest_scratch_directory_segment(segment_lower):
816
784
  return False
817
- return enclosing_system_temporary_root(destination_path) is not None
785
+ return any(
786
+ each_pattern in segment_lower
787
+ for each_pattern in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
788
+ )
818
789
 
819
790
 
820
- def _directory_segment_signals_exemption(
821
- segment_lower: str, *, is_substring_match_enabled: bool
822
- ) -> bool:
823
- """Return True when a directory segment carries a path-exemption signal.
791
+ def _path_parts_the_project_owns(destination_path: Path) -> tuple[str, ...]:
792
+ """Return the destination's path parts from its own project root downward.
824
793
 
825
794
  ::
826
795
 
827
- ok: "test_helpers" (substring on) -> True (contains the ``test_`` fragment)
828
- ok: "scripts" -> True (exact CLI-marker segment name)
829
- flag: "latest" -> False (neither exact name nor a fragment)
830
- flag: "test_edit_foo0" (substring off) -> False (pytest-shaped; exact-only mode)
796
+ ok: "/home/tests/proj/src/worker.py" (proj is the root) -> ("src", "worker.py")
797
+ ok: "/home/dana/.claude/hooks/gate.py" (no root) -> (".claude", "hooks", "gate.py")
798
+ ok: "config/x.py" (relative) -> ("config", "x.py")
831
799
 
832
- A segment is kept during staging when its name exactly matches an exemption
833
- directory name. When *is_substring_match_enabled* is True, a separator-free
834
- substring pattern that ``is_test_file`` matches anywhere in a path also
835
- counts — that is what preserves ``pkg/test_helpers/``. Substring matching is
836
- disabled for absolute paths under the system temporary directory so pytest
837
- ``tmp_path`` parents named ``test_*`` do not falsely trip test exemptions.
800
+ Directory names above the project root were chosen by whoever laid out the
801
+ disk, not by the project, so a home directory named ``tests`` or a checkout
802
+ parked under ``config`` must lend the staged path no exemption signal.
803
+ Dropping those segments is strictly narrowing: it can remove a signal, never
804
+ invent one.
805
+
806
+ A relative destination is already project-relative and is returned whole,
807
+ which keeps the result independent of the process working directory. A
808
+ destination with no project root above it -- an install target such as
809
+ ``~/.claude/hooks/`` -- keeps every segment, because those segments are the
810
+ only exemption signal such a target carries.
838
811
 
839
812
  Args:
840
- segment_lower: One lowercased directory component of the target path.
841
- is_substring_match_enabled: When False, only exact exemption names count.
813
+ destination_path: Real destination path the write or edit targets.
842
814
 
843
815
  Returns:
844
- True when staging must keep this segment to reproduce the exemption.
816
+ The path parts staging walks for exemption signals, root anchor removed.
845
817
  """
846
- if segment_lower in ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES:
847
- return True
848
- if not is_substring_match_enabled:
849
- return False
850
- return any(
851
- each_pattern in segment_lower
852
- for each_pattern in ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS
853
- )
818
+ all_path_parts = destination_path.parts
819
+ if not destination_path.anchor:
820
+ return all_path_parts
821
+ all_path_parts = all_path_parts[1:]
822
+ project_root = find_module_resolution_root(destination_path)
823
+ if project_root is None:
824
+ return all_path_parts
825
+ try:
826
+ return destination_path.resolve().relative_to(project_root).parts
827
+ except ValueError:
828
+ return all_path_parts
854
829
 
855
830
 
856
831
  def _temporary_path_preserving_directory_signal(
@@ -862,11 +837,12 @@ def _temporary_path_preserving_directory_signal(
862
837
  path fragments (``test_`` inside ``test_helpers``) match substrings of the
863
838
  real file path. Staging under a flat temp basename drops those segments.
864
839
  Mirroring the first exemption-signaling directory through the basename
865
- restores that signal. Substring fragment matching is skipped for absolute
866
- paths under the system temporary directory so pytest ``tmp_path`` parents
867
- that contain ``test_`` do not falsely trip test-file exemptions; exact
868
- exemption directory names (``config``, ``scripts``, ``tests``, ...) still
869
- anchor staging on those absolute temp paths.
840
+ restores that signal. A directory pytest generated for its own scratch tree
841
+ is recognized by name and skipped, so a ``tmp_path`` parent containing
842
+ ``test_`` never trips a test-file exemption. The staged path reads the same
843
+ wherever ``--basetemp`` places that scratch tree. The walk starts at the
844
+ destination's own project root, so a directory above the project lends it
845
+ no signal.
870
846
 
871
847
  Args:
872
848
  temporary_directory: Root of the ephemeral staging tree.
@@ -877,21 +853,14 @@ def _temporary_path_preserving_directory_signal(
877
853
  (when present) and the real basename.
878
854
  """
879
855
  destination_path = Path(file_path)
880
- path_parts = destination_path.parts
881
- if destination_path.anchor:
882
- path_parts = path_parts[1:]
856
+ path_parts = _path_parts_the_project_owns(destination_path)
883
857
  if not path_parts:
884
858
  path_parts = (destination_path.name,)
885
859
 
886
- is_substring_match_enabled = not _is_absolute_path_under_system_temporary_directory(
887
- file_path
888
- )
889
860
  start_index = len(path_parts) - 1
890
861
  for each_index, each_part in enumerate(path_parts[:-1]):
891
862
  part_lower = each_part.lower()
892
- if not _directory_segment_signals_exemption(
893
- part_lower, is_substring_match_enabled=is_substring_match_enabled
894
- ):
863
+ if not _directory_segment_signals_exemption(part_lower):
895
864
  continue
896
865
  start_index = each_index
897
866
  break
@@ -919,18 +888,20 @@ def validate_proposed_file(
919
888
  Writes the content to a temporary file that preserves the exemption-relevant
920
889
  directory tail and basename so directory-based exemptions, suffix-based
921
890
  filtering, and test-name-based filtering match the real path, then runs the
922
- file-scoped validators against it. Ruff and mypy resolve their config by
923
- walking up from the config source path, so the staged copy is graded under
924
- the project config the real path sits in rather than the temp directory's.
891
+ save-path checks in-process against it, plus Ruff. Ruff resolves its config
892
+ by walking up from the config source path, so the staged copy is graded
893
+ under the project config the real path sits in rather than the temp
894
+ directory's.
925
895
 
926
896
  Args:
927
897
  file_path: The destination path the write or edit targets.
928
898
  proposed_content: The reconstructed post-edit content of that file.
929
- config_source_path: Path ruff and mypy resolve their config from;
930
- defaults to *file_path* when the caller passes nothing.
899
+ config_source_path: Path Ruff resolves its config from; defaults to
900
+ *file_path* when the caller passes nothing.
931
901
 
932
902
  Returns:
933
- One ValidatorResult per file-scoped validator.
903
+ One ValidatorResult per save-path check, plus Ruff. Mypy never runs
904
+ here; CLI/full mode is the only caller that still runs it.
934
905
  """
935
906
  with tempfile.TemporaryDirectory() as temporary_directory:
936
907
  temporary_file = _temporary_path_preserving_directory_signal(
@@ -942,9 +913,10 @@ def validate_proposed_file(
942
913
  if config_source_path is not None
943
914
  else Path(file_path)
944
915
  )
945
- return run_file_scoped_validators(
946
- [temporary_file], config_source_path=resolved_config_source_path
947
- )
916
+ return [
917
+ *run_fast_save_validators([temporary_file]),
918
+ run_ruff_checks([temporary_file], resolved_config_source_path),
919
+ ]
948
920
 
949
921
 
950
922
  def _validator_summaries(results: List[ValidatorResult]) -> str:
@@ -1186,6 +1158,63 @@ def _line_identity(
1186
1158
  )
1187
1159
 
1188
1160
 
1161
+ def _scope_agnostic_message(identity_scope: str, violation_message: str) -> str:
1162
+ """Return the message with its own function's name and shape metrics masked.
1163
+
1164
+ ::
1165
+
1166
+ "over_long_fn", "Function 'over_long_fn' is 37 lines (max 30)"
1167
+ -> "Function '<name>' is <number> lines (max <number>)"
1168
+ "compute_total", "Magic number 199 - use named constant"
1169
+ -> "Magic number 199 - use named constant"
1170
+
1171
+ A message that names its enclosing function reports that function's shape,
1172
+ so the numbers in it belong to the same shape and are masked alongside the
1173
+ name. A message that never names the function keeps its numbers, so a magic
1174
+ value and a ruff rule code still tell two violations apart.
1175
+
1176
+ Args:
1177
+ identity_scope: The enclosing function's qualified name, or the empty
1178
+ string for a module-scope violation.
1179
+ violation_message: The message text of one violation line.
1180
+
1181
+ Returns:
1182
+ The message with the enclosing function's name replaced by a fixed
1183
+ placeholder, and its metric numbers replaced too when that name was
1184
+ present; the unchanged message otherwise.
1185
+ """
1186
+ short_scope_name = identity_scope.rpartition(QUALIFIED_SCOPE_NAME_SEPARATOR)[2]
1187
+ if not short_scope_name:
1188
+ return violation_message
1189
+ scope_name_pattern = (
1190
+ WORD_BOUNDARY_PATTERN + re.escape(short_scope_name) + WORD_BOUNDARY_PATTERN
1191
+ )
1192
+ if not re.search(scope_name_pattern, violation_message):
1193
+ return violation_message
1194
+ masked_message = re.sub(
1195
+ scope_name_pattern, SCOPE_NAME_PLACEHOLDER, violation_message
1196
+ )
1197
+ return re.sub(
1198
+ STANDALONE_NUMBER_PATTERN, SHAPE_NUMBER_PLACEHOLDER, masked_message
1199
+ )
1200
+
1201
+
1202
+ def _shape_key(violation_identity: ViolationIdentity) -> tuple[str, str]:
1203
+ """Return an identity's ``(validator, scope-agnostic message)`` shape key.
1204
+
1205
+ Args:
1206
+ violation_identity: One violation's full identity key.
1207
+
1208
+ Returns:
1209
+ The key a violation keeps when only its enclosing function's name
1210
+ changes, so a rename, a split, or a move still finds its baseline.
1211
+ """
1212
+ return (
1213
+ violation_identity[0],
1214
+ _scope_agnostic_message(violation_identity[1], violation_identity[2]),
1215
+ )
1216
+
1217
+
1189
1218
  def _violation_identities(
1190
1219
  failed_results: List[ValidatorResult], content: str
1191
1220
  ) -> Counter[ViolationIdentity]:
@@ -1194,7 +1223,8 @@ def _violation_identities(
1194
1223
  Keying on the enclosing function rather than the raw line number keeps a
1195
1224
  key stable when an edit shifts that function, and counting rather than set
1196
1225
  membership keeps a second violation of the same validator in the same
1197
- function visible as new.
1226
+ function visible as new. A rename moves that key rather than shifting it,
1227
+ which is why matching falls back to the shape key.
1198
1228
 
1199
1229
  Args:
1200
1230
  failed_results: The validator results that fired.
@@ -1227,22 +1257,37 @@ def _baseline_violation_identities(file_path: str) -> Counter[ViolationIdentity]
1227
1257
  return _violation_identities(baseline_failed, baseline_content)
1228
1258
 
1229
1259
 
1230
- def _identity_key_counts(
1231
- baseline_identities: Counter[ViolationIdentity],
1232
- ) -> Counter[tuple[str, str]]:
1233
- """Sum baseline counts down to ``(validator, enclosing function)`` keys.
1260
+ def _compatible_baseline_identity(
1261
+ line_identity: ViolationIdentity, remaining_exact: Counter[ViolationIdentity]
1262
+ ) -> Optional[ViolationIdentity]:
1263
+ """Return an unconsumed baseline identity *line_identity* may be charged to.
1264
+
1265
+ A same-validator baseline entry in the same enclosing function absorbs a
1266
+ message that drifted with the edit. A same-validator entry of the same
1267
+ shape absorbs a message whose enclosing function was renamed, split, or
1268
+ moved. The same-function entry is preferred, so a drifted message inside an
1269
+ untouched function never spends a renamed function's allowance.
1234
1270
 
1235
1271
  Args:
1236
- baseline_identities: The baseline identity multiset.
1272
+ line_identity: The proposed line's identity key.
1273
+ remaining_exact: The unconsumed baseline identity budget.
1237
1274
 
1238
1275
  Returns:
1239
- The per-key line counts with messages ignored, so a violation whose
1240
- message drifted with the edit still finds its baseline budget.
1276
+ The baseline identity to charge, or None when the budget is spent.
1241
1277
  """
1242
- key_counts: Counter[tuple[str, str]] = Counter()
1243
- for each_identity, each_count in baseline_identities.items():
1244
- key_counts[each_identity[:2]] += each_count
1245
- return key_counts
1278
+ all_available_identities = [
1279
+ each_identity
1280
+ for each_identity, each_count in remaining_exact.items()
1281
+ if each_count > 0 and each_identity[0] == line_identity[0]
1282
+ ]
1283
+ for each_identity in all_available_identities:
1284
+ if each_identity[1] == line_identity[1]:
1285
+ return each_identity
1286
+ line_shape_key = _shape_key(line_identity)
1287
+ for each_identity in all_available_identities:
1288
+ if _shape_key(each_identity) == line_shape_key:
1289
+ return each_identity
1290
+ return None
1246
1291
 
1247
1292
 
1248
1293
  def _result_with_output(
@@ -1261,7 +1306,6 @@ def _result_with_output(
1261
1306
  def _consume_exact_matches(
1262
1307
  all_line_identities: List[ViolationIdentity],
1263
1308
  remaining_exact: Counter[ViolationIdentity],
1264
- remaining_by_key: Counter[tuple[str, str]],
1265
1309
  ) -> set[int]:
1266
1310
  """Mark the lines whose full identity matches an unconsumed baseline entry.
1267
1311
 
@@ -1269,7 +1313,6 @@ def _consume_exact_matches(
1269
1313
  all_line_identities: One identity per located line, in output order.
1270
1314
  remaining_exact: The unconsumed baseline identity budget, decremented
1271
1315
  in place per match.
1272
- remaining_by_key: The unconsumed per-key budget, decremented in step.
1273
1316
 
1274
1317
  Returns:
1275
1318
  The indexes of the exactly-matched lines.
@@ -1279,7 +1322,6 @@ def _consume_exact_matches(
1279
1322
  if remaining_exact[each_identity] <= 0:
1280
1323
  continue
1281
1324
  remaining_exact[each_identity] -= 1
1282
- remaining_by_key[each_identity[:2]] -= 1
1283
1325
  all_matched_line_indexes.add(each_line_index)
1284
1326
  return all_matched_line_indexes
1285
1327
 
@@ -1288,42 +1330,45 @@ def _line_is_preexisting(
1288
1330
  line_index: int,
1289
1331
  all_line_identities: List[ViolationIdentity],
1290
1332
  all_matched_line_indexes: set[int],
1291
- remaining_by_key: Counter[tuple[str, str]],
1333
+ remaining_exact: Counter[ViolationIdentity],
1292
1334
  ) -> bool:
1293
1335
  """Return whether one located line matches the baseline budget.
1294
1336
 
1295
- An exact identity match is pre-existing. A leftover line whose
1296
- ``(validator, enclosing function)`` key still has baseline budget is
1297
- pre-existing with a drifted message. A line beyond its key's budget is new.
1337
+ An exact identity match is pre-existing. A leftover line a compatible
1338
+ unconsumed baseline entry can be charged to is pre-existing with a drifted
1339
+ message or a renamed enclosing function. Charging spends that one entry, so
1340
+ a line beyond the baseline's count for its validator is new.
1298
1341
 
1299
1342
  Args:
1300
1343
  line_index: The line's position in the located-line order.
1301
1344
  all_line_identities: One identity per located line.
1302
1345
  all_matched_line_indexes: The exactly-matched line indexes.
1303
- remaining_by_key: The unconsumed per-key budget, decremented in place.
1346
+ remaining_exact: The unconsumed baseline identity budget, decremented
1347
+ in place per charge.
1304
1348
 
1305
1349
  Returns:
1306
1350
  True when the line is pre-existing, False when it is new.
1307
1351
  """
1308
1352
  if line_index in all_matched_line_indexes:
1309
1353
  return True
1310
- each_key = all_line_identities[line_index][:2]
1311
- if remaining_by_key[each_key] > 0:
1312
- remaining_by_key[each_key] -= 1
1313
- return True
1314
- return False
1354
+ compatible_identity = _compatible_baseline_identity(
1355
+ all_line_identities[line_index], remaining_exact
1356
+ )
1357
+ if compatible_identity is None:
1358
+ return False
1359
+ remaining_exact[compatible_identity] -= 1
1360
+ return True
1315
1361
 
1316
1362
 
1317
1363
  def _partition_output_lines(
1318
1364
  each_result: ValidatorResult,
1319
1365
  name_by_line: dict[int, str],
1320
1366
  remaining_exact: Counter[ViolationIdentity],
1321
- remaining_by_key: Counter[tuple[str, str]],
1322
1367
  ) -> tuple[List[str], List[str]]:
1323
1368
  """Split one result's located lines into a (new, pre-existing) line pair.
1324
1369
 
1325
- The exact and per-key baseline budgets are consumed in place, exact
1326
- matches first, so a later result never double-spends an earlier match.
1370
+ The single baseline budget is consumed in place, exact matches first, so a
1371
+ later result never double-spends an earlier match.
1327
1372
  """
1328
1373
  located_lines = _located_violation_lines(each_result)
1329
1374
  all_line_identities = [
@@ -1331,13 +1376,13 @@ def _partition_output_lines(
1331
1376
  for each_line in located_lines
1332
1377
  ]
1333
1378
  all_matched_line_indexes = _consume_exact_matches(
1334
- all_line_identities, remaining_exact, remaining_by_key
1379
+ all_line_identities, remaining_exact
1335
1380
  )
1336
1381
  all_new_lines: List[str] = []
1337
1382
  all_preexisting_lines: List[str] = []
1338
1383
  for each_line_index, each_line in enumerate(located_lines):
1339
1384
  is_preexisting = _line_is_preexisting(
1340
- each_line_index, all_line_identities, all_matched_line_indexes, remaining_by_key
1385
+ each_line_index, all_line_identities, all_matched_line_indexes, remaining_exact
1341
1386
  )
1342
1387
  (all_preexisting_lines if is_preexisting else all_new_lines).append(each_line)
1343
1388
  return all_new_lines, all_preexisting_lines
@@ -1381,13 +1426,12 @@ def _scope_new_and_preexisting(
1381
1426
  """
1382
1427
  name_by_line = _enclosing_function_name_by_line(proposed_content)
1383
1428
  remaining_exact = Counter(baseline_identities)
1384
- remaining_by_key = _identity_key_counts(baseline_identities)
1385
1429
  all_new_results: List[ValidatorResult] = []
1386
1430
  all_preexisting_results: List[ValidatorResult] = []
1387
1431
  for each_result in all_proposed_failed_results:
1388
1432
  new_results, preexisting_results = _grouped_result_lines(
1389
1433
  each_result,
1390
- _partition_output_lines(each_result, name_by_line, remaining_exact, remaining_by_key),
1434
+ _partition_output_lines(each_result, name_by_line, remaining_exact),
1391
1435
  )
1392
1436
  all_new_results.extend(new_results)
1393
1437
  all_preexisting_results.extend(preexisting_results)
@@ -1433,10 +1477,12 @@ def _evaluate_pre_tool_use_payload() -> None:
1433
1477
  payload, so an ephemeral scratch or session scratchpad target passes without
1434
1478
  validation before any baseline-scoped decision runs. For a non-exempt target,
1435
1479
  each located violation is keyed by validator name, enclosing function, and
1436
- message, then counted against the on-disk baseline. A violation beyond the
1437
- baseline's budget for its key denies the write; one the baseline already
1438
- carries passes with a stderr advisory. Writes nothing for a clean file, an
1439
- exempt target, or an unparseable payload.
1480
+ message, then charged against the on-disk baseline. A violation the
1481
+ baseline cannot pay for denies the write, whether by an exact key, by the
1482
+ same enclosing function, or by the same message shape under a renamed
1483
+ function; one the baseline already carries passes with a stderr advisory.
1484
+ Writes nothing for a clean file, an exempt target, or an unparseable
1485
+ payload.
1440
1486
  """
1441
1487
  pre_tool_use_payload = json.load(sys.stdin)
1442
1488
  if not isinstance(pre_tool_use_payload, dict):
@@ -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
 
@@ -74,3 +74,23 @@ class TestSingleLetterVariables:
74
74
  tree = ast.parse(ALLOWED_LOOP_COUNTERS)
75
75
  violations = check_single_letter_variables(tree, "test.py")
76
76
  assert violations == []
77
+
78
+ def test_underscore_allowed_alongside_disallowed_single_letter(self) -> None:
79
+ """``_`` and the loop counters share one allow-list; every other letter fails.
80
+
81
+ config/abbreviation_checks_constants.py pins the allow-list's exact
82
+ membership; this test pins that check_single_letter_variables reads
83
+ that same list correctly for a mixed allowed/disallowed source.
84
+ """
85
+ source = '''
86
+ def process(matrix):
87
+ for i in range(10):
88
+ for j in range(10):
89
+ for k in range(10):
90
+ _ = matrix[i][j][k]
91
+ q = matrix[i][j][k]
92
+ '''
93
+ tree = ast.parse(source)
94
+ violations = check_single_letter_variables(tree, "test.py")
95
+ assert len(violations) == 1
96
+ assert "q" in violations[0].message