claude-dev-env 1.41.0 → 1.43.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 (214) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +232 -8
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +293 -8
  4. package/_shared/pr-loop/scripts/fix_hookspath.py +96 -5
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +124 -20
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +4 -4
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +90 -0
  8. package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_settings_keys_constants.py +2 -0
  9. package/_shared/pr-loop/scripts/preflight.py +13 -31
  10. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -16
  11. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +76 -33
  12. package/_shared/pr-loop/scripts/tests/conftest.py +1 -51
  13. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  14. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +4 -2
  15. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +37 -2
  16. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +4 -2
  17. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +4 -2
  18. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +6 -2
  19. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +2 -2
  20. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1 -2
  21. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +4 -2
  22. package/_shared/pr-loop/scripts/tests/test_preflight.py +17 -52
  23. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +6 -2
  24. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +5 -3
  25. package/agents/pr-description-writer.md +50 -140
  26. package/docs/PR_DESCRIPTION_GUIDE.md +101 -102
  27. package/hooks/_gh_pr_author_swap_utils.py +1 -1
  28. package/hooks/blocking/bot_mention_comment_blocker.py +4 -10
  29. package/hooks/blocking/code_rules_enforcer.py +217 -99
  30. package/hooks/blocking/code_rules_path_utils.py +8 -1
  31. package/hooks/blocking/destructive_command_blocker.py +1 -1
  32. package/hooks/blocking/es_exe_path_rewriter.py +7 -13
  33. package/hooks/blocking/gh_body_arg_blocker.py +6 -1
  34. package/hooks/blocking/gh_pr_author_enforcer.py +5 -5
  35. package/hooks/blocking/gh_pr_author_restore.py +5 -5
  36. package/hooks/blocking/hedging_language_blocker.py +4 -10
  37. package/hooks/blocking/md_path_exemptions.py +205 -0
  38. package/hooks/blocking/md_to_html_blocker.py +48 -20
  39. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -11
  40. package/hooks/blocking/pr_description_enforcer.py +626 -41
  41. package/hooks/blocking/question_to_user_enforcer.py +4 -10
  42. package/hooks/blocking/state_description_blocker.py +6 -12
  43. package/hooks/blocking/tdd_enforcer.py +1 -1
  44. package/hooks/blocking/test_bot_mention_comment_blocker.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer.py +3 -3
  46. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +1 -1
  47. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -2
  48. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +184 -0
  49. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +82 -0
  50. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +29 -29
  51. package/hooks/blocking/test_gh_body_arg_blocker.py +7 -8
  52. package/hooks/blocking/test_gh_pr_author_enforcer.py +1 -1
  53. package/hooks/blocking/test_gh_pr_author_restore.py +1 -1
  54. package/hooks/blocking/test_hedging_language_blocker.py +2 -2
  55. package/hooks/blocking/test_md_to_html_blocker.py +463 -8
  56. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +1 -1
  57. package/hooks/blocking/test_pr_description_enforcer.py +1210 -13
  58. package/hooks/blocking/test_question_to_user_enforcer.py +1 -1
  59. package/hooks/blocking/windows_rmtree_blocker.py +5 -11
  60. package/hooks/diagnostic/hook_log_extractor.py +1 -1
  61. package/hooks/diagnostic/hook_log_init.py +1 -1
  62. package/hooks/diagnostic/hook_log_stop_wrapper.py +1 -1
  63. package/hooks/diagnostic/test_hook_log_extractor.py +1 -1
  64. package/hooks/diagnostic/test_hook_log_init.py +2 -2
  65. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +1 -1
  66. package/hooks/git-hooks/gate_utils.py +1 -1
  67. package/hooks/git-hooks/pre_commit.py +1 -1
  68. package/hooks/git-hooks/pre_push.py +1 -1
  69. package/hooks/git-hooks/test_config.py +5 -5
  70. package/hooks/git-hooks/test_pre_push.py +6 -6
  71. package/hooks/{config → hooks_constants}/code_rules_enforcer_constants.py +37 -0
  72. package/hooks/hooks_constants/code_rules_path_utils_constants.py +28 -0
  73. package/hooks/hooks_constants/md_to_html_blocker_constants.py +82 -0
  74. package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_state.py +1 -1
  75. package/hooks/hooks_constants/pr_description_enforcer_constants.py +154 -0
  76. package/hooks/{config → hooks_constants}/pre_tool_use_stdin.py +1 -1
  77. package/hooks/{config → hooks_constants}/project_paths_reader.py +2 -2
  78. package/hooks/{config → hooks_constants}/test_banned_identifiers_constants.py +1 -1
  79. package/hooks/{config → hooks_constants}/test_dynamic_stderr_handler.py +1 -1
  80. package/hooks/{config → hooks_constants}/test_hardcoded_user_path_constants.py +1 -1
  81. package/hooks/{config → hooks_constants}/test_hook_log_extractor_constants.py +2 -2
  82. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +110 -0
  83. package/hooks/{config → hooks_constants}/test_messages.py +2 -6
  84. package/hooks/{config → hooks_constants}/test_path_rewriter_constants.py +1 -1
  85. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +292 -0
  86. package/hooks/{config → hooks_constants}/test_pre_tool_use_stdin.py +2 -2
  87. package/hooks/{config → hooks_constants}/test_project_paths_reader.py +3 -3
  88. package/hooks/{config → hooks_constants}/test_session_env_cleanup_constants.py +1 -1
  89. package/hooks/{config → hooks_constants}/test_setup_project_paths_constants.py +2 -2
  90. package/hooks/{config → hooks_constants}/test_unused_module_import_constants.py +1 -1
  91. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +5 -11
  92. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +1 -1
  93. package/hooks/session/gh_pr_author_session_cleanup.py +5 -6
  94. package/hooks/session/session_env_cleanup.py +4 -10
  95. package/hooks/session/test_gh_pr_author_session_cleanup.py +1 -1
  96. package/hooks/session/test_untracked_repo_detector.py +2 -2
  97. package/hooks/session/untracked_repo_detector.py +6 -12
  98. package/hooks/test__gh_pr_author_swap_utils.py +1 -1
  99. package/hooks/validators/run_all_validators.py +16 -5
  100. package/hooks/validators/test_output_formatter.py +46 -0
  101. package/hooks/workflow/doc_gist_auto_publish.py +1 -1
  102. package/hooks/workflow/md_to_html_companion.py +8 -15
  103. package/hooks/workflow/test_md_to_html_companion.py +184 -23
  104. package/package.json +1 -1
  105. package/rules/ask-user-question-required.md +1 -1
  106. package/rules/vault-context.md +1 -1
  107. package/scripts/{config → dev_env_scripts_constants}/timing.py +1 -1
  108. package/scripts/setup_project_paths.py +49 -11
  109. package/scripts/sweep_empty_dirs.py +10 -1
  110. package/scripts/test_setup_project_paths.py +2 -2
  111. package/scripts/test_sweep_empty_dirs.py +2 -6
  112. package/skills/_shared/pr-loop/scripts/_path_resolver.py +1 -1
  113. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  114. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  115. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -1
  116. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -1
  117. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  118. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  119. package/skills/bugteam/PROMPTS.md +1 -1
  120. package/skills/bugteam/SKILL.md +1 -1
  121. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  122. package/skills/bugteam/scripts/{_claude_permissions_common.py → _bugteam_permissions_common.py} +110 -13
  123. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +1 -13
  124. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +1 -16
  125. package/skills/bugteam/scripts/bugteam_preflight.py +1 -13
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +69 -0
  127. package/skills/bugteam/scripts/grant_project_claude_permissions.py +117 -12
  128. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +1 -1
  129. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  130. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +71 -25
  131. package/skills/bugteam/scripts/{test__claude_permissions_common.py → test__bugteam_permissions_common.py} +4 -4
  132. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  133. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -26
  134. package/skills/bugteam/scripts/{test_claude_permissions_common.py → test_bugteam_permissions_common.py} +3 -66
  135. package/skills/bugteam/scripts/test_bugteam_preflight.py +2 -27
  136. package/skills/bugteam/scripts/windows_safe_rmtree.py +1 -1
  137. package/skills/doc-gist/SKILL.md +1 -1
  138. package/skills/doc-gist/scripts/gist_upload.py +1 -1
  139. package/skills/implement/SKILL.md +66 -0
  140. package/skills/implement/scripts/append_note.py +133 -0
  141. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  142. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +12 -0
  143. package/skills/implement/scripts/test_append_note.py +191 -0
  144. package/skills/pr-converge/pr_converge_skill_constants/__init__.py +0 -0
  145. package/skills/pr-converge/{config → pr_converge_skill_constants}/constants.py +6 -1
  146. package/skills/pr-converge/scripts/check_bugbot_ci.py +2 -2
  147. package/skills/pr-converge/scripts/check_convergence.py +175 -29
  148. package/skills/pr-converge/scripts/check_pending_reviews.py +2 -2
  149. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +2 -2
  150. package/skills/pr-converge/scripts/post_fix_reply.py +2 -2
  151. package/skills/pr-converge/scripts/pr_converge_scripts_constants/__init__.py +0 -0
  152. package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/pr_converge_constants.py +1 -1
  153. package/skills/pr-converge/scripts/reflow_skill_md.py +90 -16
  154. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  155. package/skills/pr-converge/scripts/test_check_convergence.py +324 -0
  156. package/skills/pr-converge/scripts/test_reflow_skill_md.py +0 -31
  157. package/skills/refine/SKILL.md +257 -0
  158. package/skills/refine/templates/implementation-notes-template.html +56 -0
  159. package/skills/refine/templates/plan-template.md +60 -0
  160. package/skills/session-log/SKILL.md +98 -233
  161. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +0 -36
  162. package/hooks/config/pr_description_enforcer_constants.py +0 -19
  163. package/hooks/config/test_pr_description_enforcer_constants.py +0 -82
  164. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +0 -20
  165. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +0 -55
  166. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +0 -55
  167. package/skills/pr-converge/scripts/evict_cached_config_modules.py +0 -20
  168. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +0 -22
  169. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/__init__.py +0 -0
  170. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/code_rules_gate_constants.py +0 -0
  171. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/fix_hookspath_constants.py +0 -0
  172. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/post_audit_thread_constants.py +0 -0
  173. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/preflight_constants.py +0 -0
  174. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/reviews_disabled_constants.py +0 -0
  175. /package/hooks/git-hooks/{config.py → git_hooks_constants/__init__.py} +0 -0
  176. /package/hooks/{config → hooks_constants}/__init__.py +0 -0
  177. /package/hooks/{config → hooks_constants}/any_type_config.py +0 -0
  178. /package/hooks/{config → hooks_constants}/banned_identifiers_constants.py +0 -0
  179. /package/hooks/{config → hooks_constants}/blocking_check_limits.py +0 -0
  180. /package/hooks/{config → hooks_constants}/bot_mention_comment_blocker_constants.py +0 -0
  181. /package/hooks/{config → hooks_constants}/convergence_branch_constants.py +0 -0
  182. /package/hooks/{config → hooks_constants}/doc_gist_auto_publish_constants.py +0 -0
  183. /package/hooks/{config → hooks_constants}/dynamic_stderr_handler.py +0 -0
  184. /package/hooks/{config → hooks_constants}/gh_pr_author_swap_constants.py +0 -0
  185. /package/hooks/{config → hooks_constants}/hardcoded_user_path_constants.py +0 -0
  186. /package/hooks/{config → hooks_constants}/hook_log_extractor_constants.py +0 -0
  187. /package/hooks/{config → hooks_constants}/html_companion_constants.py +0 -0
  188. /package/hooks/{config → hooks_constants}/inline_tuple_string_magic_constants.py +0 -0
  189. /package/hooks/{config → hooks_constants}/messages.py +0 -0
  190. /package/hooks/{config → hooks_constants}/path_rewriter_constants.py +0 -0
  191. /package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_constants.py +0 -0
  192. /package/hooks/{config → hooks_constants}/session_env_cleanup_constants.py +0 -0
  193. /package/hooks/{config → hooks_constants}/setup_project_paths_constants.py +0 -0
  194. /package/hooks/{config → hooks_constants}/state_description_blocker_constants.py +0 -0
  195. /package/hooks/{config → hooks_constants}/stuttering_check_config.py +0 -0
  196. /package/hooks/{config → hooks_constants}/stuttering_import_binding_constants.py +0 -0
  197. /package/hooks/{config → hooks_constants}/sys_path_insert_constants.py +0 -0
  198. /package/hooks/{config → hooks_constants}/unused_module_import_constants.py +0 -0
  199. /package/hooks/{config → hooks_constants}/windows_rmtree_blocker_constants.py +0 -0
  200. /package/{skills/_shared/pr-loop/scripts/config → hooks/lifecycle}/__init__.py +0 -0
  201. /package/{skills/bugteam/scripts/config → hooks/session}/__init__.py +0 -0
  202. /package/scripts/{config → dev_env_scripts_constants}/__init__.py +0 -0
  203. /package/skills/{doc-gist/scripts/config → _shared/pr-loop/scripts/skills_pr_loop_constants}/__init__.py +0 -0
  204. /package/skills/_shared/pr-loop/scripts/{config → skills_pr_loop_constants}/path_resolver_constants.py +0 -0
  205. /package/skills/{pr-converge/config → bugteam/scripts/bugteam_scripts_constants}/__init__.py +0 -0
  206. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_code_rules_gate_constants.py +0 -0
  207. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_fix_hookspath_constants.py +0 -0
  208. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_preflight_constants.py +0 -0
  209. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/probe_code_rules_enforcer_check_constants.py +0 -0
  210. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/reflow_skill_md_constants.py +0 -0
  211. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/windows_safe_rmtree_constants.py +0 -0
  212. /package/skills/{pr-converge/scripts/config → doc-gist/scripts/doc_gist_scripts_constants}/__init__.py +0 -0
  213. /package/skills/doc-gist/scripts/{config → doc_gist_scripts_constants}/gist_upload_constants.py +0 -0
  214. /package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/reflow_skill_md_constants.py +0 -0
@@ -1,17 +1,21 @@
1
1
  """Unit tests for pr-description-enforcer PreToolUse hook."""
2
2
 
3
3
  import importlib.util
4
+ import inspect
4
5
  import io
5
6
  import json
6
7
  import pathlib
8
+ import re as _re
7
9
  import sys
8
10
  from unittest.mock import patch
9
11
 
12
+ import pytest
13
+
10
14
  _HOOK_DIR = pathlib.Path(__file__).parent
11
15
  if str(_HOOK_DIR) not in sys.path:
12
16
  sys.path.insert(0, str(_HOOK_DIR))
13
17
 
14
- from _gh_body_arg_utils import get_logical_first_line
18
+ from blocking._gh_body_arg_utils import get_logical_first_line, iter_significant_tokens
15
19
 
16
20
  hook_spec = importlib.util.spec_from_file_location(
17
21
  "pr_description_enforcer",
@@ -24,6 +28,24 @@ hook_spec.loader.exec_module(hook_module)
24
28
  extract_body_from_command = hook_module.extract_body_from_command
25
29
  validate_pr_body = hook_module.validate_pr_body
26
30
 
31
+
32
+ @pytest.fixture(autouse=True)
33
+ def _isolate_readability_state(tmp_path_factory, monkeypatch):
34
+ """Redirect the three readability state files to per-test temp paths for every test.
35
+
36
+ Tests that need the strike-counter behavior re-monkeypatch the same attributes to a fresh
37
+ directory where the enabled file is absent (which defaults to enabled=True). This default
38
+ keeps the readability check off for every other test in the file.
39
+ """
40
+ per_test_state_dir = tmp_path_factory.mktemp("readability_state")
41
+ strike_path = per_test_state_dir / "strikes.json"
42
+ override_path = per_test_state_dir / "overrides.json"
43
+ enabled_path = per_test_state_dir / "enabled.json"
44
+ enabled_path.write_text(json.dumps({"enabled": False}))
45
+ monkeypatch.setattr(hook_module, "READABILITY_STATE_FILE", strike_path)
46
+ monkeypatch.setattr(hook_module, "READABILITY_THRESHOLD_OVERRIDE_FILE", override_path)
47
+ monkeypatch.setattr(hook_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
48
+
27
49
  VALID_BODY = (
28
50
  "Allow commas in branch names so PRs whose head branch was generated from "
29
51
  "a title or external identifier no longer fail validation before any git "
@@ -38,8 +60,8 @@ VALID_BODY = (
38
60
  )
39
61
 
40
62
  LEGACY_DESCRIPTION_WHY_HOW_BODY = (
41
- "## Description\n\nThis PR fixes a real bug in the authentication module.\n\n"
42
- "## Why\n\nBecause it was broken in production and customers reported failures.\n\n"
63
+ "## Description\n\nFixes a real bug in the authentication module that affected production users.\n\n"
64
+ "## Why\n\nThe defect surfaced in production and customers reported repeated sign-in failures.\n\n"
43
65
  "## How\n\nRefactored the auth module to handle edge cases correctly.\n"
44
66
  )
45
67
 
@@ -105,10 +127,13 @@ def test_extract_body_from_body_equals_single_quote_form() -> None:
105
127
  assert extract_body_from_command(command) == "Some body text here."
106
128
 
107
129
 
108
- def test_extract_body_equals_shell_var_returns_empty() -> None:
109
- """Shell variable like --body=$bodyText cannot be resolved at hook time -- approve safely."""
130
+ def test_extract_body_equals_shell_var_returns_none() -> None:
131
+ """Shell variable like --body=$bodyText cannot be resolved at hook time -- the
132
+ extractor must signal this with None (unauditable), not empty string. An
133
+ empty-string return value is reserved for a literal `--body ""` which should
134
+ still be validated and blocked by the substantive-prose check."""
110
135
  command = 'gh pr create --title "T" --body=$bodyText'
111
- assert extract_body_from_command(command) == ""
136
+ assert extract_body_from_command(command) is None
112
137
 
113
138
 
114
139
  def test_extract_short_flag_equals_form() -> None:
@@ -116,10 +141,23 @@ def test_extract_short_flag_equals_form() -> None:
116
141
  assert extract_body_from_command(command) == "Some body text here."
117
142
 
118
143
 
119
- def test_extract_short_flag_shell_var_returns_empty() -> None:
120
- """Shell variable like -b=$var cannot be resolved at hook time -- approve safely."""
144
+ def test_extract_short_flag_shell_var_returns_none() -> None:
145
+ """Short-flag shell variable like -b=$var cannot be resolved at hook time --
146
+ the extractor returns None (unauditable). Literal -b="" still returns ""."""
121
147
  command = 'gh pr create --title "T" -b=$bodyVar'
122
- assert extract_body_from_command(command) == ""
148
+ assert extract_body_from_command(command) is None
149
+
150
+
151
+ def test_validate_blocks_literal_empty_body() -> None:
152
+ """A literal `gh pr create --body ""` must NOT skip enforcement. Empty-body
153
+ extraction returns "" (distinct from shell-var's None), so the validator
154
+ runs and blocks via the substantive-prose check. Conflating the two
155
+ previously allowed `--body ""` to bypass validation entirely."""
156
+ violations = validate_pr_body("")
157
+ assert violations, (
158
+ "Empty PR body must produce at least one violation (typically substantive "
159
+ f"prose); got an empty list, which would let `--body \"\"` bypass enforcement."
160
+ )
123
161
 
124
162
 
125
163
  def test_validate_passes_anthropic_standard_body() -> None:
@@ -423,7 +461,6 @@ def test_iter_significant_tokens_unclosed_quote_raises_value_error() -> None:
423
461
  Both paths result in ValueError propagating to callers.
424
462
  """
425
463
  import pytest
426
- from _gh_body_arg_utils import iter_significant_tokens
427
464
  with pytest.raises(ValueError):
428
465
  list(iter_significant_tokens('gh pr create --title="unclosed --body real_body'))
429
466
 
@@ -437,6 +474,1166 @@ def test_scan_raw_tokens_does_not_false_match_body_in_title_value(tmp_path: path
437
474
  assert result == VALID_BODY
438
475
 
439
476
 
440
- def test_extract_body_returns_none_for_unclosed_quote_value() -> None:
441
- result = extract_body_from_command("gh pr create --title T --body='unclosed")
442
- assert result is None
477
+ def _build_heavy_body(opening_header: str, testing_header: str) -> str:
478
+ intro_text = (
479
+ "Adds shape-aware validation across the pr-description-enforcer pipeline. "
480
+ "The change unifies the body audit with the Anthropic claude-code style "
481
+ "so heavy PRs carry both an opening header and a testing header."
482
+ )
483
+ return (
484
+ f"{intro_text}\n\n"
485
+ f"{opening_header}\n\n"
486
+ "The earlier flow rejected too many valid bodies on equivalence checks "
487
+ "across the three shape categories described in the guide. The fix "
488
+ "restructures the path around shape detection and surfaces the missing "
489
+ "category in the block message so the agent can correct it on first try.\n\n"
490
+ f"{testing_header}\n\n"
491
+ "- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
492
+ "- Manual smoke test against the implementation PR with a sample heavy body\n"
493
+ "- Run the readability check across the full corpus to confirm thresholds hold\n"
494
+ )
495
+
496
+
497
+ def test_compute_pr_body_shape_trivial() -> None:
498
+ """A short single-sentence body with zero headers classifies as Trivial."""
499
+ body = "Pin third-party GitHub Actions references to immutable commit SHAs."
500
+ assert hook_module._compute_pr_body_shape(body) == "trivial"
501
+
502
+
503
+ def test_compute_pr_body_shape_standard() -> None:
504
+ """A medium body with one ## header below the Heavy threshold classifies as Standard."""
505
+ body = (
506
+ "Adds a timestamp check to prevent background data pulls from overwriting "
507
+ "recent local edits. The pull engine compares the last-modified marker "
508
+ "before deciding whether to apply a remote record.\n\n"
509
+ "## Changes\n\n"
510
+ "- `pullEngine.ts`: compare lastModified before overwriting\n"
511
+ "- `pullEngine.test.ts`: 3 new cases\n"
512
+ )
513
+ assert hook_module._compute_pr_body_shape(body) == "standard"
514
+
515
+
516
+ def test_compute_pr_body_shape_heavy() -> None:
517
+ """A long body with two Heavy-detection headers classifies as Heavy."""
518
+ body = _build_heavy_body("## Problem", "## Test plan")
519
+ assert hook_module._compute_pr_body_shape(body) == "heavy"
520
+
521
+
522
+ def test_validate_heavy_body_passes_with_problem_and_test_plan() -> None:
523
+ body = _build_heavy_body("## Problem", "## Test plan")
524
+ assert validate_pr_body(body) == []
525
+
526
+
527
+ def test_validate_heavy_body_blocks_when_testing_category_missing() -> None:
528
+ """Heavy body containing two opening-category headers but no testing-category header is blocked."""
529
+ intro_text = (
530
+ "Adds shape-aware validation across the pr-description-enforcer pipeline. "
531
+ "The change unifies the body audit with the Anthropic claude-code style. "
532
+ "The block reason names the missing category for the agent to fix on first try."
533
+ )
534
+ body = (
535
+ f"{intro_text}\n\n"
536
+ "## Summary\n\n"
537
+ "Adds a check that heavy bodies carry both an opening header and a testing header. "
538
+ "The substantive prose lives outside the bullet section so the audit treats the body "
539
+ "as the heavy shape rather than the standard shape under the length threshold.\n\n"
540
+ "## Problem\n\n"
541
+ "The earlier flow rejected too many valid bodies on equivalence checks "
542
+ "across the three shape categories described in the guide. The fix "
543
+ "restructures the path around shape detection and surfaces the missing "
544
+ "category in the block message so the agent can correct it without iterating.\n\n"
545
+ "## Changes\n\n"
546
+ "- `validator.py`: shape detection at the head of the audit pipeline\n"
547
+ "- `enforcer.py`: dispatch the shape-aware checks before the substantive-prose audit\n"
548
+ )
549
+ violations = validate_pr_body(body)
550
+ assert any("testing" in each_violation.lower() for each_violation in violations)
551
+
552
+
553
+ def test_validate_trivial_body_blocks_summary_header() -> None:
554
+ """A Trivial-sized body that opens with `## Summary` is blocked as ceremony."""
555
+ body = "## Summary\n\nPin Bun to 1.3.14."
556
+ violations = validate_pr_body(body)
557
+ assert any(
558
+ "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
559
+ for each_violation in violations
560
+ )
561
+
562
+
563
+ def test_validate_trivial_body_blocks_test_plan_header() -> None:
564
+ """A Trivial-sized body that opens with `## Test plan` must trip the
565
+ ceremony-on-Trivial block. The guide says Trivial bodies have zero headers,
566
+ so the enforcer must catch every heading variant — not just the six
567
+ `Summary|Why|Overview|Description|Intro|TL;DR` originally enumerated."""
568
+ body = "## Test plan\n\nPin Bun to 1.3.14."
569
+ violations = validate_pr_body(body)
570
+ assert any(
571
+ "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
572
+ for each_violation in violations
573
+ ), f"Trivial body opening with `## Test plan` must trip ceremony block; got {violations!r}"
574
+
575
+
576
+ def test_first_non_empty_line_helper_is_removed() -> None:
577
+ """`_first_non_empty_line` was the basis of the prior ceremony-on-Trivial
578
+ check, which now uses `_iter_section_headers`. The helper has no remaining
579
+ call sites; pin its removal so it cannot drift back as dead code."""
580
+ assert not hasattr(hook_module, "_first_non_empty_line"), (
581
+ "_first_non_empty_line must be removed; the ceremony-on-Trivial check "
582
+ "now reads through _iter_section_headers instead."
583
+ )
584
+
585
+
586
+ def test_validate_trivial_body_blocks_test_plan_after_prose() -> None:
587
+ """The doc promises "Zero `##` headers" on Trivial bodies. The earlier check
588
+ only inspected the first non-empty line, so prose followed by `## Test plan`
589
+ slipped through. Tighten the check to reject ANY heading in a Trivial-sized
590
+ body so the guide and the enforcer agree."""
591
+ body = (
592
+ "Pin Bun to 1.3.14.\n\n"
593
+ "## Test plan\n\n"
594
+ "- bun test\n"
595
+ )
596
+ violations = validate_pr_body(body)
597
+ assert any(
598
+ "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
599
+ for each_violation in violations
600
+ ), f"Trivial body with later `## Test plan` must trip the block; got {violations!r}"
601
+
602
+
603
+ def test_validate_trivial_body_blocks_h1_header() -> None:
604
+ """A Trivial-sized body opening with an `# Overview` h1 must also block, since
605
+ Trivial shape allows zero structural headers of any level."""
606
+ body = "# Overview\n\nPin Bun to 1.3.14."
607
+ violations = validate_pr_body(body)
608
+ assert any(
609
+ "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
610
+ for each_violation in violations
611
+ ), f"Trivial body opening with h1 must trip ceremony block; got {violations!r}"
612
+
613
+
614
+ def test_validate_standard_body_allows_summary_header() -> None:
615
+ """A Standard-sized body that opens with `## Summary` passes the ceremony check."""
616
+ body = (
617
+ "## Summary\n\n"
618
+ "Adds a timestamp check to prevent background data pulls from overwriting "
619
+ "recent local edits. The pull engine compares the last-modified marker "
620
+ "before applying a remote record.\n\n"
621
+ "## Changes\n\n"
622
+ "- `pullEngine.ts`: compare lastModified before overwriting\n"
623
+ "- `pullEngine.test.ts`: 3 new cases\n"
624
+ )
625
+ violations = validate_pr_body(body)
626
+ assert not any(
627
+ "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
628
+ for each_violation in violations
629
+ )
630
+
631
+
632
+ def test_validate_blocks_self_closing_fixes_reference() -> None:
633
+ body = (
634
+ "Adds a timestamp check to prevent background data pulls from overwriting "
635
+ "recent local edits.\n\nFixes #467.\n"
636
+ )
637
+ violations = validate_pr_body(body, pr_number=467)
638
+ assert any(
639
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
640
+ for each_violation in violations
641
+ )
642
+
643
+
644
+ def test_validate_blocks_self_closing_resolves_reference() -> None:
645
+ body = (
646
+ "Adds a timestamp check to prevent background data pulls from overwriting "
647
+ "recent local edits.\n\nResolves #467.\n"
648
+ )
649
+ violations = validate_pr_body(body, pr_number=467)
650
+ assert any(
651
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
652
+ for each_violation in violations
653
+ )
654
+
655
+
656
+ def test_validate_blocks_lowercase_self_closing_fixes_reference() -> None:
657
+ """GitHub treats closing keywords (Fixes/Closes/Resolves) case-insensitively, so
658
+ a body opening with `fixes #<own-PR>` (lowercase) auto-closes the PR on merge
659
+ just like the capitalized form. The enforcer must catch both."""
660
+ body = (
661
+ "Adds a timestamp check to prevent background data pulls from overwriting "
662
+ "recent local edits.\n\nfixes #467.\n"
663
+ )
664
+ violations = validate_pr_body(body, pr_number=467)
665
+ assert any(
666
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
667
+ for each_violation in violations
668
+ ), f"lowercase fixes self-reference must trip the block; got {violations!r}"
669
+
670
+
671
+ def test_validate_blocks_self_closing_fix_singular_reference() -> None:
672
+ """GitHub recognizes nine closing keywords (close/closes/closed,
673
+ fix/fixes/fixed, resolve/resolves/resolved). The bare-stem variants
674
+ `Fix #N`, `Close #N`, `Resolve #N` close the PR on merge just like the
675
+ plural forms, so the enforcer must catch every variant."""
676
+ body = (
677
+ "Adds a timestamp check to prevent background data pulls from overwriting "
678
+ "recent local edits.\n\nFix #467.\n"
679
+ )
680
+ violations = validate_pr_body(body, pr_number=467)
681
+ assert any(
682
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
683
+ for each_violation in violations
684
+ ), f"`Fix #<own-PR>` self-reference must trip the block; got {violations!r}"
685
+
686
+
687
+ def test_validate_blocks_self_closing_closed_past_tense_reference() -> None:
688
+ """`Closed #<own-PR>` (past tense) closes the PR on merge; the enforcer
689
+ must catch every closing-keyword variant including past tense."""
690
+ body = (
691
+ "Adds a timestamp check to prevent background data pulls from overwriting "
692
+ "recent local edits.\n\nClosed #467.\n"
693
+ )
694
+ violations = validate_pr_body(body, pr_number=467)
695
+ assert any(
696
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
697
+ for each_violation in violations
698
+ ), f"`Closed #<own-PR>` self-reference must trip the block; got {violations!r}"
699
+
700
+
701
+ def test_validate_blocks_self_closing_resolved_past_tense_reference() -> None:
702
+ """`Resolved #<own-PR>` closes the PR on merge."""
703
+ body = (
704
+ "Adds a timestamp check to prevent background data pulls from overwriting "
705
+ "recent local edits.\n\nResolved #467.\n"
706
+ )
707
+ violations = validate_pr_body(body, pr_number=467)
708
+ assert any(
709
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
710
+ for each_violation in violations
711
+ ), f"`Resolved #<own-PR>` self-reference must trip the block; got {violations!r}"
712
+
713
+
714
+ def test_validate_blocks_uppercase_self_closing_closes_reference() -> None:
715
+ """All-caps `CLOSES #<own-PR>` also auto-closes on GitHub; the enforcer must
716
+ catch every case variant the same way GitHub does."""
717
+ body = (
718
+ "Adds a timestamp check to prevent background data pulls from overwriting "
719
+ "recent local edits.\n\nCLOSES #467.\n"
720
+ )
721
+ violations = validate_pr_body(body, pr_number=467)
722
+ assert any(
723
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
724
+ for each_violation in violations
725
+ ), f"all-caps CLOSES self-reference must trip the block; got {violations!r}"
726
+
727
+
728
+ def test_validate_allows_fixes_reference_to_different_pr() -> None:
729
+ body = (
730
+ "Adds a timestamp check to prevent background data pulls from overwriting "
731
+ "recent local edits.\n\nFixes #467.\n"
732
+ )
733
+ violations = validate_pr_body(body, pr_number=999)
734
+ assert not any(
735
+ "self" in each_violation.lower() or "own pr" in each_violation.lower()
736
+ for each_violation in violations
737
+ )
738
+
739
+
740
+ def test_validate_blocks_this_pr_opening() -> None:
741
+ body = (
742
+ "This PR adds a timestamp check to prevent background data pulls from "
743
+ "overwriting recent local edits. The pull engine compares the "
744
+ "last-modified marker before applying a remote record."
745
+ )
746
+ violations = validate_pr_body(body)
747
+ assert any("this pr" in each_violation.lower() for each_violation in violations)
748
+
749
+
750
+ def test_validate_blocks_this_pr_opening_with_non_allowlisted_verb() -> None:
751
+ """The guide describes any `This PR ...` opening as a hard block, but
752
+ `THIS_PR_OPENING_PATTERN` previously only matched a short allowlist of
753
+ verbs (adds|fixes|updates|does|is|was|will|removes|tightens|ports|refactors).
754
+ Variants like `This PR introduces`, `This PR improves`, `This PR enables`
755
+ slipped through and broke the documented contract. Catch any
756
+ `This PR` opening regardless of the following verb."""
757
+ body = (
758
+ "This PR introduces a multi-tier caching layer that wraps the existing "
759
+ "request pipeline and improves median latency on the hot path."
760
+ )
761
+ violations = validate_pr_body(body)
762
+ assert any("this pr" in each_violation.lower() for each_violation in violations), (
763
+ f"`This PR introduces` opening must trip the block regardless of verb; got {violations!r}"
764
+ )
765
+
766
+
767
+ def test_validate_blocks_this_pr_opening_with_improves() -> None:
768
+ body = (
769
+ "This PR improves the request batching algorithm so the dispatcher "
770
+ "coalesces idempotent calls before the network round-trip."
771
+ )
772
+ violations = validate_pr_body(body)
773
+ assert any("this pr" in each_violation.lower() for each_violation in violations), (
774
+ f"`This PR improves` opening must trip the block; got {violations!r}"
775
+ )
776
+
777
+
778
+ def test_validate_allows_imperative_opening() -> None:
779
+ body = (
780
+ "Adds a timestamp check to prevent background data pulls from "
781
+ "overwriting recent local edits. The pull engine compares the "
782
+ "last-modified marker before applying a remote record."
783
+ )
784
+ violations = validate_pr_body(body)
785
+ assert not any("this pr" in each_violation.lower() for each_violation in violations)
786
+
787
+
788
+ def _readability_failing_body() -> str:
789
+ """A Heavy-classified body whose intro sentence dramatically exceeds the
790
+ max-sentence-words threshold. Wraps the long sentence in `## Problem` and
791
+ `## Test plan` headers so the Heavy required-header check is satisfied
792
+ and only the readability violation fires; otherwise the missing-header
793
+ violations would inflate the result list and mask readability regressions
794
+ behind broad `any()` substring matches."""
795
+ return (
796
+ "## Problem\n\n"
797
+ "Adds a multi-step coordination protocol that traverses the entire "
798
+ "request lifecycle through every middleware layer in the system, ensuring that "
799
+ "downstream consumers observe a perfectly consistent ordering guarantee across "
800
+ "all participating subsystems including the queueing component and the storage "
801
+ "subsystem and the notification dispatch path that fans out to subscribers "
802
+ "across every channel registered against the tenant scope including email and "
803
+ "push and webhook delivery surfaces simultaneously in one transactional unit.\n\n"
804
+ "## Test plan\n\n"
805
+ "- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
806
+ )
807
+
808
+
809
+ def test_readability_strike_one_emits_metric_violation(readability_state_paths_enabled) -> None:
810
+ body = _readability_failing_body()
811
+ violations = validate_pr_body(body)
812
+ assert any(
813
+ "readability" in each_violation.lower() or "sentence" in each_violation.lower()
814
+ for each_violation in violations
815
+ )
816
+ assert not any(
817
+ "--readability-loosen" in each_violation for each_violation in violations
818
+ )
819
+ assert hook_module._read_strike_count() == 1
820
+
821
+
822
+ def test_readability_strike_two_still_metric_violation(readability_state_paths_enabled) -> None:
823
+ body = _readability_failing_body()
824
+ validate_pr_body(body)
825
+ violations = validate_pr_body(body)
826
+ assert hook_module._read_strike_count() == 2
827
+ assert not any("--readability-loosen" in each_violation for each_violation in violations)
828
+
829
+
830
+ def test_readability_strike_three_fires_escape_hatch(readability_state_paths_enabled) -> None:
831
+ body = _readability_failing_body()
832
+ validate_pr_body(body)
833
+ validate_pr_body(body)
834
+ violations = validate_pr_body(body)
835
+ assert hook_module._read_strike_count() == 3
836
+ assert any("--readability-loosen" in each_violation for each_violation in violations)
837
+ assert any("--readability-disable" in each_violation for each_violation in violations)
838
+ assert any("--readability-reset" in each_violation for each_violation in violations)
839
+
840
+
841
+ def test_extract_pr_number_from_gh_pr_edit() -> None:
842
+ command = 'gh pr edit 467 --body "some body text here"'
843
+ assert hook_module._extract_pr_number_from_command(command) == 467
844
+
845
+
846
+ def test_extract_pr_number_from_gh_pr_comment() -> None:
847
+ command = 'gh pr comment 467 --body "some comment body"'
848
+ assert hook_module._extract_pr_number_from_command(command) == 467
849
+
850
+
851
+ def test_extract_pr_number_from_gh_pr_create_returns_none() -> None:
852
+ command = 'gh pr create --repo jl-cmd/claude-code-config --body "some body"'
853
+ assert hook_module._extract_pr_number_from_command(command) is None
854
+
855
+
856
+ def test_extract_pr_number_from_malformed_command_returns_none() -> None:
857
+ command = 'gh pr edit --body "body without positional"'
858
+ assert hook_module._extract_pr_number_from_command(command) is None
859
+
860
+
861
+ def test_extract_pr_number_does_not_pick_up_number_in_title() -> None:
862
+ command = 'gh pr edit 467 --title "PR 999 was bad" --body "some body"'
863
+ assert hook_module._extract_pr_number_from_command(command) == 467
864
+
865
+
866
+ def test_loosen_cap_errors_on_fourth_invocation(readability_state_paths_enabled) -> None:
867
+ assert hook_module._apply_readability_loosen() == "ok"
868
+ assert hook_module._apply_readability_loosen() == "ok"
869
+ assert hook_module._apply_readability_loosen() == "ok"
870
+ fourth_outcome = hook_module._apply_readability_loosen()
871
+ assert fourth_outcome == "cap_reached"
872
+
873
+
874
+ def test_loosen_flesch_floor_cap_errors(readability_state_paths_enabled) -> None:
875
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
876
+ floor_value = hook_module.READABILITY_MIN_FLESCH_FLOOR
877
+ payload = {
878
+ "flesch_min": floor_value,
879
+ "max_sentence_words": 30,
880
+ "avg_sentence_words": 20,
881
+ "loosens_used": 0,
882
+ }
883
+ override_path.parent.mkdir(parents=True, exist_ok=True)
884
+ override_path.write_text(json.dumps(payload))
885
+ assert hook_module._apply_readability_loosen() == "floor_reached"
886
+
887
+
888
+ def test_loosen_max_sentence_ceiling_cap_errors(readability_state_paths_enabled) -> None:
889
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
890
+ ceiling_value = hook_module.READABILITY_MAX_SENTENCE_WORDS_CEILING
891
+ payload = {
892
+ "flesch_min": 50,
893
+ "max_sentence_words": ceiling_value,
894
+ "avg_sentence_words": 20,
895
+ "loosens_used": 0,
896
+ }
897
+ override_path.parent.mkdir(parents=True, exist_ok=True)
898
+ override_path.write_text(json.dumps(payload))
899
+ assert hook_module._apply_readability_loosen() == "ceiling_reached"
900
+
901
+
902
+ def test_loosen_avg_sentence_ceiling_cap_errors(readability_state_paths_enabled) -> None:
903
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
904
+ ceiling_value = hook_module.READABILITY_AVG_SENTENCE_WORDS_CEILING
905
+ payload = {
906
+ "flesch_min": 50,
907
+ "max_sentence_words": 30,
908
+ "avg_sentence_words": ceiling_value,
909
+ "loosens_used": 0,
910
+ }
911
+ override_path.parent.mkdir(parents=True, exist_ok=True)
912
+ override_path.write_text(json.dumps(payload))
913
+ assert hook_module._apply_readability_loosen() == "ceiling_reached"
914
+
915
+
916
+ def test_strip_leading_hash_lines_helper_is_removed() -> None:
917
+ """The unused leading-hash stripper must not exist as a module attribute."""
918
+ assert not hasattr(hook_module, "_strip_leading_hash_lines")
919
+
920
+
921
+ def test_strip_markdown_ceremony_returns_stripped_prose() -> None:
922
+ """The shared markdown stripper removes fences, inline code, blockquotes,
923
+ headings, bullets, bold, emphasis, and Markdown link targets, leaving the
924
+ underlying prose intact."""
925
+ body = "\n".join(
926
+ [
927
+ "# Heading text",
928
+ "> blockquoted content",
929
+ "- bullet content",
930
+ "**bold body**",
931
+ "*emphasized body*",
932
+ "[link label](https://example.com)",
933
+ "`inline code body`",
934
+ "```",
935
+ "fenced code body",
936
+ "```",
937
+ "plain prose line",
938
+ ]
939
+ )
940
+ stripped = hook_module._strip_markdown_ceremony(body)
941
+ assert "Heading text" not in stripped
942
+ assert "blockquoted content" in stripped
943
+ assert "bullet content" in stripped
944
+ assert "bold body" in stripped
945
+ assert "emphasized body" in stripped
946
+ assert "link label" in stripped
947
+ assert "plain prose line" in stripped
948
+ assert "inline code body" not in stripped
949
+ assert "fenced code body" not in stripped
950
+ assert "https://example.com" not in stripped
951
+
952
+
953
+ def test_strip_markdown_ceremony_used_by_substantive_prose_count() -> None:
954
+ """_count_substantive_prose_chars is consistent with the shared stripper:
955
+ its returned count matches len of the whitespace-collapsed stripped body."""
956
+ body = "# Heading\n\nA single paragraph of prose with **bold** and `code` words."
957
+ stripped = hook_module._strip_markdown_ceremony(body)
958
+ collapsed = _re.sub(r"\s+", " ", stripped).strip()
959
+ assert hook_module._count_substantive_prose_chars(body) == len(collapsed)
960
+
961
+
962
+ def test_threshold_override_file_widens_max_sentence_words(readability_state_paths_enabled) -> None:
963
+ """When max_sentence_words override is 50, the loaded thresholds reflect that value."""
964
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
965
+ payload = {
966
+ "flesch_min": 30,
967
+ "max_sentence_words": 50,
968
+ "avg_sentence_words": 40,
969
+ "loosens_used": 0,
970
+ }
971
+ override_path.parent.mkdir(parents=True, exist_ok=True)
972
+ override_path.write_text(json.dumps(payload))
973
+ thresholds = hook_module._load_readability_thresholds()
974
+ assert thresholds.max_sentence_words == 50
975
+ assert thresholds.flesch_min == 30
976
+ assert thresholds.avg_sentence_words == 40
977
+
978
+
979
+ def test_loosen_writes_expected_scaled_thresholds(readability_state_paths_enabled) -> None:
980
+ """First loosen invocation scales flesch by 0.9 and sentence widths by 10/9."""
981
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
982
+ assert hook_module._apply_readability_loosen() == "ok"
983
+ written_payload = json.loads(override_path.read_text())
984
+ assert written_payload["flesch_min"] == 45
985
+ assert written_payload["max_sentence_words"] == 32
986
+ assert written_payload["avg_sentence_words"] == 20
987
+ assert written_payload["loosens_used"] == 1
988
+
989
+
990
+ def test_dispatch_loosen_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
991
+ """The loosen handler writes its success message to the supplied output stream."""
992
+ output_stream = io.StringIO()
993
+ error_stream = io.StringIO()
994
+ with pytest.raises(SystemExit) as exit_info:
995
+ hook_module._dispatch_cli_flag(
996
+ "--readability-loosen",
997
+ output_stream=output_stream,
998
+ error_stream=error_stream,
999
+ )
1000
+ assert exit_info.value.code == 0
1001
+ assert "readability thresholds loosened 10%\n" == output_stream.getvalue()
1002
+ assert error_stream.getvalue() == ""
1003
+
1004
+
1005
+ def test_dispatch_loosen_cap_writes_to_error_stream(readability_state_paths_enabled) -> None:
1006
+ """When the loosen cap is hit, the handler writes the corrective message to error stream."""
1007
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1008
+ override_path.parent.mkdir(parents=True, exist_ok=True)
1009
+ override_path.write_text(json.dumps({"loosens_used": hook_module.READABILITY_LOOSEN_CAP}))
1010
+ output_stream = io.StringIO()
1011
+ error_stream = io.StringIO()
1012
+ with pytest.raises(SystemExit) as exit_info:
1013
+ hook_module._dispatch_cli_flag(
1014
+ "--readability-loosen",
1015
+ output_stream=output_stream,
1016
+ error_stream=error_stream,
1017
+ )
1018
+ assert exit_info.value.code == 1
1019
+ assert "loosen cap reached" in error_stream.getvalue()
1020
+ assert output_stream.getvalue() == ""
1021
+
1022
+
1023
+ def test_dispatch_loosen_floor_writes_to_error_stream(readability_state_paths_enabled) -> None:
1024
+ """When the floor is reached, the handler writes the corrective message to error stream."""
1025
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1026
+ floor_payload = {
1027
+ "flesch_min": hook_module.READABILITY_MIN_FLESCH_FLOOR,
1028
+ "max_sentence_words": 30,
1029
+ "avg_sentence_words": 20,
1030
+ "loosens_used": 0,
1031
+ }
1032
+ override_path.parent.mkdir(parents=True, exist_ok=True)
1033
+ override_path.write_text(json.dumps(floor_payload))
1034
+ output_stream = io.StringIO()
1035
+ error_stream = io.StringIO()
1036
+ with pytest.raises(SystemExit) as exit_info:
1037
+ hook_module._dispatch_cli_flag(
1038
+ "--readability-loosen",
1039
+ output_stream=output_stream,
1040
+ error_stream=error_stream,
1041
+ )
1042
+ assert exit_info.value.code == 1
1043
+ assert "floor/ceiling" in error_stream.getvalue()
1044
+ assert output_stream.getvalue() == ""
1045
+
1046
+
1047
+ def test_dispatch_reset_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1048
+ """The reset handler writes its success message to the supplied output stream."""
1049
+ output_stream = io.StringIO()
1050
+ error_stream = io.StringIO()
1051
+ with pytest.raises(SystemExit) as exit_info:
1052
+ hook_module._dispatch_cli_flag(
1053
+ "--readability-reset",
1054
+ output_stream=output_stream,
1055
+ error_stream=error_stream,
1056
+ )
1057
+ assert exit_info.value.code == 0
1058
+ assert "readability strike counter and override thresholds reset\n" == output_stream.getvalue()
1059
+ assert error_stream.getvalue() == ""
1060
+
1061
+
1062
+ def test_dispatch_disable_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1063
+ """The disable handler writes its success message to the supplied output stream."""
1064
+ output_stream = io.StringIO()
1065
+ error_stream = io.StringIO()
1066
+ with pytest.raises(SystemExit) as exit_info:
1067
+ hook_module._dispatch_cli_flag(
1068
+ "--readability-disable",
1069
+ output_stream=output_stream,
1070
+ error_stream=error_stream,
1071
+ )
1072
+ assert exit_info.value.code == 0
1073
+ assert "readability check disabled\n" == output_stream.getvalue()
1074
+ assert error_stream.getvalue() == ""
1075
+
1076
+
1077
+ def test_dispatch_enable_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1078
+ """The enable handler writes its success message to the supplied output stream."""
1079
+ output_stream = io.StringIO()
1080
+ error_stream = io.StringIO()
1081
+ with pytest.raises(SystemExit) as exit_info:
1082
+ hook_module._dispatch_cli_flag(
1083
+ "--readability-enable",
1084
+ output_stream=output_stream,
1085
+ error_stream=error_stream,
1086
+ )
1087
+ assert exit_info.value.code == 0
1088
+ assert "readability check enabled\n" == output_stream.getvalue()
1089
+ assert error_stream.getvalue() == ""
1090
+
1091
+
1092
+ def test_shape_classifier_uses_substantive_chars_not_raw_length() -> None:
1093
+ """Shape classifier and ceremony-on-Trivial check must agree on the metric used
1094
+ against TRIVIAL_BODY_CHAR_THRESHOLD. A body whose raw length passes the
1095
+ threshold but whose substantive prose does not (e.g. tiny prose with a large
1096
+ fenced code block) is genuinely Trivial in shape -- not Standard."""
1097
+ tiny_prose_with_large_code_fence = "Done.\n\n```\n" + ("x" * 300) + "\n```"
1098
+ assert len(tiny_prose_with_large_code_fence) >= hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
1099
+ assert hook_module._count_substantive_prose_chars(tiny_prose_with_large_code_fence) < hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
1100
+ assert hook_module._compute_pr_body_shape(tiny_prose_with_large_code_fence) == "trivial"
1101
+
1102
+
1103
+ def _build_main_hook_input(command: str) -> dict[str, object]:
1104
+ return {"tool_name": "Bash", "tool_input": {"command": command}}
1105
+
1106
+
1107
+ def _run_main_and_capture_decision(hook_input: dict[str, object]) -> str:
1108
+ captured_stdout = io.StringIO()
1109
+ with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
1110
+ with patch("sys.stdout", captured_stdout):
1111
+ try:
1112
+ hook_module.main()
1113
+ except SystemExit:
1114
+ pass
1115
+ return captured_stdout.getvalue()
1116
+
1117
+
1118
+ def test_body_contains_any_header_rejects_plural_extension() -> None:
1119
+ """`_body_contains_any_header` must enforce a word boundary after the
1120
+ canonical header text. `## Problems` (plural) extends the canonical
1121
+ word and must NOT satisfy `## Problem`, otherwise the Heavy
1122
+ required-header check is weaker than the documented contract."""
1123
+ body_with_plural_extension = "## Problems\n\nDetails follow."
1124
+ candidate_set = frozenset({"## Problem"})
1125
+ assert not hook_module._body_contains_any_header(body_with_plural_extension, candidate_set), (
1126
+ "`## Problems` must NOT satisfy `## Problem` (different header)"
1127
+ )
1128
+
1129
+
1130
+ def test_body_contains_any_header_accepts_punctuation_suffix() -> None:
1131
+ """The boundary rule must still accept canonical headers followed by
1132
+ non-word punctuation: colon, em-dash, parenthesis, trailing whitespace.
1133
+ Reviewers write `## Problem (context)` and `## Test plan: scope` —
1134
+ these must continue to satisfy the canonical headers."""
1135
+ candidate_set = frozenset({"## Problem"})
1136
+ for each_body in [
1137
+ "## Problem\n\nDetails.",
1138
+ "## Problem:\n\nDetails.",
1139
+ "## Problem (context)\n\nDetails.",
1140
+ "## Problem — context\n\nDetails.",
1141
+ ]:
1142
+ assert hook_module._body_contains_any_header(each_body, candidate_set), (
1143
+ f"`{each_body!r}` must satisfy `## Problem` (punctuation/space follows)"
1144
+ )
1145
+
1146
+
1147
+ def test_body_contains_any_header_rejects_alphanumeric_suffix() -> None:
1148
+ """`## Problem2`, `## ProblemX`, `## Problem_one` are different headers
1149
+ and must not match `## Problem`."""
1150
+ candidate_set = frozenset({"## Problem"})
1151
+ for each_body in [
1152
+ "## Problem2\n\nDetails.",
1153
+ "## ProblemX\n\nDetails.",
1154
+ "## Problem_one\n\nDetails.",
1155
+ ]:
1156
+ assert not hook_module._body_contains_any_header(each_body, candidate_set), (
1157
+ f"`{each_body!r}` must NOT satisfy `## Problem` (alphanumeric continuation)"
1158
+ )
1159
+
1160
+
1161
+ def test_read_strike_count_clamps_negative_to_zero(readability_state_paths_enabled) -> None:
1162
+ """A corrupted strike-count JSON state with a negative integer must not
1163
+ silently bypass escalation. Reads clamp to >= 0 so subsequent increments
1164
+ walk the strike threshold from a sane baseline."""
1165
+ strike_path, _override_path, _enabled_path = readability_state_paths_enabled
1166
+ strike_path.parent.mkdir(parents=True, exist_ok=True)
1167
+ strike_path.write_text(json.dumps({"strikes": -5}))
1168
+ assert hook_module._read_strike_count() == 0, (
1169
+ "negative strikes must clamp to 0"
1170
+ )
1171
+
1172
+
1173
+ def test_increment_strike_count_clamps_negative_starting_value(readability_state_paths_enabled) -> None:
1174
+ """`_increment_strike_count` must not propagate a corrupted negative
1175
+ starting value. The new count after one increment from a negative
1176
+ baseline is exactly 1, not (negative + 1)."""
1177
+ strike_path, _override_path, _enabled_path = readability_state_paths_enabled
1178
+ strike_path.parent.mkdir(parents=True, exist_ok=True)
1179
+ strike_path.write_text(json.dumps({"strikes": -3}))
1180
+ new_count_after_increment = hook_module._increment_strike_count()
1181
+ assert new_count_after_increment == 1, (
1182
+ f"increment from negative starting value must clamp first; got {new_count_after_increment}"
1183
+ )
1184
+
1185
+
1186
+ def test_read_loosens_used_clamps_negative_to_zero(readability_state_paths_enabled) -> None:
1187
+ """A corrupted `loosens_used` JSON state with a negative integer must
1188
+ not silently bypass the loosen cap. Reads clamp to >= 0 so the cap
1189
+ check enforces the documented ceiling."""
1190
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1191
+ override_path.parent.mkdir(parents=True, exist_ok=True)
1192
+ override_path.write_text(json.dumps({"loosens_used": -2}))
1193
+ assert hook_module._read_loosens_used() == 0, (
1194
+ "negative loosens_used must clamp to 0"
1195
+ )
1196
+
1197
+
1198
+ def test_scan_raw_tokens_for_body_docstring_reflects_none_for_shell_vars() -> None:
1199
+ """`_resolve_body_string_value` now returns `None` for unresolvable
1200
+ shell-variable bodies. `_scan_raw_tokens_for_body`'s docstring must
1201
+ reflect that contract so future maintainers do not treat `""` as the
1202
+ shell-var sentinel; literal-empty bodies still flow into validation."""
1203
+ source_text = inspect.getsource(hook_module._scan_raw_tokens_for_body)
1204
+ assert "None" in source_text, (
1205
+ f"docstring must mention None for shell-var case; got: {source_text!r}"
1206
+ )
1207
+ assert "shell var" in source_text.lower() or "shell-var" in source_text.lower(), (
1208
+ f"docstring must reference shell variables; got: {source_text!r}"
1209
+ )
1210
+ assert "may be empty for shell vars/sentinels" not in source_text, (
1211
+ "docstring must not claim `\"\"` represents shell-var bodies; that case now returns None. "
1212
+ f"Source still contains the stale phrase: {source_text!r}"
1213
+ )
1214
+
1215
+
1216
+ def test_stdlib_imports_form_one_isort_sorted_block() -> None:
1217
+ """Ruff's `I` (isort) rule treats a blank line as a section break, so
1218
+ `import shlex` sitting alone after a blank line would fail I001. Pin
1219
+ that the stdlib imports at the head of `pr_description_enforcer.py`
1220
+ sit in a single sorted block with no internal blank lines."""
1221
+ enforcer_source = inspect.getsource(hook_module)
1222
+ enforcer_lines = enforcer_source.splitlines()
1223
+ leading_stdlib_lines: list[str] = []
1224
+ for each_line in enforcer_lines:
1225
+ if each_line.startswith("import ") or each_line.startswith("from "):
1226
+ leading_stdlib_lines.append(each_line)
1227
+ continue
1228
+ if each_line.strip() == "":
1229
+ if leading_stdlib_lines and leading_stdlib_lines[-1].startswith("from "):
1230
+ break
1231
+ if leading_stdlib_lines:
1232
+ break
1233
+ if not each_line.startswith("import ") and not each_line.startswith("from ") and each_line.strip() != "":
1234
+ break
1235
+ stdlib_import_names: list[str] = []
1236
+ for each_import_line in leading_stdlib_lines:
1237
+ if each_import_line.startswith("import "):
1238
+ stdlib_import_names.append(each_import_line.split()[1])
1239
+ assert "shlex" in stdlib_import_names, (
1240
+ "`shlex` must appear in the leading stdlib import block; got: "
1241
+ f"{stdlib_import_names!r}"
1242
+ )
1243
+ assert stdlib_import_names == sorted(stdlib_import_names), (
1244
+ "Leading stdlib `import X` statements must be isort-sorted; got: "
1245
+ f"{stdlib_import_names!r}"
1246
+ )
1247
+
1248
+
1249
+ def test_command_carries_body_flag_detects_body_file() -> None:
1250
+ """`--body-file` detection must continue to work after the redundant
1251
+ explicit check is removed. The shorter `--body` substring still catches
1252
+ `--body-file` because `--body` is a prefix of `--body-file`."""
1253
+ assert hook_module._command_carries_body_flag('gh pr create --body-file body.md')
1254
+ assert hook_module._command_carries_body_flag('gh pr create --body-file=body.md')
1255
+ assert hook_module._command_carries_body_flag('gh pr edit 1 -F body.md')
1256
+ assert hook_module._command_carries_body_flag('gh pr edit 1 -F=body.md')
1257
+
1258
+
1259
+ def test_command_carries_body_flag_does_not_double_check_body_file() -> None:
1260
+ """Pin that the function does NOT execute a redundant `--body-file in command`
1261
+ check. `--body` is a substring of `--body-file`, so the longer form is
1262
+ matched implicitly by the shorter check. Pin the source so the dead branch
1263
+ cannot drift back."""
1264
+ source_text = inspect.getsource(hook_module._command_carries_body_flag)
1265
+ assert source_text.count('"--body-file"') == 0, (
1266
+ f"`--body-file` substring check is redundant with `--body`; remove it. Source:\n{source_text}"
1267
+ )
1268
+
1269
+
1270
+ def test_main_blocks_gh_pr_edit_short_body_flag() -> None:
1271
+ """gh pr edit 123 -b "short" must be caught -- the short -b flag is a valid alias for --body."""
1272
+ command = 'gh pr edit 123 -b "Too short."'
1273
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1274
+ assert "deny" in decision_output
1275
+ assert "substantive prose" in decision_output.lower()
1276
+
1277
+
1278
+ def test_main_blocks_gh_pr_edit_body_file_short_flag(tmp_path) -> None:
1279
+ """gh pr edit 123 -F body.md must be caught -- -F is the short alias for --body-file."""
1280
+ body_file = tmp_path / "body.md"
1281
+ body_file.write_text("Too short.")
1282
+ command = f'gh pr edit 123 -F {body_file}'
1283
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1284
+ assert "deny" in decision_output
1285
+ assert "substantive prose" in decision_output.lower()
1286
+
1287
+
1288
+ def test_main_blocks_gh_pr_edit_body_file_long_flag(tmp_path) -> None:
1289
+ """gh pr edit 123 --body-file body.md must also be caught (was missing from is_pr_edit detection)."""
1290
+ body_file = tmp_path / "body.md"
1291
+ body_file.write_text("Too short.")
1292
+ command = f'gh pr edit 123 --body-file {body_file}'
1293
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1294
+ assert "deny" in decision_output
1295
+
1296
+
1297
+ def test_main_blocks_gh_pr_create_body_file_short_flag(tmp_path) -> None:
1298
+ """gh pr create -F body.md must be caught -- -F is the short alias for --body-file."""
1299
+ body_file = tmp_path / "body.md"
1300
+ body_file.write_text("Too short.")
1301
+ command = f'gh pr create --title "T" -F {body_file}'
1302
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1303
+ assert "deny" in decision_output
1304
+
1305
+
1306
+ def test_main_blocks_gh_pr_create_body_file_long_flag(tmp_path) -> None:
1307
+ """gh pr create --body-file body.md must be caught -- was missing from is_pr_create detection."""
1308
+ body_file = tmp_path / "body.md"
1309
+ body_file.write_text("Too short.")
1310
+ command = f'gh pr create --title "T" --body-file {body_file}'
1311
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1312
+ assert "deny" in decision_output
1313
+
1314
+
1315
+ def test_resolve_positional_pr_number_accepts_bare_integer() -> None:
1316
+ assert hook_module._resolve_positional_pr_number("467") == 467
1317
+
1318
+
1319
+ def test_resolve_positional_pr_number_accepts_pr_url() -> None:
1320
+ assert hook_module._resolve_positional_pr_number("https://github.com/o/r/pull/467") == 467
1321
+
1322
+
1323
+ def test_resolve_positional_pr_number_rejects_non_pr_url() -> None:
1324
+ assert hook_module._resolve_positional_pr_number("https://github.com/o/r/issues/467") is None
1325
+
1326
+
1327
+ def test_resolve_positional_pr_number_rejects_shell_variable() -> None:
1328
+ assert hook_module._resolve_positional_pr_number("$PR_NUMBER") is None
1329
+
1330
+
1331
+ def test_extract_pr_number_skips_repo_value_flag() -> None:
1332
+ """gh pr edit --repo owner/r 467 --body "x" must return 467 -- the --repo value must be skipped."""
1333
+ command = 'gh pr edit --repo owner/r 467 --body "x"'
1334
+ assert hook_module._extract_pr_number_from_command(command) == 467
1335
+
1336
+
1337
+ def test_extract_pr_number_from_pr_url_positional() -> None:
1338
+ """gh pr edit https://github.com/o/r/pull/467 --body "x" must return 467 -- URL form is valid."""
1339
+ command = 'gh pr edit https://github.com/o/r/pull/467 --body "x"'
1340
+ assert hook_module._extract_pr_number_from_command(command) == 467
1341
+
1342
+
1343
+ def test_extract_pr_number_from_pr_url_after_repo_flag() -> None:
1344
+ """Combined: --repo flag plus URL positional must still resolve to the URL's PR number."""
1345
+ command = 'gh pr edit --repo owner/r https://github.com/o/r/pull/999 --body "x"'
1346
+ assert hook_module._extract_pr_number_from_command(command) == 999
1347
+
1348
+
1349
+ def test_extract_pr_number_skips_repo_equals_form() -> None:
1350
+ """gh pr edit --repo=owner/r 467 --body "x" must return 467 -- the equals-form must also be handled."""
1351
+ command = 'gh pr edit --repo=owner/r 467 --body "x"'
1352
+ assert hook_module._extract_pr_number_from_command(command) == 467
1353
+
1354
+
1355
+ def test_extract_pr_number_from_pr_url_with_trailing_query_string() -> None:
1356
+ """A PR URL with a `?diff=split` or other trailing query/fragment must still resolve.
1357
+ The trailing group `(?:[/?#].*)?` in the URL regex is what makes this work."""
1358
+ command = 'gh pr edit https://github.com/o/r/pull/467?diff=split --body "x"'
1359
+ assert hook_module._extract_pr_number_from_command(command) == 467
1360
+
1361
+
1362
+ def test_extract_pr_number_skips_body_long_flag_value() -> None:
1363
+ """gh pr edit --body "Fixes #999" 472 must return 472 -- the --body value must not
1364
+ be treated as a positional argument. Without skipping body-flag values, the body
1365
+ text would be parsed as the positional slot and PR-number extraction would fail."""
1366
+ command = 'gh pr edit --body "Fixes #999" 472'
1367
+ assert hook_module._extract_pr_number_from_command(command) == 472
1368
+
1369
+
1370
+ def test_extract_pr_number_skips_body_short_flag_value() -> None:
1371
+ """gh pr edit -b 'Fixes #999' 472 must return 472 -- short -b alias must also skip its value."""
1372
+ command = 'gh pr edit -b "Fixes #999" 472'
1373
+ assert hook_module._extract_pr_number_from_command(command) == 472
1374
+
1375
+
1376
+ def test_extract_pr_number_skips_body_file_long_flag_value() -> None:
1377
+ """gh pr edit --body-file body.md 472 must return 472 -- --body-file value must skip."""
1378
+ command = 'gh pr edit --body-file body.md 472'
1379
+ assert hook_module._extract_pr_number_from_command(command) == 472
1380
+
1381
+
1382
+ def test_extract_pr_number_skips_body_file_short_flag_value() -> None:
1383
+ """gh pr edit -F body.md 472 must return 472 -- -F short alias must also skip its value."""
1384
+ command = 'gh pr edit -F body.md 472'
1385
+ assert hook_module._extract_pr_number_from_command(command) == 472
1386
+
1387
+
1388
+ def test_extract_pr_number_skips_body_equals_form() -> None:
1389
+ """gh pr edit --body="Fixes #999" 472 must return 472 -- equals-form has the value
1390
+ attached to the same token, so only the flag token itself should be skipped."""
1391
+ command = 'gh pr edit --body="Fixes #999" 472'
1392
+ assert hook_module._extract_pr_number_from_command(command) == 472
1393
+
1394
+
1395
+ def test_command_carries_body_flag_short_b_equals_form() -> None:
1396
+ """`-b=value` short form must be detected by the pre-filter; previous version only
1397
+ checked the space-separated `-b ` substring and silently bypassed the equals form."""
1398
+ assert hook_module._command_carries_body_flag('gh pr edit 123 -b="x"') is True
1399
+
1400
+
1401
+ def test_command_carries_body_flag_short_F_equals_form() -> None:
1402
+ """`-F=path` short form must be detected by the pre-filter."""
1403
+ assert hook_module._command_carries_body_flag('gh pr edit 123 -F=body.md') is True
1404
+
1405
+
1406
+ def test_main_blocks_gh_pr_edit_short_body_equals_form() -> None:
1407
+ """gh pr edit 123 -b="short" must be caught -- the -b= equals form was bypassing
1408
+ the pre-filter and silently approving short bodies."""
1409
+ command = 'gh pr edit 123 -b="Too short."'
1410
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1411
+ assert "deny" in decision_output
1412
+ assert "substantive prose" in decision_output.lower()
1413
+
1414
+
1415
+ def test_main_blocks_gh_pr_edit_short_body_file_equals_form(tmp_path) -> None:
1416
+ """gh pr edit 123 -F=body.md must be caught -- the -F= equals form was bypassing the pre-filter."""
1417
+ body_file = tmp_path / "body.md"
1418
+ body_file.write_text("Too short.")
1419
+ command = f'gh pr edit 123 -F={body_file}'
1420
+ decision_output = _run_main_and_capture_decision(_build_main_hook_input(command))
1421
+ assert "deny" in decision_output
1422
+
1423
+
1424
+ def test_iter_section_headers_ignores_headings_inside_fenced_code_blocks() -> None:
1425
+ """Headings nested inside ``` ... ``` fences are example content, not body headers.
1426
+ The shape classifier and the Heavy required-header check must agree with the markdown
1427
+ stripper -- the body of this very test demonstrates the regression."""
1428
+ body = (
1429
+ "Intro paragraph that does not classify the body.\n\n"
1430
+ "```\n"
1431
+ "## Problem\n"
1432
+ "## Test plan\n"
1433
+ "```\n"
1434
+ )
1435
+ headers = hook_module._iter_section_headers(body)
1436
+ assert headers == [], f"Expected zero headers (fenced content), got {headers}"
1437
+ assert hook_module._compute_pr_body_shape(body) != "heavy", (
1438
+ "Body with only fenced example headers must not classify as heavy"
1439
+ )
1440
+ assert hook_module._body_contains_any_header(
1441
+ body, hook_module.ALL_HEAVY_OPENING_HEADERS
1442
+ ) is False, "Heavy opening-header check must not see fenced example content"
1443
+
1444
+
1445
+ def test_build_short_failing_body_helper_is_removed() -> None:
1446
+ """The unused test helper `_build_short_failing_body` had zero call sites and
1447
+ must not be re-introduced."""
1448
+ test_module = sys.modules[__name__]
1449
+ assert not hasattr(test_module, "_build_short_failing_body"), (
1450
+ "_build_short_failing_body was re-introduced; it has no callers in this test file."
1451
+ )
1452
+
1453
+
1454
+ def test_strike_count_rejects_boolean_value_as_strikes(readability_state_paths_enabled) -> None:
1455
+ """A corrupted strikes.json with `{"strikes": true}` must not be silently
1456
+ accepted as the integer 1. Python's `bool` is a subclass of `int`, so a bare
1457
+ `isinstance(value, int)` guard lets a malformed payload disable strike
1458
+ behavior without warning. The reader must explicitly exclude bool values."""
1459
+ strike_path, _override_path, _enabled_path = readability_state_paths_enabled
1460
+ strike_path.write_text('{"strikes": true}')
1461
+ assert hook_module._read_strike_count() == 0
1462
+
1463
+
1464
+ def test_loosens_used_rejects_boolean_value(readability_state_paths_enabled) -> None:
1465
+ """`{"loosens_used": true}` must read as the default 0, not coerce the bool
1466
+ to 1 via the `isinstance(x, int)` quirk that accepts bool."""
1467
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1468
+ override_path.write_text('{"loosens_used": true}')
1469
+ assert hook_module._read_loosens_used() == 0
1470
+
1471
+
1472
+ def test_readability_thresholds_reject_boolean_values(readability_state_paths_enabled) -> None:
1473
+ """A threshold field set to a boolean must fall back to the default integer,
1474
+ not silently coerce True to 1 or False to 0 via Python's bool-is-int quirk."""
1475
+ _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1476
+ override_path.write_text(
1477
+ '{"flesch_min": true, "max_sentence_words": false, "avg_sentence_words": true}'
1478
+ )
1479
+ thresholds = hook_module._load_readability_thresholds()
1480
+ assert thresholds.flesch_min == hook_module.DEFAULT_READABILITY_THRESHOLDS.flesch_min
1481
+ assert thresholds.max_sentence_words == hook_module.DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
1482
+ assert thresholds.avg_sentence_words == hook_module.DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
1483
+
1484
+
1485
+ def test_single_use_helper_constants_are_inlined() -> None:
1486
+ """`_vowel_set`, `_sentence_split_pattern`, and `_all_cli_flag_tokens` each
1487
+ had exactly one consumer in production. The file-global-constants rule
1488
+ requires either a second caller or a move out of module scope; inlining
1489
+ into the single consumer is the chosen resolution. Pin that the three
1490
+ names are no longer module attributes so they cannot drift back."""
1491
+ for each_name in ("_vowel_set", "_sentence_split_pattern", "_all_cli_flag_tokens"):
1492
+ assert not hasattr(hook_module, each_name), (
1493
+ f"{each_name} must be inlined into its single consumer, not "
1494
+ "carried as a file-global constant."
1495
+ )
1496
+
1497
+
1498
+ def test_readability_violation_strings_match_agent_doc_format() -> None:
1499
+ """The agent SKILL example shows the canonical readability message format
1500
+ (`Readability: longest sentence is N words (maximum 28); split or rewrite
1501
+ the longest sentence`). The hook's `_evaluate_readability_metrics` must
1502
+ emit the same `maximum N` / `split or rewrite` wording so users see the
1503
+ exact form documented in the agent file."""
1504
+ text_with_long_sentence = (
1505
+ "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu "
1506
+ "nu xi omicron pi rho sigma tau upsilon phi chi psi omega aleph "
1507
+ "beth gimel daleth he waw zayin heth teth yodh kaph lamedh mem nun."
1508
+ )
1509
+ messages_via_eval = hook_module._evaluate_readability_metrics(
1510
+ text_with_long_sentence, hook_module.DEFAULT_READABILITY_THRESHOLDS
1511
+ )
1512
+ joined_messages = "\n".join(messages_via_eval)
1513
+ assert "(maximum" in joined_messages, (
1514
+ f"Readability messages must use `maximum N` wording (matching agent doc); "
1515
+ f"got: {joined_messages!r}"
1516
+ )
1517
+ assert "split or rewrite the longest sentence" in joined_messages, (
1518
+ f"Longest-sentence message must end with `split or rewrite the longest sentence`; "
1519
+ f"got: {joined_messages!r}"
1520
+ )
1521
+
1522
+
1523
+ def test_long_body_without_heavy_headers_still_classifies_heavy() -> None:
1524
+ """The Heavy required-header check in `validate_pr_body` only runs when
1525
+ `_compute_pr_body_shape` returns HEAVY. Previously the classifier required
1526
+ BOTH length >= 500 chars AND >= 2 heavy detection headers, which meant a
1527
+ long body missing the required headers entirely was classified Standard
1528
+ and silently bypassed the missing-header enforcement. Length alone must
1529
+ drive the HEAVY classification so the validator can enforce the rule."""
1530
+ long_body_with_no_heavy_headers = (
1531
+ "Refactors the request-pipeline batcher to coalesce idempotent calls "
1532
+ "before the network round-trip. The change touches the dispatcher, the "
1533
+ "retry loop, the error normalizer, and three downstream consumers. "
1534
+ "Every test in the integration suite continues to pass without "
1535
+ "modification because the public contract is unchanged.\n\n"
1536
+ "The new coalescer reads a per-call digest, looks up an in-flight slot "
1537
+ "indexed by that digest, and appends the caller's promise to the slot "
1538
+ "instead of dispatching a duplicate request. Once the network response "
1539
+ "arrives, every queued promise resolves with the same value. Error "
1540
+ "responses propagate to every queued promise so retry logic stays "
1541
+ "consistent with the prior contract.\n"
1542
+ )
1543
+ assert (
1544
+ hook_module._count_substantive_prose_chars(long_body_with_no_heavy_headers)
1545
+ >= hook_module.HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION
1546
+ )
1547
+ assert hook_module._compute_pr_body_shape(long_body_with_no_heavy_headers) == hook_module.HEAVY_SHAPE
1548
+
1549
+
1550
+ def test_validate_heavy_body_without_required_headers_blocks() -> None:
1551
+ """End-to-end: a long body without `## Problem|Summary` or `## Test plan|...`
1552
+ must trip the Heavy missing-header violation. Previously the classifier
1553
+ bypassed Heavy classification because the body lacked the headers we were
1554
+ trying to require — a circular self-bypass."""
1555
+ long_body_missing_heavy_headers = (
1556
+ "Refactors the request-pipeline batcher to coalesce idempotent calls "
1557
+ "before the network round-trip. The change touches the dispatcher, the "
1558
+ "retry loop, the error normalizer, and three downstream consumers. "
1559
+ "Every test in the integration suite continues to pass without "
1560
+ "modification because the public contract is unchanged.\n\n"
1561
+ "The new coalescer reads a per-call digest, looks up an in-flight slot "
1562
+ "indexed by that digest, and appends the caller's promise to the slot "
1563
+ "instead of dispatching a duplicate request. Once the network response "
1564
+ "arrives, every queued promise resolves with the same value. Error "
1565
+ "responses propagate to every queued promise so retry logic stays "
1566
+ "consistent with the prior contract.\n"
1567
+ )
1568
+ violations = validate_pr_body(long_body_missing_heavy_headers)
1569
+ assert any("heavy" in each_violation.lower() for each_violation in violations), (
1570
+ f"Long body missing Heavy headers must trip the required-header check; got {violations!r}"
1571
+ )
1572
+
1573
+
1574
+ def test_compute_pr_body_shape_uses_named_shape_constants() -> None:
1575
+ """`_compute_pr_body_shape` returns the centralised shape names rather than
1576
+ inline string literals. Confirm the constants flow through end-to-end."""
1577
+ trivial_body = "Bump bun to 1.3.14."
1578
+ assert hook_module._compute_pr_body_shape(trivial_body) == hook_module.TRIVIAL_SHAPE
1579
+
1580
+
1581
+ def test_compute_flesch_reading_ease_uses_named_constants() -> None:
1582
+ """`_compute_flesch_reading_ease` must reference the named Flesch constants
1583
+ rather than embed the magic literals 206.835 / 1.015 / 84.6 / 100.0 inline.
1584
+ Smoke-test the empty-input path returns the perfect-score default."""
1585
+ perfect_score = hook_module._compute_flesch_reading_ease("")
1586
+ assert perfect_score == hook_module.FLESCH_PERFECT_SCORE
1587
+ perfect_score_no_words = hook_module._compute_flesch_reading_ease(" ")
1588
+ assert perfect_score_no_words == hook_module.FLESCH_PERFECT_SCORE
1589
+
1590
+
1591
+ def test_iter_section_headers_docstring_matches_actual_pattern() -> None:
1592
+ """`_iter_section_headers` uses `HEADING_LINE_PATTERN = ^#+`, so it returns
1593
+ every ATX heading level (`#`, `##`, `###`...), not just `##`. The docstring
1594
+ must describe that actual contract so callers cannot be misled."""
1595
+ docstring = hook_module._iter_section_headers.__doc__ or ""
1596
+ assert "every ATX heading" in docstring or "any heading level" in docstring, (
1597
+ f"_iter_section_headers docstring must document that it matches every "
1598
+ f"heading level (`HEADING_LINE_PATTERN` is `^#+`); got: {docstring!r}"
1599
+ )
1600
+
1601
+
1602
+ def test_extract_readability_target_text_strips_fences_before_finding_header() -> None:
1603
+ """`_extract_readability_target_text` must strip fenced code blocks before
1604
+ searching for the first structural header. Otherwise a fenced example like
1605
+ ```\\n## Problem\\n``` is matched as the first header and the intro / section
1606
+ boundaries collapse to bogus values."""
1607
+ body = (
1608
+ "Intro paragraph that should be the intro for readability analysis.\n\n"
1609
+ "```\n## Problem\n```\n\n"
1610
+ "## RealHeader\n\n"
1611
+ "Real first-section prose for readability measurement.\n"
1612
+ )
1613
+ target_text = hook_module._extract_readability_target_text(body)
1614
+ assert "Intro paragraph" in target_text, (
1615
+ f"Intro paragraph must survive; got {target_text!r}"
1616
+ )
1617
+ assert "Real first-section prose" in target_text, (
1618
+ f"First real section prose must follow; got {target_text!r}"
1619
+ )
1620
+
1621
+
1622
+ @pytest.fixture
1623
+ def readability_state_paths_enabled(tmp_path, monkeypatch):
1624
+ """Redirect the three readability state files to per-test temp paths while keeping
1625
+ readability enabled. The autouse `_isolate_readability_state` fixture disables
1626
+ readability by default for unrelated tests; tests exercising strike-counter or
1627
+ dispatch behavior need it ON, so this fixture re-points the three state paths
1628
+ WITHOUT stubbing _is_readability_enabled.
1629
+
1630
+ Returns:
1631
+ Tuple of (strike_path, override_path, enabled_path).
1632
+ """
1633
+ strike_path = tmp_path / "strikes.json"
1634
+ override_path = tmp_path / "overrides.json"
1635
+ enabled_path = tmp_path / "enabled.json"
1636
+ monkeypatch.setattr(hook_module, "READABILITY_STATE_FILE", strike_path)
1637
+ monkeypatch.setattr(hook_module, "READABILITY_THRESHOLD_OVERRIDE_FILE", override_path)
1638
+ monkeypatch.setattr(hook_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
1639
+ return strike_path, override_path, enabled_path