claude-dev-env 1.82.0 → 1.84.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 (217) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/audit-contract.md +24 -12
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/README.md +1 -0
  5. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  9. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  10. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  11. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  12. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  13. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  14. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  15. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  18. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  19. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  20. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  21. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  22. package/agents/clean-coder.md +3 -0
  23. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  24. package/bin/install.mjs +16 -8
  25. package/bin/install.test.mjs +17 -6
  26. package/commands/CLAUDE.md +0 -1
  27. package/docs/CODE_RULES.md +1 -1
  28. package/hooks/blocking/CLAUDE.md +2 -4
  29. package/hooks/blocking/code_rules_constants_config.py +164 -1
  30. package/hooks/blocking/code_rules_docstrings.py +428 -15
  31. package/hooks/blocking/code_rules_enforcer.py +33 -0
  32. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  33. package/hooks/blocking/code_rules_magic_values.py +5 -0
  34. package/hooks/blocking/code_rules_naming_collection.py +152 -6
  35. package/hooks/blocking/code_rules_shared.py +34 -0
  36. package/hooks/blocking/code_rules_string_magic.py +68 -0
  37. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  38. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  39. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  40. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  41. package/hooks/blocking/state_description_blocker.py +96 -5
  42. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  45. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  46. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  47. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  48. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  49. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  50. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  53. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  54. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  55. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  56. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  57. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  58. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  60. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  61. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  63. package/hooks/blocking/test_state_description_blocker.py +135 -0
  64. package/hooks/hooks.json +5 -0
  65. package/hooks/hooks_constants/CLAUDE.md +3 -4
  66. package/hooks/hooks_constants/blocking_check_limits.py +46 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  68. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  70. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  71. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  72. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  73. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  74. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  75. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  76. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  77. package/hooks/workflow/CLAUDE.md +2 -8
  78. package/package.json +1 -1
  79. package/rules/CLAUDE.md +5 -2
  80. package/rules/claude-md-orphan-file.md +5 -0
  81. package/rules/docstring-prose-matches-implementation.md +10 -1
  82. package/rules/env-var-table-code-drift.md +5 -0
  83. package/rules/es-exe-file-search.md +17 -0
  84. package/rules/no-historical-clutter.md +12 -1
  85. package/rules/orphan-css-class.md +5 -0
  86. package/rules/package-inventory-stale-entry.md +10 -0
  87. package/rules/paired-test-coverage.md +5 -0
  88. package/rules/plain-illustrative-docstrings.md +40 -1
  89. package/rules/verify-before-asking.md +7 -0
  90. package/rules/verify-runtime-state.md +40 -0
  91. package/rules/windows-filesystem-safe.md +8 -0
  92. package/rules/workers-done-before-complete.md +33 -0
  93. package/rules/workflow-substitution-slots.md +5 -0
  94. package/skills/CLAUDE.md +7 -2
  95. package/skills/autoconverge/SKILL.md +48 -61
  96. package/skills/autoconverge/reference/closing-report.md +6 -6
  97. package/skills/autoconverge/reference/convergence.md +17 -15
  98. package/skills/autoconverge/reference/gotchas.md +6 -3
  99. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  100. package/skills/autoconverge/workflow/converge.contract.test.mjs +153 -10
  101. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +55 -19
  102. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  103. package/skills/autoconverge/workflow/converge.mjs +167 -106
  104. package/skills/autoconverge/workflow/render_report.py +7 -11
  105. package/skills/bugteam/CLAUDE.md +1 -1
  106. package/skills/bugteam/PROMPTS.md +7 -6
  107. package/skills/bugteam/SKILL.md +25 -72
  108. package/skills/bugteam/reference/CLAUDE.md +1 -3
  109. package/skills/bugteam/reference/README.md +1 -1
  110. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  111. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  112. package/skills/bugteam/reference/team-setup.md +8 -7
  113. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  114. package/skills/bugteam/scripts/README.md +0 -4
  115. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  116. package/skills/code/SKILL.md +2 -0
  117. package/skills/copilot-review/CLAUDE.md +1 -1
  118. package/skills/copilot-review/SKILL.md +25 -23
  119. package/skills/everything-search/SKILL.md +5 -0
  120. package/skills/findbugs/CLAUDE.md +2 -2
  121. package/skills/findbugs/SKILL.md +22 -83
  122. package/skills/fixbugs/SKILL.md +2 -4
  123. package/skills/log-audit/CLAUDE.md +20 -0
  124. package/skills/log-audit/SKILL.md +68 -0
  125. package/skills/log-audit/reference/CLAUDE.md +9 -0
  126. package/skills/log-audit/reference/charter.md +52 -0
  127. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  128. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  129. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  130. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  131. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  132. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  133. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  134. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  135. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  136. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  137. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  138. package/skills/monitor-open-prs/SKILL.md +2 -2
  139. package/skills/post-audit-findings/SKILL.md +84 -0
  140. package/skills/pr-converge/CLAUDE.md +2 -0
  141. package/skills/pr-converge/SKILL.md +72 -59
  142. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  143. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  144. package/skills/pr-converge/reference/examples.md +5 -5
  145. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  146. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  147. package/skills/pr-converge/reference/per-tick.md +24 -45
  148. package/skills/pr-converge/reference/state-schema.md +15 -0
  149. package/skills/pr-converge/scripts/README.md +3 -5
  150. package/skills/pr-fix-protocol/SKILL.md +70 -0
  151. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  152. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  153. package/skills/pr-scope-resolve/SKILL.md +48 -0
  154. package/skills/qbug/CLAUDE.md +4 -4
  155. package/skills/qbug/SKILL.md +46 -144
  156. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  157. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  158. package/skills/reviewer-gates/SKILL.md +96 -0
  159. package/skills/session-log/CLAUDE.md +7 -7
  160. package/skills/session-log/SKILL.md +27 -44
  161. package/skills/test_markdown_link_integrity.py +103 -0
  162. package/commands/doc-gist.md +0 -16
  163. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  164. package/hooks/blocking/conftest.py +0 -30
  165. package/hooks/blocking/md_path_exemptions.py +0 -224
  166. package/hooks/blocking/md_to_html_blocker.py +0 -155
  167. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  168. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  169. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  170. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  171. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  172. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  173. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  174. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  175. package/hooks/workflow/md_to_html_companion.py +0 -358
  176. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  177. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  178. package/skills/bugteam/reference/audit-contract.md +0 -163
  179. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  180. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  181. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  182. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  183. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  184. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  185. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  186. package/skills/doc-gist/CLAUDE.md +0 -25
  187. package/skills/doc-gist/SKILL.md +0 -97
  188. package/skills/doc-gist/references/CLAUDE.md +0 -9
  189. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  190. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  191. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  192. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  193. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  194. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  195. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  196. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  197. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  198. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  199. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  200. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  201. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  202. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  203. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  204. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  205. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  206. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  207. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  208. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  209. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  210. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  211. package/skills/doc-gist/references/examples/README.md +0 -5
  212. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  213. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  214. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  215. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  216. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  217. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks an Edit that rewrites a Python code line while keeping a contradicting comment above it.
3
+
4
+ Say a comment reads ``# Mock asyncio`` right above a line that patches
5
+ ``asyncio.sleep``. An Edit that rewrites just that line to patch something
6
+ else, but leaves the comment untouched, orphans the comment: it still names
7
+ ``asyncio``, but the code below no longer does. The same gap opens when the
8
+ Edit deletes the line outright instead of rewriting it. The hook locates
9
+ each occurrence of the edit's ``old_string`` in the file and reads the line
10
+ directly above it, denying the edit when that line is a standalone ``#``
11
+ comment naming an identifier ``old_string`` carries and ``new_string`` drops.
12
+ """
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import TextIO
19
+
20
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
+ if _hooks_dir not in sys.path:
22
+ sys.path.insert(0, _hooks_dir)
23
+
24
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
25
+ from hooks_constants.pre_tool_use_dispatcher_constants import EDIT_TOOL_NAME # noqa: E402
26
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
27
+ from hooks_constants.stale_comment_reference_blocker_constants import ( # noqa: E402
28
+ ALL_COMMENT_STOPWORDS,
29
+ COMMENT_IDENTIFIER_PATTERN,
30
+ COMMENT_LINE_PREFIX,
31
+ PYTHON_FILE_SUFFIX,
32
+ STALE_COMMENT_ADDITIONAL_CONTEXT,
33
+ STALE_COMMENT_DENY_TEMPLATE,
34
+ STALE_COMMENT_SYSTEM_MESSAGE,
35
+ )
36
+
37
+
38
+ def _first_orphaned_identifier(
39
+ overlying_comment: str,
40
+ all_old_block_lines: list[str],
41
+ all_new_block_lines: list[str],
42
+ ) -> str | None:
43
+ """Return the first comment identifier the edit removes from the block.
44
+
45
+ Args:
46
+ overlying_comment: The stripped standalone comment text above the block.
47
+ all_old_block_lines: The replaced lines as they read before the edit.
48
+ all_new_block_lines: The replacement lines as they read after the edit.
49
+
50
+ Returns:
51
+ The first identifier the comment names that matches the old block and
52
+ not the new block, or None when the comment stays consistent.
53
+ """
54
+ words_in_comment = overlying_comment.lstrip(COMMENT_LINE_PREFIX).strip()
55
+ original_block_text = "\n".join(all_old_block_lines)
56
+ revised_block_text = "\n".join(all_new_block_lines)
57
+ for each_identifier in COMMENT_IDENTIFIER_PATTERN.findall(words_in_comment):
58
+ if each_identifier.lower() in ALL_COMMENT_STOPWORDS:
59
+ continue
60
+ bounded_pattern = re.compile(
61
+ "(?<![A-Za-z0-9_])" + re.escape(each_identifier) + "(?![A-Za-z0-9_])"
62
+ )
63
+ if bounded_pattern.search(original_block_text) and not bounded_pattern.search(
64
+ revised_block_text
65
+ ):
66
+ return each_identifier
67
+ return None
68
+
69
+
70
+ def _occurrence_start_offsets(
71
+ old_content: str,
72
+ old_string: str,
73
+ is_replace_all: bool,
74
+ ) -> list[int]:
75
+ """List the character offsets where old_string starts in old_content.
76
+
77
+ Walks old_content left to right the same way str.replace does, so the
78
+ offsets line up with the occurrences the edit actually rewrites.
79
+
80
+ Args:
81
+ old_content: The file text before the edit.
82
+ old_string: The text the edit replaces.
83
+ is_replace_all: Whether every occurrence is collected, matching the
84
+ Edit tool's replace_all flag, or only the first one.
85
+
86
+ Returns:
87
+ The offset of each matching occurrence, in file order.
88
+ """
89
+ all_offsets: list[int] = []
90
+ search_from = 0
91
+ while True:
92
+ found_at = old_content.find(old_string, search_from)
93
+ if found_at == -1:
94
+ return all_offsets
95
+ all_offsets.append(found_at)
96
+ if not is_replace_all:
97
+ return all_offsets
98
+ search_from = found_at + len(old_string)
99
+
100
+
101
+ def _preceding_line_text(old_content: str, occurrence_start: int) -> str | None:
102
+ """Return the stripped line directly above the line an occurrence sits in.
103
+
104
+ Args:
105
+ old_content: The file text before the edit.
106
+ occurrence_start: The character offset where the occurrence begins.
107
+
108
+ Returns:
109
+ The stripped text of the line above the occurrence's line, or None
110
+ when that line is the first line in the file.
111
+ """
112
+ containing_line_start = old_content.rfind("\n", 0, occurrence_start) + 1
113
+ if containing_line_start == 0:
114
+ return None
115
+ preceding_line_end = containing_line_start - 1
116
+ preceding_line_start = old_content.rfind("\n", 0, preceding_line_end) + 1
117
+ return old_content[preceding_line_start:preceding_line_end].strip()
118
+
119
+
120
+ def _find_stale_comment_reference(
121
+ old_content: str,
122
+ old_string: str,
123
+ new_string: str,
124
+ is_replace_all: bool,
125
+ file_path: str,
126
+ ) -> str | None:
127
+ """Check each occurrence old_string rewrites for an orphaned comment above it.
128
+
129
+ Args:
130
+ old_content: The file text before the edit.
131
+ old_string: The text the edit replaces.
132
+ new_string: The text the edit substitutes in, empty for a straight
133
+ deletion.
134
+ is_replace_all: Whether every occurrence of old_string is checked,
135
+ matching the Edit tool's replace_all flag.
136
+ file_path: The target path, named in the deny reason.
137
+
138
+ Returns:
139
+ The deny-reason text for the first occurrence whose kept comment
140
+ names an identifier old_string carries and new_string drops, or None
141
+ when every kept comment stays consistent with the rewritten line.
142
+ """
143
+ all_old_block_lines = old_string.splitlines()
144
+ all_new_block_lines = new_string.splitlines()
145
+ for each_occurrence_start in _occurrence_start_offsets(old_content, old_string, is_replace_all):
146
+ preceding_line = _preceding_line_text(old_content, each_occurrence_start)
147
+ if preceding_line is None or not preceding_line.startswith(COMMENT_LINE_PREFIX):
148
+ continue
149
+ maybe_identifier = _first_orphaned_identifier(
150
+ preceding_line, all_old_block_lines, all_new_block_lines
151
+ )
152
+ if maybe_identifier is not None:
153
+ return STALE_COMMENT_DENY_TEMPLATE.format(
154
+ file_path=file_path,
155
+ contradicted_comment=preceding_line,
156
+ orphaned_name=maybe_identifier,
157
+ )
158
+ return None
159
+
160
+
161
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
162
+ """Decide whether an Edit payload orphans a comment above a changed line.
163
+
164
+ Reads the target file from disk and checks the line directly above each
165
+ occurrence the edit rewrites for a kept standalone comment whose named
166
+ identifier the edit removes from the line below it. Non-Edit tools,
167
+ non-Python targets, unreadable files, and an old_string absent from the
168
+ file all pass.
169
+
170
+ Args:
171
+ payload_by_key: The PreToolUse payload with tool_name and tool_input.
172
+
173
+ Returns:
174
+ The deny-reason text when the edit is denied, or None when allowed.
175
+ """
176
+ raw_tool_name = payload_by_key.get("tool_name", "")
177
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
178
+ if tool_name != EDIT_TOOL_NAME:
179
+ return None
180
+
181
+ raw_tool_input = payload_by_key.get("tool_input", {})
182
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
183
+
184
+ file_path = tool_input.get("file_path", "")
185
+ if not isinstance(file_path, str) or not file_path.endswith(PYTHON_FILE_SUFFIX):
186
+ return None
187
+
188
+ raw_old_string = tool_input.get("old_string", "")
189
+ raw_new_string = tool_input.get("new_string", "")
190
+ old_string = raw_old_string if isinstance(raw_old_string, str) else ""
191
+ new_string = raw_new_string if isinstance(raw_new_string, str) else ""
192
+ if not old_string:
193
+ return None
194
+
195
+ try:
196
+ old_content = Path(file_path).read_text(encoding="utf-8")
197
+ except (OSError, UnicodeDecodeError):
198
+ return None
199
+ if old_string not in old_content:
200
+ return None
201
+
202
+ is_replace_all = tool_input.get("replace_all") is True
203
+ return _find_stale_comment_reference(
204
+ old_content, old_string, new_string, is_replace_all, file_path
205
+ )
206
+
207
+
208
+ def build_deny_payload(deny_reason: str) -> dict[str, object]:
209
+ """Build the full deny payload the hook writes for a deny-reason string.
210
+
211
+ Logs the block, then returns the permission decision with the corrective
212
+ guidance in additionalContext, the user-facing systemMessage, and output
213
+ suppression, matching the deny shape the sibling blockers write.
214
+
215
+ Args:
216
+ deny_reason: The permissionDecisionReason text for the denial.
217
+
218
+ Returns:
219
+ The deny payload dictionary the hook serializes to stdout.
220
+ """
221
+ log_hook_block(
222
+ calling_hook_name="stale_comment_reference_blocker.py",
223
+ hook_event="PreToolUse",
224
+ block_reason=deny_reason,
225
+ )
226
+ return {
227
+ "hookSpecificOutput": {
228
+ "hookEventName": "PreToolUse",
229
+ "permissionDecision": "deny",
230
+ "permissionDecisionReason": deny_reason,
231
+ "additionalContext": STALE_COMMENT_ADDITIONAL_CONTEXT,
232
+ },
233
+ "systemMessage": STALE_COMMENT_SYSTEM_MESSAGE,
234
+ "suppressOutput": True,
235
+ }
236
+
237
+
238
+ def main() -> None:
239
+ """Run the gate over the stdin payload dictionary and emit any denial.
240
+ """
241
+ payload_dictionary = read_hook_input_dictionary_from_stdin()
242
+ if payload_dictionary is None:
243
+ sys.exit(0)
244
+
245
+ deny_reason = evaluate(payload_dictionary)
246
+ if deny_reason is None:
247
+ sys.exit(0)
248
+
249
+ _emit_deny_line(build_deny_payload(deny_reason), sys.stdout)
250
+ sys.exit(0)
251
+
252
+
253
+ def _emit_deny_line(
254
+ all_deny_payload_fields: dict[str, object], destination_stream: TextIO
255
+ ) -> None:
256
+ """Write the deny payload JSON as one line to the given stream.
257
+
258
+ Args:
259
+ all_deny_payload_fields: The deny payload to serialize.
260
+ destination_stream: The stream the JSON line is written to.
261
+ """
262
+ destination_stream.write(json.dumps(all_deny_payload_fields) + "\n")
263
+ destination_stream.flush()
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse hook: blocks Write/Edit containing historical/comparative language in comments and .md files.
2
+ """PreToolUse hook: blocks Write/Edit containing historical/comparative language in comments, Python docstrings, and .md files.
3
3
 
4
4
  Enforces the "describe current state only" rule — no "instead of", "previously",
5
- "now uses", or similar transitional framing. Comments and documentation should
6
- describe what IS, not what WAS or what CHANGED.
5
+ "now uses", or similar transitional framing. Comments, docstrings, and
6
+ documentation should describe what IS, not what WAS or what CHANGED. Inside a
7
+ docstring, a phrase wrapped in double quotes or backticks is a mention rather
8
+ than a use, so quoted spans are stripped before the scan.
7
9
  """
8
10
 
11
+ import ast
9
12
  import json
10
13
  import os
11
14
  import sys
@@ -23,11 +26,15 @@ from hooks_constants.state_description_blocker_constants import ( # noqa: E402
23
26
  ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
24
27
  ALL_COMMENT_BEARING_EXTENSIONS,
25
28
  ALL_COMMENT_TRANSITION_PATTERNS,
29
+ ALL_DEFINITION_HEADER_PREFIXES,
26
30
  ALL_HASH_AND_SLASH_EXTENSIONS,
27
31
  ALL_HASH_ONLY_EXTENSIONS,
28
32
  ALL_MARKDOWN_EXTENSIONS,
29
33
  CODE_FENCE_PATTERN,
34
+ DOUBLE_QUOTED_SPAN_PATTERN,
30
35
  INLINE_CODE_PATTERN,
36
+ PYTHON_EXTENSION,
37
+ TRIPLE_QUOTED_BLOCK_PATTERN,
31
38
  )
32
39
 
33
40
 
@@ -131,17 +138,101 @@ def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> i
131
138
  return best_position
132
139
 
133
140
 
141
+ def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
142
+ """Collect the module, class, and function docstrings from a parsed tree.
143
+
144
+ Args:
145
+ tree: The parsed module tree to walk.
146
+
147
+ Returns:
148
+ Every docstring found on the module and its class/function definitions.
149
+ """
150
+ all_found_docstrings: list[str] = []
151
+ for each_node in ast.walk(tree):
152
+ if not isinstance(
153
+ each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
154
+ ):
155
+ continue
156
+ maybe_docstring = ast.get_docstring(each_node, clean=False)
157
+ if maybe_docstring:
158
+ all_found_docstrings.append(maybe_docstring)
159
+ return all_found_docstrings
160
+
161
+
162
+ def _extract_fragment_docstrings(text: str) -> list[str]:
163
+ """Collect docstring-positioned triple-quoted blocks from an unparseable fragment.
164
+
165
+ A triple-quoted block counts as a docstring when it opens the fragment
166
+ (only whitespace before it) or when the nearest preceding non-blank line is
167
+ a def/class header ending in a colon. A block assigned to a name or sitting
168
+ elsewhere in the fragment is data and is skipped.
169
+
170
+ Args:
171
+ text: The Python source fragment that failed to parse.
172
+
173
+ Returns:
174
+ The body text of each docstring-positioned triple-quoted block.
175
+ """
176
+ all_found_docstrings: list[str] = []
177
+ for each_match in TRIPLE_QUOTED_BLOCK_PATTERN.finditer(text):
178
+ body = each_match.group(1) if each_match.group(1) is not None else each_match.group(2)
179
+ leading_text = text[: each_match.start()]
180
+ if not leading_text.strip():
181
+ all_found_docstrings.append(body)
182
+ continue
183
+ if leading_text.rpartition("\n")[2].strip():
184
+ continue
185
+ all_preceding_lines = [
186
+ each_line for each_line in leading_text.splitlines() if each_line.strip()
187
+ ]
188
+ last_preceding_line = all_preceding_lines[-1].strip()
189
+ if last_preceding_line.startswith(
190
+ ALL_DEFINITION_HEADER_PREFIXES
191
+ ) and last_preceding_line.endswith(":"):
192
+ all_found_docstrings.append(body)
193
+ return all_found_docstrings
194
+
195
+
196
+ def _extract_python_docstring_text(text: str) -> str:
197
+ """Return the scannable docstring prose from Python source.
198
+
199
+ Parses the source when valid, or falls back to the docstring-positioned
200
+ triple-quoted blocks of a mid-edit fragment. Double-quoted and backticked
201
+ spans inside each docstring are mentions rather than uses, so both are
202
+ stripped from the returned prose.
203
+
204
+ Args:
205
+ text: The Python source or fragment under scan.
206
+
207
+ Returns:
208
+ The docstring prose joined into one scannable string.
209
+ """
210
+ try:
211
+ all_found_docstrings = _extract_parsed_docstrings(ast.parse(text))
212
+ except (SyntaxError, ValueError):
213
+ all_found_docstrings = _extract_fragment_docstrings(text)
214
+ all_docstring_prose = [
215
+ DOUBLE_QUOTED_SPAN_PATTERN.sub("", INLINE_CODE_PATTERN.sub("", each_docstring))
216
+ for each_docstring in all_found_docstrings
217
+ ]
218
+ return "\n".join(all_docstring_prose)
219
+
220
+
134
221
  def find_violations(text: str, file_path: str) -> list[str]:
135
222
  """Return all violated patterns found in text for the given file.
136
223
 
137
- For .md files, scans the entire text. For code files, scans only comment lines.
224
+ For .md files, scans the entire text. For code files, scans comment lines,
225
+ and for Python files also scans module/class/function docstrings.
138
226
  Returns a list of matched pattern source strings.
139
227
  """
228
+ extension = _get_file_extension(file_path)
140
229
  if is_markdown_file(file_path):
141
230
  scan_text = text
142
231
  elif is_comment_bearing_file(file_path):
143
- all_comment_lines = _extract_comment_lines(text, _get_file_extension(file_path))
232
+ all_comment_lines = _extract_comment_lines(text, extension)
144
233
  scan_text = "\n".join(all_comment_lines)
234
+ if extension == PYTHON_EXTENSION:
235
+ scan_text = "\n".join([scan_text, _extract_python_docstring_text(text)])
145
236
  else:
146
237
  return []
147
238
 
@@ -0,0 +1,132 @@
1
+ """Tests for check_config_duplicate_path_anchor.
2
+
3
+ Directories come from ``tmp_path_factory.mktemp`` with a neutral name because
4
+ the per-test ``tmp_path`` embeds the test function name, and that name would
5
+ make the test-path exemption inside the validator swallow every case.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+
14
+ import pytest
15
+
16
+ ENFORCER_FILENAME = "code_rules_enforcer.py"
17
+ ENFORCER_MODULE_NAME = "code_rules_enforcer_duplicate_anchor_tests"
18
+ SIBLING_MODULE_SOURCE = """\
19
+ from pathlib import Path
20
+
21
+ NEON_SUBMISSION_LOG_DIRECTORY = Path(__file__).resolve().parents[2] / "logs" / "theme_submissions"
22
+ """
23
+ WRITTEN_MODULE_SOURCE = """\
24
+ from pathlib import Path
25
+
26
+ LOG_BASE_DIR = Path(__file__).resolve().parents[2] / "logs"
27
+ THEME_SUBMISSIONS_LOG_FOLDER = "theme_submissions"
28
+ """
29
+
30
+
31
+ def load_enforcer_module() -> ModuleType:
32
+ loader_path = Path(__file__).parent / ENFORCER_FILENAME
33
+ module_spec = importlib.util.spec_from_file_location(ENFORCER_MODULE_NAME, loader_path)
34
+ assert module_spec is not None
35
+ assert module_spec.loader is not None
36
+ loaded_module = importlib.util.module_from_spec(module_spec)
37
+ module_spec.loader.exec_module(loaded_module)
38
+ return loaded_module
39
+
40
+
41
+ enforcer = load_enforcer_module()
42
+
43
+
44
+ def _config_directory_with_sibling(
45
+ tmp_path_factory: pytest.TempPathFactory, sibling_source: str
46
+ ) -> Path:
47
+ config_directory = tmp_path_factory.mktemp("workspace_home") / "config"
48
+ config_directory.mkdir()
49
+ (config_directory / "neon_submission.py").write_text(sibling_source, encoding="utf-8")
50
+ return config_directory
51
+
52
+
53
+ def test_should_flag_reanchored_base_already_built_by_sibling(
54
+ tmp_path_factory: pytest.TempPathFactory,
55
+ ) -> None:
56
+ config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
57
+ written_path = str(config_directory / "derived_paths.py")
58
+ issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
59
+ assert len(issues) == 1
60
+ assert "neon_submission.py" in issues[0]
61
+
62
+
63
+ def test_should_flag_parent_chain_anchor_matching_sibling_parents_index(
64
+ tmp_path_factory: pytest.TempPathFactory,
65
+ ) -> None:
66
+ config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
67
+ written_source = """\
68
+ from pathlib import Path
69
+
70
+ LOG_BASE_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
71
+ """
72
+ written_path = str(config_directory / "derived_paths.py")
73
+ issues = enforcer.check_config_duplicate_path_anchor(written_source, written_path)
74
+ assert len(issues) == 1
75
+
76
+
77
+ def test_should_allow_anchor_with_different_first_segment(
78
+ tmp_path_factory: pytest.TempPathFactory,
79
+ ) -> None:
80
+ config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
81
+ written_source = """\
82
+ from pathlib import Path
83
+
84
+ SNAPSHOT_BASE_DIR = Path(__file__).resolve().parents[2] / "snapshots"
85
+ """
86
+ written_path = str(config_directory / "derived_paths.py")
87
+ issues = enforcer.check_config_duplicate_path_anchor(written_source, written_path)
88
+ assert issues == []
89
+
90
+
91
+ def test_should_allow_anchor_with_different_depth(
92
+ tmp_path_factory: pytest.TempPathFactory,
93
+ ) -> None:
94
+ config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
95
+ written_source = """\
96
+ from pathlib import Path
97
+
98
+ LOG_BASE_DIR = Path(__file__).resolve().parents[3] / "logs"
99
+ """
100
+ written_path = str(config_directory / "derived_paths.py")
101
+ issues = enforcer.check_config_duplicate_path_anchor(written_source, written_path)
102
+ assert issues == []
103
+
104
+
105
+ def test_should_allow_module_with_no_sibling_config_files(
106
+ tmp_path_factory: pytest.TempPathFactory,
107
+ ) -> None:
108
+ config_directory = tmp_path_factory.mktemp("workspace_home") / "config"
109
+ config_directory.mkdir()
110
+ written_path = str(config_directory / "derived_paths.py")
111
+ issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
112
+ assert issues == []
113
+
114
+
115
+ def test_should_skip_modules_outside_config_directories(
116
+ tmp_path_factory: pytest.TempPathFactory,
117
+ ) -> None:
118
+ plain_directory = tmp_path_factory.mktemp("workspace_home") / "services"
119
+ plain_directory.mkdir()
120
+ (plain_directory / "neon_submission.py").write_text(SIBLING_MODULE_SOURCE, encoding="utf-8")
121
+ written_path = str(plain_directory / "derived_paths.py")
122
+ issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
123
+ assert issues == []
124
+
125
+
126
+ def test_should_skip_unparseable_sibling_modules(
127
+ tmp_path_factory: pytest.TempPathFactory,
128
+ ) -> None:
129
+ config_directory = _config_directory_with_sibling(tmp_path_factory, "def broken(:\n")
130
+ written_path = str(config_directory / "derived_paths.py")
131
+ issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
132
+ assert issues == []
@@ -71,10 +71,12 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
71
71
  "check_file_global_constants_use_count",
72
72
  "check_imports_at_top",
73
73
  "check_inline_literal_collections",
74
+ "check_join_separator_string_magic",
74
75
  "check_known_pytest_fixture_annotations",
75
76
  "check_library_print",
76
77
  "check_loop_variable_naming",
77
78
  "check_parameter_annotations",
79
+ "check_referenced_underscore_loop_variable",
78
80
  "check_return_annotations",
79
81
  "check_skip_decorators_in_tests",
80
82
  "check_string_literal_magic",