claude-dev-env 1.82.0 → 1.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/audit-contract.md +24 -12
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/README.md +1 -0
  5. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  9. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  10. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  11. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  12. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  13. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  14. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  15. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  18. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  19. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  20. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  21. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  22. package/agents/clean-coder.md +3 -0
  23. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  24. package/bin/install.mjs +16 -8
  25. package/bin/install.test.mjs +17 -6
  26. package/commands/CLAUDE.md +0 -1
  27. package/docs/CODE_RULES.md +1 -1
  28. package/hooks/blocking/CLAUDE.md +2 -4
  29. package/hooks/blocking/code_rules_constants_config.py +164 -1
  30. package/hooks/blocking/code_rules_docstrings.py +428 -15
  31. package/hooks/blocking/code_rules_enforcer.py +33 -0
  32. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  33. package/hooks/blocking/code_rules_magic_values.py +5 -0
  34. package/hooks/blocking/code_rules_naming_collection.py +152 -6
  35. package/hooks/blocking/code_rules_shared.py +34 -0
  36. package/hooks/blocking/code_rules_string_magic.py +68 -0
  37. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  38. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  39. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  40. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  41. package/hooks/blocking/state_description_blocker.py +96 -5
  42. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  43. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  45. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  46. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  47. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  48. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  49. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  50. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  53. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  54. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  55. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  56. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  57. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  58. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  60. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  61. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  63. package/hooks/blocking/test_state_description_blocker.py +135 -0
  64. package/hooks/hooks.json +5 -0
  65. package/hooks/hooks_constants/CLAUDE.md +3 -4
  66. package/hooks/hooks_constants/blocking_check_limits.py +46 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  68. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  70. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  71. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  72. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  73. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  74. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  75. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  76. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  77. package/hooks/workflow/CLAUDE.md +2 -8
  78. package/package.json +1 -1
  79. package/rules/CLAUDE.md +5 -2
  80. package/rules/claude-md-orphan-file.md +5 -0
  81. package/rules/docstring-prose-matches-implementation.md +10 -1
  82. package/rules/env-var-table-code-drift.md +5 -0
  83. package/rules/es-exe-file-search.md +17 -0
  84. package/rules/no-historical-clutter.md +12 -1
  85. package/rules/orphan-css-class.md +5 -0
  86. package/rules/package-inventory-stale-entry.md +10 -0
  87. package/rules/paired-test-coverage.md +5 -0
  88. package/rules/plain-illustrative-docstrings.md +40 -1
  89. package/rules/verify-before-asking.md +7 -0
  90. package/rules/verify-runtime-state.md +40 -0
  91. package/rules/windows-filesystem-safe.md +8 -0
  92. package/rules/workers-done-before-complete.md +33 -0
  93. package/rules/workflow-substitution-slots.md +5 -0
  94. package/skills/CLAUDE.md +7 -2
  95. package/skills/autoconverge/SKILL.md +48 -61
  96. package/skills/autoconverge/reference/closing-report.md +6 -6
  97. package/skills/autoconverge/reference/convergence.md +17 -15
  98. package/skills/autoconverge/reference/gotchas.md +6 -3
  99. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  100. package/skills/autoconverge/workflow/converge.contract.test.mjs +153 -10
  101. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +55 -19
  102. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  103. package/skills/autoconverge/workflow/converge.mjs +167 -106
  104. package/skills/autoconverge/workflow/render_report.py +7 -11
  105. package/skills/bugteam/CLAUDE.md +1 -1
  106. package/skills/bugteam/PROMPTS.md +7 -6
  107. package/skills/bugteam/SKILL.md +25 -72
  108. package/skills/bugteam/reference/CLAUDE.md +1 -3
  109. package/skills/bugteam/reference/README.md +1 -1
  110. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  111. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  112. package/skills/bugteam/reference/team-setup.md +8 -7
  113. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  114. package/skills/bugteam/scripts/README.md +0 -4
  115. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  116. package/skills/code/SKILL.md +2 -0
  117. package/skills/copilot-review/CLAUDE.md +1 -1
  118. package/skills/copilot-review/SKILL.md +25 -23
  119. package/skills/everything-search/SKILL.md +5 -0
  120. package/skills/findbugs/CLAUDE.md +2 -2
  121. package/skills/findbugs/SKILL.md +22 -83
  122. package/skills/fixbugs/SKILL.md +2 -4
  123. package/skills/log-audit/CLAUDE.md +20 -0
  124. package/skills/log-audit/SKILL.md +68 -0
  125. package/skills/log-audit/reference/CLAUDE.md +9 -0
  126. package/skills/log-audit/reference/charter.md +52 -0
  127. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  128. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  129. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  130. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  131. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  132. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  133. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  134. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  135. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  136. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  137. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  138. package/skills/monitor-open-prs/SKILL.md +2 -2
  139. package/skills/post-audit-findings/SKILL.md +84 -0
  140. package/skills/pr-converge/CLAUDE.md +2 -0
  141. package/skills/pr-converge/SKILL.md +72 -59
  142. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  143. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  144. package/skills/pr-converge/reference/examples.md +5 -5
  145. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  146. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  147. package/skills/pr-converge/reference/per-tick.md +24 -45
  148. package/skills/pr-converge/reference/state-schema.md +15 -0
  149. package/skills/pr-converge/scripts/README.md +3 -5
  150. package/skills/pr-fix-protocol/SKILL.md +70 -0
  151. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  152. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  153. package/skills/pr-scope-resolve/SKILL.md +48 -0
  154. package/skills/qbug/CLAUDE.md +4 -4
  155. package/skills/qbug/SKILL.md +46 -144
  156. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  157. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  158. package/skills/reviewer-gates/SKILL.md +96 -0
  159. package/skills/session-log/CLAUDE.md +7 -7
  160. package/skills/session-log/SKILL.md +27 -44
  161. package/skills/test_markdown_link_integrity.py +103 -0
  162. package/commands/doc-gist.md +0 -16
  163. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  164. package/hooks/blocking/conftest.py +0 -30
  165. package/hooks/blocking/md_path_exemptions.py +0 -224
  166. package/hooks/blocking/md_to_html_blocker.py +0 -155
  167. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  168. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  169. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  170. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  171. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  172. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  173. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  174. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  175. package/hooks/workflow/md_to_html_companion.py +0 -358
  176. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  177. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  178. package/skills/bugteam/reference/audit-contract.md +0 -163
  179. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  180. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  181. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  182. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  183. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  184. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  185. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  186. package/skills/doc-gist/CLAUDE.md +0 -25
  187. package/skills/doc-gist/SKILL.md +0 -97
  188. package/skills/doc-gist/references/CLAUDE.md +0 -9
  189. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  190. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  191. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  192. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  193. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  194. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  195. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  196. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  197. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  198. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  199. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  200. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  201. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  202. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  203. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  204. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  205. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  206. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  207. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  208. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  209. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  210. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  211. package/skills/doc-gist/references/examples/README.md +0 -5
  212. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  213. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  214. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  215. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  216. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  217. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -0,0 +1,49 @@
1
+ """Constants for the mine_copilot_findings script.
2
+
3
+ ALL_REVIEWER_BOT_LOGINS: the GitHub logins whose review comments count as defects.
4
+ RECENT_PULLS_ENDPOINT_TEMPLATE: the gh api path for a repo's most recent pulls.
5
+ PULL_COMMENTS_ENDPOINT_TEMPLATE: the gh api path for one pull request's comments.
6
+ RECENT_PULL_COUNT: how many recent pull requests a mining run reads.
7
+ MAX_EXAMPLES_PER_CLUSTER: example comment bodies kept per defect class.
8
+ KEYWORDS_BY_DEFECT_CLASS: lowercased substrings that sort a comment into a defect class.
9
+ PROPOSAL_BY_DEFECT_CLASS: the concrete skill-definition edit proposed for each class.
10
+ """
11
+
12
+ ALL_REVIEWER_BOT_LOGINS = ("copilot-pull-request-reviewer[bot]", "cursor[bot]")
13
+ RECENT_PULLS_ENDPOINT_TEMPLATE = (
14
+ "repos/{repo}/pulls?state=all&sort=updated&direction=desc&per_page={pull_count}"
15
+ )
16
+ PULL_COMMENTS_ENDPOINT_TEMPLATE = (
17
+ "repos/{repo}/pulls/{pull_number}/comments?per_page=100"
18
+ )
19
+ RECENT_PULL_COUNT = 20
20
+ MAX_EXAMPLES_PER_CLUSTER = 3
21
+ KEYWORDS_BY_DEFECT_CLASS = {
22
+ "missing-type-hint": ("type hint", "missing annotation", "untyped", "no return type"),
23
+ "broad-except": ("broad except", "bare except", "except exception", "catch-all except"),
24
+ "magic-value": ("magic number", "magic value", "hardcoded", "hard-coded"),
25
+ "unclear-name": ("abbreviation", "unclear name", "rename this", "single-letter"),
26
+ "missing-docstring": ("missing docstring", "no docstring", "undocumented"),
27
+ }
28
+ PROPOSAL_BY_DEFECT_CLASS = {
29
+ "missing-type-hint": (
30
+ "Tighten the code skill's type-hint rule to flag the untyped shape this "
31
+ "class keeps hitting."
32
+ ),
33
+ "broad-except": (
34
+ "Extend the no-broad-except enforcer pattern to the except shape this "
35
+ "class keeps hitting."
36
+ ),
37
+ "magic-value": (
38
+ "Extend the magic-value enforcer to the literal shape this class keeps "
39
+ "hitting."
40
+ ),
41
+ "unclear-name": (
42
+ "Add the abbreviation this class keeps flagging to the banned-identifier "
43
+ "list."
44
+ ),
45
+ "missing-docstring": (
46
+ "Tighten the public-docstring rule to the surface this class keeps "
47
+ "hitting."
48
+ ),
49
+ }
@@ -0,0 +1,302 @@
1
+ """Turn the defects reviewers keep catching into upstream skill-edit proposals.
2
+
3
+ Copilot and Bugbot flag the same kinds of defect over and over — an untyped
4
+ return here, a broad except there. Each catch is a defect that reached review
5
+ because nothing blocked it at write time. This script reads recent reviewer
6
+ comments, sorts them into defect classes, and for each class names a concrete
7
+ skill or rule edit that would block that class upstream. The proposals are for a
8
+ human to apply through review, not for the agent to commit on its own.
9
+
10
+ Usage:
11
+ mine_copilot_findings.py --repo owner/name
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ from collections import defaultdict
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ _script_directory = str(Path(__file__).resolve().parent)
25
+ if _script_directory not in sys.path:
26
+ sys.path.insert(0, _script_directory)
27
+
28
+ from log_audit_constants.mine_copilot_findings_constants import ( # noqa: E402
29
+ ALL_REVIEWER_BOT_LOGINS,
30
+ KEYWORDS_BY_DEFECT_CLASS,
31
+ MAX_EXAMPLES_PER_CLUSTER,
32
+ PROPOSAL_BY_DEFECT_CLASS,
33
+ PULL_COMMENTS_ENDPOINT_TEMPLATE,
34
+ RECENT_PULL_COUNT,
35
+ RECENT_PULLS_ENDPOINT_TEMPLATE,
36
+ )
37
+
38
+
39
+ class GitHubCommandError(RuntimeError):
40
+ """Raised when a gh api call fails or returns output that is not JSON."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ReviewerComment:
45
+ """One review comment left by a reviewer bot.
46
+
47
+ Attributes:
48
+ pull_number: The pull request the comment was left on.
49
+ author: The reviewer bot's login.
50
+ body: The comment text.
51
+ """
52
+
53
+ pull_number: int
54
+ author: str
55
+ body: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class DefectCluster:
60
+ """The reviewer comments that fall into one defect class.
61
+
62
+ Attributes:
63
+ defect_class: The class name the comments were sorted into.
64
+ count: How many comments fell into the class.
65
+ example_bodies: A capped sample of the comment bodies in the class.
66
+ """
67
+
68
+ defect_class: str
69
+ count: int
70
+ example_bodies: tuple[str, ...]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class SkillProposal:
75
+ """A proposed skill-definition edit that blocks one defect class upstream.
76
+
77
+ Attributes:
78
+ defect_class: The defect class the edit would block.
79
+ proposal: The concrete edit to make.
80
+ """
81
+
82
+ defect_class: str
83
+ proposal: str
84
+
85
+
86
+ def classify_defect(comment_body: str) -> str | None:
87
+ """Sort a review comment into a defect class by its keywords.
88
+
89
+ Args:
90
+ comment_body: The review comment text.
91
+
92
+ Returns:
93
+ The first defect class whose keywords appear in the comment, or None
94
+ when no class matches.
95
+ """
96
+ lowered_body = comment_body.lower()
97
+ for each_defect_class, each_keywords in KEYWORDS_BY_DEFECT_CLASS.items():
98
+ if any(each_keyword in lowered_body for each_keyword in each_keywords):
99
+ return each_defect_class
100
+ return None
101
+
102
+
103
+ def cluster_defects(all_comments: list[ReviewerComment]) -> list[DefectCluster]:
104
+ """Group review comments by defect class and rank by how often each recurs.
105
+
106
+ Args:
107
+ all_comments: The reviewer comments to sort.
108
+
109
+ Returns:
110
+ One cluster per defect class present, most frequent first, each with a
111
+ capped sample of its comment bodies.
112
+ """
113
+ bodies_by_class: dict[str, list[str]] = defaultdict(list)
114
+ for each_comment in all_comments:
115
+ defect_class = classify_defect(each_comment.body)
116
+ if defect_class is None:
117
+ continue
118
+ bodies_by_class[defect_class].append(each_comment.body)
119
+ clusters: list[DefectCluster] = []
120
+ for each_defect_class, each_bodies in bodies_by_class.items():
121
+ clusters.append(
122
+ DefectCluster(
123
+ defect_class=each_defect_class,
124
+ count=len(each_bodies),
125
+ example_bodies=tuple(each_bodies[:MAX_EXAMPLES_PER_CLUSTER]),
126
+ )
127
+ )
128
+ return sorted(clusters, key=lambda cluster: cluster.count, reverse=True)
129
+
130
+
131
+ def proposal_for_defect_class(defect_class: str) -> SkillProposal:
132
+ """Return the skill-edit proposal that blocks a defect class upstream.
133
+
134
+ Args:
135
+ defect_class: The defect class to propose an edit for.
136
+
137
+ Returns:
138
+ The proposal mapped to the class.
139
+ """
140
+ return SkillProposal(
141
+ defect_class=defect_class, proposal=PROPOSAL_BY_DEFECT_CLASS[defect_class]
142
+ )
143
+
144
+
145
+ def _pull_number_from_url(pull_request_url: str) -> int | None:
146
+ """Read the trailing pull number from a GitHub pull-request URL."""
147
+ last_segment = pull_request_url.rsplit("/", 1)[-1]
148
+ try:
149
+ return int(last_segment)
150
+ except ValueError:
151
+ return None
152
+
153
+
154
+ def _reviewer_comment_from_payload(payload: object) -> ReviewerComment | None:
155
+ """Build a ReviewerComment from one gh comment payload, or None to skip it."""
156
+ if not isinstance(payload, dict):
157
+ return None
158
+ user_field = payload.get("user")
159
+ login = user_field.get("login") if isinstance(user_field, dict) else None
160
+ body = payload.get("body")
161
+ pull_request_url = payload.get("pull_request_url")
162
+ if not isinstance(login, str) or login not in ALL_REVIEWER_BOT_LOGINS:
163
+ return None
164
+ if not isinstance(body, str) or not isinstance(pull_request_url, str):
165
+ return None
166
+ pull_number = _pull_number_from_url(pull_request_url)
167
+ if pull_number is None:
168
+ return None
169
+ return ReviewerComment(pull_number=pull_number, author=login, body=body)
170
+
171
+
172
+ def _flatten_pages(slurped_payload: object) -> list[object]:
173
+ """Flatten the per-page arrays a gh --slurp response holds into one list.
174
+
175
+ Args:
176
+ slurped_payload: The parsed output of a ``gh api --paginate --slurp``
177
+ call: a list whose entries are the individual page responses.
178
+
179
+ Returns:
180
+ Every entry across the pages in page order. A page that is itself a list
181
+ contributes its entries; any other page contributes itself.
182
+ """
183
+ flattened_entries: list[object] = []
184
+ if isinstance(slurped_payload, list):
185
+ for each_page in slurped_payload:
186
+ if isinstance(each_page, list):
187
+ flattened_entries.extend(each_page)
188
+ else:
189
+ flattened_entries.append(each_page)
190
+ return flattened_entries
191
+
192
+
193
+ def _run_gh_json(endpoint: str, *, should_paginate: bool = False) -> object:
194
+ """Run one gh api call for an endpoint and return its parsed JSON payload.
195
+
196
+ Args:
197
+ endpoint: The gh api endpoint path to request.
198
+ should_paginate: When True, follow pagination with ``--paginate
199
+ --slurp`` and flatten the per-page arrays into one list, so a list
200
+ endpoint with more than one page returns every entry rather than
201
+ only the first page.
202
+
203
+ Returns:
204
+ The parsed JSON payload: the flattened entry list when should_paginate
205
+ is True, otherwise the single response parsed as-is.
206
+
207
+ Raises:
208
+ GitHubCommandError: When gh is not installed, the gh api call fails, or
209
+ its output is not JSON.
210
+ """
211
+ gh_command = ["gh", "api", endpoint]
212
+ if should_paginate:
213
+ gh_command += ["--paginate", "--slurp"]
214
+ try:
215
+ completed_process = subprocess.run(
216
+ gh_command,
217
+ capture_output=True,
218
+ text=True,
219
+ check=True,
220
+ )
221
+ except FileNotFoundError as missing_gh_error:
222
+ raise GitHubCommandError(
223
+ "gh command not found; install the GitHub CLI to mine reviewer comments"
224
+ ) from missing_gh_error
225
+ except subprocess.CalledProcessError as gh_failure_error:
226
+ raise GitHubCommandError(
227
+ f"gh api {endpoint} failed: {gh_failure_error.stderr.strip()}"
228
+ ) from gh_failure_error
229
+ try:
230
+ parsed_payload = json.loads(completed_process.stdout)
231
+ except json.JSONDecodeError as decode_error:
232
+ raise GitHubCommandError(
233
+ f"gh api {endpoint} returned output that is not JSON"
234
+ ) from decode_error
235
+ if should_paginate:
236
+ return _flatten_pages(parsed_payload)
237
+ return parsed_payload
238
+
239
+
240
+ def _fetch_recent_pull_numbers(repo: str, pull_count: int) -> list[int]:
241
+ """Fetch the most recently updated pull numbers, capped at pull_count."""
242
+ pulls_payload = _run_gh_json(
243
+ RECENT_PULLS_ENDPOINT_TEMPLATE.format(repo=repo, pull_count=pull_count)
244
+ )
245
+ recent_pull_numbers: list[int] = []
246
+ if isinstance(pulls_payload, list):
247
+ for each_pull in pulls_payload:
248
+ if isinstance(each_pull, dict):
249
+ pull_number = each_pull.get("number")
250
+ if isinstance(pull_number, int):
251
+ recent_pull_numbers.append(pull_number)
252
+ return recent_pull_numbers
253
+
254
+
255
+ def _fetch_pull_comments(repo: str, pull_number: int) -> list[object]:
256
+ """Fetch one pull request's review comments through gh."""
257
+ comments_payload = _run_gh_json(
258
+ PULL_COMMENTS_ENDPOINT_TEMPLATE.format(repo=repo, pull_number=pull_number),
259
+ should_paginate=True,
260
+ )
261
+ if isinstance(comments_payload, list):
262
+ return list(comments_payload)
263
+ return []
264
+
265
+
266
+ def _fetch_reviewer_comments(repo: str) -> list[ReviewerComment]:
267
+ """Fetch reviewer-bot comments from the repo's most recent pull requests."""
268
+ recent_pull_numbers = _fetch_recent_pull_numbers(repo, RECENT_PULL_COUNT)
269
+ reviewer_comments: list[ReviewerComment] = []
270
+ for each_pull_number in recent_pull_numbers:
271
+ for each_payload in _fetch_pull_comments(repo, each_pull_number):
272
+ parsed_comment = _reviewer_comment_from_payload(each_payload)
273
+ if parsed_comment is not None:
274
+ reviewer_comments.append(parsed_comment)
275
+ return reviewer_comments
276
+
277
+
278
+ def main() -> int:
279
+ """Print one skill-edit proposal per recurring reviewer defect class.
280
+
281
+ Returns:
282
+ The process exit code; zero on success.
283
+ """
284
+ parser = argparse.ArgumentParser(
285
+ description="Mine reviewer defect patterns into skill-edit proposals."
286
+ )
287
+ parser.add_argument("--repo", required=True)
288
+ parsed_arguments = parser.parse_args()
289
+ try:
290
+ comments = _fetch_reviewer_comments(parsed_arguments.repo)
291
+ except GitHubCommandError as gh_error:
292
+ print(str(gh_error), file=sys.stderr)
293
+ return 1
294
+ clusters = cluster_defects(comments)
295
+ for each_cluster in clusters:
296
+ proposal = proposal_for_defect_class(each_cluster.defect_class)
297
+ print(f"{each_cluster.count}\t{each_cluster.defect_class}\t{proposal.proposal}")
298
+ return 0
299
+
300
+
301
+ if __name__ == "__main__":
302
+ raise SystemExit(main())
@@ -0,0 +1,160 @@
1
+ """Tests for cluster_recurrences — signature grouping and timing regressions."""
2
+
3
+ import io
4
+ import sys
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _script_directory = str(Path(__file__).resolve().parent)
11
+ if _script_directory not in sys.path:
12
+ sys.path.insert(0, _script_directory)
13
+
14
+ from cluster_recurrences import ( # noqa: E402
15
+ TimingSample,
16
+ detect_timing_regressions,
17
+ main,
18
+ normalize_signature,
19
+ recency_weight,
20
+ rank_signature_clusters,
21
+ )
22
+ from collect_log_window import LogRecord # noqa: E402
23
+
24
+
25
+ def _record(timestamp: datetime, message: str) -> LogRecord:
26
+ return LogRecord(
27
+ timestamp=timestamp, source="a_hook", level="block", message=message
28
+ )
29
+
30
+
31
+ class TestNormalizeSignature:
32
+ def test_two_messages_differing_only_in_numbers_share_a_signature(self) -> None:
33
+ first = normalize_signature("blocked at line 12 with 3 issues")
34
+ second = normalize_signature("blocked at line 348 with 9 issues")
35
+ assert first == second
36
+
37
+ def test_messages_differing_only_in_a_path_share_a_signature(self) -> None:
38
+ first = normalize_signature(r"cannot read C:\a\b\one.py right now")
39
+ second = normalize_signature(r"cannot read C:\x\y\two.py right now")
40
+ assert first == second
41
+
42
+ def test_messages_differing_only_in_a_hash_share_a_signature(self) -> None:
43
+ first = normalize_signature("commit a1b2c3d failed to apply")
44
+ second = normalize_signature("commit deadbeef failed to apply")
45
+ assert first == second
46
+
47
+ def test_distinct_messages_keep_distinct_signatures(self) -> None:
48
+ assert normalize_signature("heavy word detected") != normalize_signature(
49
+ "missing type hint"
50
+ )
51
+
52
+
53
+ class TestRecencyWeight:
54
+ def test_a_fresh_record_weighs_about_one(self) -> None:
55
+ now = datetime(2026, 7, 3, 12, 0, 0)
56
+ weight = recency_weight(now, now)
57
+ assert abs(weight - 1.0) < 0.001
58
+
59
+ def test_a_record_one_half_life_old_weighs_about_the_decay_base(self) -> None:
60
+ now = datetime(2026, 7, 3, 12, 0, 0)
61
+ one_half_life_ago = now - timedelta(hours=24)
62
+ weight = recency_weight(one_half_life_ago, now)
63
+ assert abs(weight - 0.5) < 0.001
64
+
65
+ def test_a_future_record_is_clamped_to_at_most_one(self) -> None:
66
+ now = datetime(2026, 7, 3, 12, 0, 0)
67
+ one_hour_ahead = now + timedelta(hours=1)
68
+ weight = recency_weight(one_hour_ahead, now)
69
+ assert abs(weight - 1.0) < 0.001
70
+
71
+
72
+ class TestRankSignatureClusters:
73
+ def test_the_most_frequent_recent_signature_ranks_first(self) -> None:
74
+ now = datetime(2026, 7, 3, 12, 0, 0)
75
+ records = [
76
+ _record(now - timedelta(hours=1), "blocked at line 1"),
77
+ _record(now - timedelta(hours=2), "blocked at line 2"),
78
+ _record(now - timedelta(hours=1), "blocked at line 3"),
79
+ _record(now - timedelta(hours=200), "a rare stale failure"),
80
+ ]
81
+ clusters = rank_signature_clusters(records, now)
82
+ assert clusters[0].count == 3
83
+ assert clusters[0].signature == normalize_signature("blocked at line 1")
84
+
85
+ def test_each_distinct_signature_becomes_one_cluster(self) -> None:
86
+ now = datetime(2026, 7, 3, 12, 0, 0)
87
+ records = [
88
+ _record(now, "heavy word here"),
89
+ _record(now, "missing type hint"),
90
+ ]
91
+ clusters = rank_signature_clusters(records, now)
92
+ assert len(clusters) == 2
93
+
94
+
95
+ class TestDetectTimingRegressions:
96
+ def test_flags_an_operation_whose_recent_runs_are_slower(self) -> None:
97
+ base = datetime(2026, 7, 3, 0, 0, 0)
98
+ samples = [
99
+ TimingSample("extract", base + timedelta(minutes=1), 100.0),
100
+ TimingSample("extract", base + timedelta(minutes=2), 110.0),
101
+ TimingSample("extract", base + timedelta(minutes=3), 105.0),
102
+ TimingSample("extract", base + timedelta(minutes=40), 300.0),
103
+ TimingSample("extract", base + timedelta(minutes=41), 320.0),
104
+ TimingSample("extract", base + timedelta(minutes=42), 310.0),
105
+ ]
106
+ regressions = detect_timing_regressions(samples)
107
+ assert len(regressions) == 1
108
+ assert regressions[0].operation == "extract"
109
+ assert regressions[0].ratio > 1.5
110
+
111
+ def test_ignores_a_steady_operation(self) -> None:
112
+ base = datetime(2026, 7, 3, 0, 0, 0)
113
+ samples = [
114
+ TimingSample("steady", base + timedelta(minutes=each_minute_offset), 100.0)
115
+ for each_minute_offset in range(6)
116
+ ]
117
+ assert detect_timing_regressions(samples) == []
118
+
119
+ def test_ignores_an_operation_with_too_few_samples(self) -> None:
120
+ base = datetime(2026, 7, 3, 0, 0, 0)
121
+ samples = [
122
+ TimingSample("sparse", base + timedelta(minutes=1), 100.0),
123
+ TimingSample("sparse", base + timedelta(minutes=2), 900.0),
124
+ ]
125
+ assert detect_timing_regressions(samples) == []
126
+
127
+
128
+ class TestMain:
129
+ def test_rejects_non_json_stdin(
130
+ self,
131
+ monkeypatch: pytest.MonkeyPatch,
132
+ capsys: pytest.CaptureFixture[str],
133
+ ) -> None:
134
+ monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
135
+ exit_code = main()
136
+ captured = capsys.readouterr()
137
+ assert exit_code == 1
138
+ assert "not valid JSON" in captured.err
139
+
140
+ def test_rejects_a_non_list_payload(
141
+ self,
142
+ monkeypatch: pytest.MonkeyPatch,
143
+ capsys: pytest.CaptureFixture[str],
144
+ ) -> None:
145
+ monkeypatch.setattr(sys, "stdin", io.StringIO('{"not": "a list"}'))
146
+ exit_code = main()
147
+ captured = capsys.readouterr()
148
+ assert exit_code == 1
149
+ assert "must be a list" in captured.err
150
+
151
+ def test_rejects_a_malformed_record(
152
+ self,
153
+ monkeypatch: pytest.MonkeyPatch,
154
+ capsys: pytest.CaptureFixture[str],
155
+ ) -> None:
156
+ monkeypatch.setattr(sys, "stdin", io.StringIO('[{"missing": "fields"}]'))
157
+ exit_code = main()
158
+ captured = capsys.readouterr()
159
+ assert exit_code == 1
160
+ assert "malformed record" in captured.err
@@ -0,0 +1,111 @@
1
+ """Tests for collect_log_window — parsing and windowing hook-block records."""
2
+
3
+ import json
4
+ import sys
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ _script_directory = str(Path(__file__).resolve().parent)
9
+ if _script_directory not in sys.path:
10
+ sys.path.insert(0, _script_directory)
11
+
12
+ from collect_log_window import ( # noqa: E402
13
+ LogRecord,
14
+ collect_records,
15
+ parse_log_line,
16
+ read_log_window,
17
+ )
18
+
19
+
20
+ def _block_line(timestamp_text: str, hook_name: str, reason_text: str) -> str:
21
+ return json.dumps(
22
+ {
23
+ "timestamp": timestamp_text,
24
+ "hook": hook_name,
25
+ "event": "PreToolUse",
26
+ "reason": reason_text,
27
+ }
28
+ )
29
+
30
+
31
+ class TestParseLogLine:
32
+ def test_parses_a_well_formed_block_record(self) -> None:
33
+ line = _block_line(
34
+ "2026-07-03T09:00:00", "plain_language_blocker", "Heavy word"
35
+ )
36
+ record = parse_log_line(line)
37
+ assert record == LogRecord(
38
+ timestamp=datetime(2026, 7, 3, 9, 0, 0),
39
+ source="plain_language_blocker",
40
+ level="block",
41
+ message="Heavy word",
42
+ )
43
+
44
+ def test_returns_none_for_a_blank_line(self) -> None:
45
+ assert parse_log_line(" ") is None
46
+
47
+ def test_returns_none_for_non_json(self) -> None:
48
+ assert parse_log_line("not json at all") is None
49
+
50
+ def test_returns_none_when_reason_is_missing(self) -> None:
51
+ line = json.dumps({"timestamp": "2026-07-03T09:00:00", "hook": "x"})
52
+ assert parse_log_line(line) is None
53
+
54
+ def test_returns_none_for_an_unparseable_timestamp(self) -> None:
55
+ line = _block_line("not-a-timestamp", "x", "y")
56
+ assert parse_log_line(line) is None
57
+
58
+
59
+ class TestCollectRecords:
60
+ def test_keeps_only_records_at_or_after_the_window_start(self) -> None:
61
+ window_start = datetime(2026, 7, 3, 8, 0, 0)
62
+ log_text = "\n".join(
63
+ [
64
+ _block_line("2026-07-03T07:59:59", "old_hook", "before window"),
65
+ _block_line("2026-07-03T08:00:00", "edge_hook", "on the boundary"),
66
+ _block_line("2026-07-03T09:30:00", "new_hook", "inside window"),
67
+ ]
68
+ )
69
+ records = collect_records(log_text, window_start)
70
+ assert [each_record.source for each_record in records] == [
71
+ "edge_hook",
72
+ "new_hook",
73
+ ]
74
+
75
+ def test_skips_malformed_lines(self) -> None:
76
+ window_start = datetime(2026, 7, 3, 0, 0, 0)
77
+ log_text = "\n".join(
78
+ [
79
+ "garbage",
80
+ _block_line("2026-07-03T09:00:00", "good_hook", "kept"),
81
+ "",
82
+ ]
83
+ )
84
+ records = collect_records(log_text, window_start)
85
+ assert len(records) == 1
86
+ assert records[0].source == "good_hook"
87
+
88
+
89
+ class TestReadLogWindow:
90
+ def test_returns_empty_list_when_the_log_is_absent(self, tmp_path: Path) -> None:
91
+ missing_path = tmp_path / "hook-blocks.log"
92
+ assert read_log_window(missing_path, 24, datetime(2026, 7, 3, 12, 0, 0)) == []
93
+
94
+ def test_reads_records_inside_the_window_from_a_real_file(
95
+ self, tmp_path: Path
96
+ ) -> None:
97
+ now = datetime(2026, 7, 3, 12, 0, 0)
98
+ recent_time = (now - timedelta(hours=1)).isoformat()
99
+ stale_time = (now - timedelta(hours=48)).isoformat()
100
+ log_path = tmp_path / "hook-blocks.log"
101
+ log_path.write_text(
102
+ "\n".join(
103
+ [
104
+ _block_line(stale_time, "stale_hook", "too old"),
105
+ _block_line(recent_time, "recent_hook", "in window"),
106
+ ]
107
+ ),
108
+ encoding="utf-8",
109
+ )
110
+ records = read_log_window(log_path, 24, now)
111
+ assert [each_record.source for each_record in records] == ["recent_hook"]