claude-dev-env 1.75.0 → 1.77.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/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
- package/hooks/blocking/code_rules_docstrings.py +378 -0
- package/hooks/blocking/code_rules_duplicate_body.py +378 -26
- package/hooks/blocking/code_rules_enforcer.py +48 -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/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_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -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_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/blocking_check_limits.py +56 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
- package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/claude-md-orphan-file.md +7 -8
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/package-inventory-stale-entry.md +16 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -1
- package/skills/anthropic-plan/SKILL.md +15 -2
- package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
- package/skills/autoconverge/workflow/converge.mjs +86 -110
- 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:
|
|
@@ -28,6 +28,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
|
|
|
28
28
|
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
29
29
|
| O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
|
|
30
30
|
| O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
31
|
+
| O9 | Python docstring plainness for a general developer | A changed module / class / public-function docstring's narrative prose — the summary and description before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section — reads plainly and paints a concrete scene a general developer follows on first read. Flag a narrative that stacks abstract machinery nouns into a wall (`the SIGINT install/restore/installability check, the atexit terminal-record registration, and the interrupted-run finalizer`), that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence long while joining clauses with an em-dash or a semicolon. The deterministic run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence` in `code_rules_docstrings.py`, so this lane carries the illustrative-and-brief judgment the gate cannot: whether a stranger to the code pictures the moment, the input, and the outcome after one read. See `../../rules/plain-illustrative-docstrings.md`. |
|
|
31
32
|
|
|
32
33
|
---
|
|
33
34
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 9 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
2
2
|
|
|
3
3
|
[ARTIFACT METADATA — include every changed module's docstring AND the exported symbols of that module so the audit can compare claim vs body]
|
|
4
4
|
|
|
@@ -51,9 +51,13 @@ ID prefix: `find`.
|
|
|
51
51
|
- When the diff changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the diff never touched the `.md` file, because the behavior change orphaned the doc claim.
|
|
52
52
|
- Adversarial probes: (a) for each changed producer, name the artifact it builds and grep the skill's `SKILL.md` and sibling `.md` files for any sentence naming that artifact; (b) walk the producer body's build step — does it sort, or does it merge stored names and append in file order — and compare against the doc's order word (`sorted`, `alphabetical`); (c) check whether the doc's content claim (`just the at-risk names`, `only the current set`) hides merged-in prior entries the producer carries over from the stored file.
|
|
53
53
|
|
|
54
|
+
**O9. Python docstring plainness for a general developer**
|
|
55
|
+
- For every changed module / class / public-function docstring, read the narrative prose before the first `Args:` / `Returns:` / `Raises:` / `Yields:` section as a stranger to the code. Flag a narrative that stacks abstract machinery nouns into a single wall, that defines a thing by what it is not (`the non-promoter-specific machinery`), or that runs one sentence past the word limit while joining clauses with an em-dash or a semicolon. The run-on mark is gated at Write/Edit time by `check_docstring_runon_sentence`, so this lane judges the part the gate cannot: whether the prose paints a concrete scene — the moment the code matters, the input it sees, the outcome it produces — that a general developer follows on the first read.
|
|
56
|
+
- Adversarial probes: (a) read each changed narrative and name the concrete moment, input, and outcome it paints — a narrative that names none is an O9 finding; (b) count the longest sentence's words and check for an em-dash or semicolon join — over the limit with a join is the wall mark the gate also catches; (c) rewrite each "is not" clause as a positive statement — a clause that resists rewriting because the body offers no positive description is an O9 finding.
|
|
57
|
+
|
|
54
58
|
## Cross-bucket questions to answer at the end
|
|
55
59
|
|
|
56
|
-
Q1: Across all
|
|
60
|
+
Q1: Across all 9 sub-buckets, which docstring claim is the most misleading — i.e., a future maintainer reading only the docstring would write or change code that contradicts the body? Cite file:line of the docstring AND the body line(s) that contradict it.
|
|
57
61
|
|
|
58
62
|
Q2: Which docstring claim is at highest risk of becoming load-bearing — i.e., a future caller or test author would rely on the claim to skip reading the body? Cite the claim and the use case.
|
|
59
63
|
|
|
@@ -61,13 +65,13 @@ Q3: Of the changed docstrings, which one most clearly shows a refactor was incom
|
|
|
61
65
|
|
|
62
66
|
## Output
|
|
63
67
|
|
|
64
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-
|
|
68
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-O9, produce Shape A or Shape B (with ≥3 probes). Each Shape A finding must cite (a) the docstring file:line, (b) the body file:line that contradicts it, and (c) one sentence describing the contradiction in concrete terms. Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 module-level docstring claims whose implementation moved during a refactor — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
65
69
|
|
|
66
70
|
---
|
|
67
71
|
|
|
68
72
|
# Worked example: jl-cmd/claude-code-config PR #522
|
|
69
73
|
|
|
70
|
-
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
74
|
+
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 9 sub-buckets below.
|
|
71
75
|
|
|
72
76
|
PR #522 split `pr_description_command_parser.py` into two modules — the original parser and a new `pr_description_pr_number.py` — but the originating module's docstring still claims the PR-number recovery responsibility. A sibling change to `pr_description_body_audit.py` introduced a module docstring whose verb (`detects vague language`) overstates the module's actual responsibility (it only exposes `_extract_vague_scan_text()`; detection runs elsewhere).
|
|
73
77
|
|
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 |
|
|
@@ -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)
|