claude-dev-env 1.74.0 → 1.76.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/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
- package/hooks/blocking/code_rules_duplicate_body.py +378 -26
- package/hooks/blocking/code_rules_enforcer.py +36 -5
- package/hooks/blocking/code_rules_imports_logging.py +679 -1
- package/hooks/blocking/code_rules_shared.py +8 -5
- package/hooks/blocking/code_rules_test_assertions.py +6 -7
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/hedging_language_blocker.py +1 -13
- package/hooks/blocking/intent_only_ending_blocker.py +1 -15
- package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
- package/hooks/blocking/question_to_user_enforcer.py +1 -11
- package/hooks/blocking/session_handoff_blocker.py +1 -15
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
- package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/blocking/test_verification_verdict_store.py +66 -1
- package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
- package/hooks/blocking/verification_verdict_store.py +19 -5
- package/hooks/blocking/verifier_verdict_minter.py +18 -15
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/blocking_check_limits.py +30 -1
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -1
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
- package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_mypy_validator.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -0
- package/package.json +1 -1
- package/rules/claude-md-orphan-file.md +7 -8
- package/rules/docstring-prose-matches-implementation.md +1 -0
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -1
- package/skills/anthropic-plan/SKILL.md +15 -2
- package/skills/autoconverge/SKILL.md +6 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +306 -137
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +581 -613
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
|
@@ -39,6 +39,13 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
39
39
|
TEST_FILENAME_PREFIX,
|
|
40
40
|
TESTS_PATH_SEGMENT,
|
|
41
41
|
)
|
|
42
|
+
from pr_loop_shared_constants.inline_duplicate_body_span_constants import ( # noqa: E402
|
|
43
|
+
INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX,
|
|
44
|
+
INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX,
|
|
45
|
+
INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX,
|
|
46
|
+
INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX,
|
|
47
|
+
INLINE_DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
48
|
+
)
|
|
42
49
|
|
|
43
50
|
|
|
44
51
|
ValidateContentCallable = Callable[..., list[str]]
|
|
@@ -1038,6 +1045,43 @@ def duplicate_body_span_range(violation_text: str) -> range | None:
|
|
|
1038
1045
|
return range(definition_line, definition_line + line_span)
|
|
1039
1046
|
|
|
1040
1047
|
|
|
1048
|
+
def inline_duplicate_body_span_lines(violation_text: str) -> frozenset[int] | None:
|
|
1049
|
+
"""Return the union of both spans of a same-file inline-duplicate issue, or None.
|
|
1050
|
+
|
|
1051
|
+
The same-file inline-duplicate message names two functions that share a body —
|
|
1052
|
+
the helper and the enclosing function carrying the inline copy — and the live
|
|
1053
|
+
Write/Edit hook scopes the violation by the UNION of both spans, blocking when
|
|
1054
|
+
an edit touches either function. So the message carries both spans: ``(inline
|
|
1055
|
+
duplicate body spans: helper at line H spanning P lines, enclosing at line E
|
|
1056
|
+
spanning Q lines)``. The two spans can be disjoint (an unrelated function may
|
|
1057
|
+
sit between the helper and its inline copy), so this returns the union as a
|
|
1058
|
+
line-number set rather than a single contiguous range — a range covering the
|
|
1059
|
+
gap would wrongly block an edit confined to that intervening function, which
|
|
1060
|
+
the PreToolUse path leaves unflagged.
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
violation_text: A single violation string emitted by the enforcer.
|
|
1064
|
+
|
|
1065
|
+
Returns:
|
|
1066
|
+
The frozenset of every line in the helper span and the enclosing span, or
|
|
1067
|
+
None when the text is not a same-file inline-duplicate violation.
|
|
1068
|
+
"""
|
|
1069
|
+
span_match = INLINE_DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
|
|
1070
|
+
if span_match is None:
|
|
1071
|
+
return None
|
|
1072
|
+
helper_line = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX))
|
|
1073
|
+
helper_span = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX))
|
|
1074
|
+
enclosing_line = int(
|
|
1075
|
+
span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX)
|
|
1076
|
+
)
|
|
1077
|
+
enclosing_span = int(
|
|
1078
|
+
span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX)
|
|
1079
|
+
)
|
|
1080
|
+
helper_lines = range(helper_line, helper_line + helper_span)
|
|
1081
|
+
enclosing_lines = range(enclosing_line, enclosing_line + enclosing_span)
|
|
1082
|
+
return frozenset(helper_lines) | frozenset(enclosing_lines)
|
|
1083
|
+
|
|
1084
|
+
|
|
1041
1085
|
def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
|
|
1042
1086
|
return (
|
|
1043
1087
|
function_length_span_range,
|
|
@@ -1084,11 +1128,15 @@ def split_violations_by_scope(
|
|
|
1084
1128
|
|
|
1085
1129
|
Returns:
|
|
1086
1130
|
Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
|
|
1087
|
-
None, every issue is blocking.
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1131
|
+
None, every issue is blocking. A same-file inline-duplicate violation
|
|
1132
|
+
carries both the helper span and the enclosing span;
|
|
1133
|
+
``inline_duplicate_body_span_lines`` reconstructs their union as a
|
|
1134
|
+
line-number set, and the violation is blocking when an added line falls
|
|
1135
|
+
in either span — matching the live Write/Edit hook's union scoping. Every
|
|
1136
|
+
other diff-scoped violation (function-length, HOME/TMP isolation,
|
|
1137
|
+
banned-noun, cross-file duplicate-body) carries one enclosing-unit span
|
|
1138
|
+
fragment that ``enclosing_span_range`` reconstructs through one shared
|
|
1139
|
+
extractor registry; such a violation is blocking
|
|
1092
1140
|
when its declared span intersects the added lines (the unit grew or its
|
|
1093
1141
|
signature changed in this diff) and advisory otherwise (a pre-existing
|
|
1094
1142
|
untouched unit). Every other issue is blocking when its ``Line N:``
|
|
@@ -1099,6 +1147,13 @@ def split_violations_by_scope(
|
|
|
1099
1147
|
blocking: list[str] = []
|
|
1100
1148
|
advisory: list[str] = []
|
|
1101
1149
|
for each_issue in all_issues:
|
|
1150
|
+
inline_duplicate_lines = inline_duplicate_body_span_lines(each_issue)
|
|
1151
|
+
if inline_duplicate_lines is not None:
|
|
1152
|
+
if inline_duplicate_lines & all_added_line_numbers:
|
|
1153
|
+
blocking.append(each_issue)
|
|
1154
|
+
else:
|
|
1155
|
+
advisory.append(each_issue)
|
|
1156
|
+
continue
|
|
1102
1157
|
span_range = enclosing_span_range(each_issue)
|
|
1103
1158
|
if span_range is not None:
|
|
1104
1159
|
if any(each_line in all_added_line_numbers for each_line in span_range):
|
|
@@ -9,6 +9,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
|
|
|
9
9
|
| `claude_permissions_constants.py` | `grant_project_claude_permissions.py` and `revoke_project_claude_permissions.py` — permission rule strings and settings.json keys |
|
|
10
10
|
| `claude_settings_keys_constants.py` | Top-level `~/.claude/settings.json` key names used across the permission helpers |
|
|
11
11
|
| `code_rules_gate_constants.py` | `code_rules_gate.py` — file extensions, git diff subcommands, test filename patterns |
|
|
12
|
+
| `inline_duplicate_body_span_constants.py` | `code_rules_gate.py` — regex and capture-group indices for the same-file inline-duplicate message, which carries both the helper and the enclosing span the gate reconstructs the union from |
|
|
12
13
|
| `fix_hookspath_constants.py` | `fix_hookspath.py` — verification suffix and related strings |
|
|
13
14
|
| `post_audit_thread_constants.py` | `post_audit_thread.py` — HTTP status codes, retry counts, GitHub API paths |
|
|
14
15
|
| `preflight_constants.py` | `preflight.py` — env-var names, git subcommands, pytest exit codes, test discovery patterns |
|
package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Pattern and capture-group constants for the same-file inline-duplicate span.
|
|
2
|
+
|
|
3
|
+
The same-file inline-duplicate enforcer message names two functions that share a
|
|
4
|
+
body — the helper and the enclosing function carrying the inline copy — and the
|
|
5
|
+
live Write/Edit hook scopes the violation by the UNION of both spans. So the
|
|
6
|
+
message carries both spans, and ``code_rules_gate.inline_duplicate_body_span_lines``
|
|
7
|
+
parses them to reconstruct that union when the commit/push gate scopes a deferred
|
|
8
|
+
violation by added line. These constants describe the message suffix the enforcer
|
|
9
|
+
emits: ``(inline duplicate body spans: helper at line H spanning P lines,
|
|
10
|
+
enclosing at line E spanning Q lines)``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
INLINE_DUPLICATE_BODY_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
|
|
16
|
+
r"\(inline duplicate body spans: helper at line (\d+) spanning (\d+) lines, "
|
|
17
|
+
r"enclosing at line (\d+) spanning (\d+) lines\)"
|
|
18
|
+
)
|
|
19
|
+
INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX: int = 1
|
|
20
|
+
INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX: int = 2
|
|
21
|
+
INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX: int = 3
|
|
22
|
+
INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX: int = 4
|
|
@@ -32,7 +32,7 @@ def _load_gate_module() -> ModuleType:
|
|
|
32
32
|
gate_module = _load_gate_module()
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def
|
|
35
|
+
def _load_duplicate_body_module() -> ModuleType:
|
|
36
36
|
package_root = gate_module.resolve_claude_dev_env_root(
|
|
37
37
|
Path(gate_module.__file__).resolve()
|
|
38
38
|
)
|
|
@@ -42,10 +42,16 @@ def _load_duplicate_body_check() -> Callable[..., list[str]]:
|
|
|
42
42
|
assert spec.loader is not None
|
|
43
43
|
module = importlib.util.module_from_spec(spec)
|
|
44
44
|
spec.loader.exec_module(module)
|
|
45
|
-
return module
|
|
45
|
+
return module
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
_duplicate_body_module = _load_duplicate_body_module()
|
|
49
|
+
check_duplicate_function_body_across_files = (
|
|
50
|
+
_duplicate_body_module.check_duplicate_function_body_across_files
|
|
51
|
+
)
|
|
52
|
+
check_same_file_inline_duplicate_body = (
|
|
53
|
+
_duplicate_body_module.check_same_file_inline_duplicate_body
|
|
54
|
+
)
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
|
|
@@ -767,6 +773,144 @@ def test_split_violations_advises_duplicate_body_when_span_misses_added_lines(
|
|
|
767
773
|
assert blocking == []
|
|
768
774
|
|
|
769
775
|
|
|
776
|
+
_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST = (
|
|
777
|
+
"Function '_wait_for_render' duplicates an inline block in '_navigate_then_wait'"
|
|
778
|
+
" — this function body is also present inline (Reuse before create / DRY) "
|
|
779
|
+
"(inline duplicate body spans: helper at line 4 spanning 10 lines, "
|
|
780
|
+
"enclosing at line 16 spanning 11 lines)"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def test_inline_duplicate_body_span_lines_unions_helper_and_enclosing_spans() -> None:
|
|
785
|
+
"""The same-file inline-duplicate message carries both spans, and the gate
|
|
786
|
+
recovers their union as a line-number set so a touch of either function blocks —
|
|
787
|
+
mirroring the live Write/Edit hook's union scoping."""
|
|
788
|
+
span_lines = gate_module.inline_duplicate_body_span_lines(
|
|
789
|
+
_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST
|
|
790
|
+
)
|
|
791
|
+
assert span_lines == frozenset(range(4, 14)) | frozenset(range(16, 27))
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def test_inline_duplicate_body_span_lines_returns_none_for_other_messages() -> None:
|
|
795
|
+
"""A cross-file duplicate-body message carries the single-span suffix, which the
|
|
796
|
+
inline-duplicate extractor must not claim — so the gate routes it to the
|
|
797
|
+
single-range extractor registry instead."""
|
|
798
|
+
cross_file_message = (
|
|
799
|
+
"Function 'strip' duplicates existing_helper.py::strip — extract a shared "
|
|
800
|
+
"helper (duplicate body span at line 3, spanning 5 lines)"
|
|
801
|
+
)
|
|
802
|
+
assert gate_module.inline_duplicate_body_span_lines(cross_file_message) is None
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def test_inline_duplicate_blocks_when_only_enclosing_copy_added() -> None:
|
|
806
|
+
"""The finding's real-world shape: the helper pre-exists (untouched) and a copy
|
|
807
|
+
is added INTO a growing enclosing function. An added line in the enclosing span
|
|
808
|
+
alone must block, because the live Write/Edit hook scopes by the union of both
|
|
809
|
+
spans and blocks the same edit."""
|
|
810
|
+
added_line_in_enclosing_only = 18
|
|
811
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
812
|
+
[_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST],
|
|
813
|
+
all_added_line_numbers={added_line_in_enclosing_only},
|
|
814
|
+
)
|
|
815
|
+
assert blocking == [_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST]
|
|
816
|
+
assert advisory == []
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def test_inline_duplicate_blocks_when_only_helper_copy_added() -> None:
|
|
820
|
+
"""The mirror case: an edit touching only the helper span blocks too, since the
|
|
821
|
+
union covers both functions."""
|
|
822
|
+
added_line_in_helper_only = 5
|
|
823
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
824
|
+
[_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST],
|
|
825
|
+
all_added_line_numbers={added_line_in_helper_only},
|
|
826
|
+
)
|
|
827
|
+
assert blocking == [_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST]
|
|
828
|
+
assert advisory == []
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def test_inline_duplicate_advises_when_gap_line_added() -> None:
|
|
832
|
+
"""An edit confined to an unrelated function that sits strictly between the
|
|
833
|
+
helper (lines 4-13) and the enclosing copy (lines 16-26) — line 14, in the gap —
|
|
834
|
+
must not block, matching the live hook that leaves such an edit unflagged. A
|
|
835
|
+
single contiguous range over both spans would wrongly block this; the union set
|
|
836
|
+
keeps the gap out of scope."""
|
|
837
|
+
gap_line_between_spans = 14
|
|
838
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
839
|
+
[_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST],
|
|
840
|
+
all_added_line_numbers={gap_line_between_spans},
|
|
841
|
+
)
|
|
842
|
+
assert advisory == [_INLINE_DUPLICATE_MESSAGE_HELPER_FIRST]
|
|
843
|
+
assert blocking == []
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
_INLINE_DUPLICATE_END_TO_END_SOURCE = (
|
|
847
|
+
"import asyncio\n"
|
|
848
|
+
"\n"
|
|
849
|
+
"\n"
|
|
850
|
+
"async def _wait_for_render(automation: object) -> None:\n"
|
|
851
|
+
" assert automation.detector is not None\n"
|
|
852
|
+
" try:\n"
|
|
853
|
+
" await automation.detector.wait_for_element(selector)\n"
|
|
854
|
+
" except (TimeoutError, RuntimeError) as render_error:\n"
|
|
855
|
+
" logger.warning('did not render: %s', render_error)\n"
|
|
856
|
+
"\n"
|
|
857
|
+
"\n"
|
|
858
|
+
"async def _navigate_then_wait(automation: object) -> None:\n"
|
|
859
|
+
" await automation.cdp.navigate(url)\n"
|
|
860
|
+
" assert automation.detector is not None\n"
|
|
861
|
+
" try:\n"
|
|
862
|
+
" await automation.detector.wait_for_element(selector)\n"
|
|
863
|
+
" except (TimeoutError, RuntimeError) as render_error:\n"
|
|
864
|
+
" logger.warning('did not render: %s', render_error)\n"
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_gate_blocks_inline_duplicate_when_only_enclosing_copy_is_added() -> None:
|
|
869
|
+
"""End-to-end parity check for the finding's real-world shape. The helper is
|
|
870
|
+
defined first (pre-existing, untouched) and the inline copy lives in a later
|
|
871
|
+
enclosing function; an edit that adds only the enclosing copy lines makes the
|
|
872
|
+
live Write/Edit hook BLOCK. The deferred message the commit/push gate re-scopes
|
|
873
|
+
must produce the same verdict — blocking, not advisory — so the two enforcement
|
|
874
|
+
surfaces agree.
|
|
875
|
+
"""
|
|
876
|
+
enclosing_definition_line = (
|
|
877
|
+
_INLINE_DUPLICATE_END_TO_END_SOURCE.splitlines().index(
|
|
878
|
+
"async def _navigate_then_wait(automation: object) -> None:"
|
|
879
|
+
)
|
|
880
|
+
+ 1
|
|
881
|
+
)
|
|
882
|
+
enclosing_added_lines = set(range(enclosing_definition_line, enclosing_definition_line + 6))
|
|
883
|
+
|
|
884
|
+
pretooluse_issues = check_same_file_inline_duplicate_body(
|
|
885
|
+
_INLINE_DUPLICATE_END_TO_END_SOURCE,
|
|
886
|
+
"account_switcher.py",
|
|
887
|
+
all_changed_lines=enclosing_added_lines,
|
|
888
|
+
)
|
|
889
|
+
assert any("_wait_for_render" in each_issue for each_issue in pretooluse_issues), (
|
|
890
|
+
"The live Write/Edit path must block when the enclosing copy is added, "
|
|
891
|
+
f"got: {pretooluse_issues}"
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
deferred_issues = check_same_file_inline_duplicate_body(
|
|
895
|
+
_INLINE_DUPLICATE_END_TO_END_SOURCE,
|
|
896
|
+
"account_switcher.py",
|
|
897
|
+
all_changed_lines=enclosing_added_lines,
|
|
898
|
+
defer_scope_to_caller=True,
|
|
899
|
+
)
|
|
900
|
+
blocking, advisory = gate_module.split_violations_by_scope(
|
|
901
|
+
deferred_issues, enclosing_added_lines
|
|
902
|
+
)
|
|
903
|
+
assert any("_wait_for_render" in each_issue for each_issue in blocking), (
|
|
904
|
+
"The commit/push gate must reconstruct the union scope and BLOCK the same "
|
|
905
|
+
f"enclosing-only edit the live hook blocks, got blocking: {blocking}, "
|
|
906
|
+
f"advisory: {advisory}"
|
|
907
|
+
)
|
|
908
|
+
assert advisory == [], (
|
|
909
|
+
"No inline-duplicate violation may land in advisory when the gate and the "
|
|
910
|
+
f"live hook agree on blocking, got advisory: {advisory}"
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
770
914
|
def test_collect_partitioned_violations_advises_pre_existing_sibling_duplicate(
|
|
771
915
|
temporary_git_repository: Path,
|
|
772
916
|
) -> None:
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -23,7 +23,7 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
23
23
|
|
|
24
24
|
`code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
|
|
25
25
|
|
|
26
|
-
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
|
|
26
|
+
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
|
|
27
27
|
|
|
28
28
|
Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
|
|
29
29
|
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -30,8 +30,8 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
30
30
|
| `code_rules_dead_dataclass_field.py` | Dataclass fields with no consuming references |
|
|
31
31
|
| `code_rules_dead_module_constant.py` | `UPPER_SNAKE` constants in `*_constants.py` modules with no importers |
|
|
32
32
|
| `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
|
|
33
|
-
| `code_rules_duplicate_body.py` |
|
|
34
|
-
| `code_rules_imports_logging.py` | Imports at top of file; logging format-arg style |
|
|
33
|
+
| `code_rules_duplicate_body.py` | A function body copied from a sibling module, or a helper body inlined as a block inside a larger function in the same file |
|
|
34
|
+
| `code_rules_imports_logging.py` | Imports at top of file; logging format-arg style; printf tokens in `str.format`-logger messages |
|
|
35
35
|
| `code_rules_magic_values.py` | No magic numbers or strings in production code bodies |
|
|
36
36
|
| `code_rules_mock_completeness.py` | Mock calls that skip required arguments |
|
|
37
37
|
| `code_rules_naming_collection.py` | Collection names must use `all_*` prefix |
|
|
@@ -63,6 +63,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
63
63
|
| `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
|
|
64
64
|
| `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
|
|
65
65
|
| `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
|
|
66
|
+
| `duplicate_rmtree_helper_blocker.py` | PreToolUse (Write/Edit) | A local re-definition of the Windows-safe rmtree helper trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`) in place of importing a shared helper |
|
|
66
67
|
| `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
|
|
67
68
|
| `gh_body_arg_blocker.py` | PreToolUse (Bash) | `gh` commands passing `--body`/`-b` directly (requires `--body-file` instead) |
|
|
68
69
|
| `gh_pr_author_enforcer.py` | PreToolUse | Enforces PR author identity rules |
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook: blocks a per-directory CLAUDE.md
|
|
2
|
+
"""PreToolUse hook: blocks a per-directory CLAUDE.md that names a file absent from its subtree.
|
|
3
3
|
|
|
4
4
|
A per-directory ``CLAUDE.md`` documents the files reachable from its own
|
|
5
|
-
directory in a markdown table whose first column names each file in backticks
|
|
6
|
-
|
|
5
|
+
directory in a markdown table whose first column names each file in backticks,
|
|
6
|
+
and shows run commands inside fenced code blocks that invoke those files. When a
|
|
7
|
+
first-column cell, or an interpreter invocation inside a fenced run command
|
|
8
|
+
(``python script.py``), names a bare filename that exists nowhere under the scan
|
|
7
9
|
root (the CLAUDE.md directory's parent, which covers the directory, its
|
|
8
|
-
subdirectories, and its siblings), the
|
|
9
|
-
|
|
10
|
-
``CLAUDE.md`` and blocks the write when any such cell names a file
|
|
11
|
-
the scan root. A table block
|
|
12
|
-
source (a ``../`` token
|
|
13
|
-
|
|
10
|
+
subdirectories, and its siblings), the doc points a reader at a file that is not
|
|
11
|
+
there. This hook fires on Write, Edit, and MultiEdit targeting a file named
|
|
12
|
+
``CLAUDE.md`` and blocks the write when any such cell or run command names a file
|
|
13
|
+
absent from the scan root. A table block or a run-command fence whose own region
|
|
14
|
+
declares an explicit relative-path source (a ``../`` token, in the rows/fence or
|
|
15
|
+
the prose that introduces it) documents files outside the subtree, so that block's
|
|
16
|
+
rows or that fence's commands are left alone — the exemption is scoped to the
|
|
17
|
+
region, not the whole file.
|
|
14
18
|
"""
|
|
15
19
|
|
|
16
20
|
import json
|
|
@@ -26,6 +30,7 @@ if _hooks_dir not in sys.path:
|
|
|
26
30
|
from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E402
|
|
27
31
|
ALL_NOISE_DIRECTORY_NAMES,
|
|
28
32
|
ALL_REFERENCED_FILE_EXTENSIONS,
|
|
33
|
+
ALL_RUN_COMMAND_SCRIPT_EXTENSIONS,
|
|
29
34
|
CLAUDE_MD_FILENAME,
|
|
30
35
|
CODE_FENCE_PATTERN,
|
|
31
36
|
FIRST_COLUMN_BACKTICK_PATTERN,
|
|
@@ -36,6 +41,7 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
|
|
|
36
41
|
ORPHAN_FILE_SYSTEM_MESSAGE,
|
|
37
42
|
REGION_BOUNDARY_PATTERN,
|
|
38
43
|
RELATIVE_PATH_SOURCE_PATTERN,
|
|
44
|
+
RUN_COMMAND_SCRIPT_PATTERN,
|
|
39
45
|
SEPARATOR_CELL_PATTERN,
|
|
40
46
|
TABLE_ROW_PATTERN,
|
|
41
47
|
)
|
|
@@ -210,6 +216,147 @@ def _block_filenames(all_region_lines: list[str], all_block_lines: list[str]) ->
|
|
|
210
216
|
return block_filenames
|
|
211
217
|
|
|
212
218
|
|
|
219
|
+
def _script_basename_from_token(script_token: str) -> str | None:
|
|
220
|
+
"""Return the bare basename one captured script token names, when it has one.
|
|
221
|
+
|
|
222
|
+
A path-qualified script keeps only its final segment, so ``tools/build_bundle.mjs``
|
|
223
|
+
yields ``build_bundle.mjs`` — the basename the directory file would match. A
|
|
224
|
+
script named by an explicit relative-path source (a ``../`` token in the token
|
|
225
|
+
itself, as in ``../shared/preflight.py``) lives outside the subtree by design, so
|
|
226
|
+
it is exempt and yields None. This token-scoped ``../`` check is one of two
|
|
227
|
+
relative-path exemptions: ``find_run_command_filenames`` also skips a whole fence
|
|
228
|
+
whose introducing prose region declares a ``../`` source, which is the
|
|
229
|
+
region-scoped counterpart to the table-cell exemption. A token whose basename
|
|
230
|
+
carries no recognized script extension yields None.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
script_token: One script path captured from an interpreter invocation.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The bare script basename, or None when the token is relative-path-sourced
|
|
237
|
+
or carries no recognized extension.
|
|
238
|
+
"""
|
|
239
|
+
if _declares_relative_path_source(script_token):
|
|
240
|
+
return None
|
|
241
|
+
basename = os.path.basename(script_token.replace("\\", "/").rstrip("/"))
|
|
242
|
+
if not basename:
|
|
243
|
+
return None
|
|
244
|
+
_, extension = os.path.splitext(basename)
|
|
245
|
+
if extension.lower() not in ALL_RUN_COMMAND_SCRIPT_EXTENSIONS:
|
|
246
|
+
return None
|
|
247
|
+
return basename
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _command_text_before_comment(fenced_line: str) -> str:
|
|
251
|
+
"""Return the runnable portion of a fenced line, with any shell comment removed.
|
|
252
|
+
|
|
253
|
+
A shell comment starts at a ``#`` that begins a word — at the line's start or
|
|
254
|
+
after whitespace — outside any quoted span, and runs to end of line. The text
|
|
255
|
+
after it documents a removed or alternative command rather than a runnable
|
|
256
|
+
contract, so it is dropped: a full-line comment yields an empty string, and an
|
|
257
|
+
inline trailing comment (``python real.py # was: python old.py``) yields only
|
|
258
|
+
the part before the ``#``. A ``#`` inside single or double quotes, or one
|
|
259
|
+
glued to a preceding non-space character, stays in the runnable text.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
fenced_line: A single line drawn from inside a fenced code block.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The line truncated at its first unquoted word-leading ``#``, or the whole
|
|
266
|
+
line when it carries no such comment marker.
|
|
267
|
+
"""
|
|
268
|
+
open_quote_character = ""
|
|
269
|
+
previous_character = ""
|
|
270
|
+
for each_index, each_character in enumerate(fenced_line):
|
|
271
|
+
if open_quote_character:
|
|
272
|
+
if each_character == open_quote_character:
|
|
273
|
+
open_quote_character = ""
|
|
274
|
+
elif each_character in ("'", '"'):
|
|
275
|
+
open_quote_character = each_character
|
|
276
|
+
elif each_character == "#" and (each_index == 0 or previous_character.isspace()):
|
|
277
|
+
return fenced_line[:each_index]
|
|
278
|
+
previous_character = each_character
|
|
279
|
+
return fenced_line
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _run_command_filenames_in_line(fenced_line: str) -> list[str]:
|
|
283
|
+
"""Return each script basename the interpreter invocations on this line name.
|
|
284
|
+
|
|
285
|
+
A fenced run-command line such as ``python tools/verify.py --flag`` invokes a
|
|
286
|
+
script file; this returns that script's bare basename. A line that chains
|
|
287
|
+
several invocations with a shell separator (``python deploy.py && node build.mjs``,
|
|
288
|
+
``python first.py; python second.py``) contributes each invocation's basename in
|
|
289
|
+
order. A shell comment — a ``#`` that begins a word outside any quoted span,
|
|
290
|
+
whether it opens the line or trails a command (``python real.py # was python
|
|
291
|
+
old.py``) — documents a removed or alternative command rather than a runnable
|
|
292
|
+
contract, so the text from that ``#`` to end of line contributes nothing. A line
|
|
293
|
+
with no interpreter invocation contributes nothing.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
fenced_line: A single line drawn from inside a fenced code block.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Each bare script basename the line's invocations name, in order; empty when
|
|
300
|
+
the line is a comment or names no runnable script.
|
|
301
|
+
"""
|
|
302
|
+
runnable_text = _command_text_before_comment(fenced_line)
|
|
303
|
+
line_filenames: list[str] = []
|
|
304
|
+
for each_match in RUN_COMMAND_SCRIPT_PATTERN.finditer(runnable_text):
|
|
305
|
+
each_basename = _script_basename_from_token(each_match.group(1).strip())
|
|
306
|
+
if each_basename is not None:
|
|
307
|
+
line_filenames.append(each_basename)
|
|
308
|
+
return line_filenames
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def find_run_command_filenames(content: str) -> list[str]:
|
|
312
|
+
"""Return each script basename a fenced run command invokes, in order.
|
|
313
|
+
|
|
314
|
+
Walks the content line by line, inspecting only lines inside a fenced code
|
|
315
|
+
block (between a ``` or ~~~ fence pair). A fenced run-command line that invokes
|
|
316
|
+
an interpreter on a script (``python script.py``, ``node bundle.mjs``,
|
|
317
|
+
``pwsh build.ps1``) contributes that script's bare basename, and a line that
|
|
318
|
+
chains several invocations with a shell separator contributes each one. A line
|
|
319
|
+
outside any fence is prose, not a live command, and contributes nothing — an
|
|
320
|
+
inline ``python x.py`` in a sentence is documentation, not a runnable contract.
|
|
321
|
+
A fence whose introducing region declares an explicit relative-path source (a
|
|
322
|
+
``../`` token in the prose accumulated since the prior fence or heading)
|
|
323
|
+
documents commands that run scripts in a sibling tree, so that fence's run
|
|
324
|
+
commands are skipped — mirroring the region-scoped table-cell ``../``
|
|
325
|
+
exemption. The region resets when a fence closes, so the exemption is scoped to
|
|
326
|
+
that fence alone: a second fence under the same heading, introduced by prose
|
|
327
|
+
that names no ``../`` source, is still inspected.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
content: The CLAUDE.md content being written.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Each script basename a fenced run command names, in the order it appears;
|
|
334
|
+
duplicates preserved.
|
|
335
|
+
"""
|
|
336
|
+
run_command_filenames: list[str] = []
|
|
337
|
+
pending_region: list[str] = []
|
|
338
|
+
is_inside_code_fence = False
|
|
339
|
+
is_region_relative_path_sourced = False
|
|
340
|
+
for each_line in content.splitlines():
|
|
341
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
342
|
+
if not is_inside_code_fence:
|
|
343
|
+
is_region_relative_path_sourced = _declares_relative_path_source(
|
|
344
|
+
"\n".join(pending_region)
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
pending_region = []
|
|
348
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
349
|
+
continue
|
|
350
|
+
if is_inside_code_fence:
|
|
351
|
+
if not is_region_relative_path_sourced:
|
|
352
|
+
run_command_filenames.extend(_run_command_filenames_in_line(each_line))
|
|
353
|
+
continue
|
|
354
|
+
if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
|
|
355
|
+
pending_region = []
|
|
356
|
+
pending_region.append(each_line)
|
|
357
|
+
return run_command_filenames
|
|
358
|
+
|
|
359
|
+
|
|
213
360
|
def _resolve_scan_root(claude_md_directory: Path) -> Path:
|
|
214
361
|
"""Return the directory whose subtree bounds the filename existence search.
|
|
215
362
|
|
|
@@ -358,16 +505,17 @@ def _present_referenced_filenames(
|
|
|
358
505
|
def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]:
|
|
359
506
|
"""Return the referenced filenames absent from the CLAUDE.md's scan root.
|
|
360
507
|
|
|
361
|
-
A referenced filename
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
508
|
+
A referenced filename comes from two sources: a bare filename a table cell
|
|
509
|
+
names, and a script a fenced run command invokes (``python script.py``). It is
|
|
510
|
+
missing when it exists nowhere under the scan root — the CLAUDE.md directory's
|
|
511
|
+
parent (or the directory itself when it has no distinct parent), which covers
|
|
512
|
+
the directory, its subdirectories, and its siblings. A table block that
|
|
513
|
+
declares an explicit relative-path source (a ``../`` token in the block or the
|
|
514
|
+
prose that introduces it) yields no findings for that block's rows, since those
|
|
515
|
+
files legitimately live elsewhere; an unrelated block in the same file is still
|
|
516
|
+
checked. When the content references no bare filename, no findings result and
|
|
517
|
+
the subtree walk is skipped. A filesystem error that halts the whole subtree
|
|
518
|
+
walk yields no findings (fail open), so an unreadable tree never blocks a write.
|
|
371
519
|
|
|
372
520
|
Args:
|
|
373
521
|
content: The CLAUDE.md content being written.
|
|
@@ -377,7 +525,9 @@ def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]
|
|
|
377
525
|
Each referenced filename with no matching file under the scan root, in
|
|
378
526
|
first-seen order with duplicates removed, capped at the issue budget.
|
|
379
527
|
"""
|
|
380
|
-
referenced_filenames = find_referenced_filenames(content)
|
|
528
|
+
referenced_filenames = find_referenced_filenames(content) + find_run_command_filenames(
|
|
529
|
+
content
|
|
530
|
+
)
|
|
381
531
|
if not referenced_filenames:
|
|
382
532
|
return []
|
|
383
533
|
scan_root = _resolve_scan_root(claude_md_directory)
|