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.
Files changed (47) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/CLAUDE.md +2 -2
  9. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  10. package/hooks/blocking/code_rules_docstrings.py +378 -0
  11. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  12. package/hooks/blocking/code_rules_enforcer.py +48 -5
  13. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  14. package/hooks/blocking/code_rules_shared.py +8 -5
  15. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  16. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  21. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  22. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  23. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  24. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  25. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  27. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  28. package/hooks/blocking/verification_verdict_store.py +19 -5
  29. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  30. package/hooks/hooks_constants/blocking_check_limits.py +56 -0
  31. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
  33. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/claude-md-orphan-file.md +7 -8
  37. package/rules/docstring-prose-matches-implementation.md +5 -1
  38. package/rules/package-inventory-stale-entry.md +16 -0
  39. package/rules/plain-illustrative-docstrings.md +56 -0
  40. package/skills/anthropic-plan/CLAUDE.md +1 -1
  41. package/skills/anthropic-plan/SKILL.md +15 -2
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
  43. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
  44. package/skills/autoconverge/workflow/converge.mjs +86 -110
  45. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  46. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  47. 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. Every diff-scoped violation
1088
- (function-length, HOME/TMP isolation, banned-noun, duplicate-body)
1089
- carries an enclosing-unit span fragment that ``enclosing_span_range``
1090
- reconstructs through one shared extractor registry; such a violation is
1091
- blocking
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 |
@@ -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 _load_duplicate_body_check() -> Callable[..., list[str]]:
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.check_duplicate_function_body_across_files
45
+ return module
46
46
 
47
47
 
48
- check_duplicate_function_body_across_files = _load_duplicate_body_check()
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 8 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.
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 8 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.
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-O8, 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.
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 8 sub-buckets below.
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
 
@@ -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
 
@@ -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` | Functions whose bodies match another function's body in the same file |
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 table that names a file absent from its subtree.
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
- When a first-column cell names a bare filename that exists nowhere under the scan
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 table points a reader at a file that is
9
- not there. This hook fires on Write, Edit, and MultiEdit targeting a file named
10
- ``CLAUDE.md`` and blocks the write when any such cell names a file absent from
11
- the scan root. A table block whose own region declares an explicit relative-path
12
- source (a ``../`` token) documents files outside the subtree, so that block's
13
- rows are left alone the exemption is scoped to the block, not the whole file.
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 is missing when it exists nowhere under the scan root
362
- the CLAUDE.md directory's parent (or the directory itself when it has no
363
- distinct parent), which covers the directory, its subdirectories, and its
364
- siblings. A table block that declares an explicit relative-path source (a
365
- ``../`` token in the block or the prose that introduces it) yields no findings
366
- for that block's rows, since those files legitimately live elsewhere; an
367
- unrelated block in the same file is still checked. When the content references
368
- no bare filename, no findings result and the subtree walk is skipped. A
369
- filesystem error that halts the whole subtree walk yields no findings (fail
370
- open), so an unreadable tree never blocks a write.
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)