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
@@ -17,6 +17,7 @@ from code_rules_path_utils import ( # noqa: E402
17
17
  )
18
18
  from code_rules_shared import ( # noqa: E402
19
19
  _extract_fstring_literal_parts,
20
+ docstring_line_numbers,
20
21
  is_test_file,
21
22
  )
22
23
 
@@ -156,6 +157,7 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
156
157
 
157
158
  issues = []
158
159
  lines = content.split("\n")
160
+ all_docstring_line_numbers = docstring_line_numbers(content)
159
161
  is_inside_function = False
160
162
 
161
163
  number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
@@ -167,6 +169,9 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
167
169
  if not stripped:
168
170
  continue
169
171
 
172
+ if each_line_number in all_docstring_line_numbers:
173
+ continue
174
+
170
175
  if re.match(r"^(async\s+)?def\s+\w+", stripped):
171
176
  is_inside_function = True
172
177
  continue
@@ -271,6 +271,147 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
271
271
  return issues
272
272
 
273
273
 
274
+ def _comprehension_rebinds_name(comprehension_node: ast.expr, target_name: str) -> bool:
275
+ """True when any generator of the comprehension binds its own target_name-named variable."""
276
+ generators = getattr(comprehension_node, "generators", [])
277
+ for each_generator in generators:
278
+ for each_name_node in _walk_assignment_targets(each_generator.target):
279
+ if each_name_node.id == target_name:
280
+ return True
281
+ return False
282
+
283
+
284
+ def _function_binds_name(
285
+ function_node: ast.Lambda | ast.FunctionDef | ast.AsyncFunctionDef, target_name: str
286
+ ) -> bool:
287
+ """True when a lambda or nested def has a parameter named target_name."""
288
+ argument_specification = function_node.args
289
+ all_argument_nodes = [
290
+ *argument_specification.posonlyargs,
291
+ *argument_specification.args,
292
+ *argument_specification.kwonlyargs,
293
+ ]
294
+ if argument_specification.vararg is not None:
295
+ all_argument_nodes.append(argument_specification.vararg)
296
+ if argument_specification.kwarg is not None:
297
+ all_argument_nodes.append(argument_specification.kwarg)
298
+ return any(each_argument.arg == target_name for each_argument in all_argument_nodes)
299
+
300
+
301
+ def _function_enclosing_scope_expressions(
302
+ function_node: ast.Lambda | ast.FunctionDef | ast.AsyncFunctionDef,
303
+ ) -> list[ast.expr]:
304
+ """Return the sub-expressions a lambda or def evaluates in its enclosing scope.
305
+
306
+ Parameter defaults and decorators run before the parameters bind, so a Load of
307
+ the outer name there still reads the outer name; the body never does once a
308
+ parameter shadows it.
309
+ """
310
+ argument_specification = function_node.args
311
+ enclosing_expressions: list[ast.expr] = [*argument_specification.defaults]
312
+ enclosing_expressions.extend(
313
+ each_default
314
+ for each_default in argument_specification.kw_defaults
315
+ if each_default is not None
316
+ )
317
+ if isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
318
+ enclosing_expressions.extend(function_node.decorator_list)
319
+ return enclosing_expressions
320
+
321
+
322
+ def _loop_rebinds_name(loop_node: ast.For | ast.AsyncFor, target_name: str) -> bool:
323
+ """True when the loop's own target rebinds target_name."""
324
+ return any(
325
+ each_name_node.id == target_name
326
+ for each_name_node in _walk_assignment_targets(loop_node.target)
327
+ )
328
+
329
+
330
+ def _node_reads_name(node: ast.AST, target_name: str) -> bool:
331
+ """True when node contains a genuine Load of target_name, honoring scope shadowing.
332
+
333
+ A comprehension, a lambda, a nested def, or a nested loop that rebinds
334
+ target_name owns that name inside its body, so a Load there reads the inner
335
+ variable, not the outer loop variable. Only the parts each construct evaluates
336
+ in the enclosing scope can still read the outer name: a comprehension's or
337
+ nested loop's iterable, and a function's parameter defaults and decorators.
338
+ """
339
+ comprehension_node_types = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
340
+ if isinstance(node, comprehension_node_types) and _comprehension_rebinds_name(
341
+ node, target_name
342
+ ):
343
+ outermost_iterable = node.generators[0].iter
344
+ return _node_reads_name(outermost_iterable, target_name)
345
+ if isinstance(node, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef)) and _function_binds_name(
346
+ node, target_name
347
+ ):
348
+ return any(
349
+ _node_reads_name(each_expression, target_name)
350
+ for each_expression in _function_enclosing_scope_expressions(node)
351
+ )
352
+ if isinstance(node, (ast.For, ast.AsyncFor)) and _loop_rebinds_name(node, target_name):
353
+ return _node_reads_name(node.iter, target_name)
354
+ if isinstance(node, ast.Name) and node.id == target_name and isinstance(node.ctx, ast.Load):
355
+ return True
356
+ for each_child_node in ast.iter_child_nodes(node):
357
+ if _node_reads_name(each_child_node, target_name):
358
+ return True
359
+ return False
360
+
361
+
362
+ def _loop_body_references_name(loop_node: ast.For | ast.AsyncFor, target_name: str) -> bool:
363
+ for each_statement in [*loop_node.body, *loop_node.orelse]:
364
+ if _node_reads_name(each_statement, target_name):
365
+ return True
366
+ return False
367
+
368
+
369
+ def check_referenced_underscore_loop_variable(content: str, file_path: str) -> list[str]:
370
+ """Flag a leading-underscore loop variable the loop body reads.
371
+
372
+ A leading underscore marks a name as a throwaway the reader can ignore, so
373
+ ``for _foreign_module_name in names:`` promises the body never touches it.
374
+ When the body then reads ``del sys.modules[_foreign_module_name]``, the
375
+ marker lies: the value carries meaning and earns a real name. This check
376
+ fires in test files too — a conftest that reuses an underscore-marked loop
377
+ variable is the exact shape this closes — because the misleading marker is a
378
+ naming smell everywhere, not a convention the test-file exemption waives.
379
+ Workflow-registry and migration files are exempt. A bare ``_`` target and a
380
+ genuinely unread ``_unused`` throwaway both pass.
381
+
382
+ Args:
383
+ content: The source text to inspect.
384
+ file_path: The path the source will be written to, used for exemptions.
385
+
386
+ Returns:
387
+ One issue per leading-underscore loop variable that its loop body reads.
388
+ """
389
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
390
+ return []
391
+ try:
392
+ tree = ast.parse(content)
393
+ except SyntaxError:
394
+ return []
395
+ issues: list[str] = []
396
+ for each_node in ast.walk(tree):
397
+ if not isinstance(each_node, (ast.For, ast.AsyncFor)):
398
+ continue
399
+ for each_name_node in _collect_target_names(each_node.target):
400
+ target_name = each_name_node.id
401
+ if not target_name.startswith("_"):
402
+ continue
403
+ if target_name.strip("_") == "":
404
+ continue
405
+ if not _loop_body_references_name(each_node, target_name):
406
+ continue
407
+ issues.append(
408
+ f"Line {each_name_node.lineno}: loop variable {target_name!r} is read"
409
+ f" in its body - drop the leading underscore throwaway marker and"
410
+ f" give it a real name (CODE_RULES §5)"
411
+ )
412
+ return issues
413
+
414
+
274
415
  def _name_carries_token(name: str, token: str) -> bool:
275
416
  """True when name carries token as a whole underscore-delimited word."""
276
417
  return re.search(POLARITY_TOKEN_BOUNDARY_PATTERN % re.escape(token), name) is not None
@@ -295,12 +436,17 @@ def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[st
295
436
  def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
296
437
  """Flag a boolean assignment whose target and callee assert opposite polarity.
297
438
 
298
- Catches the self-contradicting statement ``is_inside_allowed =
299
- _point_hits_any_forbidden(...)`` — a target name carrying one polarity token
300
- (``allowed``) assigned directly from a single call whose callee name carries
301
- the antonym token (``forbidden``). One side lies about the behavior, so the
302
- reader cannot trust either name. Rename the callee to a neutral form that
303
- reads truthfully at every call site.
439
+ ::
440
+
441
+ is_inside_allowed = _point_hits_any_forbidden(...)
442
+ ^^^^^^^ ^^^^^^^^^
443
+ allowed vs. forbidden ⚠ the two names clash
444
+ ok: is_inside_allowed = _point_inside_allowed_region(...)
445
+ flag: is_inside_allowed = _point_hits_any_forbidden(...)
446
+
447
+ The target token and the callee token contradict each other, so the reader
448
+ cannot tell which name states the truth. Rename the callee to a neutral form
449
+ the two names agree on at every call site.
304
450
  """
305
451
  if is_test_file(file_path):
306
452
  return []
@@ -273,6 +273,40 @@ def is_migration_file(file_path: str) -> bool:
273
273
  return any(pattern.replace("\\", "/") in path_lower for pattern in ALL_MIGRATION_PATH_PATTERNS)
274
274
 
275
275
 
276
+ def docstring_line_numbers(content: str) -> set[int]:
277
+ """Return every source line that sits inside a string-literal statement.
278
+
279
+ A diagram-first docstring often draws its point across several lines, and
280
+ those lines can hold a bare number (a row marker) or a contrast row
281
+ (``flag: is_ok = do_thing(...)``). Those are prose, not code, so a
282
+ line-based lint check that reads them as literals fires a false positive.
283
+ This walks the parsed source and collects the line numbers spanned by every
284
+ string-literal expression statement — module, class, and function
285
+ docstrings, plus any bare string statement — so those checks can skip them.
286
+ Unparseable source yields an empty set.
287
+
288
+ Args:
289
+ content: The Python source to scan.
290
+
291
+ Returns:
292
+ The 1-indexed line numbers that fall inside a string-literal statement.
293
+ """
294
+ try:
295
+ parsed_tree = ast.parse(content)
296
+ except SyntaxError:
297
+ return set()
298
+ all_docstring_line_numbers: set[int] = set()
299
+ for each_node in ast.walk(parsed_tree):
300
+ if not (isinstance(each_node, ast.Expr) and _statement_is_docstring(each_node)):
301
+ continue
302
+ end_line_number = each_node.end_lineno
303
+ if end_line_number is None:
304
+ continue
305
+ for each_line_number in range(each_node.lineno, end_line_number + 1):
306
+ all_docstring_line_numbers.add(each_line_number)
307
+ return all_docstring_line_numbers
308
+
309
+
276
310
  def _build_parent_map(module_tree: ast.Module) -> dict[int, ast.AST]:
277
311
  """Map child node id() to its parent node for ancestor walking."""
278
312
  parent_by_child_id: dict[int, ast.AST] = {}
@@ -123,6 +123,74 @@ def check_string_literal_magic(content: str, file_path: str) -> list[str]:
123
123
  return issues
124
124
 
125
125
 
126
+ def check_join_separator_string_magic(content: str, file_path: str) -> list[str]:
127
+ """Flag a bare string-literal separator passed to ``str.join`` in a body.
128
+
129
+ A call like ``", ".join(all_paths)`` hard-codes the delimiter that stitches
130
+ a list into one string. That delimiter is a magic value: change the wording
131
+ once and every reader has to grep the whole file for the stray literal. A
132
+ named constant in ``config/`` gives the delimiter one home. The empty
133
+ separator ``"".join(...)`` is left alone because it carries no delimiter —
134
+ it is the idiomatic way to concatenate parts. Config files, test files,
135
+ workflow-registry files, and migration files are exempt.
136
+
137
+ Args:
138
+ content: The source text to inspect.
139
+ file_path: The path the source will be written to, used for exemptions.
140
+
141
+ Returns:
142
+ One issue per non-empty string-literal ``.join`` separator found in a
143
+ function body.
144
+ """
145
+ if is_test_file(file_path):
146
+ return []
147
+ if is_config_file(file_path):
148
+ return []
149
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
150
+ return []
151
+ try:
152
+ tree = ast.parse(content)
153
+ except SyntaxError:
154
+ return []
155
+ issues: list[str] = []
156
+ flagged_node_ids: set[int] = set()
157
+ for each_function_node in ast.walk(tree):
158
+ if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
159
+ continue
160
+ for each_body_statement in each_function_node.body:
161
+ for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
162
+ if not isinstance(each_descendant, ast.Call):
163
+ continue
164
+ separator_literal = _join_call_string_separator(each_descendant)
165
+ if separator_literal is None:
166
+ continue
167
+ if id(each_descendant) in flagged_node_ids:
168
+ continue
169
+ flagged_node_ids.add(id(each_descendant))
170
+ issues.append(
171
+ f"Line {each_descendant.lineno}: string separator"
172
+ f" {separator_literal!r} passed to .join(...)"
173
+ f" - extract to a named constant in config/"
174
+ )
175
+ return issues
176
+
177
+
178
+ def _join_call_string_separator(call_node: ast.Call) -> str | None:
179
+ called = call_node.func
180
+ if not isinstance(called, ast.Attribute):
181
+ return None
182
+ if called.attr != "join":
183
+ return None
184
+ separator_node = called.value
185
+ if not isinstance(separator_node, ast.Constant):
186
+ return None
187
+ if not isinstance(separator_node.value, str):
188
+ return None
189
+ if separator_node.value == "":
190
+ return None
191
+ return separator_node.value
192
+
193
+
126
194
  def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
127
195
  if is_test_file(file_path):
128
196
  return []
@@ -72,10 +72,10 @@ def path_is_exempt(file_path: str) -> bool:
72
72
  Returns:
73
73
  True when the path's basename is the canonical shared-helper home, an
74
74
  rmtree-blocker hook source, one of the existing in-repo definition sites
75
- (session_env_cleanup.py, _md_to_html_blocker_test_support.py,
76
- teardown_worktrees.py), or a test file. A definition there is intentional.
77
- Basename equality (not substring containment) prevents a sibling whose name
78
- merely contains an exempt fragment from bypassing the block.
75
+ (session_env_cleanup.py, teardown_worktrees.py), or a test file. A
76
+ definition there is intentional. Basename equality (not substring
77
+ containment) prevents a sibling whose name merely contains an exempt
78
+ fragment from bypassing the block.
79
79
  """
80
80
  normalized_path = file_path.replace("\\", "/")
81
81
  file_name = normalized_path.rsplit("/", 1)[-1]
@@ -8,9 +8,9 @@ decision when any hook denied (carrying every denying reason) or exits zero to
8
8
  allow.
9
9
 
10
10
  The per-hook coverage matrix:
11
- - Write -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
12
- - Edit -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
13
- - MultiEdit -> Group B only (7 hooks)
11
+ - Write -> Group A (11 hooks) + Group B (8 hooks) = 19 hooks
12
+ - Edit -> Group A (11 hooks) + Group B (8 hooks) + the Edit-only hook = 20 hooks
13
+ - MultiEdit -> Group B only (8 hooks)
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block a reviewer-spawn trigger when that reviewer is down.
3
+
4
+ autoconverge already skips a reviewer step when its own pre-check reports
5
+ that reviewer unavailable. That skip only protects the trigger commands the
6
+ workflow generates and inspects itself. This hook is the backstop: it reads
7
+ the Bash command a tool call is about to run and blocks it when the matched
8
+ reviewer reports unavailable.
9
+
10
+ The gate only inspects a command carrying the literal sentinel marker
11
+ `CLAUDE_REVIEWER_GATE=autoconverge`. autoconverge prepends this marker to its
12
+ own trigger commands. A command without the marker is never inspected, so a
13
+ manual or unrelated `gh` call always passes through untouched.
14
+
15
+ A Copilot trigger is a command containing both `requested_reviewers` and
16
+ `copilot-pull-request-reviewer[bot]`. A Bugbot trigger is a command
17
+ containing `post_fix_reply.py` and a `--body "bugbot run"` argument.
18
+
19
+ For the matched reviewer, the gate runs
20
+ `reviewer_availability.py --reviewer copilot|bugbot` and denies the call only
21
+ when that script exits with the documented reviewer-down code (3). Any other
22
+ exit code is an unexpected script failure, not a down report, so the call is
23
+ allowed (fail open). The deny reason names the reviewer and carries
24
+ the script's output. The script path resolves relative to this hook's
25
+ install location by default, or from the
26
+ `REVIEWER_SPAWN_GATE_AVAILABILITY_SCRIPT_PATH` environment variable when set.
27
+ A missing script, a timeout, or a failure to launch it allows the call
28
+ (fail open), so a broken availability check never blocks a legitimate run.
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
38
+ if _hooks_dir not in sys.path:
39
+ sys.path.insert(0, _hooks_dir)
40
+
41
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
42
+ from hooks_constants.reviewer_spawn_gate_constants import ( # noqa: E402
43
+ ALL_COPILOT_TRIGGER_MARKERS,
44
+ AVAILABILITY_DOWN_EXIT_CODE,
45
+ AVAILABILITY_REVIEWER_FLAG,
46
+ AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME,
47
+ AVAILABILITY_SCRIPT_RELATIVE_PATH,
48
+ AVAILABILITY_SCRIPT_TIMEOUT_SECONDS,
49
+ BASH_TOOL_NAME,
50
+ BUGBOT_REVIEWER_LABEL,
51
+ BUGBOT_REVIEWER_TOKEN,
52
+ BUGBOT_RUN_BODY_PATTERN,
53
+ BUGBOT_TRIGGER_SCRIPT_MARKER,
54
+ COPILOT_REVIEWER_LABEL,
55
+ COPILOT_REVIEWER_TOKEN,
56
+ DENY_REASON_TEMPLATE,
57
+ GATE_SENTINEL_MARKER,
58
+ )
59
+
60
+
61
+ def _matches_copilot_trigger(command: str) -> bool:
62
+ return all(each_marker in command for each_marker in ALL_COPILOT_TRIGGER_MARKERS)
63
+
64
+
65
+ def _matches_bugbot_trigger(command: str) -> bool:
66
+ return BUGBOT_TRIGGER_SCRIPT_MARKER in command and bool(
67
+ BUGBOT_RUN_BODY_PATTERN.search(command)
68
+ )
69
+
70
+
71
+ def _reviewer_trigger(command: str) -> tuple[str, str] | None:
72
+ """Return the reviewer token and label a scoped command triggers.
73
+
74
+ Args:
75
+ command: The Bash command text carrying the gate's sentinel marker.
76
+
77
+ Returns:
78
+ The matched reviewer's CLI token paired with its display label, or
79
+ None when the command matches neither the Copilot review-request
80
+ shape nor the Bugbot rerun-comment shape.
81
+ """
82
+ if _matches_copilot_trigger(command):
83
+ return COPILOT_REVIEWER_TOKEN, COPILOT_REVIEWER_LABEL
84
+ if _matches_bugbot_trigger(command):
85
+ return BUGBOT_REVIEWER_TOKEN, BUGBOT_REVIEWER_LABEL
86
+ return None
87
+
88
+
89
+ def _resolve_availability_script_path() -> Path:
90
+ override_path = os.environ.get(AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, "")
91
+ if override_path:
92
+ return Path(override_path)
93
+ plugin_root = Path(__file__).resolve().parents[2]
94
+ return plugin_root / AVAILABILITY_SCRIPT_RELATIVE_PATH
95
+
96
+
97
+ def _probe_reviewer_availability(reviewer_token: str) -> tuple[bool, str]:
98
+ """Run the shared availability script for a reviewer and report the outcome.
99
+
100
+ Args:
101
+ reviewer_token: The reviewer's CLI token (copilot or bugbot) passed to
102
+ the availability script's --reviewer flag.
103
+
104
+ Returns:
105
+ A pair of (is_available, detail_text). is_available is False only
106
+ when the script exits with the documented reviewer-down code;
107
+ detail_text carries the script's combined stdout and stderr for the
108
+ deny message. A missing script, a timeout, an OS-level failure to
109
+ launch the script, or any exit code other than the documented down
110
+ code reports available (fail open), so an availability-check crash
111
+ never blocks a legitimate run.
112
+ """
113
+ script_path = _resolve_availability_script_path()
114
+ if not script_path.is_file():
115
+ return True, f"availability script not found at {script_path}"
116
+ try:
117
+ completed_process = subprocess.run(
118
+ [sys.executable, str(script_path), AVAILABILITY_REVIEWER_FLAG, reviewer_token],
119
+ capture_output=True,
120
+ text=True,
121
+ encoding="utf-8",
122
+ errors="replace",
123
+ timeout=AVAILABILITY_SCRIPT_TIMEOUT_SECONDS,
124
+ check=False,
125
+ )
126
+ except (OSError, subprocess.TimeoutExpired) as availability_error:
127
+ return True, f"availability script failed to run: {availability_error}"
128
+ availability_detail_text = (completed_process.stdout + completed_process.stderr).strip()
129
+ is_reviewer_down = completed_process.returncode == AVAILABILITY_DOWN_EXIT_CODE
130
+ return not is_reviewer_down, availability_detail_text
131
+
132
+
133
+ def main() -> None:
134
+ try:
135
+ hook_input = json.load(sys.stdin)
136
+ except json.JSONDecodeError:
137
+ sys.exit(0)
138
+ if not isinstance(hook_input, dict):
139
+ sys.exit(0)
140
+
141
+ tool_name = hook_input.get("tool_name", "")
142
+ if tool_name != BASH_TOOL_NAME:
143
+ sys.exit(0)
144
+
145
+ tool_input = hook_input.get("tool_input", {})
146
+ command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
147
+ if not command or GATE_SENTINEL_MARKER not in command:
148
+ sys.exit(0)
149
+
150
+ reviewer_trigger = _reviewer_trigger(command)
151
+ if reviewer_trigger is None:
152
+ sys.exit(0)
153
+ reviewer_token, reviewer_label = reviewer_trigger
154
+
155
+ is_available, availability_detail = _probe_reviewer_availability(reviewer_token)
156
+ if is_available:
157
+ sys.exit(0)
158
+
159
+ deny_reason = DENY_REASON_TEMPLATE.format(
160
+ reviewer_label=reviewer_label, availability_detail=availability_detail
161
+ )
162
+ deny_payload = {
163
+ "hookSpecificOutput": {
164
+ "hookEventName": "PreToolUse",
165
+ "permissionDecision": "deny",
166
+ "permissionDecisionReason": deny_reason,
167
+ }
168
+ }
169
+ log_hook_block(
170
+ calling_hook_name="reviewer_spawn_gate.py",
171
+ hook_event="PreToolUse",
172
+ block_reason=deny_reason,
173
+ tool_name=tool_name,
174
+ offending_input_preview=command,
175
+ )
176
+ print(json.dumps(deny_payload))
177
+ sys.stdout.flush()
178
+ sys.exit(0)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()