claude-dev-env 2.2.0 → 2.3.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/CLAUDE.md +1 -1
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/code-verifier.md +1 -1
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/config/verified_commit_constants.py +11 -0
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/blocking/test_verification_verdict_store.py +93 -0
- package/hooks/blocking/verification_verdict_store.py +91 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/check.ps1 +32 -6
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/team-advisor/SKILL.md +3 -2
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -6,12 +6,14 @@ Exit code 0 = all checks pass, 1 = violations found.
|
|
|
6
6
|
# pragma: no-tdd-gate
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import ast
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
import tempfile
|
|
14
15
|
import time
|
|
16
|
+
from collections import Counter
|
|
15
17
|
from dataclasses import dataclass
|
|
16
18
|
from datetime import datetime
|
|
17
19
|
from pathlib import Path
|
|
@@ -779,6 +781,22 @@ def validate_proposed_file(
|
|
|
779
781
|
return run_file_scoped_validators([temporary_file])
|
|
780
782
|
|
|
781
783
|
|
|
784
|
+
def _validator_summaries(results: List[ValidatorResult]) -> str:
|
|
785
|
+
"""Join one ``name (checks): output`` summary per result with a separator.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
results: The validator results to summarize.
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
The joined summary text shared by the deny reason and the warning.
|
|
792
|
+
"""
|
|
793
|
+
validator_summary_separator = " | "
|
|
794
|
+
return validator_summary_separator.join(
|
|
795
|
+
f"{each_result.name} (checks {each_result.checks}): {each_result.output.strip()}"
|
|
796
|
+
for each_result in results
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
|
|
782
800
|
def _proposed_content_deny_reason(failed_results: List[ValidatorResult]) -> str:
|
|
783
801
|
"""Compose the deny reason naming each failing validator and its output.
|
|
784
802
|
|
|
@@ -788,15 +806,9 @@ def _proposed_content_deny_reason(failed_results: List[ValidatorResult]) -> str:
|
|
|
788
806
|
Returns:
|
|
789
807
|
The composed ``permissionDecisionReason`` text.
|
|
790
808
|
"""
|
|
791
|
-
violation_summaries = [
|
|
792
|
-
f"{each_result.name} (checks {each_result.checks}): {each_result.output.strip()}"
|
|
793
|
-
for each_result in failed_results
|
|
794
|
-
]
|
|
795
|
-
deny_reason_item_separator = " | "
|
|
796
|
-
joined_summaries = deny_reason_item_separator.join(violation_summaries)
|
|
797
809
|
return (
|
|
798
810
|
f"BLOCKED: [validators] {len(failed_results)} "
|
|
799
|
-
f"validator(s) failed: {
|
|
811
|
+
f"validator(s) failed: {_validator_summaries(failed_results)}"
|
|
800
812
|
)
|
|
801
813
|
|
|
802
814
|
|
|
@@ -818,16 +830,447 @@ def _emit_pre_tool_use_deny(deny_reason: str) -> None:
|
|
|
818
830
|
sys.stdout.flush()
|
|
819
831
|
|
|
820
832
|
|
|
833
|
+
def _record_function_spans(
|
|
834
|
+
parent_node: ast.AST, name_prefix: str, name_by_line: dict[int, str]
|
|
835
|
+
) -> None:
|
|
836
|
+
"""Assign each line inside a function to that function's qualified name.
|
|
837
|
+
|
|
838
|
+
Inner functions overwrite the enclosing name, so a line resolves to its
|
|
839
|
+
innermost function; a method resolves to ``Class.method``.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
parent_node: The AST node whose children are walked.
|
|
843
|
+
name_prefix: The dotted qualifier accumulated from enclosing scopes.
|
|
844
|
+
name_by_line: The line-to-name map filled in place.
|
|
845
|
+
"""
|
|
846
|
+
for each_child in ast.iter_child_nodes(parent_node):
|
|
847
|
+
if isinstance(each_child, ast.ClassDef):
|
|
848
|
+
_record_function_spans(each_child, f"{name_prefix}{each_child.name}.", name_by_line)
|
|
849
|
+
elif isinstance(each_child, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
850
|
+
qualified_name = f"{name_prefix}{each_child.name}"
|
|
851
|
+
last_line = each_child.end_lineno or each_child.lineno
|
|
852
|
+
for each_line in range(each_child.lineno, last_line + 1):
|
|
853
|
+
name_by_line[each_line] = qualified_name
|
|
854
|
+
_record_function_spans(each_child, f"{qualified_name}.", name_by_line)
|
|
855
|
+
else:
|
|
856
|
+
_record_function_spans(each_child, name_prefix, name_by_line)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _enclosing_function_name_by_line(content: str) -> dict[int, str]:
|
|
860
|
+
"""Map each source line to its innermost enclosing function's qualified name.
|
|
861
|
+
|
|
862
|
+
::
|
|
863
|
+
|
|
864
|
+
def outer(): # lines 1-4 -> "outer"
|
|
865
|
+
def inner(): # lines 2-3 -> "outer.inner"
|
|
866
|
+
return None
|
|
867
|
+
return inner
|
|
868
|
+
log_start() # line 5 -> "" (module scope)
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
content: The full source text to parse.
|
|
872
|
+
|
|
873
|
+
Returns:
|
|
874
|
+
A line-to-name map; a line outside every function has no entry, so a
|
|
875
|
+
lookup yields the empty string for module scope. An unparseable source
|
|
876
|
+
yields an empty map.
|
|
877
|
+
"""
|
|
878
|
+
try:
|
|
879
|
+
tree = ast.parse(content)
|
|
880
|
+
except SyntaxError:
|
|
881
|
+
return {}
|
|
882
|
+
name_by_line: dict[int, str] = {}
|
|
883
|
+
_record_function_spans(tree, "", name_by_line)
|
|
884
|
+
return name_by_line
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _violation_line_number(output_line: str) -> int:
|
|
888
|
+
"""Return the source line a validator's location prefix names.
|
|
889
|
+
|
|
890
|
+
::
|
|
891
|
+
|
|
892
|
+
/pkg/legacy_module.py:37: magic number -> line number 37
|
|
893
|
+
/pkg/legacy_module.py:37:5: F401 unused -> line number 37
|
|
894
|
+
a summary line with no file location -> line number 0
|
|
895
|
+
1 | import os (ruff code frame) -> line number 0
|
|
896
|
+
|
|
897
|
+
The line is the first colon-delimited field that is all digits, with every
|
|
898
|
+
later prefix field also all digits (the column) and every earlier field
|
|
899
|
+
reading like a path — no pipes or quotes, though spaces are allowed so a
|
|
900
|
+
spaced directory or file name still parses. A ruff code-frame line quoting
|
|
901
|
+
source text carries a pipe or quote before any digits, so frame and
|
|
902
|
+
summary noise resolves to 0.
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
output_line: One printed ``Violation`` line from a validator.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
The parsed line number, or 0 for a line with no ``file:line`` prefix.
|
|
909
|
+
"""
|
|
910
|
+
prefix_fields = output_line.partition(": ")[0].split(":")
|
|
911
|
+
for each_field_index, each_field in enumerate(prefix_fields):
|
|
912
|
+
if not each_field.isdigit():
|
|
913
|
+
continue
|
|
914
|
+
return _line_number_when_prefix_is_a_location(prefix_fields, each_field_index)
|
|
915
|
+
return 0
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _line_number_when_prefix_is_a_location(
|
|
919
|
+
prefix_fields: List[str], digit_field_index: int
|
|
920
|
+
) -> int:
|
|
921
|
+
"""Return the digit field as a line number when its prefix reads ``path:line``.
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
prefix_fields: The colon-split fields of the text before the message.
|
|
925
|
+
digit_field_index: The index of the first all-digit field.
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
The line number, or 0 when the surrounding fields do not form a
|
|
929
|
+
``path:line[:col]`` location.
|
|
930
|
+
"""
|
|
931
|
+
non_path_characters = ("|", '"')
|
|
932
|
+
if digit_field_index == 0:
|
|
933
|
+
return 0
|
|
934
|
+
path_fields = prefix_fields[:digit_field_index]
|
|
935
|
+
looks_like_a_path = not any(
|
|
936
|
+
each_character in each_path_field
|
|
937
|
+
for each_path_field in path_fields
|
|
938
|
+
for each_character in non_path_characters
|
|
939
|
+
)
|
|
940
|
+
trailing_fields = prefix_fields[digit_field_index + 1 :]
|
|
941
|
+
if looks_like_a_path and all(each_field.isdigit() for each_field in trailing_fields):
|
|
942
|
+
return int(prefix_fields[digit_field_index])
|
|
943
|
+
return 0
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _identity_scope(output_line: str, name_by_line: dict[int, str]) -> str:
|
|
947
|
+
"""Return the enclosing-function name a single violation line belongs to.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
output_line: One printed ``Violation`` line from a validator.
|
|
951
|
+
name_by_line: The line-to-name map for the content that produced it.
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
The enclosing function's qualified name, or the empty string for a
|
|
955
|
+
module-scope or unlocatable violation.
|
|
956
|
+
"""
|
|
957
|
+
return name_by_line.get(_violation_line_number(output_line), "")
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _failed_results(all_results: List[ValidatorResult]) -> List[ValidatorResult]:
|
|
961
|
+
"""Return the results that fired — not passed and not skipped."""
|
|
962
|
+
return [
|
|
963
|
+
each_result
|
|
964
|
+
for each_result in all_results
|
|
965
|
+
if not each_result.passed and not each_result.skipped
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
ViolationIdentity = tuple[str, str, str]
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _violation_message(output_line: str) -> str:
|
|
973
|
+
"""Return the message text after the ``path:line[:col]: `` location prefix.
|
|
974
|
+
|
|
975
|
+
Args:
|
|
976
|
+
output_line: One located violation line from a validator.
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
The text after the first colon-space separator.
|
|
980
|
+
"""
|
|
981
|
+
return output_line.partition(": ")[2]
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _located_violation_lines(each_result: ValidatorResult) -> List[str]:
|
|
985
|
+
"""Return the result's output lines that carry a real ``file:line`` location.
|
|
986
|
+
|
|
987
|
+
Ruff code frames, help hints, and ``Found N errors`` summaries carry no
|
|
988
|
+
location, so they are dropped rather than classified.
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
each_result: The failing validator result to filter.
|
|
992
|
+
|
|
993
|
+
Returns:
|
|
994
|
+
The located violation lines in output order.
|
|
995
|
+
"""
|
|
996
|
+
return [
|
|
997
|
+
each_output_line
|
|
998
|
+
for each_output_line in each_result.output.splitlines()
|
|
999
|
+
if _violation_line_number(each_output_line) > 0
|
|
1000
|
+
]
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _line_identity(
|
|
1004
|
+
validator_name: str, output_line: str, name_by_line: dict[int, str]
|
|
1005
|
+
) -> ViolationIdentity:
|
|
1006
|
+
"""Return one line's ``(validator, enclosing function, message)`` identity key.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
validator_name: The name of the validator that printed the line.
|
|
1010
|
+
output_line: One located violation line.
|
|
1011
|
+
name_by_line: The line-to-name map for the content that produced it.
|
|
1012
|
+
|
|
1013
|
+
Returns:
|
|
1014
|
+
The identity key for baseline comparison.
|
|
1015
|
+
"""
|
|
1016
|
+
return (
|
|
1017
|
+
validator_name,
|
|
1018
|
+
_identity_scope(output_line, name_by_line),
|
|
1019
|
+
_violation_message(output_line),
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _violation_identities(
|
|
1024
|
+
failed_results: List[ValidatorResult], content: str
|
|
1025
|
+
) -> Counter[ViolationIdentity]:
|
|
1026
|
+
"""Count each located violation line by its identity key.
|
|
1027
|
+
|
|
1028
|
+
Keying on the enclosing function rather than the raw line number keeps a
|
|
1029
|
+
key stable when an edit shifts that function, and counting rather than set
|
|
1030
|
+
membership keeps a second violation of the same validator in the same
|
|
1031
|
+
function visible as new.
|
|
1032
|
+
|
|
1033
|
+
Args:
|
|
1034
|
+
failed_results: The validator results that fired.
|
|
1035
|
+
content: The source text those results were produced against.
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
The multiset of violation identity keys.
|
|
1039
|
+
"""
|
|
1040
|
+
name_by_line = _enclosing_function_name_by_line(content)
|
|
1041
|
+
return Counter(
|
|
1042
|
+
_line_identity(each_result.name, each_output_line, name_by_line)
|
|
1043
|
+
for each_result in failed_results
|
|
1044
|
+
for each_output_line in _located_violation_lines(each_result)
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _baseline_violation_identities(file_path: str) -> Counter[ViolationIdentity]:
|
|
1049
|
+
"""Return the violation identity counts the on-disk file already carries.
|
|
1050
|
+
|
|
1051
|
+
Args:
|
|
1052
|
+
file_path: The write's target path, read as the pre-edit baseline.
|
|
1053
|
+
|
|
1054
|
+
Returns:
|
|
1055
|
+
The baseline identity multiset, empty when the file is absent or empty.
|
|
1056
|
+
"""
|
|
1057
|
+
baseline_content = _read_target_file_content(file_path)
|
|
1058
|
+
if not baseline_content:
|
|
1059
|
+
return Counter()
|
|
1060
|
+
baseline_failed = _failed_results(validate_proposed_file(file_path, baseline_content))
|
|
1061
|
+
return _violation_identities(baseline_failed, baseline_content)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _identity_key_counts(
|
|
1065
|
+
baseline_identities: Counter[ViolationIdentity],
|
|
1066
|
+
) -> Counter[tuple[str, str]]:
|
|
1067
|
+
"""Sum baseline counts down to ``(validator, enclosing function)`` keys.
|
|
1068
|
+
|
|
1069
|
+
Args:
|
|
1070
|
+
baseline_identities: The baseline identity multiset.
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
The per-key line counts with messages ignored, so a violation whose
|
|
1074
|
+
message drifted with the edit still finds its baseline budget.
|
|
1075
|
+
"""
|
|
1076
|
+
key_counts: Counter[tuple[str, str]] = Counter()
|
|
1077
|
+
for each_identity, each_count in baseline_identities.items():
|
|
1078
|
+
key_counts[each_identity[:2]] += each_count
|
|
1079
|
+
return key_counts
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _result_with_output(
|
|
1083
|
+
source_result: ValidatorResult, all_output_lines: List[str]
|
|
1084
|
+
) -> ValidatorResult:
|
|
1085
|
+
"""Return a copy of *source_result* carrying only *all_output_lines*."""
|
|
1086
|
+
output_line_separator = "\n"
|
|
1087
|
+
return ValidatorResult(
|
|
1088
|
+
name=source_result.name,
|
|
1089
|
+
checks=source_result.checks,
|
|
1090
|
+
passed=False,
|
|
1091
|
+
output=output_line_separator.join(all_output_lines),
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _consume_exact_matches(
|
|
1096
|
+
all_line_identities: List[ViolationIdentity],
|
|
1097
|
+
remaining_exact: Counter[ViolationIdentity],
|
|
1098
|
+
remaining_by_key: Counter[tuple[str, str]],
|
|
1099
|
+
) -> set[int]:
|
|
1100
|
+
"""Mark the lines whose full identity matches an unconsumed baseline entry.
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
all_line_identities: One identity per located line, in output order.
|
|
1104
|
+
remaining_exact: The unconsumed baseline identity budget, decremented
|
|
1105
|
+
in place per match.
|
|
1106
|
+
remaining_by_key: The unconsumed per-key budget, decremented in step.
|
|
1107
|
+
|
|
1108
|
+
Returns:
|
|
1109
|
+
The indexes of the exactly-matched lines.
|
|
1110
|
+
"""
|
|
1111
|
+
all_matched_line_indexes: set[int] = set()
|
|
1112
|
+
for each_line_index, each_identity in enumerate(all_line_identities):
|
|
1113
|
+
if remaining_exact[each_identity] <= 0:
|
|
1114
|
+
continue
|
|
1115
|
+
remaining_exact[each_identity] -= 1
|
|
1116
|
+
remaining_by_key[each_identity[:2]] -= 1
|
|
1117
|
+
all_matched_line_indexes.add(each_line_index)
|
|
1118
|
+
return all_matched_line_indexes
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _line_is_preexisting(
|
|
1122
|
+
line_index: int,
|
|
1123
|
+
all_line_identities: List[ViolationIdentity],
|
|
1124
|
+
all_matched_line_indexes: set[int],
|
|
1125
|
+
remaining_by_key: Counter[tuple[str, str]],
|
|
1126
|
+
) -> bool:
|
|
1127
|
+
"""Return whether one located line matches the baseline budget.
|
|
1128
|
+
|
|
1129
|
+
An exact identity match is pre-existing. A leftover line whose
|
|
1130
|
+
``(validator, enclosing function)`` key still has baseline budget is
|
|
1131
|
+
pre-existing with a drifted message. A line beyond its key's budget is new.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
line_index: The line's position in the located-line order.
|
|
1135
|
+
all_line_identities: One identity per located line.
|
|
1136
|
+
all_matched_line_indexes: The exactly-matched line indexes.
|
|
1137
|
+
remaining_by_key: The unconsumed per-key budget, decremented in place.
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
True when the line is pre-existing, False when it is new.
|
|
1141
|
+
"""
|
|
1142
|
+
if line_index in all_matched_line_indexes:
|
|
1143
|
+
return True
|
|
1144
|
+
each_key = all_line_identities[line_index][:2]
|
|
1145
|
+
if remaining_by_key[each_key] > 0:
|
|
1146
|
+
remaining_by_key[each_key] -= 1
|
|
1147
|
+
return True
|
|
1148
|
+
return False
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _partition_output_lines(
|
|
1152
|
+
each_result: ValidatorResult,
|
|
1153
|
+
name_by_line: dict[int, str],
|
|
1154
|
+
remaining_exact: Counter[ViolationIdentity],
|
|
1155
|
+
remaining_by_key: Counter[tuple[str, str]],
|
|
1156
|
+
) -> tuple[List[str], List[str]]:
|
|
1157
|
+
"""Split one result's located lines into a (new, pre-existing) line pair.
|
|
1158
|
+
|
|
1159
|
+
The exact and per-key baseline budgets are consumed in place, exact
|
|
1160
|
+
matches first, so a later result never double-spends an earlier match.
|
|
1161
|
+
"""
|
|
1162
|
+
located_lines = _located_violation_lines(each_result)
|
|
1163
|
+
all_line_identities = [
|
|
1164
|
+
_line_identity(each_result.name, each_line, name_by_line)
|
|
1165
|
+
for each_line in located_lines
|
|
1166
|
+
]
|
|
1167
|
+
all_matched_line_indexes = _consume_exact_matches(
|
|
1168
|
+
all_line_identities, remaining_exact, remaining_by_key
|
|
1169
|
+
)
|
|
1170
|
+
all_new_lines: List[str] = []
|
|
1171
|
+
all_preexisting_lines: List[str] = []
|
|
1172
|
+
for each_line_index, each_line in enumerate(located_lines):
|
|
1173
|
+
is_preexisting = _line_is_preexisting(
|
|
1174
|
+
each_line_index, all_line_identities, all_matched_line_indexes, remaining_by_key
|
|
1175
|
+
)
|
|
1176
|
+
(all_preexisting_lines if is_preexisting else all_new_lines).append(each_line)
|
|
1177
|
+
return all_new_lines, all_preexisting_lines
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _grouped_result_lines(
|
|
1181
|
+
each_result: ValidatorResult, all_partitioned_lines: tuple[List[str], List[str]]
|
|
1182
|
+
) -> tuple[List[ValidatorResult], List[ValidatorResult]]:
|
|
1183
|
+
"""Return one result's (new, pre-existing) groups from its partitioned lines.
|
|
1184
|
+
|
|
1185
|
+
A failed result with no located line at all cannot be baseline-matched, so
|
|
1186
|
+
it stays new in full — the gate fails closed rather than letting an
|
|
1187
|
+
unlocatable failure through.
|
|
1188
|
+
"""
|
|
1189
|
+
all_new_lines, all_preexisting_lines = all_partitioned_lines
|
|
1190
|
+
if not all_new_lines and not all_preexisting_lines:
|
|
1191
|
+
return [each_result], []
|
|
1192
|
+
new_results = [_result_with_output(each_result, all_new_lines)] if all_new_lines else []
|
|
1193
|
+
preexisting_results = (
|
|
1194
|
+
[_result_with_output(each_result, all_preexisting_lines)]
|
|
1195
|
+
if all_preexisting_lines
|
|
1196
|
+
else []
|
|
1197
|
+
)
|
|
1198
|
+
return new_results, preexisting_results
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _scope_new_and_preexisting(
|
|
1202
|
+
all_proposed_failed_results: List[ValidatorResult],
|
|
1203
|
+
proposed_content: str,
|
|
1204
|
+
baseline_identities: Counter[ViolationIdentity],
|
|
1205
|
+
) -> tuple[List[ValidatorResult], List[ValidatorResult]]:
|
|
1206
|
+
"""Group proposed violations into newly-introduced and pre-existing results.
|
|
1207
|
+
|
|
1208
|
+
Args:
|
|
1209
|
+
all_proposed_failed_results: The validators that fired on the proposed file.
|
|
1210
|
+
proposed_content: The reconstructed post-edit source text.
|
|
1211
|
+
baseline_identities: The identity counts the on-disk baseline carries.
|
|
1212
|
+
|
|
1213
|
+
Returns:
|
|
1214
|
+
A ``(new_results, preexisting_results)`` pair.
|
|
1215
|
+
"""
|
|
1216
|
+
name_by_line = _enclosing_function_name_by_line(proposed_content)
|
|
1217
|
+
remaining_exact = Counter(baseline_identities)
|
|
1218
|
+
remaining_by_key = _identity_key_counts(baseline_identities)
|
|
1219
|
+
all_new_results: List[ValidatorResult] = []
|
|
1220
|
+
all_preexisting_results: List[ValidatorResult] = []
|
|
1221
|
+
for each_result in all_proposed_failed_results:
|
|
1222
|
+
new_results, preexisting_results = _grouped_result_lines(
|
|
1223
|
+
each_result,
|
|
1224
|
+
_partition_output_lines(each_result, name_by_line, remaining_exact, remaining_by_key),
|
|
1225
|
+
)
|
|
1226
|
+
all_new_results.extend(new_results)
|
|
1227
|
+
all_preexisting_results.extend(preexisting_results)
|
|
1228
|
+
return all_new_results, all_preexisting_results
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _emit_pre_existing_warning(all_preexisting_results: List[ValidatorResult]) -> None:
|
|
1232
|
+
"""Write a stderr advisory naming each pre-existing violation left in place."""
|
|
1233
|
+
advisory_summaries = _validator_summaries(all_preexisting_results)
|
|
1234
|
+
sys.stderr.write(
|
|
1235
|
+
"[run_all_validators] allowed with warning: "
|
|
1236
|
+
f"pre-existing violation(s) unchanged: {advisory_summaries}\n"
|
|
1237
|
+
)
|
|
1238
|
+
sys.stderr.flush()
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _decide_pre_tool_use(file_path: str, proposed_content: str) -> None:
|
|
1242
|
+
"""Deny only violations absent from the baseline; warn on the ones that persist.
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
file_path: The write's target path.
|
|
1246
|
+
proposed_content: The reconstructed post-edit content of that file.
|
|
1247
|
+
"""
|
|
1248
|
+
all_proposed_failed = _failed_results(
|
|
1249
|
+
validate_proposed_file(file_path, proposed_content)
|
|
1250
|
+
)
|
|
1251
|
+
if not all_proposed_failed:
|
|
1252
|
+
return
|
|
1253
|
+
baseline_identities = _baseline_violation_identities(file_path)
|
|
1254
|
+
all_new_results, all_preexisting_results = _scope_new_and_preexisting(
|
|
1255
|
+
all_proposed_failed, proposed_content, baseline_identities
|
|
1256
|
+
)
|
|
1257
|
+
if all_preexisting_results:
|
|
1258
|
+
_emit_pre_existing_warning(all_preexisting_results)
|
|
1259
|
+
if all_new_results:
|
|
1260
|
+
_emit_pre_tool_use_deny(_proposed_content_deny_reason(all_new_results))
|
|
1261
|
+
|
|
1262
|
+
|
|
821
1263
|
def _evaluate_pre_tool_use_payload() -> None:
|
|
822
|
-
"""Read the PreToolUse payload from stdin and deny
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1264
|
+
"""Read the PreToolUse payload from stdin and deny only newly-introduced violations.
|
|
1265
|
+
|
|
1266
|
+
The path-based exemption decision runs against the real target path from the
|
|
1267
|
+
payload, so an ephemeral scratch or session scratchpad target passes without
|
|
1268
|
+
validation before any baseline-scoped decision runs. For a non-exempt target,
|
|
1269
|
+
each located violation is keyed by validator name, enclosing function, and
|
|
1270
|
+
message, then counted against the on-disk baseline. A violation beyond the
|
|
1271
|
+
baseline's budget for its key denies the write; one the baseline already
|
|
1272
|
+
carries passes with a stderr advisory. Writes nothing for a clean file, an
|
|
1273
|
+
exempt target, or an unparseable payload.
|
|
831
1274
|
"""
|
|
832
1275
|
pre_tool_use_payload = json.load(sys.stdin)
|
|
833
1276
|
if not isinstance(pre_tool_use_payload, dict):
|
|
@@ -844,14 +1287,7 @@ def _evaluate_pre_tool_use_payload() -> None:
|
|
|
844
1287
|
proposed_content = reconstruct_proposed_content(tool_name, tool_input)
|
|
845
1288
|
if not proposed_content:
|
|
846
1289
|
return
|
|
847
|
-
|
|
848
|
-
failed_results = [
|
|
849
|
-
each_result
|
|
850
|
-
for each_result in all_results
|
|
851
|
-
if not each_result.passed and not each_result.skipped
|
|
852
|
-
]
|
|
853
|
-
if failed_results:
|
|
854
|
-
_emit_pre_tool_use_deny(_proposed_content_deny_reason(failed_results))
|
|
1290
|
+
_decide_pre_tool_use(file_path, proposed_content)
|
|
855
1291
|
|
|
856
1292
|
|
|
857
1293
|
def run_pre_tool_use_gate() -> int:
|
|
@@ -25,3 +25,24 @@ def test_run_ruff_check_returns_passed_for_empty_files() -> None:
|
|
|
25
25
|
result = run_ruff_check([])
|
|
26
26
|
assert result.passed is True
|
|
27
27
|
assert "No files" in result.output
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_run_ruff_check_emits_location_prefixed_lines(tmp_path: Path) -> None:
|
|
31
|
+
"""Each reported violation carries a ``path:line:col:`` prefix on its own line.
|
|
32
|
+
|
|
33
|
+
The run_all_validators PreToolUse gate parses these prefixes to scope
|
|
34
|
+
violations to a baseline, so the output format is pinned to the concise
|
|
35
|
+
shape rather than the ruff default, which moved locations onto separate
|
|
36
|
+
``-->`` lines.
|
|
37
|
+
"""
|
|
38
|
+
violating_file = tmp_path / "unused_import_module.py"
|
|
39
|
+
violating_file.write_text("import os\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
result = run_ruff_check([violating_file])
|
|
42
|
+
|
|
43
|
+
assert result.passed is False
|
|
44
|
+
location_prefix = f"{violating_file}:1:8:"
|
|
45
|
+
assert any(
|
|
46
|
+
each_line.startswith(location_prefix) and "F401" in each_line
|
|
47
|
+
for each_line in result.output.splitlines()
|
|
48
|
+
), result.output
|