claude-dev-env 2.30.1 → 2.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +12 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +63 -16
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -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
|
|
29
|
-
|
|
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
|
|
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
|
|
792
|
-
"""Return True when
|
|
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: "
|
|
797
|
-
ok: "
|
|
798
|
-
flag: "
|
|
799
|
-
flag: "
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
776
|
+
segment_lower: One lowercased directory component of the target path.
|
|
808
777
|
|
|
809
778
|
Returns:
|
|
810
|
-
True when
|
|
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
|
-
|
|
815
|
-
|
|
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
|
|
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
|
|
821
|
-
|
|
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: "
|
|
828
|
-
ok: "
|
|
829
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
816
|
+
The path parts staging walks for exemption signals, root anchor removed.
|
|
845
817
|
"""
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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.
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
|
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
|
-
|
|
923
|
-
walking up from the config source path, so the staged copy is graded
|
|
924
|
-
the project config the real path sits in rather than the temp
|
|
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
|
|
930
|
-
|
|
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
|
|
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
|
|
946
|
-
[temporary_file],
|
|
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
|
|
1231
|
-
|
|
1232
|
-
) ->
|
|
1233
|
-
"""
|
|
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
|
-
|
|
1272
|
+
line_identity: The proposed line's identity key.
|
|
1273
|
+
remaining_exact: The unconsumed baseline identity budget.
|
|
1237
1274
|
|
|
1238
1275
|
Returns:
|
|
1239
|
-
The
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
|
1326
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
1437
|
-
baseline
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
|
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 =
|
|
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
|