claude-dev-env 1.74.0 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) 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/docs/CODE_RULES.md +1 -1
  6. package/hooks/blocking/CLAUDE.md +3 -2
  7. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  8. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  9. package/hooks/blocking/code_rules_enforcer.py +36 -5
  10. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  11. package/hooks/blocking/code_rules_shared.py +8 -5
  12. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
  14. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  15. package/hooks/blocking/hedging_language_blocker.py +1 -13
  16. package/hooks/blocking/intent_only_ending_blocker.py +1 -15
  17. package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
  18. package/hooks/blocking/question_to_user_enforcer.py +1 -11
  19. package/hooks/blocking/session_handoff_blocker.py +1 -15
  20. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  22. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  23. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  24. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  25. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  26. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  27. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
  28. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  29. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  30. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  31. package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
  32. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  33. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  34. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  35. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  36. package/hooks/blocking/verification_verdict_store.py +19 -5
  37. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  38. package/hooks/hooks_constants/CLAUDE.md +4 -1
  39. package/hooks/hooks_constants/blocking_check_limits.py +30 -1
  40. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -1
  42. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  43. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  44. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  45. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
  46. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
  47. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  48. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  49. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  50. package/hooks/hooks_constants/text_stripping.py +36 -0
  51. package/hooks/validation/CLAUDE.md +1 -0
  52. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  53. package/hooks/validation/test_mypy_validator.py +1 -1
  54. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  55. package/hooks/workflow/auto_formatter.py +8 -5
  56. package/hooks/workflow/test_auto_formatter.py +33 -0
  57. package/package.json +1 -1
  58. package/rules/claude-md-orphan-file.md +7 -8
  59. package/rules/docstring-prose-matches-implementation.md +1 -0
  60. package/rules/package-inventory-stale-entry.md +8 -0
  61. package/rules/windows-filesystem-safe.md +2 -0
  62. package/skills/anthropic-plan/CLAUDE.md +1 -1
  63. package/skills/anthropic-plan/SKILL.md +15 -2
  64. package/skills/autoconverge/SKILL.md +6 -3
  65. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  66. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  67. package/skills/autoconverge/workflow/converge.contract.test.mjs +306 -137
  68. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  69. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -44
  70. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  71. package/skills/autoconverge/workflow/converge.mjs +581 -613
  72. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  73. package/skills/autoconverge/workflow/render_report.py +2 -6
  74. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  75. package/skills/autoconverge/workflow/test_render_report.py +1 -0
  76. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  77. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  78. 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:
@@ -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 |
@@ -63,6 +63,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
63
63
  | `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
64
64
  | `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
65
65
  | `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
66
+ | `duplicate_rmtree_helper_blocker.py` | PreToolUse (Write/Edit) | A local re-definition of the Windows-safe rmtree helper trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`) in place of importing a shared helper |
66
67
  | `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
67
68
  | `gh_body_arg_blocker.py` | PreToolUse (Bash) | `gh` commands passing `--body`/`-b` directly (requires `--body-file` instead) |
68
69
  | `gh_pr_author_enforcer.py` | PreToolUse | Enforces PR author identity rules |
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse hook: blocks a per-directory CLAUDE.md 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)