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