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.
- package/CLAUDE.md +16 -13
- package/_shared/pr-loop/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
- package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -4
- package/hooks/blocking/code_rules_constants_config.py +164 -1
- package/hooks/blocking/code_rules_docstrings.py +428 -15
- package/hooks/blocking/code_rules_enforcer.py +33 -0
- package/hooks/blocking/code_rules_imports_logging.py +867 -1
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +152 -6
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/code_rules_string_magic.py +68 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/reviewer_spawn_gate.py +182 -0
- package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
- package/hooks/blocking/state_description_blocker.py +96 -5
- package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
- package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
- package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
- package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
- package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
- package/hooks/blocking/test_state_description_blocker.py +135 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +3 -4
- package/hooks/hooks_constants/blocking_check_limits.py +46 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +5 -2
- package/rules/claude-md-orphan-file.md +5 -0
- package/rules/docstring-prose-matches-implementation.md +10 -1
- package/rules/env-var-table-code-drift.md +5 -0
- package/rules/es-exe-file-search.md +17 -0
- package/rules/no-historical-clutter.md +12 -1
- package/rules/orphan-css-class.md +5 -0
- package/rules/package-inventory-stale-entry.md +10 -0
- package/rules/paired-test-coverage.md +5 -0
- package/rules/plain-illustrative-docstrings.md +40 -1
- package/rules/verify-before-asking.md +7 -0
- package/rules/verify-runtime-state.md +40 -0
- package/rules/windows-filesystem-safe.md +8 -0
- package/rules/workers-done-before-complete.md +33 -0
- package/rules/workflow-substitution-slots.md +5 -0
- package/skills/CLAUDE.md +7 -2
- package/skills/autoconverge/SKILL.md +48 -61
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +153 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +55 -19
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +167 -106
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/everything-search/SKILL.md +5 -0
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +72 -59
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +15 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 (
|
|
12
|
-
- Edit -> Group A (11 hooks) + Group B (
|
|
13
|
-
- MultiEdit -> Group B only (
|
|
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()
|