claude-dev-env 2.11.0 → 2.13.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 (203) hide show
  1. package/_shared/advisor/reference/sol-rung.md +5 -2
  2. package/_shared/advisor/reference/warm-up.md +1 -0
  3. package/_shared/advisor/scripts/codex_sol_advisor.py +96 -31
  4. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +5 -0
  5. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +173 -11
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/test_code_rules_gate.py +0 -26
  8. package/agents/CLAUDE.md +1 -2
  9. package/agents/pr-description-writer.md +11 -81
  10. package/bin/install.mjs +48 -9
  11. package/bin/install.prune.test.mjs +26 -0
  12. package/bin/install.settings-defaults.test.mjs +60 -33
  13. package/bin/install.test.mjs +2 -4
  14. package/commands/CLAUDE.md +1 -0
  15. package/commands/sr-loop.md +52 -0
  16. package/docs/CLAUDE.md +0 -1
  17. package/docs/references/CLAUDE.md +0 -1
  18. package/hooks/CLAUDE.md +1 -1
  19. package/hooks/blocking/CLAUDE.md +2 -27
  20. package/hooks/blocking/config/CLAUDE.md +1 -13
  21. package/hooks/blocking/conventional_pr_title_gate.py +1 -2
  22. package/hooks/blocking/send_user_file_open_locally_blocker.py +1 -1
  23. package/hooks/blocking/stop_dispatcher.py +4 -4
  24. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -3
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +6 -7
  26. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +4 -3
  27. package/hooks/blocking/test_shared_stdin_adoption.py +0 -2
  28. package/hooks/diagnostic/CLAUDE.md +3 -3
  29. package/hooks/diagnostic/hook_log_extractor.py +2 -36
  30. package/hooks/diagnostic/hook_log_stop_wrapper.py +6 -155
  31. package/hooks/diagnostic/test_hook_log_extractor.py +8 -21
  32. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +3 -331
  33. package/hooks/git-hooks/git_hooks_constants/__init__.py +0 -6
  34. package/hooks/git-hooks/pre_push.py +3 -85
  35. package/hooks/git-hooks/test_pre_push.py +0 -130
  36. package/hooks/hooks.json +0 -35
  37. package/hooks/hooks_constants/CLAUDE.md +0 -9
  38. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -11
  39. package/hooks/hooks_constants/convergence_branch_constants.py +0 -1
  40. package/hooks/hooks_constants/fable_spawn_gate_constants.py +3 -4
  41. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -8
  42. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +1 -6
  43. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -26
  45. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +0 -5
  46. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -1
  47. package/hooks/lifecycle/CLAUDE.md +1 -3
  48. package/package.json +1 -1
  49. package/rules/CLAUDE.md +0 -2
  50. package/rules/gh-cli-conventions.md +0 -1
  51. package/rules/git-workflow.md +1 -3
  52. package/scripts/CLAUDE.md +0 -1
  53. package/scripts/_code_review_test_support.py +0 -95
  54. package/scripts/dev_env_scripts_constants/code_review_constants.py +9 -98
  55. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +1 -44
  56. package/scripts/invoke_code_review.py +8 -451
  57. package/scripts/test_invoke_code_review.py +5 -61
  58. package/scripts/test_invoke_code_review_cli.py +1 -45
  59. package/scripts/test_invoke_code_review_contract.py +1 -118
  60. package/settings.json +1 -10
  61. package/skills/CLAUDE.md +8 -0
  62. package/skills/autoconverge/reference/CLAUDE.md +1 -1
  63. package/skills/autoconverge/reference/convergence.md +7 -7
  64. package/skills/autoconverge/reference/gotchas.md +3 -10
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +53 -1388
  66. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +4 -3
  67. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +33 -493
  68. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +15 -97
  69. package/skills/autoconverge/workflow/converge.mjs +201 -238
  70. package/skills/autoconverge/workflow/converge.precatch.test.mjs +1 -1
  71. package/skills/bugteam/CONSTRAINTS.md +1 -1
  72. package/skills/bugteam/SKILL.md +9 -0
  73. package/skills/bugteam/reference/audit-and-teammates.md +10 -6
  74. package/skills/bugteam/test_skill_additions.py +9 -0
  75. package/skills/comments/SKILL.md +65 -0
  76. package/skills/descriptions/SKILL.md +51 -0
  77. package/skills/emergencies/SKILL.md +42 -0
  78. package/skills/imagegen/SKILL.md +21 -0
  79. package/skills/imagegen/scripts/config/__init__.py +1 -0
  80. package/skills/imagegen/scripts/config/constants.py +28 -0
  81. package/skills/imagegen/scripts/imagegen.py +45 -0
  82. package/skills/imagegen/scripts/imagegen_core.py +528 -0
  83. package/skills/imagegen/scripts/test_imagegen.py +204 -0
  84. package/skills/orchestrator/SKILL.md +2 -4
  85. package/skills/plan-to-pr/SKILL.md +6 -7
  86. package/skills/plan-to-pr/reference/final-validation-tasks.md +1 -1
  87. package/skills/plan-to-pr/reference/process-inventory.md +2 -2
  88. package/skills/plan-to-pr/reference/review-loop.md +2 -2
  89. package/skills/plan-to-pr/reference/run-record.schema.json +1 -1
  90. package/skills/plan-to-pr/reference/task-seeds.md +1 -1
  91. package/skills/plan-to-pr/reference/task-ticket.md +3 -4
  92. package/skills/plan-to-pr/scripts/config/constants.py +1 -2
  93. package/skills/plan-to-pr/scripts/test_validate_protocol.py +1 -1
  94. package/skills/plan-to-pr/test_skill_contract.py +2 -2
  95. package/skills/pr-converge/SKILL.md +13 -16
  96. package/skills/pr-converge/reference/progress-checklist.md +6 -5
  97. package/skills/pr-converge/reference/state-schema.md +1 -27
  98. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  99. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +3 -3
  100. package/skills/pr-loop-lifecycle/SKILL.md +1 -1
  101. package/skills/pr-loop-lifecycle/reference/teardown-publish-permissions.md +1 -1
  102. package/skills/prototype/SKILL.md +4 -4
  103. package/skills/prototype/reference/honest-limitations.md +4 -4
  104. package/skills/prototype/reference/promotion-tasks.md +2 -2
  105. package/skills/prototype/workflows/promotion.md +4 -6
  106. package/skills/reviews/SKILL.md +85 -0
  107. package/skills/small-cl/SKILL.md +52 -0
  108. package/skills/usage-pause/SKILL.md +1 -1
  109. package/skills/usage-pause/scripts/resolve_usage_window.py +32 -4
  110. package/skills/usage-pause/scripts/test_resolve_usage_window.py +26 -0
  111. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +3 -1
  112. package/agents/code-verifier.md +0 -74
  113. package/docs/PR_DESCRIPTION_GUIDE.md +0 -157
  114. package/docs/references/code-review-enforcement.md +0 -132
  115. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +0 -53
  116. package/hooks/blocking/code_review_gate_deny.py +0 -74
  117. package/hooks/blocking/code_review_pr_create_gate.py +0 -198
  118. package/hooks/blocking/code_review_push_gate.py +0 -145
  119. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +0 -348
  120. package/hooks/blocking/code_review_stamp_store.py +0 -233
  121. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +0 -7
  122. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +0 -15
  123. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +0 -212
  124. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +0 -138
  125. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +0 -49
  126. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +0 -38
  127. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +0 -483
  128. package/hooks/blocking/config/code_review_enforcement_constants.py +0 -143
  129. package/hooks/blocking/config/test_code_review_enforcement_constants.py +0 -166
  130. package/hooks/blocking/config/verified_commit_constants.py +0 -160
  131. package/hooks/blocking/config/verified_commit_context_constants.py +0 -21
  132. package/hooks/blocking/config/verified_commit_gate_output_constants.py +0 -14
  133. package/hooks/blocking/conftest.py +0 -123
  134. package/hooks/blocking/convergence_gate_blocker.py +0 -310
  135. package/hooks/blocking/pr_converge_bugteam_enforcer.py +0 -170
  136. package/hooks/blocking/pr_description_body_audit.py +0 -148
  137. package/hooks/blocking/pr_description_command_parser.py +0 -237
  138. package/hooks/blocking/pr_description_enforcer.py +0 -227
  139. package/hooks/blocking/pr_description_pr_number.py +0 -155
  140. package/hooks/blocking/pr_description_proof_of_work.py +0 -385
  141. package/hooks/blocking/pr_description_readability.py +0 -364
  142. package/hooks/blocking/reviewer_spawn_gate.py +0 -182
  143. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +0 -62
  144. package/hooks/blocking/test_code_review_gate_deny.py +0 -54
  145. package/hooks/blocking/test_code_review_pr_create_gate.py +0 -199
  146. package/hooks/blocking/test_code_review_push_gate.py +0 -205
  147. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +0 -199
  148. package/hooks/blocking/test_code_review_stamp_store.py +0 -205
  149. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +0 -710
  150. package/hooks/blocking/test_code_verifier_tools_contract.py +0 -28
  151. package/hooks/blocking/test_convergence_gate_blocker.py +0 -428
  152. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +0 -311
  153. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +0 -184
  154. package/hooks/blocking/test_pr_description_enforcer.py +0 -221
  155. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +0 -247
  156. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +0 -493
  157. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +0 -366
  158. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +0 -159
  159. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +0 -175
  160. package/hooks/blocking/test_pr_description_enforcer_readability.py +0 -443
  161. package/hooks/blocking/test_pr_description_proof_of_work.py +0 -313
  162. package/hooks/blocking/test_reviewer_spawn_gate.py +0 -230
  163. package/hooks/blocking/test_verdict_directory_write_blocker.py +0 -804
  164. package/hooks/blocking/test_verification_verdict_store.py +0 -974
  165. package/hooks/blocking/test_verified_commit_config_bootstrap.py +0 -67
  166. package/hooks/blocking/test_verified_commit_docs_delta.py +0 -176
  167. package/hooks/blocking/test_verified_commit_gate.py +0 -581
  168. package/hooks/blocking/test_verified_commit_gate_additional_context.py +0 -134
  169. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +0 -131
  170. package/hooks/blocking/test_verifier_verdict_minter.py +0 -299
  171. package/hooks/blocking/tests/test_verified_commit_gate.py +0 -41
  172. package/hooks/blocking/verdict_directory_write_blocker.py +0 -687
  173. package/hooks/blocking/verification_verdict_store.py +0 -1039
  174. package/hooks/blocking/verified_commit_config_bootstrap.py +0 -63
  175. package/hooks/blocking/verified_commit_gate.py +0 -113
  176. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +0 -28
  177. package/hooks/blocking/verified_commit_gate_parts/__init__.py +0 -1
  178. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +0 -174
  179. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +0 -53
  180. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +0 -80
  181. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +0 -170
  182. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +0 -217
  183. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +0 -10
  184. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +0 -94
  185. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +0 -17
  186. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +0 -38
  187. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +0 -71
  188. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +0 -96
  189. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +0 -167
  190. package/hooks/blocking/verifier_verdict_minter.py +0 -280
  191. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +0 -64
  192. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +0 -53
  193. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_constants.py +0 -55
  194. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_state.py +0 -67
  195. package/hooks/hooks_constants/pr_description_enforcer_constants.py +0 -167
  196. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -107
  197. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +0 -41
  198. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +0 -292
  199. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +0 -198
  200. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +0 -283
  201. package/rules/proof-of-work-pr-comments.md +0 -22
  202. package/rules/verified-commit-gate-skip.md +0 -4
  203. package/scripts/Show-Asset.ps1 +0 -106
@@ -1,221 +0,0 @@
1
- """Unit tests for pr-description-enforcer PreToolUse hook entry flow."""
2
-
3
- import importlib.util
4
- import io
5
- import json
6
- import pathlib
7
- import sys
8
- from unittest.mock import patch
9
-
10
- import pytest
11
-
12
- _HOOK_DIR = pathlib.Path(__file__).parent
13
- _HOOKS_ROOT = _HOOK_DIR.parent
14
- if str(_HOOKS_ROOT) not in sys.path:
15
- sys.path.insert(0, str(_HOOKS_ROOT))
16
- if str(_HOOK_DIR) not in sys.path:
17
- sys.path.insert(0, str(_HOOK_DIR))
18
-
19
- from blocking import pr_description_readability as readability_module
20
- from blocking.pr_description_command_parser import extract_body_from_command
21
-
22
- hook_spec = importlib.util.spec_from_file_location(
23
- "pr_description_enforcer",
24
- _HOOK_DIR / "pr_description_enforcer.py",
25
- )
26
- assert hook_spec is not None
27
- assert hook_spec.loader is not None
28
- hook_module = importlib.util.module_from_spec(hook_spec)
29
- hook_spec.loader.exec_module(hook_module)
30
- validate_pr_body = hook_module.validate_pr_body
31
-
32
-
33
- @pytest.fixture(autouse=True)
34
- def _isolate_readability_state(tmp_path_factory, monkeypatch):
35
- """Redirect the three readability state files to per-test temp paths for every test.
36
-
37
- The enabled file is written with enabled=False so the readability check stays
38
- off for the entry-flow tests, isolating them from readability scoring and the
39
- live state directory.
40
- """
41
- per_test_state_dir = tmp_path_factory.mktemp("readability_state")
42
- strike_path = per_test_state_dir / "strikes.json"
43
- override_path = per_test_state_dir / "overrides.json"
44
- enabled_path = per_test_state_dir / "enabled.json"
45
- enabled_path.write_text(json.dumps({"enabled": False}))
46
- monkeypatch.setattr(readability_module, "READABILITY_STATE_FILE", strike_path)
47
- monkeypatch.setattr(readability_module, "READABILITY_THRESHOLD_OVERRIDE_FILE", override_path)
48
- monkeypatch.setattr(readability_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
49
-
50
-
51
- VALID_BODY = (
52
- "Allow commas in branch names so PRs whose head branch was generated from "
53
- "a title or external identifier no longer fail validation before any git "
54
- "operation.\n\n"
55
- "Fixes #1300.\n\n"
56
- "## Changes\n\n"
57
- "- `src/github/operations/branch.ts`: add `,` to the whitelist regex\n"
58
- "- `test/branch.test.ts`: 3 new cases covering comma-bearing branch names\n\n"
59
- "## Test plan\n\n"
60
- "- `bun test test/branch.test.ts`\n"
61
- "- `bun run typecheck`\n"
62
- )
63
-
64
-
65
- def test_validate_blocks_literal_empty_body() -> None:
66
- """A literal `gh pr create --body ""` must NOT skip enforcement. Empty-body
67
- extraction returns "" (distinct from shell-var's None), so the validator
68
- runs and blocks via the substantive-prose check. Conflating the two
69
- previously allowed `--body ""` to bypass validation entirely."""
70
- violations = validate_pr_body("")
71
- assert violations, (
72
- "Empty PR body must produce at least one violation (typically substantive "
73
- f"prose); got an empty list, which would let `--body \"\"` bypass enforcement."
74
- )
75
-
76
-
77
- def test_body_file_content_validated(tmp_path: pathlib.Path) -> None:
78
- body_file = tmp_path / "body.md"
79
- body_file.write_text("Too short.")
80
- body = extract_body_from_command(
81
- f'gh pr create --title "T" --body-file {body_file}'
82
- )
83
- assert body == "Too short."
84
- violations = validate_pr_body(body)
85
- assert violations
86
-
87
-
88
- def test_main_does_not_block_when_dash_b_only_appears_in_word() -> None:
89
- hook_input = {
90
- "tool_name": "Bash",
91
- "tool_input": {"command": 'gh pr create --title "fix sub-branch handling"'},
92
- }
93
- captured_stdout = io.StringIO()
94
- with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
95
- with patch("sys.stdout", captured_stdout):
96
- try:
97
- hook_module.main()
98
- except SystemExit:
99
- pass
100
- assert "deny" not in captured_stdout.getvalue()
101
-
102
-
103
- def test_main_does_not_block_when_no_body_flag_present() -> None:
104
- hook_input = {
105
- "tool_name": "Bash",
106
- "tool_input": {"command": 'gh pr create --title "My PR"'},
107
- }
108
- captured_stdout = io.StringIO()
109
- with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
110
- with patch("sys.stdout", captured_stdout):
111
- try:
112
- hook_module.main()
113
- except SystemExit:
114
- pass
115
- assert "deny" not in captured_stdout.getvalue()
116
-
117
-
118
- def test_main_allows_through_stdin_sentinel_body_file() -> None:
119
- """--body-file - must not be blocked (stdin body is unauditable)."""
120
- import io
121
- import json
122
- from unittest.mock import patch
123
- hook_input = {
124
- "tool_name": "Bash",
125
- "tool_input": {"command": 'gh pr create --title "T" --body-file -'},
126
- }
127
- captured_stdout = io.StringIO()
128
- with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
129
- with patch("sys.stdout", captured_stdout):
130
- try:
131
- hook_module.main()
132
- except SystemExit:
133
- pass
134
- assert "deny" not in captured_stdout.getvalue()
135
-
136
-
137
- def _build_main_hook_input(command: str) -> dict[str, object]:
138
- return {"tool_name": "Bash", "tool_input": {"command": command}}
139
-
140
-
141
- def _run_main_and_capture_decision(hook_input: dict[str, object]) -> str:
142
- captured_stdout = io.StringIO()
143
- with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
144
- with patch("sys.stdout", captured_stdout):
145
- try:
146
- hook_module.main()
147
- except SystemExit:
148
- pass
149
- return captured_stdout.getvalue()
150
-
151
-
152
- def test_main_blocks_gh_pr_edit_short_body_flag() -> None:
153
- """gh pr edit 123 -b "short" must be caught -- the short -b flag is a valid alias for --body."""
154
- command = 'gh pr edit 123 -b "Too short."'
155
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
156
- assert "deny" in decision_output
157
- assert "substantive prose" in decision_output.lower()
158
-
159
-
160
- def test_main_blocks_gh_pr_edit_body_file_short_flag(tmp_path) -> None:
161
- """gh pr edit 123 -F body.md must be caught -- -F is the short alias for --body-file."""
162
- body_file = tmp_path / "body.md"
163
- body_file.write_text("Too short.")
164
- command = f'gh pr edit 123 -F {body_file}'
165
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
166
- assert "deny" in decision_output
167
- assert "substantive prose" in decision_output.lower()
168
-
169
-
170
- def test_main_blocks_gh_pr_edit_body_file_long_flag(tmp_path) -> None:
171
- """gh pr edit 123 --body-file body.md must also be caught (was missing from is_pr_edit detection)."""
172
- body_file = tmp_path / "body.md"
173
- body_file.write_text("Too short.")
174
- command = f'gh pr edit 123 --body-file {body_file}'
175
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
176
- assert "deny" in decision_output
177
-
178
-
179
- def test_main_blocks_gh_pr_create_body_file_short_flag(tmp_path) -> None:
180
- """gh pr create -F body.md must be caught -- -F is the short alias for --body-file."""
181
- body_file = tmp_path / "body.md"
182
- body_file.write_text("Too short.")
183
- command = f'gh pr create --title "T" -F {body_file}'
184
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
185
- assert "deny" in decision_output
186
-
187
-
188
- def test_main_blocks_gh_pr_create_body_file_long_flag(tmp_path) -> None:
189
- """gh pr create --body-file body.md must be caught -- was missing from is_pr_create detection."""
190
- body_file = tmp_path / "body.md"
191
- body_file.write_text("Too short.")
192
- command = f'gh pr create --title "T" --body-file {body_file}'
193
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
194
- assert "deny" in decision_output
195
-
196
-
197
- def test_main_blocks_gh_pr_edit_short_body_equals_form() -> None:
198
- """gh pr edit 123 -b="short" must be caught -- the -b= equals form was bypassing
199
- the pre-filter and silently approving short bodies."""
200
- command = 'gh pr edit 123 -b="Too short."'
201
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
202
- assert "deny" in decision_output
203
- assert "substantive prose" in decision_output.lower()
204
-
205
-
206
- def test_main_blocks_gh_pr_edit_short_body_file_equals_form(tmp_path) -> None:
207
- """gh pr edit 123 -F=body.md must be caught -- the -F= equals form was bypassing the pre-filter."""
208
- body_file = tmp_path / "body.md"
209
- body_file.write_text("Too short.")
210
- command = f'gh pr edit 123 -F={body_file}'
211
- decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
212
- assert "deny" in decision_output
213
-
214
-
215
- def test_build_short_failing_body_helper_is_removed() -> None:
216
- """The unused test helper `_build_short_failing_body` had zero call sites and
217
- must not be re-introduced."""
218
- test_module = sys.modules[__name__]
219
- assert not hasattr(test_module, "_build_short_failing_body"), (
220
- "_build_short_failing_body was re-introduced; it has no callers in this test file."
221
- )
@@ -1,247 +0,0 @@
1
- """Unit tests for pr-description-enforcer body markdown and shape helpers."""
2
-
3
- import importlib.util
4
- import pathlib
5
- import re as _re
6
- import sys
7
-
8
- _HOOK_DIR = pathlib.Path(__file__).parent
9
- _HOOKS_ROOT = _HOOK_DIR.parent
10
- if str(_HOOKS_ROOT) not in sys.path:
11
- sys.path.insert(0, str(_HOOKS_ROOT))
12
- if str(_HOOK_DIR) not in sys.path:
13
- sys.path.insert(0, str(_HOOK_DIR))
14
-
15
- from hooks_constants.pr_description_enforcer_constants import ALL_HEAVY_OPENING_HEADERS
16
-
17
- body_audit_spec = importlib.util.spec_from_file_location(
18
- "pr_description_body_audit",
19
- _HOOK_DIR / "pr_description_body_audit.py",
20
- )
21
- assert body_audit_spec is not None
22
- assert body_audit_spec.loader is not None
23
- hook_module = importlib.util.module_from_spec(body_audit_spec)
24
- body_audit_spec.loader.exec_module(hook_module)
25
-
26
-
27
- def _build_heavy_body(opening_header: str, testing_header: str) -> str:
28
- intro_text = (
29
- "Adds shape-aware validation across the pr-description-enforcer pipeline. "
30
- "The change unifies the body audit with the Anthropic claude-code style "
31
- "so heavy PRs carry both an opening header and a testing header."
32
- )
33
- return (
34
- f"{intro_text}\n\n"
35
- f"{opening_header}\n\n"
36
- "The earlier flow rejected too many valid bodies on equivalence checks "
37
- "across the three shape categories described in the guide. The fix "
38
- "restructures the path around shape detection and surfaces the missing "
39
- "category in the block message so the agent can correct it on first try.\n\n"
40
- f"{testing_header}\n\n"
41
- "- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
42
- "- Manual smoke test against the implementation PR with a sample heavy body\n"
43
- "- Run the readability check across the full corpus to confirm thresholds hold\n"
44
- )
45
-
46
-
47
- def test_compute_pr_body_shape_trivial() -> None:
48
- """A short single-sentence body with zero headers classifies as Trivial."""
49
- body = "Pin third-party GitHub Actions references to immutable commit SHAs."
50
- assert hook_module._compute_pr_body_shape(body) == "trivial"
51
-
52
-
53
- def test_compute_pr_body_shape_standard() -> None:
54
- """A medium body with one ## header below the Heavy threshold classifies as Standard."""
55
- body = (
56
- "Adds a timestamp check to prevent background data pulls from overwriting "
57
- "recent local edits. The pull engine compares the last-modified marker "
58
- "before deciding whether to apply a remote record.\n\n"
59
- "## Changes\n\n"
60
- "- `pullEngine.ts`: compare lastModified before overwriting\n"
61
- "- `pullEngine.test.ts`: 3 new cases\n"
62
- )
63
- assert hook_module._compute_pr_body_shape(body) == "standard"
64
-
65
-
66
- def test_compute_pr_body_shape_heavy() -> None:
67
- """A long body with two Heavy-detection headers classifies as Heavy."""
68
- body = _build_heavy_body("## Problem", "## Test plan")
69
- assert hook_module._compute_pr_body_shape(body) == "heavy"
70
-
71
-
72
- def test_first_non_empty_line_helper_is_removed() -> None:
73
- """`_first_non_empty_line` was the basis of the prior ceremony-on-Trivial
74
- check, which now uses `_iter_section_headers`. The helper has no remaining
75
- call sites; pin its removal so it cannot drift back as dead code."""
76
- assert not hasattr(hook_module, "_first_non_empty_line"), (
77
- "_first_non_empty_line must be removed; the ceremony-on-Trivial check "
78
- "now reads through _iter_section_headers instead."
79
- )
80
-
81
-
82
- def test_strip_leading_hash_lines_helper_is_removed() -> None:
83
- """The unused leading-hash stripper must not exist as a module attribute."""
84
- assert not hasattr(hook_module, "_strip_leading_hash_lines")
85
-
86
-
87
- def test_strip_markdown_ceremony_returns_stripped_prose() -> None:
88
- """The shared markdown stripper removes fences, inline code, blockquotes,
89
- headings, bullets, bold, emphasis, and Markdown link targets, leaving the
90
- underlying prose intact."""
91
- body = "\n".join(
92
- [
93
- "# Heading text",
94
- "> blockquoted content",
95
- "- bullet content",
96
- "**bold body**",
97
- "*emphasized body*",
98
- "[link label](https://example.com)",
99
- "`inline code body`",
100
- "```",
101
- "fenced code body",
102
- "```",
103
- "plain prose line",
104
- ]
105
- )
106
- stripped = hook_module.strip_markdown_ceremony(body)
107
- assert "Heading text" not in stripped
108
- assert "blockquoted content" in stripped
109
- assert "bullet content" in stripped
110
- assert "bold body" in stripped
111
- assert "emphasized body" in stripped
112
- assert "link label" in stripped
113
- assert "plain prose line" in stripped
114
- assert "inline code body" not in stripped
115
- assert "fenced code body" not in stripped
116
- assert "https://example.com" not in stripped
117
-
118
-
119
- def test_strip_markdown_ceremony_used_by_substantive_prose_count() -> None:
120
- """_count_substantive_prose_chars is consistent with the shared stripper:
121
- its returned count matches len of the whitespace-collapsed stripped body."""
122
- body = "# Heading\n\nA single paragraph of prose with **bold** and `code` words."
123
- stripped = hook_module.strip_markdown_ceremony(body)
124
- collapsed = _re.sub(r"\s+", " ", stripped).strip()
125
- assert hook_module._count_substantive_prose_chars(body) == len(collapsed)
126
-
127
-
128
- def test_shape_classifier_uses_substantive_chars_not_raw_length() -> None:
129
- """Shape classifier and ceremony-on-Trivial check must agree on the metric used
130
- against TRIVIAL_BODY_CHAR_THRESHOLD. A body whose raw length passes the
131
- threshold but whose substantive prose does not (e.g. tiny prose with a large
132
- fenced code block) is genuinely Trivial in shape -- not Standard."""
133
- tiny_prose_with_large_code_fence = "Done.\n\n```\n" + ("x" * 300) + "\n```"
134
- assert len(tiny_prose_with_large_code_fence) >= hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
135
- assert (
136
- hook_module._count_substantive_prose_chars(tiny_prose_with_large_code_fence)
137
- < hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
138
- )
139
- assert hook_module._compute_pr_body_shape(tiny_prose_with_large_code_fence) == "trivial"
140
-
141
-
142
- def test_body_contains_any_header_rejects_plural_extension() -> None:
143
- """`_body_contains_any_header` must enforce a word boundary after the
144
- canonical header text. `## Problems` (plural) extends the canonical
145
- word and must NOT satisfy `## Problem`, otherwise the Heavy
146
- required-header check is weaker than the documented contract."""
147
- body_with_plural_extension = "## Problems\n\nDetails follow."
148
- candidate_set = frozenset({"## Problem"})
149
- assert not hook_module._body_contains_any_header(body_with_plural_extension, candidate_set), (
150
- "`## Problems` must NOT satisfy `## Problem` (different header)"
151
- )
152
-
153
-
154
- def test_body_contains_any_header_accepts_punctuation_suffix() -> None:
155
- """The boundary rule must still accept canonical headers followed by
156
- non-word punctuation: colon, em-dash, parenthesis, trailing whitespace.
157
- Reviewers write `## Problem (context)` and `## Test plan: scope` —
158
- these must continue to satisfy the canonical headers."""
159
- candidate_set = frozenset({"## Problem"})
160
- for each_body in [
161
- "## Problem\n\nDetails.",
162
- "## Problem:\n\nDetails.",
163
- "## Problem (context)\n\nDetails.",
164
- "## Problem — context\n\nDetails.",
165
- ]:
166
- assert hook_module._body_contains_any_header(each_body, candidate_set), (
167
- f"`{each_body!r}` must satisfy `## Problem` (punctuation/space follows)"
168
- )
169
-
170
-
171
- def test_body_contains_any_header_rejects_alphanumeric_suffix() -> None:
172
- """`## Problem2`, `## ProblemX`, `## Problem_one` are different headers
173
- and must not match `## Problem`."""
174
- candidate_set = frozenset({"## Problem"})
175
- for each_body in [
176
- "## Problem2\n\nDetails.",
177
- "## ProblemX\n\nDetails.",
178
- "## Problem_one\n\nDetails.",
179
- ]:
180
- assert not hook_module._body_contains_any_header(each_body, candidate_set), (
181
- f"`{each_body!r}` must NOT satisfy `## Problem` (alphanumeric continuation)"
182
- )
183
-
184
-
185
- def test_iter_section_headers_ignores_headings_inside_fenced_code_blocks() -> None:
186
- """Headings nested inside ``` ... ``` fences are example content, not body headers.
187
- The shape classifier and the Heavy required-header check must agree with the markdown
188
- stripper -- the body of this very test demonstrates the regression."""
189
- body = (
190
- "Intro paragraph that does not classify the body.\n\n```\n## Problem\n## Test plan\n```\n"
191
- )
192
- headers = hook_module._iter_section_headers(body)
193
- assert headers == [], f"Expected zero headers (fenced content), got {headers}"
194
- assert hook_module._compute_pr_body_shape(body) != "heavy", (
195
- "Body with only fenced example headers must not classify as heavy"
196
- )
197
- assert hook_module._body_contains_any_header(body, ALL_HEAVY_OPENING_HEADERS) is False, (
198
- "Heavy opening-header check must not see fenced example content"
199
- )
200
-
201
-
202
- def test_long_body_without_heavy_headers_still_classifies_heavy() -> None:
203
- """The Heavy required-header check in `validate_pr_body` only runs when
204
- `_compute_pr_body_shape` returns HEAVY. Previously the classifier required
205
- BOTH length >= 500 chars AND >= 2 heavy detection headers, which meant a
206
- long body missing the required headers entirely was classified Standard
207
- and silently bypassed the missing-header enforcement. Length alone must
208
- drive the HEAVY classification so the validator can enforce the rule."""
209
- long_body_with_no_heavy_headers = (
210
- "Refactors the request-pipeline batcher to coalesce idempotent calls "
211
- "before the network round-trip. The change touches the dispatcher, the "
212
- "retry loop, the error normalizer, and three downstream consumers. "
213
- "Every test in the integration suite continues to pass without "
214
- "modification because the public contract is unchanged.\n\n"
215
- "The new coalescer reads a per-call digest, looks up an in-flight slot "
216
- "indexed by that digest, and appends the caller's promise to the slot "
217
- "instead of dispatching a duplicate request. Once the network response "
218
- "arrives, every queued promise resolves with the same value. Error "
219
- "responses propagate to every queued promise so retry logic stays "
220
- "consistent with the prior contract.\n"
221
- )
222
- assert (
223
- hook_module._count_substantive_prose_chars(long_body_with_no_heavy_headers)
224
- >= hook_module.HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION
225
- )
226
- assert (
227
- hook_module._compute_pr_body_shape(long_body_with_no_heavy_headers)
228
- == hook_module.HEAVY_SHAPE
229
- )
230
-
231
-
232
- def test_compute_pr_body_shape_uses_named_shape_constants() -> None:
233
- """`_compute_pr_body_shape` returns the centralised shape names rather than
234
- inline string literals. Confirm the constants flow through end-to-end."""
235
- trivial_body = "Bump bun to 1.3.14."
236
- assert hook_module._compute_pr_body_shape(trivial_body) == hook_module.TRIVIAL_SHAPE
237
-
238
-
239
- def test_iter_section_headers_docstring_matches_actual_pattern() -> None:
240
- """`_iter_section_headers` uses `HEADING_LINE_PATTERN = ^#+`, so it returns
241
- every ATX heading level (`#`, `##`, `###`...), not just `##`. The docstring
242
- must describe that actual contract so callers cannot be misled."""
243
- docstring = hook_module._iter_section_headers.__doc__ or ""
244
- assert "every ATX heading" in docstring or "any heading level" in docstring, (
245
- f"_iter_section_headers docstring must document that it matches every "
246
- f"heading level (`HEADING_LINE_PATTERN` is `^#+`); got: {docstring!r}"
247
- )