claude-dev-env 1.42.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 (207) hide show
  1. package/_shared/pr-loop/scripts/_claude_permissions_common.py +1 -5
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +293 -8
  3. package/_shared/pr-loop/scripts/fix_hookspath.py +96 -5
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +3 -16
  5. package/_shared/pr-loop/scripts/post_audit_thread.py +4 -4
  6. package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_permissions_constants.py +1 -1
  7. package/_shared/pr-loop/scripts/preflight.py +13 -31
  8. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -16
  9. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +3 -16
  10. package/_shared/pr-loop/scripts/tests/conftest.py +1 -51
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +4 -4
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +4 -2
  13. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +4 -2
  14. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +4 -2
  15. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +4 -2
  16. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +6 -2
  17. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +2 -2
  18. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1 -2
  19. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +4 -2
  20. package/_shared/pr-loop/scripts/tests/test_preflight.py +17 -52
  21. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +6 -2
  22. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +2 -2
  23. package/agents/pr-description-writer.md +50 -140
  24. package/docs/PR_DESCRIPTION_GUIDE.md +101 -102
  25. package/hooks/_gh_pr_author_swap_utils.py +1 -1
  26. package/hooks/blocking/bot_mention_comment_blocker.py +4 -10
  27. package/hooks/blocking/code_rules_enforcer.py +217 -99
  28. package/hooks/blocking/code_rules_path_utils.py +8 -1
  29. package/hooks/blocking/destructive_command_blocker.py +1 -1
  30. package/hooks/blocking/es_exe_path_rewriter.py +7 -13
  31. package/hooks/blocking/gh_body_arg_blocker.py +6 -1
  32. package/hooks/blocking/gh_pr_author_enforcer.py +5 -5
  33. package/hooks/blocking/gh_pr_author_restore.py +5 -5
  34. package/hooks/blocking/hedging_language_blocker.py +4 -10
  35. package/hooks/blocking/md_path_exemptions.py +205 -0
  36. package/hooks/blocking/md_to_html_blocker.py +48 -20
  37. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -11
  38. package/hooks/blocking/pr_description_enforcer.py +626 -41
  39. package/hooks/blocking/question_to_user_enforcer.py +4 -10
  40. package/hooks/blocking/state_description_blocker.py +6 -12
  41. package/hooks/blocking/tdd_enforcer.py +1 -1
  42. package/hooks/blocking/test_bot_mention_comment_blocker.py +1 -1
  43. package/hooks/blocking/test_code_rules_enforcer.py +3 -3
  44. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +1 -1
  45. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -2
  46. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +184 -0
  47. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +82 -0
  48. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +29 -29
  49. package/hooks/blocking/test_gh_body_arg_blocker.py +7 -8
  50. package/hooks/blocking/test_gh_pr_author_enforcer.py +1 -1
  51. package/hooks/blocking/test_gh_pr_author_restore.py +1 -1
  52. package/hooks/blocking/test_hedging_language_blocker.py +2 -2
  53. package/hooks/blocking/test_md_to_html_blocker.py +463 -8
  54. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +1 -1
  55. package/hooks/blocking/test_pr_description_enforcer.py +1210 -13
  56. package/hooks/blocking/test_question_to_user_enforcer.py +1 -1
  57. package/hooks/blocking/windows_rmtree_blocker.py +5 -11
  58. package/hooks/diagnostic/hook_log_extractor.py +1 -1
  59. package/hooks/diagnostic/hook_log_init.py +1 -1
  60. package/hooks/diagnostic/hook_log_stop_wrapper.py +1 -1
  61. package/hooks/diagnostic/test_hook_log_extractor.py +1 -1
  62. package/hooks/diagnostic/test_hook_log_init.py +2 -2
  63. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +1 -1
  64. package/hooks/git-hooks/gate_utils.py +1 -1
  65. package/hooks/git-hooks/pre_commit.py +1 -1
  66. package/hooks/git-hooks/pre_push.py +1 -1
  67. package/hooks/git-hooks/test_config.py +5 -5
  68. package/hooks/git-hooks/test_pre_push.py +6 -6
  69. package/hooks/{config → hooks_constants}/code_rules_enforcer_constants.py +37 -0
  70. package/hooks/hooks_constants/code_rules_path_utils_constants.py +28 -0
  71. package/hooks/hooks_constants/md_to_html_blocker_constants.py +82 -0
  72. package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_state.py +1 -1
  73. package/hooks/hooks_constants/pr_description_enforcer_constants.py +154 -0
  74. package/hooks/{config → hooks_constants}/pre_tool_use_stdin.py +1 -1
  75. package/hooks/{config → hooks_constants}/project_paths_reader.py +2 -2
  76. package/hooks/{config → hooks_constants}/test_banned_identifiers_constants.py +1 -1
  77. package/hooks/{config → hooks_constants}/test_dynamic_stderr_handler.py +1 -1
  78. package/hooks/{config → hooks_constants}/test_hardcoded_user_path_constants.py +1 -1
  79. package/hooks/{config → hooks_constants}/test_hook_log_extractor_constants.py +2 -2
  80. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +110 -0
  81. package/hooks/{config → hooks_constants}/test_messages.py +2 -6
  82. package/hooks/{config → hooks_constants}/test_path_rewriter_constants.py +1 -1
  83. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +292 -0
  84. package/hooks/{config → hooks_constants}/test_pre_tool_use_stdin.py +2 -2
  85. package/hooks/{config → hooks_constants}/test_project_paths_reader.py +3 -3
  86. package/hooks/{config → hooks_constants}/test_session_env_cleanup_constants.py +1 -1
  87. package/hooks/{config → hooks_constants}/test_setup_project_paths_constants.py +2 -2
  88. package/hooks/{config → hooks_constants}/test_unused_module_import_constants.py +1 -1
  89. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +5 -11
  90. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +1 -1
  91. package/hooks/session/gh_pr_author_session_cleanup.py +5 -6
  92. package/hooks/session/session_env_cleanup.py +4 -10
  93. package/hooks/session/test_gh_pr_author_session_cleanup.py +1 -1
  94. package/hooks/session/test_untracked_repo_detector.py +2 -2
  95. package/hooks/session/untracked_repo_detector.py +6 -12
  96. package/hooks/test__gh_pr_author_swap_utils.py +1 -1
  97. package/hooks/validators/run_all_validators.py +16 -5
  98. package/hooks/validators/test_output_formatter.py +46 -0
  99. package/hooks/workflow/doc_gist_auto_publish.py +1 -1
  100. package/hooks/workflow/md_to_html_companion.py +8 -15
  101. package/hooks/workflow/test_md_to_html_companion.py +184 -23
  102. package/package.json +1 -1
  103. package/rules/ask-user-question-required.md +1 -1
  104. package/rules/vault-context.md +1 -1
  105. package/scripts/{config → dev_env_scripts_constants}/timing.py +1 -1
  106. package/scripts/setup_project_paths.py +49 -11
  107. package/scripts/sweep_empty_dirs.py +10 -1
  108. package/scripts/test_setup_project_paths.py +2 -2
  109. package/scripts/test_sweep_empty_dirs.py +2 -6
  110. package/skills/_shared/pr-loop/scripts/_path_resolver.py +1 -1
  111. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  112. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  113. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -1
  114. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -1
  115. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  116. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  117. package/skills/bugteam/PROMPTS.md +1 -1
  118. package/skills/bugteam/SKILL.md +1 -1
  119. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  120. package/skills/bugteam/scripts/{_claude_permissions_common.py → _bugteam_permissions_common.py} +1 -13
  121. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +1 -13
  122. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +1 -16
  123. package/skills/bugteam/scripts/bugteam_preflight.py +1 -13
  124. package/skills/bugteam/scripts/grant_project_claude_permissions.py +2 -8
  125. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +1 -1
  126. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  127. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +2 -8
  128. package/skills/bugteam/scripts/{test__claude_permissions_common.py → test__bugteam_permissions_common.py} +4 -4
  129. package/skills/bugteam/scripts/test_agent_config_carveout.py +2 -2
  130. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -26
  131. package/skills/bugteam/scripts/{test_claude_permissions_common.py → test_bugteam_permissions_common.py} +3 -66
  132. package/skills/bugteam/scripts/test_bugteam_preflight.py +2 -27
  133. package/skills/bugteam/scripts/windows_safe_rmtree.py +1 -1
  134. package/skills/doc-gist/SKILL.md +1 -1
  135. package/skills/doc-gist/scripts/gist_upload.py +1 -1
  136. package/skills/implement/SKILL.md +2 -2
  137. package/skills/implement/scripts/append_note.py +1 -1
  138. package/skills/pr-converge/pr_converge_skill_constants/__init__.py +0 -0
  139. package/skills/pr-converge/{config → pr_converge_skill_constants}/constants.py +1 -1
  140. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  141. package/skills/pr-converge/scripts/check_convergence.py +11 -4
  142. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  143. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  144. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  145. package/skills/pr-converge/scripts/pr_converge_scripts_constants/__init__.py +0 -0
  146. package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/pr_converge_constants.py +1 -1
  147. package/skills/pr-converge/scripts/reflow_skill_md.py +90 -16
  148. package/skills/pr-converge/scripts/test_check_convergence.py +18 -0
  149. package/skills/pr-converge/scripts/test_reflow_skill_md.py +0 -31
  150. package/skills/session-log/SKILL.md +98 -233
  151. package/hooks/config/pr_description_enforcer_constants.py +0 -19
  152. package/hooks/config/test_pr_description_enforcer_constants.py +0 -82
  153. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +0 -55
  154. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +0 -55
  155. package/skills/pr-converge/scripts/conftest.py +0 -60
  156. package/skills/pr-converge/scripts/evict_cached_config_modules.py +0 -20
  157. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +0 -22
  158. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/__init__.py +0 -0
  159. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/claude_settings_keys_constants.py +0 -0
  160. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/code_rules_gate_constants.py +0 -0
  161. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/fix_hookspath_constants.py +0 -0
  162. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/post_audit_thread_constants.py +0 -0
  163. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/preflight_constants.py +0 -0
  164. /package/_shared/pr-loop/scripts/{config → pr_loop_shared_constants}/reviews_disabled_constants.py +0 -0
  165. /package/hooks/git-hooks/{config.py → git_hooks_constants/__init__.py} +0 -0
  166. /package/hooks/{config → hooks_constants}/__init__.py +0 -0
  167. /package/hooks/{config → hooks_constants}/any_type_config.py +0 -0
  168. /package/hooks/{config → hooks_constants}/banned_identifiers_constants.py +0 -0
  169. /package/hooks/{config → hooks_constants}/blocking_check_limits.py +0 -0
  170. /package/hooks/{config → hooks_constants}/bot_mention_comment_blocker_constants.py +0 -0
  171. /package/hooks/{config → hooks_constants}/convergence_branch_constants.py +0 -0
  172. /package/hooks/{config → hooks_constants}/doc_gist_auto_publish_constants.py +0 -0
  173. /package/hooks/{config → hooks_constants}/dynamic_stderr_handler.py +0 -0
  174. /package/hooks/{config → hooks_constants}/gh_pr_author_swap_constants.py +0 -0
  175. /package/hooks/{config → hooks_constants}/hardcoded_user_path_constants.py +0 -0
  176. /package/hooks/{config → hooks_constants}/hook_log_extractor_constants.py +0 -0
  177. /package/hooks/{config → hooks_constants}/html_companion_constants.py +0 -0
  178. /package/hooks/{config → hooks_constants}/inline_tuple_string_magic_constants.py +0 -0
  179. /package/hooks/{config → hooks_constants}/messages.py +0 -0
  180. /package/hooks/{config → hooks_constants}/path_rewriter_constants.py +0 -0
  181. /package/hooks/{config → hooks_constants}/pr_converge_bugteam_enforcer_constants.py +0 -0
  182. /package/hooks/{config → hooks_constants}/session_env_cleanup_constants.py +0 -0
  183. /package/hooks/{config → hooks_constants}/setup_project_paths_constants.py +0 -0
  184. /package/hooks/{config → hooks_constants}/state_description_blocker_constants.py +0 -0
  185. /package/hooks/{config → hooks_constants}/stuttering_check_config.py +0 -0
  186. /package/hooks/{config → hooks_constants}/stuttering_import_binding_constants.py +0 -0
  187. /package/hooks/{config → hooks_constants}/sys_path_insert_constants.py +0 -0
  188. /package/hooks/{config → hooks_constants}/unused_module_import_constants.py +0 -0
  189. /package/hooks/{config → hooks_constants}/windows_rmtree_blocker_constants.py +0 -0
  190. /package/{skills/_shared/pr-loop/scripts/config → hooks/lifecycle}/__init__.py +0 -0
  191. /package/{skills/bugteam/scripts/config → hooks/session}/__init__.py +0 -0
  192. /package/scripts/{config → dev_env_scripts_constants}/__init__.py +0 -0
  193. /package/skills/{doc-gist/scripts/config → _shared/pr-loop/scripts/skills_pr_loop_constants}/__init__.py +0 -0
  194. /package/skills/_shared/pr-loop/scripts/{config → skills_pr_loop_constants}/path_resolver_constants.py +0 -0
  195. /package/skills/{implement/scripts/config → bugteam/scripts/bugteam_scripts_constants}/__init__.py +0 -0
  196. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_code_rules_gate_constants.py +0 -0
  197. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_fix_hookspath_constants.py +0 -0
  198. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/bugteam_preflight_constants.py +0 -0
  199. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/claude_permissions_common_constants.py +0 -0
  200. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/probe_code_rules_enforcer_check_constants.py +0 -0
  201. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/reflow_skill_md_constants.py +0 -0
  202. /package/skills/bugteam/scripts/{config → bugteam_scripts_constants}/windows_safe_rmtree_constants.py +0 -0
  203. /package/skills/{pr-converge/config → doc-gist/scripts/doc_gist_scripts_constants}/__init__.py +0 -0
  204. /package/skills/doc-gist/scripts/{config → doc_gist_scripts_constants}/gist_upload_constants.py +0 -0
  205. /package/skills/{pr-converge/scripts/config → implement/scripts/implement_scripts_constants}/__init__.py +0 -0
  206. /package/skills/implement/scripts/{config → implement_scripts_constants}/notes_constants.py +0 -0
  207. /package/skills/pr-converge/scripts/{config → pr_converge_scripts_constants}/reflow_skill_md_constants.py +0 -0
@@ -1,12 +1,17 @@
1
1
  import json
2
+ import math
2
3
  import os
3
4
  import re
5
+ import shlex
4
6
  import sys
5
7
  from pathlib import Path
8
+ from typing import TextIO
6
9
 
7
- import shlex
10
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
11
+ if _hooks_dir not in sys.path:
12
+ sys.path.insert(0, _hooks_dir)
8
13
 
9
- from _gh_body_arg_utils import (
14
+ from blocking._gh_body_arg_utils import ( # noqa: E402
10
15
  all_body_flag_prefixes,
11
16
  all_body_flags,
12
17
  all_value_flag_equals_prefixes,
@@ -21,25 +26,46 @@ from _gh_body_arg_utils import (
21
26
  )
22
27
 
23
28
 
24
- def _insert_hooks_tree_for_imports() -> None:
25
- hooks_tree = Path(__file__).resolve().parent.parent
26
- hooks_tree_string = str(hooks_tree)
27
- if hooks_tree_string not in sys.path:
28
- sys.path.insert(0, hooks_tree_string)
29
-
30
-
31
- _insert_hooks_tree_for_imports()
32
-
33
- from config.pr_description_enforcer_constants import (
29
+ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
30
+ ALL_HEAVY_OPENING_HEADERS,
31
+ ALL_HEAVY_TESTING_HEADERS,
32
+ ALL_READABILITY_CLI_FLAG_TOKENS,
33
+ ATOMIC_WRITE_TEMP_SUFFIX,
34
34
  BLOCKQUOTE_MARKER_PATTERN,
35
35
  BOLD_PAIR_PATTERN,
36
36
  BULLET_MARKER_PATTERN,
37
+ DEFAULT_READABILITY_THRESHOLDS,
37
38
  FENCED_CODE_BLOCK_PATTERN,
39
+ FLESCH_BASE_SCORE,
40
+ FLESCH_PERFECT_SCORE,
41
+ FLESCH_SYLLABLES_PER_WORD_COEFFICIENT,
42
+ FLESCH_WORDS_PER_SENTENCE_COEFFICIENT,
43
+ GH_PR_COMMAND_MIN_TOKEN_COUNT,
38
44
  HEADING_LINE_PATTERN,
45
+ HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION,
46
+ HEAVY_SHAPE,
39
47
  INLINE_CODE_PATTERN,
40
48
  LINK_TEXT_PATTERN,
41
49
  MINIMUM_SUBSTANTIVE_PROSE_CHARS,
42
50
  PR_GUIDE_PATH,
51
+ READABILITY_AVG_SENTENCE_WORDS_CEILING,
52
+ READABILITY_ENABLED_STATE_FILE,
53
+ READABILITY_FLESCH_LOOSEN_FACTOR,
54
+ READABILITY_LOOSEN_CAP,
55
+ READABILITY_MAX_SENTENCE_WORDS_CEILING,
56
+ READABILITY_MIN_FLESCH_FLOOR,
57
+ READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR,
58
+ READABILITY_STATE_FILE,
59
+ READABILITY_STRIKE_THRESHOLD,
60
+ READABILITY_THRESHOLD_OVERRIDE_FILE,
61
+ ReadabilityThresholds,
62
+ SELF_CLOSING_REFERENCE_MESSAGE_PREFIX,
63
+ SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX,
64
+ SELF_REFERENCE_PATTERN_TEMPLATE,
65
+ STANDARD_SHAPE,
66
+ THIS_PR_OPENING_PATTERN,
67
+ TRIVIAL_BODY_CHAR_THRESHOLD,
68
+ TRIVIAL_SHAPE,
43
69
  WHITESPACE_RUN_PATTERN,
44
70
  )
45
71
 
@@ -60,7 +86,9 @@ _non_body_value_flag_equals_prefixes: tuple[str, ...] = tuple(
60
86
  sorted(
61
87
  (
62
88
  prefix for prefix in all_value_flag_equals_prefixes
63
- if not prefix.startswith("--body") and not prefix.startswith("-b=")
89
+ if not prefix.startswith("--body")
90
+ and not prefix.startswith("-b=")
91
+ and not prefix.startswith("-F=")
64
92
  ),
65
93
  key=len,
66
94
  reverse=True,
@@ -134,10 +162,17 @@ def _resolve_body_file_value(raw_value_token: str) -> str | None:
134
162
  return None
135
163
 
136
164
 
137
- def _resolve_body_string_value(raw_value_token: str) -> str:
165
+ def _resolve_body_string_value(raw_value_token: str) -> str | None:
166
+ """Return the literal body string, or None when the value is an
167
+ unresolvable shell variable.
168
+
169
+ Distinguishing the two cases lets `main()` skip enforcement only for
170
+ unauditable bodies; a literal `--body ""` still returns `""` and flows
171
+ into `validate_pr_body` so the substantive-prose check blocks it.
172
+ """
138
173
  stripped_value = _strip_surrounding_quotes(raw_value_token)
139
174
  if _is_unresolvable_shell_value(stripped_value):
140
- return ""
175
+ return None
141
176
  return stripped_value
142
177
 
143
178
 
@@ -179,8 +214,13 @@ def _scan_raw_tokens_for_body(all_raw_tokens: list[str]) -> str | None | bool:
179
214
  """Return the body value from a raw token list, or False if no body flag found.
180
215
 
181
216
  Returns False when no body/body-file flag is present (caller should continue).
182
- Returns None when a body-file flag is present but malformed (no value follows).
183
- Returns str for body string values (may be empty for shell vars/sentinels).
217
+ Returns None when a body-file flag is present but malformed (no value
218
+ follows), OR when the body value is an unresolvable shell variable (e.g.
219
+ `--body "$VAR"`) — in either case the body is unauditable and the caller
220
+ skips enforcement.
221
+ Returns str for resolved body string values. An empty string `""` is a
222
+ literal-empty body (e.g. `--body ""`) and must still flow into
223
+ `validate_pr_body` so the substantive-prose check blocks it.
184
224
  """
185
225
  token_index = 0
186
226
  while token_index < len(all_raw_tokens):
@@ -275,42 +315,463 @@ def extract_body_from_command(
275
315
  if not body_flag_found_in_significant:
276
316
  return None
277
317
 
278
- result = _scan_raw_tokens_for_body(all_raw_tokens)
279
- if result is False:
318
+ scan_outcome = _scan_raw_tokens_for_body(all_raw_tokens)
319
+ if isinstance(scan_outcome, bool):
280
320
  return None
281
- return result
321
+ return scan_outcome
282
322
 
283
323
 
284
- def _count_substantive_prose_chars(body: str) -> int:
285
- """Return the count of prose characters after stripping Markdown ceremony.
324
+ def _strip_markdown_ceremony(body: str) -> str:
325
+ """Return the body with Markdown ceremony stripped to leave underlying prose.
286
326
 
287
327
  Removes fenced code, inline code, heading lines, blockquote markers,
288
328
  bullet list markers, bold/emphasis markers, and Markdown link targets.
329
+ Whitespace is preserved so callers can collapse or measure it as needed.
330
+ """
331
+ body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
332
+ body_without_inline_code = INLINE_CODE_PATTERN.sub("", body_without_fences)
333
+ body_without_blockquotes = BLOCKQUOTE_MARKER_PATTERN.sub("", body_without_inline_code)
334
+ body_without_headings = HEADING_LINE_PATTERN.sub("", body_without_blockquotes)
335
+ body_without_bullets = BULLET_MARKER_PATTERN.sub("", body_without_headings)
336
+ body_without_bold = BOLD_PAIR_PATTERN.sub(r"\1", body_without_bullets)
337
+ body_without_emphasis = body_without_bold.replace("*", "")
338
+ body_without_links = LINK_TEXT_PATTERN.sub(r"\1", body_without_emphasis)
339
+ return body_without_links
340
+
341
+
342
+ def _count_substantive_prose_chars(body: str) -> int:
343
+ """Return the count of prose characters after stripping Markdown ceremony.
344
+
289
345
  Collapses internal whitespace so a body of only headers and bullets --
290
346
  no real WHY paragraph -- registers as effectively empty.
291
347
  """
292
- body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub('', body)
293
- body_without_inline_code = INLINE_CODE_PATTERN.sub('', body_without_fences)
294
- body_without_blockquotes = BLOCKQUOTE_MARKER_PATTERN.sub('', body_without_inline_code)
295
- body_without_headings = HEADING_LINE_PATTERN.sub('', body_without_blockquotes)
296
- body_without_bullets = BULLET_MARKER_PATTERN.sub('', body_without_headings)
297
- body_without_bold = BOLD_PAIR_PATTERN.sub(r'\1', body_without_bullets)
298
- body_without_emphasis = body_without_bold.replace('*', '')
299
- body_without_links = LINK_TEXT_PATTERN.sub(r'\1', body_without_emphasis)
300
- body_collapsed = WHITESPACE_RUN_PATTERN.sub(' ', body_without_links).strip()
348
+ stripped_body = _strip_markdown_ceremony(body)
349
+ body_collapsed = WHITESPACE_RUN_PATTERN.sub(' ', stripped_body).strip()
301
350
  return len(body_collapsed)
302
351
 
303
352
 
304
- def validate_pr_body(body: str) -> list[str]:
305
- """Audit a PR body for substantive-prose and vague-language violations.
353
+ def _iter_section_headers(body: str) -> list[str]:
354
+ """Return every ATX heading line in the body, preserving canonical form.
355
+
356
+ HEADING_LINE_PATTERN matches the leading hash run (one or more hash
357
+ characters at line start), so the result spans every ATX level.
358
+ Downstream callers in this module only test specific two-hash header
359
+ strings, so matching every heading level keeps the parser permissive
360
+ without changing behaviour for the canonical two-hash header shape.
361
+
362
+ Fenced code blocks are stripped first so example markdown nested inside ``` fences
363
+ (a PR body that demonstrates the Heavy shape, for instance) is not counted as a
364
+ structural header. This keeps the shape classifier and Heavy required-header check
365
+ aligned with `_strip_markdown_ceremony`, which already strips fences before measuring.
366
+ """
367
+ body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
368
+ all_headers: list[str] = []
369
+ for each_match in HEADING_LINE_PATTERN.finditer(body_without_fences):
370
+ header_text = each_match.group(0).strip()
371
+ all_headers.append(header_text)
372
+ return all_headers
373
+
374
+
375
+ def _compute_pr_body_shape(body: str) -> str:
376
+ """Classify a PR body as `trivial`, `standard`, or `heavy` from content alone.
377
+
378
+ Uses substantive prose chars (post-Markdown-strip) rather than raw length so the
379
+ classifier and the ceremony-on-Trivial check both measure the same metric against
380
+ TRIVIAL_BODY_CHAR_THRESHOLD; otherwise a body can be classified Standard by shape
381
+ while simultaneously being flagged as Trivial-sized by the ceremony check.
382
+ """
383
+ substantive_length = _count_substantive_prose_chars(body)
384
+ header_count = len(_iter_section_headers(body))
385
+
386
+ if substantive_length < TRIVIAL_BODY_CHAR_THRESHOLD and header_count == 0:
387
+ return TRIVIAL_SHAPE
388
+
389
+ if substantive_length >= HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION:
390
+ return HEAVY_SHAPE
391
+
392
+ return STANDARD_SHAPE
393
+
394
+
395
+ def _body_contains_any_header(body: str, all_candidate_headers: frozenset[str]) -> bool:
396
+ body_headers_lower = {each_header.lower() for each_header in _iter_section_headers(body)}
397
+ for each_candidate in all_candidate_headers:
398
+ candidate_lower = each_candidate.lower()
399
+ for each_present in body_headers_lower:
400
+ if each_present == candidate_lower:
401
+ return True
402
+ if each_present.startswith(candidate_lower):
403
+ character_after_candidate = each_present[len(candidate_lower)]
404
+ if not (character_after_candidate.isalnum() or character_after_candidate == "_"):
405
+ return True
406
+ return False
407
+
408
+
409
+ def _matches_self_closing_reference(body: str, pr_number: int) -> bool:
410
+ pattern_source = SELF_REFERENCE_PATTERN_TEMPLATE.format(pr_number=pr_number)
411
+ compiled_pattern = re.compile(pattern_source, re.IGNORECASE)
412
+ return compiled_pattern.search(body) is not None
413
+
414
+
415
+ def _opens_with_this_pr_phrase(body: str) -> bool:
416
+ return THIS_PR_OPENING_PATTERN.search(body) is not None
417
+
418
+
419
+ def _atomic_write_json(target_path: Path, all_payload_fields: dict[str, object]) -> None:
420
+ target_path.parent.mkdir(parents=True, exist_ok=True)
421
+ temporary_path = target_path.with_suffix(target_path.suffix + ATOMIC_WRITE_TEMP_SUFFIX)
422
+ with open(temporary_path, "w", encoding=file_encoding_utf8) as write_handle:
423
+ json.dump(all_payload_fields, write_handle)
424
+ os.replace(temporary_path, target_path)
425
+
426
+
427
+ def _read_json_or_default(target_path: Path, all_default_payload_fields: dict[str, object]) -> dict[str, object]:
428
+ if not target_path.exists():
429
+ return dict(all_default_payload_fields)
430
+ try:
431
+ with open(target_path, "r", encoding=file_encoding_utf8) as read_handle:
432
+ loaded_payload = json.load(read_handle)
433
+ except (FileNotFoundError, PermissionError, OSError, json.JSONDecodeError):
434
+ return dict(all_default_payload_fields)
435
+ if not isinstance(loaded_payload, dict):
436
+ return dict(all_default_payload_fields)
437
+ return loaded_payload
438
+
439
+
440
+ def _read_strike_count() -> int:
441
+ payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
442
+ raw_count = payload.get("strikes", 0)
443
+ if isinstance(raw_count, int) and not isinstance(raw_count, bool):
444
+ return max(raw_count, 0)
445
+ return 0
446
+
447
+
448
+ def _increment_strike_count() -> int:
449
+ payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
450
+ raw_count = payload.get("strikes", 0)
451
+ is_valid_integer = isinstance(raw_count, int) and not isinstance(raw_count, bool)
452
+ starting_count = max(raw_count, 0) if is_valid_integer else 0
453
+ new_count = starting_count + 1
454
+ _atomic_write_json(READABILITY_STATE_FILE, {"strikes": new_count})
455
+ return new_count
456
+
457
+
458
+ def _reset_strike_count() -> None:
459
+ _atomic_write_json(READABILITY_STATE_FILE, {"strikes": 0})
460
+
461
+
462
+ def _load_readability_thresholds() -> ReadabilityThresholds:
463
+ payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
464
+ flesch_min_value = payload.get("flesch_min", DEFAULT_READABILITY_THRESHOLDS.flesch_min)
465
+ max_sentence_value = payload.get(
466
+ "max_sentence_words", DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
467
+ )
468
+ avg_sentence_value = payload.get(
469
+ "avg_sentence_words", DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
470
+ )
471
+ flesch_is_int = isinstance(flesch_min_value, int) and not isinstance(flesch_min_value, bool)
472
+ max_is_int = isinstance(max_sentence_value, int) and not isinstance(max_sentence_value, bool)
473
+ avg_is_int = isinstance(avg_sentence_value, int) and not isinstance(avg_sentence_value, bool)
474
+ resolved_flesch = flesch_min_value if flesch_is_int else DEFAULT_READABILITY_THRESHOLDS.flesch_min
475
+ resolved_max = max_sentence_value if max_is_int else DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
476
+ resolved_avg = avg_sentence_value if avg_is_int else DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
477
+ return ReadabilityThresholds(
478
+ flesch_min=resolved_flesch,
479
+ max_sentence_words=resolved_max,
480
+ avg_sentence_words=resolved_avg,
481
+ )
482
+
483
+
484
+ def _read_loosens_used() -> int:
485
+ payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
486
+ raw_count = payload.get("loosens_used", 0)
487
+ if isinstance(raw_count, int) and not isinstance(raw_count, bool):
488
+ return max(raw_count, 0)
489
+ return 0
490
+
491
+
492
+ def _is_readability_enabled() -> bool:
493
+ payload = _read_json_or_default(READABILITY_ENABLED_STATE_FILE, {"enabled": True})
494
+ enabled_value = payload.get("enabled", True)
495
+ if isinstance(enabled_value, bool):
496
+ return enabled_value
497
+ return True
498
+
499
+
500
+ def _set_readability_enabled(enabled: bool) -> None:
501
+ _atomic_write_json(READABILITY_ENABLED_STATE_FILE, {"enabled": enabled})
502
+
503
+
504
+ def _count_syllables_in_word(word: str) -> int:
505
+ all_vowel_characters: frozenset[str] = frozenset("aeiouy")
506
+ cleaned_word = "".join(each_character for each_character in word.lower() if each_character.isalpha())
507
+ if not cleaned_word:
508
+ return 0
509
+ syllable_count = 0
510
+ is_previous_character_vowel = False
511
+ for each_character in cleaned_word:
512
+ is_vowel = each_character in all_vowel_characters
513
+ if is_vowel and not is_previous_character_vowel:
514
+ syllable_count += 1
515
+ is_previous_character_vowel = is_vowel
516
+ if cleaned_word.endswith("e") and syllable_count > 1:
517
+ syllable_count -= 1
518
+ return max(syllable_count, 1)
519
+
520
+
521
+ def _split_sentences(text: str) -> list[str]:
522
+ sentence_split_pattern = re.compile(r"[.!?]+\s+")
523
+ cleaned_text = text.strip()
524
+ if not cleaned_text:
525
+ return []
526
+ raw_pieces = sentence_split_pattern.split(cleaned_text)
527
+ all_sentences = [each_piece.strip() for each_piece in raw_pieces if each_piece.strip()]
528
+ return all_sentences
529
+
530
+
531
+ def _compute_flesch_reading_ease(text: str) -> float:
532
+ all_sentences = _split_sentences(text)
533
+ if not all_sentences:
534
+ return FLESCH_PERFECT_SCORE
535
+ all_words: list[str] = []
536
+ total_syllables = 0
537
+ for each_sentence in all_sentences:
538
+ sentence_words = [each_token for each_token in re.split(r"\s+", each_sentence) if each_token]
539
+ all_words.extend(sentence_words)
540
+ for each_word in sentence_words:
541
+ total_syllables += _count_syllables_in_word(each_word)
542
+ total_words = len(all_words)
543
+ if total_words == 0:
544
+ return FLESCH_PERFECT_SCORE
545
+ total_sentences = len(all_sentences)
546
+ return (
547
+ FLESCH_BASE_SCORE
548
+ - FLESCH_WORDS_PER_SENTENCE_COEFFICIENT * (total_words / total_sentences)
549
+ - FLESCH_SYLLABLES_PER_WORD_COEFFICIENT * (total_syllables / total_words)
550
+ )
551
+
552
+
553
+ def _extract_readability_target_text(body: str) -> str:
554
+ intro_paragraph = ""
555
+ body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
556
+ body_after_strip = body_without_fences.lstrip()
557
+ blank_line_position = body_after_strip.find("\n\n")
558
+ header_position_match = HEADING_LINE_PATTERN.search(body_after_strip)
559
+ header_position = header_position_match.start() if header_position_match else -1
560
+
561
+ if blank_line_position == -1 and header_position == -1:
562
+ intro_paragraph = body_after_strip
563
+ elif blank_line_position == -1:
564
+ intro_paragraph = body_after_strip[:header_position]
565
+ elif header_position == -1:
566
+ intro_paragraph = body_after_strip[:blank_line_position]
567
+ else:
568
+ first_boundary = min(blank_line_position, header_position)
569
+ intro_paragraph = body_after_strip[:first_boundary]
570
+
571
+ first_body_section = ""
572
+ if header_position_match is not None:
573
+ section_start = header_position_match.end()
574
+ remainder = body_after_strip[section_start:]
575
+ next_header_match = HEADING_LINE_PATTERN.search(remainder)
576
+ if next_header_match is not None:
577
+ first_body_section = remainder[: next_header_match.start()]
578
+ else:
579
+ first_body_section = remainder
580
+
581
+ combined_text = f"{intro_paragraph}\n\n{first_body_section}"
582
+ return _strip_markdown_ceremony(combined_text)
583
+
584
+
585
+ def _evaluate_readability_metrics(
586
+ target_text: str,
587
+ thresholds: ReadabilityThresholds,
588
+ ) -> list[str]:
589
+ all_metric_violations: list[str] = []
590
+ all_sentences = _split_sentences(target_text)
591
+ if not all_sentences:
592
+ return all_metric_violations
593
+ word_counts_per_sentence: list[int] = []
594
+ for each_sentence in all_sentences:
595
+ sentence_words = [each_token for each_token in re.split(r"\s+", each_sentence) if each_token]
596
+ word_counts_per_sentence.append(len(sentence_words))
597
+ max_sentence_words = max(word_counts_per_sentence) if word_counts_per_sentence else 0
598
+ average_sentence_words = (
599
+ sum(word_counts_per_sentence) / len(word_counts_per_sentence)
600
+ if word_counts_per_sentence
601
+ else 0.0
602
+ )
603
+ if max_sentence_words > thresholds.max_sentence_words:
604
+ all_metric_violations.append(
605
+ f"Readability: longest sentence is {max_sentence_words} words "
606
+ f"(maximum {thresholds.max_sentence_words}); "
607
+ "split or rewrite the longest sentence"
608
+ )
609
+ if average_sentence_words > thresholds.avg_sentence_words:
610
+ all_metric_violations.append(
611
+ f"Readability: average sentence is {average_sentence_words:.1f} words "
612
+ f"(maximum {thresholds.avg_sentence_words}); "
613
+ "shorten or split your longest sentences"
614
+ )
615
+ flesch_score = _compute_flesch_reading_ease(target_text)
616
+ if flesch_score < thresholds.flesch_min:
617
+ all_metric_violations.append(
618
+ f"Readability: Flesch Reading Ease is {flesch_score:.1f} "
619
+ f"(minimum {thresholds.flesch_min}); use shorter words and sentences"
620
+ )
621
+ return all_metric_violations
622
+
623
+
624
+ def _build_readability_escape_hatch_message() -> str:
625
+ return (
626
+ "Readability strike threshold reached. Pick one: "
627
+ "(1) python <enforcer-path> --readability-loosen to widen thresholds 10%, "
628
+ "(2) python <enforcer-path> --readability-disable to skip the readability check, "
629
+ "(3) python <enforcer-path> --readability-reset to zero the strike counter, "
630
+ "(4) reply with the body plus the intended message to report a false positive."
631
+ )
632
+
633
+
634
+ def _apply_readability_loosen() -> str:
635
+ current_thresholds = _load_readability_thresholds()
636
+ loosens_used = _read_loosens_used()
637
+
638
+ if loosens_used >= READABILITY_LOOSEN_CAP:
639
+ return "cap_reached"
640
+
641
+ if current_thresholds.flesch_min <= READABILITY_MIN_FLESCH_FLOOR:
642
+ return "floor_reached"
643
+
644
+ if current_thresholds.max_sentence_words >= READABILITY_MAX_SENTENCE_WORDS_CEILING:
645
+ return "ceiling_reached"
646
+
647
+ if current_thresholds.avg_sentence_words >= READABILITY_AVG_SENTENCE_WORDS_CEILING:
648
+ return "ceiling_reached"
649
+
650
+ next_flesch = max(
651
+ READABILITY_MIN_FLESCH_FLOOR,
652
+ math.floor(current_thresholds.flesch_min * READABILITY_FLESCH_LOOSEN_FACTOR),
653
+ )
654
+ next_max_sentence = min(
655
+ READABILITY_MAX_SENTENCE_WORDS_CEILING,
656
+ math.ceil(current_thresholds.max_sentence_words * READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR),
657
+ )
658
+ next_avg_sentence = min(
659
+ READABILITY_AVG_SENTENCE_WORDS_CEILING,
660
+ math.ceil(current_thresholds.avg_sentence_words * READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR),
661
+ )
662
+
663
+ next_payload: dict[str, object] = {
664
+ "flesch_min": next_flesch,
665
+ "max_sentence_words": next_max_sentence,
666
+ "avg_sentence_words": next_avg_sentence,
667
+ "loosens_used": loosens_used + 1,
668
+ }
669
+ _atomic_write_json(READABILITY_THRESHOLD_OVERRIDE_FILE, next_payload)
670
+ return "ok"
671
+
672
+
673
+ def _apply_readability_reset() -> None:
674
+ _reset_strike_count()
675
+ _atomic_write_json(READABILITY_THRESHOLD_OVERRIDE_FILE, {"loosens_used": 0})
676
+
677
+
678
+ def _resolve_positional_pr_number(token: str) -> int | None:
679
+ """Return the PR number named by a positional token, or None if it is not one.
680
+
681
+ Accepts either a bare integer literal or a GitHub PR URL whose final path
682
+ segment is ``/pull/<number>``. The token may carry surrounding quotes;
683
+ unresolvable shell variables are rejected.
684
+ """
685
+ stripped_candidate = _strip_surrounding_quotes(token)
686
+ if _is_unresolvable_shell_value(stripped_candidate):
687
+ return None
688
+ url_match = re.match(
689
+ r"^https?://[^/]+/[^/]+/[^/]+/pull/(\d+)(?:[/?#].*)?$",
690
+ stripped_candidate,
691
+ )
692
+ if url_match is not None:
693
+ try:
694
+ return int(url_match.group(1))
695
+ except ValueError:
696
+ return None
697
+ try:
698
+ return int(stripped_candidate)
699
+ except ValueError:
700
+ return None
701
+
702
+
703
+ def _extract_pr_number_from_command(command: str) -> int | None:
704
+ """Return the PR number positional argument from a `gh pr edit|comment` command.
705
+
706
+ Skips value-taking non-body flags (and their value tokens) so that ``--repo owner/r``
707
+ pairs do not consume the trailing PR number. Accepts both a bare integer literal
708
+ and a GitHub PR URL (``https://github.com/o/r/pull/<n>``) in the positional slot.
709
+
710
+ Args:
711
+ command: The raw shell command captured by the hook.
712
+
713
+ Returns:
714
+ The PR number when one positional value (integer or URL) is present, else None.
715
+ """
716
+ logical_line = get_logical_first_line(command)
717
+ if not logical_line:
718
+ return None
719
+ try:
720
+ all_tokens = shlex.split(logical_line, posix=False)
721
+ except ValueError:
722
+ return None
723
+ if len(all_tokens) < GH_PR_COMMAND_MIN_TOKEN_COUNT:
724
+ return None
725
+ if all_tokens[0] != "gh" or all_tokens[1] != "pr":
726
+ return None
727
+ subcommand_token = all_tokens[2]
728
+ if subcommand_token not in {"edit", "comment"}:
729
+ return None
730
+ all_value_taking_bare_flags: frozenset[str] = (
731
+ _non_body_value_flags | all_body_flags | {body_file_flag, body_file_short_flag}
732
+ )
733
+ token_index = GH_PR_COMMAND_MIN_TOKEN_COUNT
734
+ while token_index < len(all_tokens):
735
+ current_token = all_tokens[token_index]
736
+ matched_equals_prefix = (
737
+ _match_non_body_value_flag_equals_prefix(current_token)
738
+ or _match_body_flag_equals_prefix(current_token)
739
+ or _match_body_file_equals_prefix(current_token)
740
+ )
741
+ if matched_equals_prefix is not None:
742
+ first_value_token = current_token[len(matched_equals_prefix):]
743
+ remaining_raw_tokens = all_tokens[token_index + 1:]
744
+ extra_skip = count_extra_tokens_to_skip_for_split_quoted_value(
745
+ remaining_raw_tokens, first_value_token
746
+ ) or 0
747
+ token_index += 1 + extra_skip
748
+ continue
749
+ if current_token in all_value_taking_bare_flags:
750
+ token_index += 1
751
+ if token_index < len(all_tokens):
752
+ token_index += 1
753
+ continue
754
+ if _is_flag_shaped_token(current_token):
755
+ token_index += 1
756
+ continue
757
+ resolved_pr_number = _resolve_positional_pr_number(current_token)
758
+ if resolved_pr_number is not None:
759
+ return resolved_pr_number
760
+ return None
761
+ return None
762
+
763
+
764
+ def validate_pr_body(body: str, pr_number: int | None = None) -> list[str]:
765
+ """Audit a PR body against the Anthropic claude-code style rules.
306
766
 
307
767
  Args:
308
768
  body: The PR body markdown text to audit.
769
+ pr_number: The PR number when known (gh pr edit / gh pr comment); None at gh pr create time.
309
770
 
310
771
  Returns:
311
772
  A list of human-readable violation messages. Empty when the body passes.
312
773
  """
313
- violations = []
774
+ violations: list[str] = []
314
775
 
315
776
  substantive_chars = _count_substantive_prose_chars(body)
316
777
  if substantive_chars < MINIMUM_SUBSTANTIVE_PROSE_CHARS:
@@ -319,14 +780,135 @@ def validate_pr_body(body: str) -> list[str]:
319
780
  "substantive explanation, not only headers and bullets"
320
781
  )
321
782
 
783
+ body_shape = _compute_pr_body_shape(body)
784
+
785
+ if body_shape == HEAVY_SHAPE:
786
+ if not _body_contains_any_header(body, ALL_HEAVY_OPENING_HEADERS):
787
+ violations.append(
788
+ f"Heavy PR body missing required opening header -- add one of "
789
+ f"{sorted(ALL_HEAVY_OPENING_HEADERS)}"
790
+ )
791
+ if not _body_contains_any_header(body, ALL_HEAVY_TESTING_HEADERS):
792
+ violations.append(
793
+ f"Heavy PR body missing required testing-category header -- add one of "
794
+ f"{sorted(ALL_HEAVY_TESTING_HEADERS)}"
795
+ )
796
+
797
+ body_has_any_header = len(_iter_section_headers(body)) > 0
798
+ body_is_trivial_sized = substantive_chars < TRIVIAL_BODY_CHAR_THRESHOLD
799
+ if body_has_any_header and body_is_trivial_sized:
800
+ violations.append(
801
+ "Trivial PR body contains a ceremony header -- drop every header "
802
+ "and write the one-sentence body directly"
803
+ )
804
+
805
+ if pr_number is not None and _matches_self_closing_reference(body, pr_number):
806
+ violations.append(
807
+ f"{SELF_CLOSING_REFERENCE_MESSAGE_PREFIX}{pr_number}{SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX}"
808
+ )
809
+
810
+ if _opens_with_this_pr_phrase(body):
811
+ violations.append(
812
+ "PR body opens with 'This PR ...' -- open with an imperative verb "
813
+ "(Adds, Fixes, Updates, Removes, Tightens, Ports)"
814
+ )
815
+
322
816
  vague_matches = VAGUE_LANGUAGE_PATTERN.findall(body)
323
817
  if vague_matches:
324
- violations.append(f"Vague language detected: {', '.join(vague_matches)} -- be specific about what changed and why")
818
+ violations.append(
819
+ f"Vague language detected: {', '.join(vague_matches)} -- "
820
+ "be specific about what changed and why"
821
+ )
822
+
823
+ if _is_readability_enabled():
824
+ thresholds = _load_readability_thresholds()
825
+ target_text = _extract_readability_target_text(body)
826
+ metric_violations = _evaluate_readability_metrics(target_text, thresholds)
827
+ if metric_violations:
828
+ post_increment_count = _increment_strike_count()
829
+ if post_increment_count >= READABILITY_STRIKE_THRESHOLD:
830
+ violations.append(_build_readability_escape_hatch_message())
831
+ else:
832
+ violations.extend(metric_violations)
325
833
 
326
834
  return violations
327
835
 
328
836
 
837
+ def _dispatch_cli_flag(
838
+ flag_token: str,
839
+ output_stream: TextIO,
840
+ error_stream: TextIO,
841
+ ) -> None:
842
+ """Handle a single readability-management CLI flag and exit the process."""
843
+ if flag_token == "--readability-loosen":
844
+ outcome = _apply_readability_loosen()
845
+ if outcome == "cap_reached":
846
+ error_stream.write(
847
+ "loosen cap reached; use --readability-disable or --readability-reset\n"
848
+ )
849
+ sys.exit(1)
850
+ if outcome in {"floor_reached", "ceiling_reached"}:
851
+ error_stream.write(
852
+ "thresholds already at floor/ceiling; use --readability-disable or --readability-reset\n"
853
+ )
854
+ sys.exit(1)
855
+ output_stream.write("readability thresholds loosened 10%\n")
856
+ sys.exit(0)
857
+ if flag_token == "--readability-reset":
858
+ _apply_readability_reset()
859
+ output_stream.write("readability strike counter and override thresholds reset\n")
860
+ sys.exit(0)
861
+ if flag_token == "--readability-disable":
862
+ _set_readability_enabled(False)
863
+ output_stream.write("readability check disabled\n")
864
+ sys.exit(0)
865
+ if flag_token == "--readability-enable":
866
+ _set_readability_enabled(True)
867
+ output_stream.write("readability check enabled\n")
868
+ sys.exit(0)
869
+
870
+
871
+ def _command_carries_body_flag(command: str) -> bool:
872
+ """Return True when the command string carries any body or body-file flag.
873
+
874
+ Detects the body/body-file forms accepted by ``gh pr {create,edit,comment}``:
875
+
876
+ - Long flags: a single ``"--body" in command`` substring check catches
877
+ every long form — ``--body``, ``--body=<value>``, ``--body-file``, and
878
+ ``--body-file=<value>`` — because ``--body`` is a prefix of
879
+ ``--body-file``. No separate ``--body-file`` check is needed.
880
+ - Short flags, space-separated: ``-b <value>``, ``-F <value>`` — matched
881
+ as `` -b `` and `` -F `` so the literal substring cannot collide with a
882
+ surrounding token (e.g. ``-base``, ``-Foo``).
883
+ - Short flags, equal-attached: ``-b=<value>``, ``-F=<value>`` — matched
884
+ as `` -b=`` and `` -F=`` for the same anti-collision reason. The test
885
+ suite relies on this detection path.
886
+
887
+ Args:
888
+ command: The raw shell command captured by the hook.
889
+
890
+ Returns:
891
+ True if any documented body or body-file flag appears in the command.
892
+ """
893
+ return (
894
+ "--body" in command
895
+ or " -b " in command
896
+ or " -b=" in command
897
+ or " -F " in command
898
+ or " -F=" in command
899
+ )
900
+
901
+
329
902
  def main() -> None:
903
+ for each_argv_token in sys.argv[1:]:
904
+ if each_argv_token in ALL_READABILITY_CLI_FLAG_TOKENS:
905
+ _dispatch_cli_flag(
906
+ each_argv_token,
907
+ output_stream=sys.stdout,
908
+ error_stream=sys.stderr,
909
+ )
910
+ return
911
+
330
912
  try:
331
913
  input_data = json.load(sys.stdin)
332
914
  except json.JSONDecodeError:
@@ -340,10 +922,12 @@ def main() -> None:
340
922
  if not command:
341
923
  sys.exit(0)
342
924
 
343
- is_pr_create = "gh pr create" in command and ("--body" in command or "-b " in command)
344
- is_pr_edit = "gh pr edit" in command and "--body" in command
925
+ has_any_body_flag = _command_carries_body_flag(command)
926
+ is_pr_create = "gh pr create" in command and has_any_body_flag
927
+ is_pr_edit = "gh pr edit" in command and has_any_body_flag
928
+ is_pr_comment = "gh pr comment" in command and has_any_body_flag
345
929
 
346
- if not (is_pr_create or is_pr_edit):
930
+ if not (is_pr_create or is_pr_edit or is_pr_comment):
347
931
  sys.exit(0)
348
932
 
349
933
  body = extract_body_from_command(command)
@@ -351,10 +935,11 @@ def main() -> None:
351
935
  if body is None:
352
936
  sys.exit(0)
353
937
 
354
- if not body:
355
- sys.exit(0)
938
+ extracted_pr_number = None
939
+ if is_pr_edit or is_pr_comment:
940
+ extracted_pr_number = _extract_pr_number_from_command(command)
356
941
 
357
- violations = validate_pr_body(body)
942
+ violations = validate_pr_body(body, pr_number=extracted_pr_number)
358
943
 
359
944
  if violations:
360
945
  violation_list = "; ".join(violations)