claude-dev-env 1.38.1 → 1.40.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 +10 -36
- package/_shared/pr-loop/audit-reply-template.md +147 -0
- package/_shared/pr-loop/fix-protocol.md +25 -4
- package/_shared/pr-loop/gh-payloads.md +37 -50
- package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +199 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/state-schema.md +1 -1
- package/agents/clean-coder.md +2 -2
- package/agents/pr-description-writer.md +150 -52
- package/bin/install.mjs +6 -7
- package/bin/install.test.mjs +8 -0
- package/commands/doc-gist.md +16 -0
- package/commands/plan.md +0 -2
- package/commands/review-plan.md +1 -1
- package/docs/CODE_RULES.md +122 -2
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
- package/hooks/blocking/code_rules_enforcer.py +1143 -129
- package/hooks/blocking/convergence_gate_blocker.py +130 -0
- package/hooks/blocking/destructive_command_blocker.py +74 -0
- package/hooks/blocking/gh_body_arg_blocker.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +119 -0
- package/hooks/blocking/pr_description_enforcer.py +57 -22
- package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
- package/hooks/blocking/test_code_rules_enforcer.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
- package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
- package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
- package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
- package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
- package/hooks/blocking/test_destructive_command_blocker.py +146 -0
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
- package/hooks/blocking/test_md_to_html_blocker.py +317 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/any_type_config.py +7 -0
- package/hooks/config/banned_identifiers_constants.py +11 -0
- package/hooks/config/blocking_check_limits.py +38 -0
- package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
- package/hooks/config/code_rules_enforcer_constants.py +53 -0
- package/hooks/config/convergence_branch_constants.py +9 -0
- package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
- package/hooks/config/html_companion_constants.py +20 -0
- package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
- package/hooks/config/pr_description_enforcer_constants.py +14 -0
- package/hooks/config/test_banned_identifiers_constants.py +17 -0
- package/hooks/hooks.json +28 -20
- package/hooks/pyproject.toml +69 -0
- package/hooks/validators/mypy_integration.py +47 -1
- package/hooks/validators/run_all_validators.py +3 -3
- package/hooks/validators/test_mypy_integration.py +50 -1
- package/hooks/workflow/doc_gist_auto_publish.py +144 -0
- package/hooks/workflow/md_to_html_companion.py +365 -0
- package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
- package/hooks/workflow/test_md_to_html_companion.py +452 -0
- package/package.json +1 -1
- package/rules/gh-body-file.md +2 -0
- package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
- package/scripts/check.ps1 +106 -0
- package/scripts/config/timing.py +11 -0
- package/scripts/sweep_empty_dirs.py +138 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_sweep_empty_dirs.py +183 -0
- package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
- package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
- package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
- package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
- package/skills/bugteam/CONSTRAINTS.md +21 -22
- package/skills/bugteam/EXAMPLES.md +3 -3
- package/skills/bugteam/PROMPTS.md +227 -67
- package/skills/bugteam/SKILL.md +132 -455
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +112 -39
- package/skills/bugteam/reference/audit-contract.md +4 -22
- package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
- package/skills/bugteam/reference/design-rationale.md +2 -2
- package/skills/bugteam/reference/github-pr-reviews.md +50 -57
- package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
- package/skills/bugteam/reference/team-setup.md +111 -9
- package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
- package/skills/bugteam/scripts/README.md +60 -0
- package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
- package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
- package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
- package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
- package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
- package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
- package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
- package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
- package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
- package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
- package/skills/bugteam/test_skill_additions.py +1 -11
- package/skills/code/SKILL.md +176 -0
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/doc-gist/SKILL.md +99 -0
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
- package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
- package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
- package/skills/doc-gist/references/examples/05-design-system.html +629 -0
- package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
- package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
- package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
- package/skills/doc-gist/references/examples/11-status-report.html +528 -0
- package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
- package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
- package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
- package/skills/doc-gist/references/examples/README.md +5 -0
- package/skills/doc-gist/scripts/config/__init__.py +0 -0
- package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
- package/skills/doc-gist/scripts/gist_upload.py +177 -0
- package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
- package/skills/findbugs/SKILL.md +96 -2
- package/skills/monitor-open-prs/SKILL.md +14 -32
- package/skills/monitor-open-prs/test_skill_contract.py +0 -11
- package/skills/pr-consistency-audit/SKILL.md +112 -0
- package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
- package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
- package/skills/pr-converge/SKILL.md +229 -23
- package/skills/pr-converge/config/__init__.py +0 -0
- package/skills/pr-converge/config/constants.py +63 -0
- package/skills/pr-converge/reference/convergence-gates.md +138 -44
- package/skills/pr-converge/reference/examples.md +43 -11
- package/skills/pr-converge/reference/fix-protocol.md +6 -5
- package/skills/pr-converge/reference/ground-rules.md +5 -3
- package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
- package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
- package/skills/pr-converge/reference/per-tick.md +107 -31
- package/skills/pr-converge/reference/state-schema.md +22 -1
- package/skills/pr-converge/reference/stop-conditions.md +9 -7
- package/skills/pr-converge/scripts/README.md +34 -46
- package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
- package/skills/pr-converge/scripts/check_convergence.py +497 -0
- package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
- package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
- package/skills/qbug/SKILL.md +157 -27
- package/skills/session-log/SKILL.md +216 -114
- package/skills/session-tidy/SKILL.md +1 -1
- package/skills/skill-builder/SKILL.md +138 -56
- package/skills/skill-builder/references/delegation-map.md +72 -113
- package/skills/skill-builder/references/progressive-disclosure.md +122 -0
- package/skills/skill-builder/references/self-audit-checklist.md +92 -0
- package/skills/skill-builder/references/skill-types.md +228 -0
- package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
- package/skills/skill-builder/templates/gap-analysis.md +15 -8
- package/skills/skill-builder/workflows/improve-skill.md +86 -57
- package/skills/skill-builder/workflows/new-skill.md +80 -168
- package/skills/skill-builder/workflows/polish-skill.md +78 -54
- package/skills/structure-prompt/SKILL.md +50 -0
- package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
- package/skills/structure-prompt/reference/block-classification.md +27 -0
- package/skills/structure-prompt/reference/canonical-case.md +48 -0
- package/skills/structure-prompt/reference/citation-depth.md +70 -0
- package/skills/structure-prompt/reference/cleanup.md +33 -0
- package/skills/structure-prompt/reference/constraints.md +33 -0
- package/skills/structure-prompt/reference/directives.md +37 -0
- package/skills/structure-prompt/reference/examples.md +72 -0
- package/skills/structure-prompt/reference/instantiation.md +51 -0
- package/skills/structure-prompt/reference/output-contract.md +72 -0
- package/skills/structure-prompt/reference/per-category.md +23 -0
- package/skills/structure-prompt/reference/persona.md +38 -0
- package/skills/structure-prompt/reference/research.md +33 -0
- package/skills/structure-prompt/reference/structure.md +28 -0
- package/agents/code-standards-agent.md +0 -93
- package/agents/groq-coder.md +0 -113
- package/agents/plan-executor.md +0 -226
- package/agents/project-docs-analyzer.md +0 -53
- package/agents/project-structure-organizer-agent.md +0 -72
- package/agents/skill-to-agent-converter.md +0 -370
- package/agents/skill-writer-agent.md +0 -470
- package/agents/user-docs-writer.md +0 -67
- package/agents/workflow-visual-documenter.md +0 -82
- package/commands/readability-review.md +0 -20
- package/hooks/mypy.ini +0 -2
- package/hooks/notification/attention_needed_notify.py +0 -71
- package/hooks/notification/claude_notification_handler.py +0 -67
- package/hooks/notification/notification_utils.py +0 -267
- package/hooks/notification/subagent_complete_notify.py +0 -381
- package/hooks/notification/test_attention_needed_notify.py +0 -47
- package/hooks/notification/test_claude_notification_handler.py +0 -54
- package/hooks/notification/test_notification_utils.py +0 -91
- package/hooks/notification/test_subagent_complete_notify.py +0 -79
- package/scripts/config/groq_bugteam_config.py +0 -230
- package/scripts/config/test_groq_bugteam_config.py +0 -83
- package/scripts/config/test_spec_implementer_prompt.py +0 -32
- package/scripts/groq_bugteam.README.md +0 -131
- package/scripts/groq_bugteam.py +0 -647
- package/scripts/groq_bugteam_dotenv.py +0 -40
- package/scripts/groq_bugteam_spec.py +0 -226
- package/scripts/test_groq_bugteam.py +0 -529
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
- package/scripts/test_groq_bugteam_dotenv.py +0 -66
- package/scripts/test_groq_bugteam_spec.py +0 -338
- package/skills/bugteam/SKILL_EVALS.md +0 -309
- package/skills/dream/SKILL.md +0 -118
- package/skills/ingest/SKILL.md +0 -40
- package/skills/npm-creator/SKILL.md +0 -187
- package/skills/readability-review/SKILL.md +0 -127
- package/skills/resume-review/SKILL.md +0 -261
- package/skills/rule-audit/SKILL.md +0 -307
- package/skills/rule-creator/SKILL.md +0 -150
- package/skills/searching-obsidian-vault/SKILL.md +0 -131
- package/skills/skill-writer/REFERENCE.md +0 -284
- package/skills/skill-writer/SKILL.md +0 -222
- package/skills/tdd-team/SKILL.md +0 -128
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block gh pr ready until convergence pre-conditions pass.
|
|
3
|
+
|
|
4
|
+
Runs check_convergence.py against the PR and denies the gh pr ready
|
|
5
|
+
call if any condition fails. The agent sees exactly which conditions
|
|
6
|
+
failed and can address them before retrying.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
|
|
18
|
+
direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
|
|
19
|
+
if direct_match:
|
|
20
|
+
return int(direct_match.group(1))
|
|
21
|
+
try:
|
|
22
|
+
completed_process = subprocess.run(
|
|
23
|
+
["gh", "pr", "view", "--json", "number", "--jq", ".number"],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
cwd=cwd or None,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
except OSError:
|
|
30
|
+
return None
|
|
31
|
+
if completed_process.returncode != 0:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return int(completed_process.stdout.strip())
|
|
35
|
+
except (ValueError, TypeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
|
|
40
|
+
try:
|
|
41
|
+
completed_process = subprocess.run(
|
|
42
|
+
["gh", "repo", "view", "--json", "owner,name", "--jq", ".owner.login,.name"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
cwd=cwd or None,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
if completed_process.returncode != 0:
|
|
51
|
+
return None
|
|
52
|
+
parts = completed_process.stdout.strip().splitlines()
|
|
53
|
+
if len(parts) <= 1:
|
|
54
|
+
match = re.match(
|
|
55
|
+
r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$",
|
|
56
|
+
completed_process.stdout.strip(),
|
|
57
|
+
)
|
|
58
|
+
if match:
|
|
59
|
+
return match.group(1), match.group(2)
|
|
60
|
+
return None
|
|
61
|
+
return parts[0], parts[1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
check_convergence_script = str(
|
|
66
|
+
Path.home() / ".claude/skills/pr-converge/scripts/check_convergence.py"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not Path(check_convergence_script).is_file():
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
hook_input = json.load(sys.stdin)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
tool_name = hook_input.get("tool_name", "")
|
|
78
|
+
if tool_name != "Bash":
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
|
|
81
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
82
|
+
gh_pr_ready_pattern = re.compile(r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)")
|
|
83
|
+
if not gh_pr_ready_pattern.search(command):
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
|
|
86
|
+
cwd = hook_input.get("tool_input", {}).get("cwd")
|
|
87
|
+
pr_number = _resolve_pr_number(command, cwd)
|
|
88
|
+
if pr_number is None:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
|
|
91
|
+
owner_repo = _resolve_owner_repo(cwd)
|
|
92
|
+
if owner_repo is None:
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
owner, repo = owner_repo
|
|
95
|
+
|
|
96
|
+
completed_process = subprocess.run(
|
|
97
|
+
[
|
|
98
|
+
sys.executable,
|
|
99
|
+
check_convergence_script,
|
|
100
|
+
"--owner",
|
|
101
|
+
owner,
|
|
102
|
+
"--repo",
|
|
103
|
+
repo,
|
|
104
|
+
"--pr-number",
|
|
105
|
+
str(pr_number),
|
|
106
|
+
],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if completed_process.returncode in (0, 2):
|
|
113
|
+
sys.exit(0)
|
|
114
|
+
|
|
115
|
+
deny_payload = {
|
|
116
|
+
"hookSpecificOutput": {
|
|
117
|
+
"hookEventName": "PreToolUse",
|
|
118
|
+
"permissionDecision": "deny",
|
|
119
|
+
"permissionDecisionReason": (
|
|
120
|
+
"Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
print(json.dumps(deny_payload))
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -9,6 +9,16 @@ import sys
|
|
|
9
9
|
import tempfile
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
13
|
+
if _hooks_dir not in sys.path:
|
|
14
|
+
sys.path.insert(0, _hooks_dir)
|
|
15
|
+
|
|
16
|
+
from config.convergence_branch_constants import ( # noqa: E402
|
|
17
|
+
ALL_CONVERGENCE_BRANCH_PREFIXES,
|
|
18
|
+
CONVERGENCE_BRANCH_SUFFIX_PATTERN,
|
|
19
|
+
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
|
|
20
|
+
)
|
|
21
|
+
|
|
12
22
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
13
23
|
GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
|
|
14
24
|
GH_REDIRECT_ACTIVE_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
@@ -86,6 +96,9 @@ DESTRUCTIVE_BASH_PATTERNS = [
|
|
|
86
96
|
(re.compile(r'\bDROP\s+TABLE\b', re.IGNORECASE), "DROP TABLE (destroys database table)"),
|
|
87
97
|
(re.compile(r'\bDROP\s+DATABASE\b', re.IGNORECASE), "DROP DATABASE (destroys entire database)"),
|
|
88
98
|
(re.compile(r'\bTRUNCATE\s+TABLE\b', re.IGNORECASE), "TRUNCATE TABLE (removes all table rows)"),
|
|
99
|
+
(re.compile(r'\bgit\s+(?:[^\s]+\s+)*--no-verify\b', re.IGNORECASE), "git --no-verify (skips pre-commit / pre-push hooks; NON-NEGOTIABLE per git-workflow.md)"),
|
|
100
|
+
(re.compile(r'\bgit\s+(?:[^\s]+\s+)*--no-gpg-sign\b', re.IGNORECASE), "git --no-gpg-sign (bypasses commit signing; NON-NEGOTIABLE per git-workflow.md)"),
|
|
101
|
+
(re.compile(r"\bgit\s+-c\s+['\"]?commit\.gpgsign=['\"]?false['\"]?(?!\w)", re.IGNORECASE), "git -c commit.gpgsign=false (bypasses commit signing; NON-NEGOTIABLE per git-workflow.md)"),
|
|
89
102
|
]
|
|
90
103
|
|
|
91
104
|
def find_destructive_pattern(command: str) -> str | None:
|
|
@@ -483,6 +496,52 @@ def _git_reset_hard_allowed_for_command(command: str, current_working_directory:
|
|
|
483
496
|
return False
|
|
484
497
|
|
|
485
498
|
|
|
499
|
+
def _is_convergence_branch(branch: str) -> bool:
|
|
500
|
+
all_convergence_branch_prefixes = ALL_CONVERGENCE_BRANCH_PREFIXES
|
|
501
|
+
for each_prefix in all_convergence_branch_prefixes:
|
|
502
|
+
if branch.startswith(each_prefix):
|
|
503
|
+
return True
|
|
504
|
+
return bool(re.match(CONVERGENCE_BRANCH_SUFFIX_PATTERN, branch))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _all_refspecs_are_convergence_branches(post_remote_text: str) -> bool:
|
|
508
|
+
if not post_remote_text.strip():
|
|
509
|
+
return False
|
|
510
|
+
is_any_refspec_checked = False
|
|
511
|
+
for each_token in post_remote_text.split():
|
|
512
|
+
if each_token.startswith("-"):
|
|
513
|
+
continue
|
|
514
|
+
is_any_refspec_checked = True
|
|
515
|
+
destination_branch = each_token.split(":")[-1]
|
|
516
|
+
if not _is_convergence_branch(destination_branch):
|
|
517
|
+
return False
|
|
518
|
+
return is_any_refspec_checked
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _force_push_targets_convergence_branch(command: str) -> bool:
|
|
522
|
+
convergence_force_push_detection_pattern = (
|
|
523
|
+
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN
|
|
524
|
+
)
|
|
525
|
+
is_force_push_found = False
|
|
526
|
+
for each_match in re.finditer(
|
|
527
|
+
convergence_force_push_detection_pattern, command, re.IGNORECASE
|
|
528
|
+
):
|
|
529
|
+
is_force_push_found = True
|
|
530
|
+
post_push_text = each_match.group(1).strip()
|
|
531
|
+
all_tokens = post_push_text.split()
|
|
532
|
+
remote_index = 1 if all_tokens and all_tokens[0] in ("--force", "-f") else 0
|
|
533
|
+
all_refspec_tokens = [
|
|
534
|
+
token for token in all_tokens[remote_index + 1 :]
|
|
535
|
+
if token not in ("--force", "-f")
|
|
536
|
+
]
|
|
537
|
+
post_remote_text = " ".join(all_refspec_tokens)
|
|
538
|
+
if not post_remote_text:
|
|
539
|
+
return False
|
|
540
|
+
if not _all_refspecs_are_convergence_branches(post_remote_text):
|
|
541
|
+
return False
|
|
542
|
+
return is_force_push_found
|
|
543
|
+
|
|
544
|
+
|
|
486
545
|
def main() -> None:
|
|
487
546
|
try:
|
|
488
547
|
hook_input = json.load(sys.stdin)
|
|
@@ -524,6 +583,21 @@ def main() -> None:
|
|
|
524
583
|
if _git_reset_hard_allowed_for_command(command, os.getcwd()):
|
|
525
584
|
sys.exit(0)
|
|
526
585
|
|
|
586
|
+
if (
|
|
587
|
+
matched_description is not None
|
|
588
|
+
and "git push" in matched_description
|
|
589
|
+
and ("force" in matched_description or "-f" in matched_description)
|
|
590
|
+
and _force_push_targets_convergence_branch(command)
|
|
591
|
+
):
|
|
592
|
+
for each_pattern, each_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
593
|
+
if "git push" in each_description and ("force" in each_description or "-f" in each_description):
|
|
594
|
+
continue
|
|
595
|
+
if each_pattern.search(command):
|
|
596
|
+
matched_description = each_description
|
|
597
|
+
break
|
|
598
|
+
else:
|
|
599
|
+
sys.exit(0)
|
|
600
|
+
|
|
527
601
|
if matched_description is not None:
|
|
528
602
|
ask_response = {
|
|
529
603
|
"hookSpecificOutput": {
|
|
@@ -24,6 +24,7 @@ import re
|
|
|
24
24
|
import sys
|
|
25
25
|
|
|
26
26
|
from _gh_body_arg_utils import (
|
|
27
|
+
_is_bash_continuation,
|
|
27
28
|
all_body_flags,
|
|
28
29
|
all_body_flag_prefixes,
|
|
29
30
|
get_logical_first_line,
|
|
@@ -69,6 +70,32 @@ def _logical_line_has_bare_body_token(logical_line: str) -> bool:
|
|
|
69
70
|
return bool(_BARE_BODY_TOKEN_PATTERN.search(logical_line))
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def _has_backtick(command: str) -> bool:
|
|
74
|
+
"""Return True if command contains a backtick that is not a bash continuation.
|
|
75
|
+
|
|
76
|
+
Joins all bash `` \\ `` continuation lines so backticks in multi-line body
|
|
77
|
+
values on later non-continuation lines are not missed. Only strips bash
|
|
78
|
+
continuations — this hook runs on the Bash tool, and PowerShell-style
|
|
79
|
+
backtick continuations are not continuation markers in bash.
|
|
80
|
+
|
|
81
|
+
Scans the entire command string for backtick characters, not just --body
|
|
82
|
+
argument content. This is intentionally conservative — any command
|
|
83
|
+
containing backticks should use --body-file regardless of where they
|
|
84
|
+
appear. False positives (backticks in non-body flags) are safe
|
|
85
|
+
over-blocks.
|
|
86
|
+
"""
|
|
87
|
+
continuation_separator = " "
|
|
88
|
+
all_joined_lines: list[str] = []
|
|
89
|
+
for each_line in command.splitlines():
|
|
90
|
+
stripped_line = each_line.rstrip()
|
|
91
|
+
if _is_bash_continuation(stripped_line):
|
|
92
|
+
all_joined_lines.append(stripped_line[:-1].rstrip() + continuation_separator)
|
|
93
|
+
continue
|
|
94
|
+
all_joined_lines.append(each_line)
|
|
95
|
+
full_logical_command = "".join(all_joined_lines)
|
|
96
|
+
return "`" in full_logical_command
|
|
97
|
+
|
|
98
|
+
|
|
72
99
|
def _uses_body_string_arg(command: str) -> bool:
|
|
73
100
|
"""Return True if command calls an affected gh subcommand with --body <string>.
|
|
74
101
|
|
|
@@ -114,6 +141,9 @@ def main() -> None:
|
|
|
114
141
|
if not _uses_body_string_arg(command):
|
|
115
142
|
sys.exit(0)
|
|
116
143
|
|
|
144
|
+
if not _has_backtick(command):
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
|
|
117
147
|
deny_payload = {
|
|
118
148
|
"hookSpecificOutput": {
|
|
119
149
|
"hookEventName": "PreToolUse",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks Write/Edit targeting .md files, redirecting to .html.
|
|
3
|
+
|
|
4
|
+
HTML preserves spatial structure (diffs, timelines, comparisons, diagrams)
|
|
5
|
+
that markdown flattens. See https://thariqs.github.io/html-effectiveness/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_markdown_extension = ".md"
|
|
15
|
+
_html_effectiveness_url = "https://thariqs.github.io/html-effectiveness/"
|
|
16
|
+
_exempt_root_filenames = ("readme.md", "changelog.md")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_exempt_path(file_path: str) -> bool:
|
|
20
|
+
normalized = os.path.normpath(file_path).replace("\\", "/")
|
|
21
|
+
lower_normalized = normalized.lower()
|
|
22
|
+
if "/.claude/" in lower_normalized or lower_normalized.startswith(".claude/"):
|
|
23
|
+
return True
|
|
24
|
+
basename = os.path.basename(normalized)
|
|
25
|
+
if basename.lower() in _exempt_root_filenames:
|
|
26
|
+
directory = os.path.dirname(normalized)
|
|
27
|
+
if directory in ("", "."):
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _block_reason(file_path: str) -> str:
|
|
33
|
+
return (
|
|
34
|
+
f"BLOCKED: Write/Edit to .md file '{file_path}' is not permitted. "
|
|
35
|
+
"Use .html files instead for documentation. "
|
|
36
|
+
f"See {_html_effectiveness_url} for why HTML "
|
|
37
|
+
"is more effective than Markdown for structured information."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _block_context() -> str:
|
|
42
|
+
return (
|
|
43
|
+
"Generate a self-contained .html file instead of .md. "
|
|
44
|
+
"Design freely — HTML can express spatial structure, interactivity, "
|
|
45
|
+
"and visual hierarchy that markdown cannot.\n\n"
|
|
46
|
+
"Reference for HTML effectiveness patterns:\n"
|
|
47
|
+
f"{_html_effectiveness_url}\n"
|
|
48
|
+
"Exceptions (.md still allowed):\n"
|
|
49
|
+
"- Files inside .claude/ directories\n"
|
|
50
|
+
"- README.md and CHANGELOG.md at repo root"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _block_system_message() -> str:
|
|
55
|
+
return (
|
|
56
|
+
".md files are blocked in this project — generate a self-contained .html "
|
|
57
|
+
f"file instead. See {_html_effectiveness_url} for "
|
|
58
|
+
"design patterns and examples. Exemptions: .claude/ infrastructure, "
|
|
59
|
+
"README.md, CHANGELOG.md at repo root."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> None:
|
|
64
|
+
"""Read hook input JSON from stdin, deny .md writes or pass through silently.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
None (exits process).
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
input_data = json.load(sys.stdin)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
if not isinstance(input_data, dict):
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
tool_name = input_data.get("tool_name", "")
|
|
78
|
+
if not isinstance(tool_name, str):
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
|
|
81
|
+
tool_input = input_data.get("tool_input", {})
|
|
82
|
+
if not isinstance(tool_input, dict):
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
|
|
85
|
+
if tool_name not in ("Write", "Edit"):
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
|
|
88
|
+
file_path = tool_input.get("file_path", "")
|
|
89
|
+
if not file_path:
|
|
90
|
+
sys.exit(0)
|
|
91
|
+
|
|
92
|
+
if not file_path.lower().endswith(_markdown_extension):
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
if _is_exempt_path(file_path):
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
block_payload = {
|
|
99
|
+
"hookSpecificOutput": {
|
|
100
|
+
"hookEventName": "PreToolUse",
|
|
101
|
+
"permissionDecision": "deny",
|
|
102
|
+
"permissionDecisionReason": _block_reason(file_path),
|
|
103
|
+
"additionalContext": _block_context(),
|
|
104
|
+
},
|
|
105
|
+
"systemMessage": _block_system_message(),
|
|
106
|
+
"suppressOutput": True,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
114
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
115
|
+
output_stream.flush()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
|
@@ -20,16 +20,30 @@ from _gh_body_arg_utils import (
|
|
|
20
20
|
iter_significant_tokens,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
-
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
24
|
-
PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
25
|
+
hooks_tree = Path(__file__).resolve().parent.parent
|
|
26
|
+
hooks_tree_string = str(hooks_tree)
|
|
27
|
+
if hooks_tree_string not in sys.path:
|
|
28
|
+
sys.path.insert(0, hooks_tree_string)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_insert_hooks_tree_for_imports()
|
|
32
|
+
|
|
33
|
+
from config.pr_description_enforcer_constants import (
|
|
34
|
+
BLOCKQUOTE_MARKER_PATTERN,
|
|
35
|
+
BOLD_PAIR_PATTERN,
|
|
36
|
+
BULLET_MARKER_PATTERN,
|
|
37
|
+
FENCED_CODE_BLOCK_PATTERN,
|
|
38
|
+
HEADING_LINE_PATTERN,
|
|
39
|
+
INLINE_CODE_PATTERN,
|
|
40
|
+
LINK_TEXT_PATTERN,
|
|
41
|
+
MINIMUM_SUBSTANTIVE_PROSE_CHARS,
|
|
42
|
+
WHITESPACE_RUN_PATTERN,
|
|
43
|
+
)
|
|
31
44
|
|
|
32
|
-
|
|
45
|
+
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
46
|
+
PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
|
|
33
47
|
|
|
34
48
|
VAGUE_LANGUAGE_PATTERN = re.compile(
|
|
35
49
|
r'\b(fix(?:ed)? (?:bug|issue|it)|update(?:d)? code|minor changes|various (?:fixes|updates|improvements))\b',
|
|
@@ -269,23 +283,43 @@ def extract_body_from_command(
|
|
|
269
283
|
return result
|
|
270
284
|
|
|
271
285
|
|
|
286
|
+
def _count_substantive_prose_chars(body: str) -> int:
|
|
287
|
+
"""Return the count of prose characters after stripping Markdown ceremony.
|
|
288
|
+
|
|
289
|
+
Removes fenced code, inline code, heading lines, blockquote markers,
|
|
290
|
+
bullet list markers, bold/emphasis markers, and Markdown link targets.
|
|
291
|
+
Collapses internal whitespace so a body of only headers and bullets --
|
|
292
|
+
no real WHY paragraph -- registers as effectively empty.
|
|
293
|
+
"""
|
|
294
|
+
body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub('', body)
|
|
295
|
+
body_without_inline_code = INLINE_CODE_PATTERN.sub('', body_without_fences)
|
|
296
|
+
body_without_blockquotes = BLOCKQUOTE_MARKER_PATTERN.sub('', body_without_inline_code)
|
|
297
|
+
body_without_headings = HEADING_LINE_PATTERN.sub('', body_without_blockquotes)
|
|
298
|
+
body_without_bullets = BULLET_MARKER_PATTERN.sub('', body_without_headings)
|
|
299
|
+
body_without_bold = BOLD_PAIR_PATTERN.sub(r'\1', body_without_bullets)
|
|
300
|
+
body_without_emphasis = body_without_bold.replace('*', '')
|
|
301
|
+
body_without_links = LINK_TEXT_PATTERN.sub(r'\1', body_without_emphasis)
|
|
302
|
+
body_collapsed = WHITESPACE_RUN_PATTERN.sub(' ', body_without_links).strip()
|
|
303
|
+
return len(body_collapsed)
|
|
304
|
+
|
|
305
|
+
|
|
272
306
|
def validate_pr_body(body: str) -> list[str]:
|
|
273
|
-
|
|
274
|
-
body_lower = body.lower()
|
|
307
|
+
"""Audit a PR body for substantive-prose and vague-language violations.
|
|
275
308
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if f"## {header}" not in body_lower and f"**{header}" not in body_lower
|
|
279
|
-
]
|
|
309
|
+
Args:
|
|
310
|
+
body: The PR body markdown text to audit.
|
|
280
311
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
312
|
+
Returns:
|
|
313
|
+
A list of human-readable violation messages. Empty when the body passes.
|
|
314
|
+
"""
|
|
315
|
+
violations = []
|
|
284
316
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
317
|
+
substantive_chars = _count_substantive_prose_chars(body)
|
|
318
|
+
if substantive_chars < MINIMUM_SUBSTANTIVE_PROSE_CHARS:
|
|
319
|
+
violations.append(
|
|
320
|
+
"PR body lacks substantive prose -- include a Why paragraph or "
|
|
321
|
+
"substantive explanation, not only headers and bullets"
|
|
322
|
+
)
|
|
289
323
|
|
|
290
324
|
vague_matches = VAGUE_LANGUAGE_PATTERN.findall(body)
|
|
291
325
|
if vague_matches:
|
|
@@ -329,7 +363,8 @@ def main() -> None:
|
|
|
329
363
|
pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
|
|
330
364
|
denial_reason = (
|
|
331
365
|
f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
|
|
332
|
-
f"
|
|
366
|
+
f"Use the pr-description-writer agent to author the body in Anthropic claude-code style. "
|
|
367
|
+
f"Guide:{pr_guide_reference}"
|
|
333
368
|
)
|
|
334
369
|
result = {
|
|
335
370
|
"hookSpecificOutput": {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Unit tests for bot-mention-comment-blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
10
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
12
|
+
|
|
13
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
14
|
+
"bot_mention_comment_blocker",
|
|
15
|
+
_HOOK_DIR / "bot_mention_comment_blocker.py",
|
|
16
|
+
)
|
|
17
|
+
assert hook_spec is not None
|
|
18
|
+
assert hook_spec.loader is not None
|
|
19
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
20
|
+
hook_spec.loader.exec_module(hook_module)
|
|
21
|
+
|
|
22
|
+
_detect_bot_mention = hook_module._detect_bot_mention
|
|
23
|
+
_body_contains_token = hook_module._body_contains_token
|
|
24
|
+
|
|
25
|
+
from config.bot_mention_comment_blocker_constants import (
|
|
26
|
+
CORRECTIVE_MESSAGE_COPILOT,
|
|
27
|
+
CORRECTIVE_MESSAGE_CURSOR,
|
|
28
|
+
CURSOR_MENTION_TOKEN,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_passes_clean_body() -> None:
|
|
33
|
+
assert _detect_bot_mention("bugbot run") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_passes_empty_body() -> None:
|
|
37
|
+
assert _detect_bot_mention("") is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_passes_unrelated_body() -> None:
|
|
41
|
+
assert _detect_bot_mention("please review this PR") is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_blocks_cursor_mention() -> None:
|
|
45
|
+
reason = _detect_bot_mention("@cursor bugbot run")
|
|
46
|
+
assert reason is not None
|
|
47
|
+
assert "bugbot run" in reason
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_blocks_cursor_bracket_mention() -> None:
|
|
51
|
+
reason = _detect_bot_mention("@cursor[bot] bugbot run")
|
|
52
|
+
assert reason is not None
|
|
53
|
+
assert "bugbot run" in reason
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_blocks_copilot_mention() -> None:
|
|
57
|
+
reason = _detect_bot_mention("@copilot review this")
|
|
58
|
+
assert reason is not None
|
|
59
|
+
assert "copilot-pull-request-reviewer" in reason
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_returns_cursor_message_for_cursor() -> None:
|
|
63
|
+
assert _detect_bot_mention("@cursor run") == CORRECTIVE_MESSAGE_CURSOR
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_returns_copilot_message_for_copilot() -> None:
|
|
67
|
+
assert _detect_bot_mention("@copilot help") == CORRECTIVE_MESSAGE_COPILOT
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_copilot_wins_when_both_present() -> None:
|
|
71
|
+
assert _detect_bot_mention("@cursor and @copilot") == CORRECTIVE_MESSAGE_COPILOT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_body_contains_token_case_insensitive() -> None:
|
|
75
|
+
assert _body_contains_token("Hello @CURSOR world", "@cursor")
|
|
76
|
+
assert _body_contains_token("Hello @CoPilot world", "@copilot")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_body_contains_token_no_at_sign() -> None:
|
|
80
|
+
assert not _body_contains_token("cursor without at-sign", CURSOR_MENTION_TOKEN)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
from unittest import mock
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run_main_with_io(input_text: str) -> str:
|
|
87
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
88
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
|
|
89
|
+
try:
|
|
90
|
+
hook_module.main()
|
|
91
|
+
except SystemExit:
|
|
92
|
+
pass
|
|
93
|
+
return mock_stdout.getvalue()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_main_blocks_matching_cursor_comment() -> None:
|
|
97
|
+
hook_input = {
|
|
98
|
+
"tool_name": "mcp__plugin_github_github__add_issue_comment",
|
|
99
|
+
"tool_input": {"body": "@cursor bugbot run"},
|
|
100
|
+
}
|
|
101
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
102
|
+
output = json.loads(output_text)
|
|
103
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_main_passes_wrong_tool_name() -> None:
|
|
107
|
+
hook_input = {
|
|
108
|
+
"tool_name": "some_other_tool",
|
|
109
|
+
"tool_input": {"body": "@cursor bugbot run"},
|
|
110
|
+
}
|
|
111
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_main_passes_empty_body() -> None:
|
|
115
|
+
hook_input = {
|
|
116
|
+
"tool_name": "mcp__plugin_github_github__add_issue_comment",
|
|
117
|
+
"tool_input": {"body": ""},
|
|
118
|
+
}
|
|
119
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_main_passes_non_matching_body() -> None:
|
|
123
|
+
hook_input = {
|
|
124
|
+
"tool_name": "mcp__plugin_github_github__add_issue_comment",
|
|
125
|
+
"tool_input": {"body": "please review this PR"},
|
|
126
|
+
}
|
|
127
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_main_passes_malformed_json() -> None:
|
|
131
|
+
assert _run_main_with_io("not valid json {{{") == ""
|
|
@@ -1192,3 +1192,24 @@ def test_sys_path_insert_should_skip_hook_infrastructure_files() -> None:
|
|
|
1192
1192
|
assert issues == [], (
|
|
1193
1193
|
f"Hook infrastructure files are exempt from this rule, got: {issues}"
|
|
1194
1194
|
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def test_validate_content_honors_empty_full_file_content_for_thin_wrapper_check() -> None:
|
|
1198
|
+
"""An empty `full_file_content` must not be silently replaced with the pre-edit fragment.
|
|
1199
|
+
|
|
1200
|
+
Regression for loop1-8: the `or` short-circuit at the thin-wrapper call
|
|
1201
|
+
site treated `""` identically to `None`, so an Edit collapsing a file to
|
|
1202
|
+
empty was scanned against the pre-edit fragment instead of the empty
|
|
1203
|
+
post-edit content. Mirror the canonical idiom at line 3438.
|
|
1204
|
+
"""
|
|
1205
|
+
pre_edit_fragment_with_imports_only = (
|
|
1206
|
+
"from real_module import do_thing\n__all__ = ['do_thing']\n"
|
|
1207
|
+
)
|
|
1208
|
+
issues = code_rules_enforcer.validate_content(
|
|
1209
|
+
pre_edit_fragment_with_imports_only,
|
|
1210
|
+
"/project/src/aliases.py",
|
|
1211
|
+
full_file_content="",
|
|
1212
|
+
)
|
|
1213
|
+
assert not any("thin wrapper" in each.lower() for each in issues), (
|
|
1214
|
+
f"empty post-edit file must not be flagged as a thin wrapper, got: {issues!r}"
|
|
1215
|
+
)
|