claude-dev-env 1.50.0 → 1.50.1

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.
@@ -1,21 +1,23 @@
1
- """Unit tests for pr-description-enforcer PreToolUse hook."""
1
+ """Unit tests for pr-description-enforcer PreToolUse hook entry flow."""
2
2
 
3
3
  import importlib.util
4
- import inspect
5
4
  import io
6
5
  import json
7
6
  import pathlib
8
- import re as _re
9
7
  import sys
10
8
  from unittest.mock import patch
11
9
 
12
10
  import pytest
13
11
 
14
12
  _HOOK_DIR = pathlib.Path(__file__).parent
13
+ _HOOKS_ROOT = _HOOK_DIR.parent
14
+ if str(_HOOKS_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(_HOOKS_ROOT))
15
16
  if str(_HOOK_DIR) not in sys.path:
16
17
  sys.path.insert(0, str(_HOOK_DIR))
17
18
 
18
- from blocking._gh_body_arg_utils import get_logical_first_line, iter_significant_tokens
19
+ from blocking import pr_description_readability as readability_module
20
+ from blocking.pr_description_command_parser import extract_body_from_command
19
21
 
20
22
  hook_spec = importlib.util.spec_from_file_location(
21
23
  "pr_description_enforcer",
@@ -25,7 +27,6 @@ assert hook_spec is not None
25
27
  assert hook_spec.loader is not None
26
28
  hook_module = importlib.util.module_from_spec(hook_spec)
27
29
  hook_spec.loader.exec_module(hook_module)
28
- extract_body_from_command = hook_module.extract_body_from_command
29
30
  validate_pr_body = hook_module.validate_pr_body
30
31
 
31
32
 
@@ -33,18 +34,19 @@ validate_pr_body = hook_module.validate_pr_body
33
34
  def _isolate_readability_state(tmp_path_factory, monkeypatch):
34
35
  """Redirect the three readability state files to per-test temp paths for every test.
35
36
 
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.
37
+ The enabled file is written with enabled=False so the readability check stays
38
+ off for the entry-flow tests, isolating them from readability scoring and the
39
+ live state directory.
39
40
  """
40
41
  per_test_state_dir = tmp_path_factory.mktemp("readability_state")
41
42
  strike_path = per_test_state_dir / "strikes.json"
42
43
  override_path = per_test_state_dir / "overrides.json"
43
44
  enabled_path = per_test_state_dir / "enabled.json"
44
45
  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)
46
+ monkeypatch.setattr(readability_module, "READABILITY_STATE_FILE", strike_path)
47
+ monkeypatch.setattr(readability_module, "READABILITY_THRESHOLD_OVERRIDE_FILE", override_path)
48
+ monkeypatch.setattr(readability_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
49
+
48
50
 
49
51
  VALID_BODY = (
50
52
  "Allow commas in branch names so PRs whose head branch was generated from "
@@ -59,94 +61,6 @@ VALID_BODY = (
59
61
  "- `bun run typecheck`\n"
60
62
  )
61
63
 
62
- LEGACY_DESCRIPTION_WHY_HOW_BODY = (
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"
65
- "## How\n\nRefactored the auth module to handle edge cases correctly.\n"
66
- )
67
-
68
-
69
- def test_extract_body_from_body_string() -> None:
70
- command = 'gh pr create --title "T" --body "Description and some text."'
71
- assert "Description" in extract_body_from_command(command)
72
-
73
-
74
- def test_extract_body_from_body_file_space_form(tmp_path: pathlib.Path) -> None:
75
- body_file = tmp_path / "body.md"
76
- body_file.write_text(VALID_BODY)
77
- command = f'gh pr create --title "T" --body-file {body_file}'
78
- assert extract_body_from_command(command) == VALID_BODY
79
-
80
-
81
- def test_extract_body_from_body_file_equals_form(tmp_path: pathlib.Path) -> None:
82
- body_file = tmp_path / "body.md"
83
- body_file.write_text(VALID_BODY)
84
- command = f'gh pr create --title "T" --body-file="{body_file}"'
85
- assert extract_body_from_command(command) == VALID_BODY
86
-
87
-
88
- def test_extract_body_from_body_file_equals_form_with_spaces(
89
- tmp_path: pathlib.Path,
90
- ) -> None:
91
- """Quoted --body-file=VALUE with spaces in path must be reassembled, not truncated."""
92
- body_file = tmp_path / "my body with spaces.md"
93
- body_file.write_text(VALID_BODY)
94
- command = f'gh pr create --title "T" --body-file="{body_file}"'
95
- assert extract_body_from_command(command) == VALID_BODY
96
-
97
-
98
- def test_extract_body_file_missing_path_returns_none() -> None:
99
- command = 'gh pr create --title "T" --body-file /nonexistent/path.md'
100
- assert extract_body_from_command(command) is None
101
-
102
-
103
- def test_extract_body_file_shell_variable_returns_none() -> None:
104
- """Shell variables like $bodyPath can't be resolved at hook time -- return None to skip enforcement."""
105
- command = 'gh pr create --title "T" --body-file $bodyPath'
106
- assert extract_body_from_command(command) is None
107
-
108
-
109
- def test_extract_body_file_no_false_positive_in_title() -> None:
110
- command = 'gh pr create --title "use --body-file /tmp/x.md" --body "actual body"'
111
- extracted_body = extract_body_from_command(command)
112
- assert extracted_body == "actual body"
113
-
114
-
115
- def test_no_false_positive_body_in_title_string_value() -> None:
116
- command = 'gh pr create --title \'use --body "x"\' --body "actual body"'
117
- assert extract_body_from_command(command) == "actual body"
118
-
119
-
120
- def test_extract_body_from_body_equals_double_quote_form() -> None:
121
- command = 'gh pr create --title "T" --body="Some body text here."'
122
- assert extract_body_from_command(command) == "Some body text here."
123
-
124
-
125
- def test_extract_body_from_body_equals_single_quote_form() -> None:
126
- command = "gh pr create --title 'T' --body='Some body text here.'"
127
- assert extract_body_from_command(command) == "Some body text here."
128
-
129
-
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."""
135
- command = 'gh pr create --title "T" --body=$bodyText'
136
- assert extract_body_from_command(command) is None
137
-
138
-
139
- def test_extract_short_flag_equals_form() -> None:
140
- command = 'gh pr create --title "T" -b="Some body text here."'
141
- assert extract_body_from_command(command) == "Some body text here."
142
-
143
-
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 ""."""
147
- command = 'gh pr create --title "T" -b=$bodyVar'
148
- assert extract_body_from_command(command) is None
149
-
150
64
 
151
65
  def test_validate_blocks_literal_empty_body() -> None:
152
66
  """A literal `gh pr create --body ""` must NOT skip enforcement. Empty-body
@@ -160,144 +74,6 @@ def test_validate_blocks_literal_empty_body() -> None:
160
74
  )
161
75
 
162
76
 
163
- def test_validate_passes_anthropic_standard_body() -> None:
164
- assert validate_pr_body(VALID_BODY) == []
165
-
166
-
167
- def test_validate_passes_legacy_description_why_how_body() -> None:
168
- """Existing Description/Why/How bodies must still pass -- the relaxed rule only widens what's accepted."""
169
- assert validate_pr_body(LEGACY_DESCRIPTION_WHY_HOW_BODY) == []
170
-
171
-
172
- def test_validate_passes_sectionless_prose_body() -> None:
173
- """Anthropic's trivial-PR shape is one sentence with no headers."""
174
- body = (
175
- "Pin third-party GitHub Actions references to immutable commit SHAs "
176
- "so a tag move cannot redirect CI to attacker-controlled code."
177
- )
178
- assert validate_pr_body(body) == []
179
-
180
-
181
- def test_validate_blocks_skeleton_body_with_only_headers_and_bullets() -> None:
182
- """Sections + bullets without any prose Why is rejected -- the substantive-prose check catches this."""
183
- body = (
184
- "## Summary\n\n"
185
- "## Changes\n\n"
186
- "- `a`\n"
187
- "- `b`\n"
188
- "- `c`\n"
189
- )
190
- violations = validate_pr_body(body)
191
- assert any("substantive prose" in each_violation.lower() for each_violation in violations)
192
-
193
-
194
- def test_validate_blocks_blockquoted_headings_with_no_real_prose() -> None:
195
- """Regression: blockquote markers must strip BEFORE heading stripping.
196
-
197
- A line like `> ## Summary` starts with `>`, so `^#+[ \\t].*$` cannot match it
198
- in heading position. If blockquote markers are stripped after, the bare
199
- `## Summary` text survives into the prose stream and inflates the count.
200
- Correct order strips `> ` first, then the line becomes a real heading and
201
- drops out, leaving an effectively empty body below the 40-character minimum.
202
- """
203
- body = "> ## Summary\n> ## Why\n> ## How"
204
- violations = validate_pr_body(body)
205
- assert any("substantive prose" in each_violation.lower() for each_violation in violations)
206
-
207
-
208
- def test_validate_passes_prose_after_bare_hashes_with_no_space() -> None:
209
- """Bug regression: `##\\n` followed by prose must not have its prose eaten by the heading regex.
210
-
211
- The previous pattern `^#+\\s.*$` matched `\\s` against the newline, then `.*$` greedily
212
- consumed the next line. The fix restricts the whitespace class to `[ \\t]` so only true
213
- headings (`## text`) match, leaving prose-after-bare-hashes intact for substantive-prose counting.
214
- """
215
- body = (
216
- "##\nThis is real prose that should not be eaten by the heading regex, "
217
- "it should pass the 40-character minimum."
218
- )
219
- assert validate_pr_body(body) == []
220
-
221
-
222
- def test_validate_blocks_vague_language() -> None:
223
- body = VALID_BODY + "\nFixed bug in the auth module.\n"
224
- violations = validate_pr_body(body)
225
- assert any("Vague language" in each_violation for each_violation in violations)
226
-
227
-
228
- def _has_vague_language_violation(all_violations: list[str]) -> bool:
229
- return any("Vague language" in each_violation for each_violation in all_violations)
230
-
231
-
232
- def test_vague_language_inside_fenced_code_block_is_exempt() -> None:
233
- body = (
234
- "The allocator now bounds retries so a runaway request cannot exhaust the "
235
- "connection pool under sustained load.\n\n"
236
- "```bash\ngit commit -m \"fixed bug in parser\"\n```\n"
237
- )
238
- assert not _has_vague_language_violation(validate_pr_body(body))
239
-
240
-
241
- def test_vague_language_inside_inline_code_span_is_exempt() -> None:
242
- body = (
243
- "This change documents the historical commit message `fixed bug` referenced "
244
- "in the changelog and rewrites the surrounding allocator narrative for clarity.\n"
245
- )
246
- assert not _has_vague_language_violation(validate_pr_body(body))
247
-
248
-
249
- def test_vague_language_inside_blockquote_line_is_exempt() -> None:
250
- body = (
251
- "> The reviewer wrote: minor changes were requested here.\n\n"
252
- "The allocator rewrite removes the unbounded retry loop and adds a hard ceiling "
253
- "so a single client cannot starve the pool.\n"
254
- )
255
- assert not _has_vague_language_violation(validate_pr_body(body))
256
-
257
-
258
- def test_vague_language_inside_markdown_table_is_exempt() -> None:
259
- body = (
260
- "The commit-message guide contrasts weak and strong messages so contributors "
261
- "learn the difference before opening a pull request.\n\n"
262
- "| Bad message | Good message |\n"
263
- "| --- | --- |\n"
264
- "| fixed bug | bound retry loop in allocator |\n"
265
- "| update code | rename pool field to active_count |\n"
266
- )
267
- assert not _has_vague_language_violation(validate_pr_body(body))
268
-
269
-
270
- def test_vague_language_in_bare_prose_still_blocks() -> None:
271
- body = (
272
- "The allocator rewrite removes the unbounded retry loop and adds a hard "
273
- "ceiling so a single client cannot starve the pool. Fixed bug in the parser.\n"
274
- )
275
- assert _has_vague_language_violation(validate_pr_body(body))
276
-
277
-
278
- def test_vague_language_inside_heading_is_exempt() -> None:
279
- body = (
280
- "## Fixed bug in the allocator\n\n"
281
- "The allocator rewrite removes the unbounded retry loop and adds a hard "
282
- "ceiling so a single client cannot starve the connection pool.\n"
283
- )
284
- assert not _has_vague_language_violation(validate_pr_body(body))
285
-
286
-
287
- def test_vague_language_in_single_pipe_prose_line_still_blocks() -> None:
288
- body = (
289
- "The allocator rewrite removes the unbounded retry loop and adds a hard "
290
- "ceiling so a single client cannot starve the connection pool.\n\n"
291
- "| fixed bug\n"
292
- )
293
- assert _has_vague_language_violation(validate_pr_body(body))
294
-
295
-
296
- def test_validate_blocks_short_body() -> None:
297
- violations = validate_pr_body("Too short.")
298
- assert any("substantive prose" in each_violation.lower() for each_violation in violations)
299
-
300
-
301
77
  def test_body_file_content_validated(tmp_path: pathlib.Path) -> None:
302
78
  body_file = tmp_path / "body.md"
303
79
  body_file.write_text("Too short.")
@@ -309,21 +85,6 @@ def test_body_file_content_validated(tmp_path: pathlib.Path) -> None:
309
85
  assert violations
310
86
 
311
87
 
312
- def test_extract_body_string_value_skips_body_file_path_token() -> None:
313
- command = 'gh pr create --body-file --body "actual text"'
314
- assert extract_body_from_command(command) is None
315
-
316
-
317
- def test_get_logical_first_line_does_not_join_bash_command_substitution() -> None:
318
- command = 'VAR=`cmd`\ngh pr create --body "text"'
319
- assert get_logical_first_line(command) == "VAR=`cmd`"
320
-
321
-
322
- def test_get_logical_first_line_joins_powershell_backtick_continuation() -> None:
323
- command = 'Some-Command -Param `\n"value"'
324
- assert get_logical_first_line(command) == 'Some-Command -Param "value"'
325
-
326
-
327
88
  def test_main_does_not_block_when_dash_b_only_appears_in_word() -> None:
328
89
  hook_input = {
329
90
  "tool_name": "Bash",
@@ -354,102 +115,6 @@ def test_main_does_not_block_when_no_body_flag_present() -> None:
354
115
  assert "deny" not in captured_stdout.getvalue()
355
116
 
356
117
 
357
- def test_extract_body_from_body_file_short_F_form(tmp_path: pathlib.Path) -> None:
358
- """`gh pr create -F PATH` (short form of --body-file) must read the file."""
359
- body_file = tmp_path / "body.md"
360
- body_file.write_text(VALID_BODY)
361
- command = f'gh pr create --title "T" -F {body_file}'
362
- assert extract_body_from_command(command) == VALID_BODY
363
-
364
-
365
- def test_extract_body_ignores_body_inside_title_quoted_value() -> None:
366
- """Migration to shared iterator: `--title "contains --body here"` must not false-match."""
367
- command = 'gh pr create --title "contains --body here" --body-file /tmp/real.md'
368
- extracted_body = extract_body_from_command(command)
369
- assert extracted_body is None or extracted_body == ""
370
-
371
-
372
- def test_extract_body_reassembles_split_quoted_equals_value() -> None:
373
- """`--body="has multiple spaces inside"` must reassemble across posix=False tokens."""
374
- command = 'gh pr create --title "T" --body="this body has multiple words"'
375
- assert extract_body_from_command(command) == "this body has multiple words"
376
-
377
-
378
- def test_read_body_file_rejects_relative_path_traversal(tmp_path, monkeypatch) -> None:
379
- import importlib.util, pathlib, sys
380
- _HOOK_DIR = pathlib.Path(__file__).parent
381
- if str(_HOOK_DIR) not in sys.path:
382
- sys.path.insert(0, str(_HOOK_DIR))
383
- spec = importlib.util.spec_from_file_location('pde', _HOOK_DIR / 'pr_description_enforcer.py')
384
- m = importlib.util.module_from_spec(spec)
385
- spec.loader.exec_module(m)
386
- import os, pytest
387
- sentinel_directory = tmp_path / 'sentinel'
388
- sentinel_directory.mkdir()
389
- working_directory = tmp_path / 'workdir'
390
- working_directory.mkdir()
391
- sentinel_file = sentinel_directory / 'secret.txt'
392
- sentinel_file.write_text('secret')
393
- monkeypatch.chdir(working_directory)
394
- rel_path = os.path.relpath(str(sentinel_file))
395
- assert '..' in rel_path, 'chdir to a sibling of the sentinel must produce a traversal relpath'
396
- with pytest.raises(m.PathTraversalError):
397
- m._read_body_file_contents(rel_path)
398
-
399
-
400
- def test_read_body_file_allows_absolute_path_outside_cwd(tmp_path) -> None:
401
- import importlib.util, pathlib, sys
402
- _HOOK_DIR = pathlib.Path(__file__).parent
403
- spec = importlib.util.spec_from_file_location('pde2', _HOOK_DIR / 'pr_description_enforcer.py')
404
- m = importlib.util.module_from_spec(spec)
405
- spec.loader.exec_module(m)
406
- body_file = tmp_path / 'body.md'
407
- body_file.write_text('hello')
408
- result = m._read_body_file_contents(str(body_file))
409
- assert result == 'hello'
410
-
411
-
412
- def test_reassemble_split_quoted_value_returns_none_for_unclosed_quote() -> None:
413
- import importlib.util, pathlib, sys
414
- _HOOK_DIR = pathlib.Path(__file__).parent
415
- spec = importlib.util.spec_from_file_location('pde3', _HOOK_DIR / 'pr_description_enforcer.py')
416
- m = importlib.util.module_from_spec(spec)
417
- spec.loader.exec_module(m)
418
- result = m._reassemble_split_quoted_value("'unclosed", [])
419
- assert result is None
420
-
421
-
422
- def test_extract_body_returns_none_for_unclosed_quote_value() -> None:
423
- result = extract_body_from_command("gh pr create --title T --body='unclosed")
424
- assert result is None
425
-
426
-
427
-
428
- def test_body_file_stdin_sentinel_returns_none() -> None:
429
- """--body-file - (stdin sentinel) must return None so enforcer skips validation."""
430
- command = 'gh pr create --title "T" --body-file -'
431
- assert extract_body_from_command(command) is None
432
-
433
-
434
- def test_body_file_shell_variable_returns_none() -> None:
435
- """--body-file $VAR cannot be audited at hook time -- must return None, not empty string."""
436
- command = 'gh pr create --title "T" --body-file $BODY_VAR'
437
- assert extract_body_from_command(command) is None
438
-
439
-
440
- def test_body_file_path_traversal_returns_none() -> None:
441
- """Path traversal rejection must return None so enforcer does not raise false positive."""
442
- import os
443
- import importlib.util
444
- import pathlib
445
- _HOOK_DIR = pathlib.Path(__file__).parent
446
- spec = importlib.util.spec_from_file_location('pde_t', _HOOK_DIR / 'pr_description_enforcer.py')
447
- m = importlib.util.module_from_spec(spec)
448
- spec.loader.exec_module(m)
449
- result = m._resolve_body_file_value("../../../etc/passwd")
450
- assert result is None
451
-
452
-
453
118
  def test_main_allows_through_stdin_sentinel_body_file() -> None:
454
119
  """--body-file - must not be blocked (stdin body is unauditable)."""
455
120
  import io
@@ -469,705 +134,6 @@ def test_main_allows_through_stdin_sentinel_body_file() -> None:
469
134
  assert "deny" not in captured_stdout.getvalue()
470
135
 
471
136
 
472
- def test_read_body_file_rejects_absolute_symlink_outside_cwd(tmp_path: pathlib.Path) -> None:
473
- """Absolute symlink pointing outside cwd must raise PathTraversalError."""
474
- import importlib.util
475
- import pytest
476
- _HOOK_DIR = pathlib.Path(__file__).parent
477
- spec = importlib.util.spec_from_file_location('pde_sym', _HOOK_DIR / 'pr_description_enforcer.py')
478
- m = importlib.util.module_from_spec(spec)
479
- spec.loader.exec_module(m)
480
- target_file = tmp_path / "secret.txt"
481
- target_file.write_text("secret content")
482
- link_path = tmp_path / "evil_link"
483
- try:
484
- link_path.symlink_to(target_file)
485
- except (OSError, NotImplementedError):
486
- pytest.skip("symlinks not supported on this platform")
487
- with pytest.raises(m.PathTraversalError):
488
- m._read_body_file_contents(str(link_path))
489
-
490
-
491
- def test_read_body_file_allows_real_absolute_file_inside_cwd(tmp_path: pathlib.Path) -> None:
492
- """Real absolute file path that exists must be read successfully."""
493
- import importlib.util
494
- _HOOK_DIR = pathlib.Path(__file__).parent
495
- spec = importlib.util.spec_from_file_location('pde_abs', _HOOK_DIR / 'pr_description_enforcer.py')
496
- m = importlib.util.module_from_spec(spec)
497
- spec.loader.exec_module(m)
498
- body_file = tmp_path / "body.md"
499
- body_file.write_text("hello body")
500
- result = m._read_body_file_contents(str(body_file))
501
- assert result == "hello body"
502
-
503
-
504
- def test_read_body_file_allows_in_cwd_symlink_pointing_into_cwd(tmp_path: pathlib.Path) -> None:
505
- """Symlink inside cwd pointing to another file inside cwd must be readable."""
506
- import importlib.util
507
- _HOOK_DIR = pathlib.Path(__file__).parent
508
- spec = importlib.util.spec_from_file_location('pde_inlink', _HOOK_DIR / 'pr_description_enforcer.py')
509
- m = importlib.util.module_from_spec(spec)
510
- spec.loader.exec_module(m)
511
- real_file = tmp_path / "real.md"
512
- real_file.write_text("real content")
513
- link_file = tmp_path / "link.md"
514
- try:
515
- link_file.symlink_to(real_file)
516
- except (OSError, NotImplementedError):
517
- import pytest
518
- pytest.skip("symlinks not supported on this platform")
519
- with patch("pathlib.Path.cwd", return_value=tmp_path):
520
- result = m._read_body_file_contents(str(link_file))
521
- assert result == "real content"
522
-
523
-
524
- def test_iter_significant_tokens_unclosed_quote_raises_value_error() -> None:
525
- """Unclosed quoted value in a value-taking flag raises ValueError so callers block conservatively.
526
-
527
- For equals-form: --title="unclosed raises ValueError (unclosed quote not in remaining tokens).
528
- For space-form: shlex.split itself raises ValueError before iter_significant_tokens is entered.
529
- Both paths result in ValueError propagating to callers.
530
- """
531
- import pytest
532
- with pytest.raises(ValueError):
533
- list(iter_significant_tokens('gh pr create --title="unclosed --body real_body'))
534
-
535
-
536
- def test_scan_raw_tokens_does_not_false_match_body_in_title_value(tmp_path: pathlib.Path) -> None:
537
- """--title 'using --body-file is required' must not match --body-file inside the title value."""
538
- body_file = tmp_path / "real_body.md"
539
- body_file.write_text(VALID_BODY)
540
- command = f'gh pr create --title "using --body-file is required" --body-file {body_file}'
541
- result = extract_body_from_command(command)
542
- assert result == VALID_BODY
543
-
544
-
545
- def _build_heavy_body(opening_header: str, testing_header: str) -> str:
546
- intro_text = (
547
- "Adds shape-aware validation across the pr-description-enforcer pipeline. "
548
- "The change unifies the body audit with the Anthropic claude-code style "
549
- "so heavy PRs carry both an opening header and a testing header."
550
- )
551
- return (
552
- f"{intro_text}\n\n"
553
- f"{opening_header}\n\n"
554
- "The earlier flow rejected too many valid bodies on equivalence checks "
555
- "across the three shape categories described in the guide. The fix "
556
- "restructures the path around shape detection and surfaces the missing "
557
- "category in the block message so the agent can correct it on first try.\n\n"
558
- f"{testing_header}\n\n"
559
- "- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
560
- "- Manual smoke test against the implementation PR with a sample heavy body\n"
561
- "- Run the readability check across the full corpus to confirm thresholds hold\n"
562
- )
563
-
564
-
565
- def test_compute_pr_body_shape_trivial() -> None:
566
- """A short single-sentence body with zero headers classifies as Trivial."""
567
- body = "Pin third-party GitHub Actions references to immutable commit SHAs."
568
- assert hook_module._compute_pr_body_shape(body) == "trivial"
569
-
570
-
571
- def test_compute_pr_body_shape_standard() -> None:
572
- """A medium body with one ## header below the Heavy threshold classifies as Standard."""
573
- body = (
574
- "Adds a timestamp check to prevent background data pulls from overwriting "
575
- "recent local edits. The pull engine compares the last-modified marker "
576
- "before deciding whether to apply a remote record.\n\n"
577
- "## Changes\n\n"
578
- "- `pullEngine.ts`: compare lastModified before overwriting\n"
579
- "- `pullEngine.test.ts`: 3 new cases\n"
580
- )
581
- assert hook_module._compute_pr_body_shape(body) == "standard"
582
-
583
-
584
- def test_compute_pr_body_shape_heavy() -> None:
585
- """A long body with two Heavy-detection headers classifies as Heavy."""
586
- body = _build_heavy_body("## Problem", "## Test plan")
587
- assert hook_module._compute_pr_body_shape(body) == "heavy"
588
-
589
-
590
- def test_validate_heavy_body_passes_with_problem_and_test_plan() -> None:
591
- body = _build_heavy_body("## Problem", "## Test plan")
592
- assert validate_pr_body(body) == []
593
-
594
-
595
- def test_validate_heavy_body_blocks_when_testing_category_missing() -> None:
596
- """Heavy body containing two opening-category headers but no testing-category header is blocked."""
597
- intro_text = (
598
- "Adds shape-aware validation across the pr-description-enforcer pipeline. "
599
- "The change unifies the body audit with the Anthropic claude-code style. "
600
- "The block reason names the missing category for the agent to fix on first try."
601
- )
602
- body = (
603
- f"{intro_text}\n\n"
604
- "## Summary\n\n"
605
- "Adds a check that heavy bodies carry both an opening header and a testing header. "
606
- "The substantive prose lives outside the bullet section so the audit treats the body "
607
- "as the heavy shape rather than the standard shape under the length threshold.\n\n"
608
- "## Problem\n\n"
609
- "The earlier flow rejected too many valid bodies on equivalence checks "
610
- "across the three shape categories described in the guide. The fix "
611
- "restructures the path around shape detection and surfaces the missing "
612
- "category in the block message so the agent can correct it without iterating.\n\n"
613
- "## Changes\n\n"
614
- "- `validator.py`: shape detection at the head of the audit pipeline\n"
615
- "- `enforcer.py`: dispatch the shape-aware checks before the substantive-prose audit\n"
616
- )
617
- violations = validate_pr_body(body)
618
- assert any("testing" in each_violation.lower() for each_violation in violations)
619
-
620
-
621
- def test_validate_trivial_body_blocks_summary_header() -> None:
622
- """A Trivial-sized body that opens with `## Summary` is blocked as ceremony."""
623
- body = "## Summary\n\nPin Bun to 1.3.14."
624
- violations = validate_pr_body(body)
625
- assert any(
626
- "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
627
- for each_violation in violations
628
- )
629
-
630
-
631
- def test_validate_trivial_body_blocks_test_plan_header() -> None:
632
- """A Trivial-sized body that opens with `## Test plan` must trip the
633
- ceremony-on-Trivial block. The guide says Trivial bodies have zero headers,
634
- so the enforcer must catch every heading variant — not just the six
635
- `Summary|Why|Overview|Description|Intro|TL;DR` originally enumerated."""
636
- body = "## Test plan\n\nPin Bun to 1.3.14."
637
- violations = validate_pr_body(body)
638
- assert any(
639
- "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
640
- for each_violation in violations
641
- ), f"Trivial body opening with `## Test plan` must trip ceremony block; got {violations!r}"
642
-
643
-
644
- def test_first_non_empty_line_helper_is_removed() -> None:
645
- """`_first_non_empty_line` was the basis of the prior ceremony-on-Trivial
646
- check, which now uses `_iter_section_headers`. The helper has no remaining
647
- call sites; pin its removal so it cannot drift back as dead code."""
648
- assert not hasattr(hook_module, "_first_non_empty_line"), (
649
- "_first_non_empty_line must be removed; the ceremony-on-Trivial check "
650
- "now reads through _iter_section_headers instead."
651
- )
652
-
653
-
654
- def test_validate_trivial_body_blocks_test_plan_after_prose() -> None:
655
- """The doc promises "Zero `##` headers" on Trivial bodies. The earlier check
656
- only inspected the first non-empty line, so prose followed by `## Test plan`
657
- slipped through. Tighten the check to reject ANY heading in a Trivial-sized
658
- body so the guide and the enforcer agree."""
659
- body = (
660
- "Pin Bun to 1.3.14.\n\n"
661
- "## Test plan\n\n"
662
- "- bun test\n"
663
- )
664
- violations = validate_pr_body(body)
665
- assert any(
666
- "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
667
- for each_violation in violations
668
- ), f"Trivial body with later `## Test plan` must trip the block; got {violations!r}"
669
-
670
-
671
- def test_validate_trivial_body_blocks_h1_header() -> None:
672
- """A Trivial-sized body opening with an `# Overview` h1 must also block, since
673
- Trivial shape allows zero structural headers of any level."""
674
- body = "# Overview\n\nPin Bun to 1.3.14."
675
- violations = validate_pr_body(body)
676
- assert any(
677
- "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
678
- for each_violation in violations
679
- ), f"Trivial body opening with h1 must trip ceremony block; got {violations!r}"
680
-
681
-
682
- def test_validate_standard_body_allows_summary_header() -> None:
683
- """A Standard-sized body that opens with `## Summary` passes the ceremony check."""
684
- body = (
685
- "## Summary\n\n"
686
- "Adds a timestamp check to prevent background data pulls from overwriting "
687
- "recent local edits. The pull engine compares the last-modified marker "
688
- "before applying a remote record.\n\n"
689
- "## Changes\n\n"
690
- "- `pullEngine.ts`: compare lastModified before overwriting\n"
691
- "- `pullEngine.test.ts`: 3 new cases\n"
692
- )
693
- violations = validate_pr_body(body)
694
- assert not any(
695
- "ceremony" in each_violation.lower() or "trivial" in each_violation.lower()
696
- for each_violation in violations
697
- )
698
-
699
-
700
- def test_validate_blocks_self_closing_fixes_reference() -> None:
701
- body = (
702
- "Adds a timestamp check to prevent background data pulls from overwriting "
703
- "recent local edits.\n\nFixes #467.\n"
704
- )
705
- violations = validate_pr_body(body, pr_number=467)
706
- assert any(
707
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
708
- for each_violation in violations
709
- )
710
-
711
-
712
- def test_validate_blocks_self_closing_resolves_reference() -> None:
713
- body = (
714
- "Adds a timestamp check to prevent background data pulls from overwriting "
715
- "recent local edits.\n\nResolves #467.\n"
716
- )
717
- violations = validate_pr_body(body, pr_number=467)
718
- assert any(
719
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
720
- for each_violation in violations
721
- )
722
-
723
-
724
- def test_validate_blocks_lowercase_self_closing_fixes_reference() -> None:
725
- """GitHub treats closing keywords (Fixes/Closes/Resolves) case-insensitively, so
726
- a body opening with `fixes #<own-PR>` (lowercase) auto-closes the PR on merge
727
- just like the capitalized form. The enforcer must catch both."""
728
- body = (
729
- "Adds a timestamp check to prevent background data pulls from overwriting "
730
- "recent local edits.\n\nfixes #467.\n"
731
- )
732
- violations = validate_pr_body(body, pr_number=467)
733
- assert any(
734
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
735
- for each_violation in violations
736
- ), f"lowercase fixes self-reference must trip the block; got {violations!r}"
737
-
738
-
739
- def test_validate_blocks_self_closing_fix_singular_reference() -> None:
740
- """GitHub recognizes nine closing keywords (close/closes/closed,
741
- fix/fixes/fixed, resolve/resolves/resolved). The bare-stem variants
742
- `Fix #N`, `Close #N`, `Resolve #N` close the PR on merge just like the
743
- plural forms, so the enforcer must catch every variant."""
744
- body = (
745
- "Adds a timestamp check to prevent background data pulls from overwriting "
746
- "recent local edits.\n\nFix #467.\n"
747
- )
748
- violations = validate_pr_body(body, pr_number=467)
749
- assert any(
750
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
751
- for each_violation in violations
752
- ), f"`Fix #<own-PR>` self-reference must trip the block; got {violations!r}"
753
-
754
-
755
- def test_validate_blocks_self_closing_closed_past_tense_reference() -> None:
756
- """`Closed #<own-PR>` (past tense) closes the PR on merge; the enforcer
757
- must catch every closing-keyword variant including past tense."""
758
- body = (
759
- "Adds a timestamp check to prevent background data pulls from overwriting "
760
- "recent local edits.\n\nClosed #467.\n"
761
- )
762
- violations = validate_pr_body(body, pr_number=467)
763
- assert any(
764
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
765
- for each_violation in violations
766
- ), f"`Closed #<own-PR>` self-reference must trip the block; got {violations!r}"
767
-
768
-
769
- def test_validate_blocks_self_closing_resolved_past_tense_reference() -> None:
770
- """`Resolved #<own-PR>` closes the PR on merge."""
771
- body = (
772
- "Adds a timestamp check to prevent background data pulls from overwriting "
773
- "recent local edits.\n\nResolved #467.\n"
774
- )
775
- violations = validate_pr_body(body, pr_number=467)
776
- assert any(
777
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
778
- for each_violation in violations
779
- ), f"`Resolved #<own-PR>` self-reference must trip the block; got {violations!r}"
780
-
781
-
782
- def test_validate_blocks_uppercase_self_closing_closes_reference() -> None:
783
- """All-caps `CLOSES #<own-PR>` also auto-closes on GitHub; the enforcer must
784
- catch every case variant the same way GitHub does."""
785
- body = (
786
- "Adds a timestamp check to prevent background data pulls from overwriting "
787
- "recent local edits.\n\nCLOSES #467.\n"
788
- )
789
- violations = validate_pr_body(body, pr_number=467)
790
- assert any(
791
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
792
- for each_violation in violations
793
- ), f"all-caps CLOSES self-reference must trip the block; got {violations!r}"
794
-
795
-
796
- def test_validate_allows_fixes_reference_to_different_pr() -> None:
797
- body = (
798
- "Adds a timestamp check to prevent background data pulls from overwriting "
799
- "recent local edits.\n\nFixes #467.\n"
800
- )
801
- violations = validate_pr_body(body, pr_number=999)
802
- assert not any(
803
- "self" in each_violation.lower() or "own pr" in each_violation.lower()
804
- for each_violation in violations
805
- )
806
-
807
-
808
- def test_validate_blocks_this_pr_opening() -> None:
809
- body = (
810
- "This PR adds a timestamp check to prevent background data pulls from "
811
- "overwriting recent local edits. The pull engine compares the "
812
- "last-modified marker before applying a remote record."
813
- )
814
- violations = validate_pr_body(body)
815
- assert any("this pr" in each_violation.lower() for each_violation in violations)
816
-
817
-
818
- def test_validate_blocks_this_pr_opening_with_non_allowlisted_verb() -> None:
819
- """The guide describes any `This PR ...` opening as a hard block, but
820
- `THIS_PR_OPENING_PATTERN` previously only matched a short allowlist of
821
- verbs (adds|fixes|updates|does|is|was|will|removes|tightens|ports|refactors).
822
- Variants like `This PR introduces`, `This PR improves`, `This PR enables`
823
- slipped through and broke the documented contract. Catch any
824
- `This PR` opening regardless of the following verb."""
825
- body = (
826
- "This PR introduces a multi-tier caching layer that wraps the existing "
827
- "request pipeline and improves median latency on the hot path."
828
- )
829
- violations = validate_pr_body(body)
830
- assert any("this pr" in each_violation.lower() for each_violation in violations), (
831
- f"`This PR introduces` opening must trip the block regardless of verb; got {violations!r}"
832
- )
833
-
834
-
835
- def test_validate_blocks_this_pr_opening_with_improves() -> None:
836
- body = (
837
- "This PR improves the request batching algorithm so the dispatcher "
838
- "coalesces idempotent calls before the network round-trip."
839
- )
840
- violations = validate_pr_body(body)
841
- assert any("this pr" in each_violation.lower() for each_violation in violations), (
842
- f"`This PR improves` opening must trip the block; got {violations!r}"
843
- )
844
-
845
-
846
- def test_validate_allows_imperative_opening() -> None:
847
- body = (
848
- "Adds a timestamp check to prevent background data pulls from "
849
- "overwriting recent local edits. The pull engine compares the "
850
- "last-modified marker before applying a remote record."
851
- )
852
- violations = validate_pr_body(body)
853
- assert not any("this pr" in each_violation.lower() for each_violation in violations)
854
-
855
-
856
- def _readability_failing_body() -> str:
857
- """A Heavy-classified body whose intro sentence dramatically exceeds the
858
- max-sentence-words threshold. Wraps the long sentence in `## Problem` and
859
- `## Test plan` headers so the Heavy required-header check is satisfied
860
- and only the readability violation fires; otherwise the missing-header
861
- violations would inflate the result list and mask readability regressions
862
- behind broad `any()` substring matches."""
863
- return (
864
- "## Problem\n\n"
865
- "Adds a multi-step coordination protocol that traverses the entire "
866
- "request lifecycle through every middleware layer in the system, ensuring that "
867
- "downstream consumers observe a perfectly consistent ordering guarantee across "
868
- "all participating subsystems including the queueing component and the storage "
869
- "subsystem and the notification dispatch path that fans out to subscribers "
870
- "across every channel registered against the tenant scope including email and "
871
- "push and webhook delivery surfaces simultaneously in one transactional unit.\n\n"
872
- "## Test plan\n\n"
873
- "- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
874
- )
875
-
876
-
877
- def test_readability_strike_one_emits_metric_violation(readability_state_paths_enabled) -> None:
878
- body = _readability_failing_body()
879
- violations = validate_pr_body(body)
880
- assert any(
881
- "readability" in each_violation.lower() or "sentence" in each_violation.lower()
882
- for each_violation in violations
883
- )
884
- assert not any(
885
- "--readability-loosen" in each_violation for each_violation in violations
886
- )
887
- assert hook_module._read_strike_count() == 1
888
-
889
-
890
- def test_readability_strike_two_still_metric_violation(readability_state_paths_enabled) -> None:
891
- body = _readability_failing_body()
892
- validate_pr_body(body)
893
- violations = validate_pr_body(body)
894
- assert hook_module._read_strike_count() == 2
895
- assert not any("--readability-loosen" in each_violation for each_violation in violations)
896
-
897
-
898
- def test_readability_strike_three_fires_escape_hatch(readability_state_paths_enabled) -> None:
899
- body = _readability_failing_body()
900
- validate_pr_body(body)
901
- validate_pr_body(body)
902
- violations = validate_pr_body(body)
903
- assert hook_module._read_strike_count() == 3
904
- assert any("--readability-loosen" in each_violation for each_violation in violations)
905
- assert any("--readability-disable" in each_violation for each_violation in violations)
906
- assert any("--readability-reset" in each_violation for each_violation in violations)
907
-
908
-
909
- def test_extract_pr_number_from_gh_pr_edit() -> None:
910
- command = 'gh pr edit 467 --body "some body text here"'
911
- assert hook_module._extract_pr_number_from_command(command) == 467
912
-
913
-
914
- def test_extract_pr_number_from_gh_pr_comment() -> None:
915
- command = 'gh pr comment 467 --body "some comment body"'
916
- assert hook_module._extract_pr_number_from_command(command) == 467
917
-
918
-
919
- def test_extract_pr_number_from_gh_pr_create_returns_none() -> None:
920
- command = 'gh pr create --repo jl-cmd/claude-code-config --body "some body"'
921
- assert hook_module._extract_pr_number_from_command(command) is None
922
-
923
-
924
- def test_extract_pr_number_from_malformed_command_returns_none() -> None:
925
- command = 'gh pr edit --body "body without positional"'
926
- assert hook_module._extract_pr_number_from_command(command) is None
927
-
928
-
929
- def test_extract_pr_number_does_not_pick_up_number_in_title() -> None:
930
- command = 'gh pr edit 467 --title "PR 999 was bad" --body "some body"'
931
- assert hook_module._extract_pr_number_from_command(command) == 467
932
-
933
-
934
- def test_loosen_cap_errors_on_fourth_invocation(readability_state_paths_enabled) -> None:
935
- assert hook_module._apply_readability_loosen() == "ok"
936
- assert hook_module._apply_readability_loosen() == "ok"
937
- assert hook_module._apply_readability_loosen() == "ok"
938
- fourth_outcome = hook_module._apply_readability_loosen()
939
- assert fourth_outcome == "cap_reached"
940
-
941
-
942
- def test_loosen_flesch_floor_cap_errors(readability_state_paths_enabled) -> None:
943
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
944
- floor_value = hook_module.READABILITY_MIN_FLESCH_FLOOR
945
- payload = {
946
- "flesch_min": floor_value,
947
- "max_sentence_words": 30,
948
- "avg_sentence_words": 20,
949
- "loosens_used": 0,
950
- }
951
- override_path.parent.mkdir(parents=True, exist_ok=True)
952
- override_path.write_text(json.dumps(payload))
953
- assert hook_module._apply_readability_loosen() == "floor_reached"
954
-
955
-
956
- def test_loosen_max_sentence_ceiling_cap_errors(readability_state_paths_enabled) -> None:
957
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
958
- ceiling_value = hook_module.READABILITY_MAX_SENTENCE_WORDS_CEILING
959
- payload = {
960
- "flesch_min": 50,
961
- "max_sentence_words": ceiling_value,
962
- "avg_sentence_words": 20,
963
- "loosens_used": 0,
964
- }
965
- override_path.parent.mkdir(parents=True, exist_ok=True)
966
- override_path.write_text(json.dumps(payload))
967
- assert hook_module._apply_readability_loosen() == "ceiling_reached"
968
-
969
-
970
- def test_loosen_avg_sentence_ceiling_cap_errors(readability_state_paths_enabled) -> None:
971
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
972
- ceiling_value = hook_module.READABILITY_AVG_SENTENCE_WORDS_CEILING
973
- payload = {
974
- "flesch_min": 50,
975
- "max_sentence_words": 30,
976
- "avg_sentence_words": ceiling_value,
977
- "loosens_used": 0,
978
- }
979
- override_path.parent.mkdir(parents=True, exist_ok=True)
980
- override_path.write_text(json.dumps(payload))
981
- assert hook_module._apply_readability_loosen() == "ceiling_reached"
982
-
983
-
984
- def test_strip_leading_hash_lines_helper_is_removed() -> None:
985
- """The unused leading-hash stripper must not exist as a module attribute."""
986
- assert not hasattr(hook_module, "_strip_leading_hash_lines")
987
-
988
-
989
- def test_strip_markdown_ceremony_returns_stripped_prose() -> None:
990
- """The shared markdown stripper removes fences, inline code, blockquotes,
991
- headings, bullets, bold, emphasis, and Markdown link targets, leaving the
992
- underlying prose intact."""
993
- body = "\n".join(
994
- [
995
- "# Heading text",
996
- "> blockquoted content",
997
- "- bullet content",
998
- "**bold body**",
999
- "*emphasized body*",
1000
- "[link label](https://example.com)",
1001
- "`inline code body`",
1002
- "```",
1003
- "fenced code body",
1004
- "```",
1005
- "plain prose line",
1006
- ]
1007
- )
1008
- stripped = hook_module._strip_markdown_ceremony(body)
1009
- assert "Heading text" not in stripped
1010
- assert "blockquoted content" in stripped
1011
- assert "bullet content" in stripped
1012
- assert "bold body" in stripped
1013
- assert "emphasized body" in stripped
1014
- assert "link label" in stripped
1015
- assert "plain prose line" in stripped
1016
- assert "inline code body" not in stripped
1017
- assert "fenced code body" not in stripped
1018
- assert "https://example.com" not in stripped
1019
-
1020
-
1021
- def test_strip_markdown_ceremony_used_by_substantive_prose_count() -> None:
1022
- """_count_substantive_prose_chars is consistent with the shared stripper:
1023
- its returned count matches len of the whitespace-collapsed stripped body."""
1024
- body = "# Heading\n\nA single paragraph of prose with **bold** and `code` words."
1025
- stripped = hook_module._strip_markdown_ceremony(body)
1026
- collapsed = _re.sub(r"\s+", " ", stripped).strip()
1027
- assert hook_module._count_substantive_prose_chars(body) == len(collapsed)
1028
-
1029
-
1030
- def test_threshold_override_file_widens_max_sentence_words(readability_state_paths_enabled) -> None:
1031
- """When max_sentence_words override is 50, the loaded thresholds reflect that value."""
1032
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1033
- payload = {
1034
- "flesch_min": 30,
1035
- "max_sentence_words": 50,
1036
- "avg_sentence_words": 40,
1037
- "loosens_used": 0,
1038
- }
1039
- override_path.parent.mkdir(parents=True, exist_ok=True)
1040
- override_path.write_text(json.dumps(payload))
1041
- thresholds = hook_module._load_readability_thresholds()
1042
- assert thresholds.max_sentence_words == 50
1043
- assert thresholds.flesch_min == 30
1044
- assert thresholds.avg_sentence_words == 40
1045
-
1046
-
1047
- def test_loosen_writes_expected_scaled_thresholds(readability_state_paths_enabled) -> None:
1048
- """First loosen invocation scales flesch by 0.9 and sentence widths by 10/9."""
1049
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1050
- assert hook_module._apply_readability_loosen() == "ok"
1051
- written_payload = json.loads(override_path.read_text())
1052
- assert written_payload["flesch_min"] == 45
1053
- assert written_payload["max_sentence_words"] == 32
1054
- assert written_payload["avg_sentence_words"] == 20
1055
- assert written_payload["loosens_used"] == 1
1056
-
1057
-
1058
- def test_dispatch_loosen_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1059
- """The loosen handler writes its success message to the supplied output stream."""
1060
- output_stream = io.StringIO()
1061
- error_stream = io.StringIO()
1062
- with pytest.raises(SystemExit) as exit_info:
1063
- hook_module._dispatch_cli_flag(
1064
- "--readability-loosen",
1065
- output_stream=output_stream,
1066
- error_stream=error_stream,
1067
- )
1068
- assert exit_info.value.code == 0
1069
- assert "readability thresholds loosened 10%\n" == output_stream.getvalue()
1070
- assert error_stream.getvalue() == ""
1071
-
1072
-
1073
- def test_dispatch_loosen_cap_writes_to_error_stream(readability_state_paths_enabled) -> None:
1074
- """When the loosen cap is hit, the handler writes the corrective message to error stream."""
1075
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1076
- override_path.parent.mkdir(parents=True, exist_ok=True)
1077
- override_path.write_text(json.dumps({"loosens_used": hook_module.READABILITY_LOOSEN_CAP}))
1078
- output_stream = io.StringIO()
1079
- error_stream = io.StringIO()
1080
- with pytest.raises(SystemExit) as exit_info:
1081
- hook_module._dispatch_cli_flag(
1082
- "--readability-loosen",
1083
- output_stream=output_stream,
1084
- error_stream=error_stream,
1085
- )
1086
- assert exit_info.value.code == 1
1087
- assert "loosen cap reached" in error_stream.getvalue()
1088
- assert output_stream.getvalue() == ""
1089
-
1090
-
1091
- def test_dispatch_loosen_floor_writes_to_error_stream(readability_state_paths_enabled) -> None:
1092
- """When the floor is reached, the handler writes the corrective message to error stream."""
1093
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1094
- floor_payload = {
1095
- "flesch_min": hook_module.READABILITY_MIN_FLESCH_FLOOR,
1096
- "max_sentence_words": 30,
1097
- "avg_sentence_words": 20,
1098
- "loosens_used": 0,
1099
- }
1100
- override_path.parent.mkdir(parents=True, exist_ok=True)
1101
- override_path.write_text(json.dumps(floor_payload))
1102
- output_stream = io.StringIO()
1103
- error_stream = io.StringIO()
1104
- with pytest.raises(SystemExit) as exit_info:
1105
- hook_module._dispatch_cli_flag(
1106
- "--readability-loosen",
1107
- output_stream=output_stream,
1108
- error_stream=error_stream,
1109
- )
1110
- assert exit_info.value.code == 1
1111
- assert "floor/ceiling" in error_stream.getvalue()
1112
- assert output_stream.getvalue() == ""
1113
-
1114
-
1115
- def test_dispatch_reset_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1116
- """The reset handler writes its success message to the supplied output stream."""
1117
- output_stream = io.StringIO()
1118
- error_stream = io.StringIO()
1119
- with pytest.raises(SystemExit) as exit_info:
1120
- hook_module._dispatch_cli_flag(
1121
- "--readability-reset",
1122
- output_stream=output_stream,
1123
- error_stream=error_stream,
1124
- )
1125
- assert exit_info.value.code == 0
1126
- assert "readability strike counter and override thresholds reset\n" == output_stream.getvalue()
1127
- assert error_stream.getvalue() == ""
1128
-
1129
-
1130
- def test_dispatch_disable_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1131
- """The disable handler writes its success message to the supplied output stream."""
1132
- output_stream = io.StringIO()
1133
- error_stream = io.StringIO()
1134
- with pytest.raises(SystemExit) as exit_info:
1135
- hook_module._dispatch_cli_flag(
1136
- "--readability-disable",
1137
- output_stream=output_stream,
1138
- error_stream=error_stream,
1139
- )
1140
- assert exit_info.value.code == 0
1141
- assert "readability check disabled\n" == output_stream.getvalue()
1142
- assert error_stream.getvalue() == ""
1143
-
1144
-
1145
- def test_dispatch_enable_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
1146
- """The enable handler writes its success message to the supplied output stream."""
1147
- output_stream = io.StringIO()
1148
- error_stream = io.StringIO()
1149
- with pytest.raises(SystemExit) as exit_info:
1150
- hook_module._dispatch_cli_flag(
1151
- "--readability-enable",
1152
- output_stream=output_stream,
1153
- error_stream=error_stream,
1154
- )
1155
- assert exit_info.value.code == 0
1156
- assert "readability check enabled\n" == output_stream.getvalue()
1157
- assert error_stream.getvalue() == ""
1158
-
1159
-
1160
- def test_shape_classifier_uses_substantive_chars_not_raw_length() -> None:
1161
- """Shape classifier and ceremony-on-Trivial check must agree on the metric used
1162
- against TRIVIAL_BODY_CHAR_THRESHOLD. A body whose raw length passes the
1163
- threshold but whose substantive prose does not (e.g. tiny prose with a large
1164
- fenced code block) is genuinely Trivial in shape -- not Standard."""
1165
- tiny_prose_with_large_code_fence = "Done.\n\n```\n" + ("x" * 300) + "\n```"
1166
- assert len(tiny_prose_with_large_code_fence) >= hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
1167
- assert hook_module._count_substantive_prose_chars(tiny_prose_with_large_code_fence) < hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
1168
- assert hook_module._compute_pr_body_shape(tiny_prose_with_large_code_fence) == "trivial"
1169
-
1170
-
1171
137
  def _build_main_hook_input(command: str) -> dict[str, object]:
1172
138
  return {"tool_name": "Bash", "tool_input": {"command": command}}
1173
139
 
@@ -1183,158 +149,6 @@ def _run_main_and_capture_decision(hook_input: dict[str, object]) -> str:
1183
149
  return captured_stdout.getvalue()
1184
150
 
1185
151
 
1186
- def test_body_contains_any_header_rejects_plural_extension() -> None:
1187
- """`_body_contains_any_header` must enforce a word boundary after the
1188
- canonical header text. `## Problems` (plural) extends the canonical
1189
- word and must NOT satisfy `## Problem`, otherwise the Heavy
1190
- required-header check is weaker than the documented contract."""
1191
- body_with_plural_extension = "## Problems\n\nDetails follow."
1192
- candidate_set = frozenset({"## Problem"})
1193
- assert not hook_module._body_contains_any_header(body_with_plural_extension, candidate_set), (
1194
- "`## Problems` must NOT satisfy `## Problem` (different header)"
1195
- )
1196
-
1197
-
1198
- def test_body_contains_any_header_accepts_punctuation_suffix() -> None:
1199
- """The boundary rule must still accept canonical headers followed by
1200
- non-word punctuation: colon, em-dash, parenthesis, trailing whitespace.
1201
- Reviewers write `## Problem (context)` and `## Test plan: scope` —
1202
- these must continue to satisfy the canonical headers."""
1203
- candidate_set = frozenset({"## Problem"})
1204
- for each_body in [
1205
- "## Problem\n\nDetails.",
1206
- "## Problem:\n\nDetails.",
1207
- "## Problem (context)\n\nDetails.",
1208
- "## Problem — context\n\nDetails.",
1209
- ]:
1210
- assert hook_module._body_contains_any_header(each_body, candidate_set), (
1211
- f"`{each_body!r}` must satisfy `## Problem` (punctuation/space follows)"
1212
- )
1213
-
1214
-
1215
- def test_body_contains_any_header_rejects_alphanumeric_suffix() -> None:
1216
- """`## Problem2`, `## ProblemX`, `## Problem_one` are different headers
1217
- and must not match `## Problem`."""
1218
- candidate_set = frozenset({"## Problem"})
1219
- for each_body in [
1220
- "## Problem2\n\nDetails.",
1221
- "## ProblemX\n\nDetails.",
1222
- "## Problem_one\n\nDetails.",
1223
- ]:
1224
- assert not hook_module._body_contains_any_header(each_body, candidate_set), (
1225
- f"`{each_body!r}` must NOT satisfy `## Problem` (alphanumeric continuation)"
1226
- )
1227
-
1228
-
1229
- def test_read_strike_count_clamps_negative_to_zero(readability_state_paths_enabled) -> None:
1230
- """A corrupted strike-count JSON state with a negative integer must not
1231
- silently bypass escalation. Reads clamp to >= 0 so subsequent increments
1232
- walk the strike threshold from a sane baseline."""
1233
- strike_path, _override_path, _enabled_path = readability_state_paths_enabled
1234
- strike_path.parent.mkdir(parents=True, exist_ok=True)
1235
- strike_path.write_text(json.dumps({"strikes": -5}))
1236
- assert hook_module._read_strike_count() == 0, (
1237
- "negative strikes must clamp to 0"
1238
- )
1239
-
1240
-
1241
- def test_increment_strike_count_clamps_negative_starting_value(readability_state_paths_enabled) -> None:
1242
- """`_increment_strike_count` must not propagate a corrupted negative
1243
- starting value. The new count after one increment from a negative
1244
- baseline is exactly 1, not (negative + 1)."""
1245
- strike_path, _override_path, _enabled_path = readability_state_paths_enabled
1246
- strike_path.parent.mkdir(parents=True, exist_ok=True)
1247
- strike_path.write_text(json.dumps({"strikes": -3}))
1248
- new_count_after_increment = hook_module._increment_strike_count()
1249
- assert new_count_after_increment == 1, (
1250
- f"increment from negative starting value must clamp first; got {new_count_after_increment}"
1251
- )
1252
-
1253
-
1254
- def test_read_loosens_used_clamps_negative_to_zero(readability_state_paths_enabled) -> None:
1255
- """A corrupted `loosens_used` JSON state with a negative integer must
1256
- not silently bypass the loosen cap. Reads clamp to >= 0 so the cap
1257
- check enforces the documented ceiling."""
1258
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1259
- override_path.parent.mkdir(parents=True, exist_ok=True)
1260
- override_path.write_text(json.dumps({"loosens_used": -2}))
1261
- assert hook_module._read_loosens_used() == 0, (
1262
- "negative loosens_used must clamp to 0"
1263
- )
1264
-
1265
-
1266
- def test_scan_raw_tokens_for_body_docstring_reflects_none_for_shell_vars() -> None:
1267
- """`_resolve_body_string_value` now returns `None` for unresolvable
1268
- shell-variable bodies. `_scan_raw_tokens_for_body`'s docstring must
1269
- reflect that contract so future maintainers do not treat `""` as the
1270
- shell-var sentinel; literal-empty bodies still flow into validation."""
1271
- source_text = inspect.getsource(hook_module._scan_raw_tokens_for_body)
1272
- assert "None" in source_text, (
1273
- f"docstring must mention None for shell-var case; got: {source_text!r}"
1274
- )
1275
- assert "shell var" in source_text.lower() or "shell-var" in source_text.lower(), (
1276
- f"docstring must reference shell variables; got: {source_text!r}"
1277
- )
1278
- assert "may be empty for shell vars/sentinels" not in source_text, (
1279
- "docstring must not claim `\"\"` represents shell-var bodies; that case now returns None. "
1280
- f"Source still contains the stale phrase: {source_text!r}"
1281
- )
1282
-
1283
-
1284
- def test_stdlib_imports_form_one_isort_sorted_block() -> None:
1285
- """Ruff's `I` (isort) rule treats a blank line as a section break, so
1286
- `import shlex` sitting alone after a blank line would fail I001. Pin
1287
- that the stdlib imports at the head of `pr_description_enforcer.py`
1288
- sit in a single sorted block with no internal blank lines."""
1289
- enforcer_source = inspect.getsource(hook_module)
1290
- enforcer_lines = enforcer_source.splitlines()
1291
- leading_stdlib_lines: list[str] = []
1292
- for each_line in enforcer_lines:
1293
- if each_line.startswith("import ") or each_line.startswith("from "):
1294
- leading_stdlib_lines.append(each_line)
1295
- continue
1296
- if each_line.strip() == "":
1297
- if leading_stdlib_lines and leading_stdlib_lines[-1].startswith("from "):
1298
- break
1299
- if leading_stdlib_lines:
1300
- break
1301
- if not each_line.startswith("import ") and not each_line.startswith("from ") and each_line.strip() != "":
1302
- break
1303
- stdlib_import_names: list[str] = []
1304
- for each_import_line in leading_stdlib_lines:
1305
- if each_import_line.startswith("import "):
1306
- stdlib_import_names.append(each_import_line.split()[1])
1307
- assert "shlex" in stdlib_import_names, (
1308
- "`shlex` must appear in the leading stdlib import block; got: "
1309
- f"{stdlib_import_names!r}"
1310
- )
1311
- assert stdlib_import_names == sorted(stdlib_import_names), (
1312
- "Leading stdlib `import X` statements must be isort-sorted; got: "
1313
- f"{stdlib_import_names!r}"
1314
- )
1315
-
1316
-
1317
- def test_command_carries_body_flag_detects_body_file() -> None:
1318
- """`--body-file` detection must continue to work after the redundant
1319
- explicit check is removed. The shorter `--body` substring still catches
1320
- `--body-file` because `--body` is a prefix of `--body-file`."""
1321
- assert hook_module._command_carries_body_flag('gh pr create --body-file body.md')
1322
- assert hook_module._command_carries_body_flag('gh pr create --body-file=body.md')
1323
- assert hook_module._command_carries_body_flag('gh pr edit 1 -F body.md')
1324
- assert hook_module._command_carries_body_flag('gh pr edit 1 -F=body.md')
1325
-
1326
-
1327
- def test_command_carries_body_flag_does_not_double_check_body_file() -> None:
1328
- """Pin that the function does NOT execute a redundant `--body-file in command`
1329
- check. `--body` is a substring of `--body-file`, so the longer form is
1330
- matched implicitly by the shorter check. Pin the source so the dead branch
1331
- cannot drift back."""
1332
- source_text = inspect.getsource(hook_module._command_carries_body_flag)
1333
- assert source_text.count('"--body-file"') == 0, (
1334
- f"`--body-file` substring check is redundant with `--body`; remove it. Source:\n{source_text}"
1335
- )
1336
-
1337
-
1338
152
  def test_main_blocks_gh_pr_edit_short_body_flag() -> None:
1339
153
  """gh pr edit 123 -b "short" must be caught -- the short -b flag is a valid alias for --body."""
1340
154
  command = 'gh pr edit 123 -b "Too short."'
@@ -1380,97 +194,6 @@ def test_main_blocks_gh_pr_create_body_file_long_flag(tmp_path) -> None:
1380
194
  assert "deny" in decision_output
1381
195
 
1382
196
 
1383
- def test_resolve_positional_pr_number_accepts_bare_integer() -> None:
1384
- assert hook_module._resolve_positional_pr_number("467") == 467
1385
-
1386
-
1387
- def test_resolve_positional_pr_number_accepts_pr_url() -> None:
1388
- assert hook_module._resolve_positional_pr_number("https://github.com/o/r/pull/467") == 467
1389
-
1390
-
1391
- def test_resolve_positional_pr_number_rejects_non_pr_url() -> None:
1392
- assert hook_module._resolve_positional_pr_number("https://github.com/o/r/issues/467") is None
1393
-
1394
-
1395
- def test_resolve_positional_pr_number_rejects_shell_variable() -> None:
1396
- assert hook_module._resolve_positional_pr_number("$PR_NUMBER") is None
1397
-
1398
-
1399
- def test_extract_pr_number_skips_repo_value_flag() -> None:
1400
- """gh pr edit --repo owner/r 467 --body "x" must return 467 -- the --repo value must be skipped."""
1401
- command = 'gh pr edit --repo owner/r 467 --body "x"'
1402
- assert hook_module._extract_pr_number_from_command(command) == 467
1403
-
1404
-
1405
- def test_extract_pr_number_from_pr_url_positional() -> None:
1406
- """gh pr edit https://github.com/o/r/pull/467 --body "x" must return 467 -- URL form is valid."""
1407
- command = 'gh pr edit https://github.com/o/r/pull/467 --body "x"'
1408
- assert hook_module._extract_pr_number_from_command(command) == 467
1409
-
1410
-
1411
- def test_extract_pr_number_from_pr_url_after_repo_flag() -> None:
1412
- """Combined: --repo flag plus URL positional must still resolve to the URL's PR number."""
1413
- command = 'gh pr edit --repo owner/r https://github.com/o/r/pull/999 --body "x"'
1414
- assert hook_module._extract_pr_number_from_command(command) == 999
1415
-
1416
-
1417
- def test_extract_pr_number_skips_repo_equals_form() -> None:
1418
- """gh pr edit --repo=owner/r 467 --body "x" must return 467 -- the equals-form must also be handled."""
1419
- command = 'gh pr edit --repo=owner/r 467 --body "x"'
1420
- assert hook_module._extract_pr_number_from_command(command) == 467
1421
-
1422
-
1423
- def test_extract_pr_number_from_pr_url_with_trailing_query_string() -> None:
1424
- """A PR URL with a `?diff=split` or other trailing query/fragment must still resolve.
1425
- The trailing group `(?:[/?#].*)?` in the URL regex is what makes this work."""
1426
- command = 'gh pr edit https://github.com/o/r/pull/467?diff=split --body "x"'
1427
- assert hook_module._extract_pr_number_from_command(command) == 467
1428
-
1429
-
1430
- def test_extract_pr_number_skips_body_long_flag_value() -> None:
1431
- """gh pr edit --body "Fixes #999" 472 must return 472 -- the --body value must not
1432
- be treated as a positional argument. Without skipping body-flag values, the body
1433
- text would be parsed as the positional slot and PR-number extraction would fail."""
1434
- command = 'gh pr edit --body "Fixes #999" 472'
1435
- assert hook_module._extract_pr_number_from_command(command) == 472
1436
-
1437
-
1438
- def test_extract_pr_number_skips_body_short_flag_value() -> None:
1439
- """gh pr edit -b 'Fixes #999' 472 must return 472 -- short -b alias must also skip its value."""
1440
- command = 'gh pr edit -b "Fixes #999" 472'
1441
- assert hook_module._extract_pr_number_from_command(command) == 472
1442
-
1443
-
1444
- def test_extract_pr_number_skips_body_file_long_flag_value() -> None:
1445
- """gh pr edit --body-file body.md 472 must return 472 -- --body-file value must skip."""
1446
- command = 'gh pr edit --body-file body.md 472'
1447
- assert hook_module._extract_pr_number_from_command(command) == 472
1448
-
1449
-
1450
- def test_extract_pr_number_skips_body_file_short_flag_value() -> None:
1451
- """gh pr edit -F body.md 472 must return 472 -- -F short alias must also skip its value."""
1452
- command = 'gh pr edit -F body.md 472'
1453
- assert hook_module._extract_pr_number_from_command(command) == 472
1454
-
1455
-
1456
- def test_extract_pr_number_skips_body_equals_form() -> None:
1457
- """gh pr edit --body="Fixes #999" 472 must return 472 -- equals-form has the value
1458
- attached to the same token, so only the flag token itself should be skipped."""
1459
- command = 'gh pr edit --body="Fixes #999" 472'
1460
- assert hook_module._extract_pr_number_from_command(command) == 472
1461
-
1462
-
1463
- def test_command_carries_body_flag_short_b_equals_form() -> None:
1464
- """`-b=value` short form must be detected by the pre-filter; previous version only
1465
- checked the space-separated `-b ` substring and silently bypassed the equals form."""
1466
- assert hook_module._command_carries_body_flag('gh pr edit 123 -b="x"') is True
1467
-
1468
-
1469
- def test_command_carries_body_flag_short_F_equals_form() -> None:
1470
- """`-F=path` short form must be detected by the pre-filter."""
1471
- assert hook_module._command_carries_body_flag('gh pr edit 123 -F=body.md') is True
1472
-
1473
-
1474
197
  def test_main_blocks_gh_pr_edit_short_body_equals_form() -> None:
1475
198
  """gh pr edit 123 -b="short" must be caught -- the -b= equals form was bypassing
1476
199
  the pre-filter and silently approving short bodies."""
@@ -1489,27 +212,6 @@ def test_main_blocks_gh_pr_edit_short_body_file_equals_form(tmp_path) -> None:
1489
212
  assert "deny" in decision_output
1490
213
 
1491
214
 
1492
- def test_iter_section_headers_ignores_headings_inside_fenced_code_blocks() -> None:
1493
- """Headings nested inside ``` ... ``` fences are example content, not body headers.
1494
- The shape classifier and the Heavy required-header check must agree with the markdown
1495
- stripper -- the body of this very test demonstrates the regression."""
1496
- body = (
1497
- "Intro paragraph that does not classify the body.\n\n"
1498
- "```\n"
1499
- "## Problem\n"
1500
- "## Test plan\n"
1501
- "```\n"
1502
- )
1503
- headers = hook_module._iter_section_headers(body)
1504
- assert headers == [], f"Expected zero headers (fenced content), got {headers}"
1505
- assert hook_module._compute_pr_body_shape(body) != "heavy", (
1506
- "Body with only fenced example headers must not classify as heavy"
1507
- )
1508
- assert hook_module._body_contains_any_header(
1509
- body, hook_module.ALL_HEAVY_OPENING_HEADERS
1510
- ) is False, "Heavy opening-header check must not see fenced example content"
1511
-
1512
-
1513
215
  def test_build_short_failing_body_helper_is_removed() -> None:
1514
216
  """The unused test helper `_build_short_failing_body` had zero call sites and
1515
217
  must not be re-introduced."""
@@ -1517,191 +219,3 @@ def test_build_short_failing_body_helper_is_removed() -> None:
1517
219
  assert not hasattr(test_module, "_build_short_failing_body"), (
1518
220
  "_build_short_failing_body was re-introduced; it has no callers in this test file."
1519
221
  )
1520
-
1521
-
1522
- def test_strike_count_rejects_boolean_value_as_strikes(readability_state_paths_enabled) -> None:
1523
- """A corrupted strikes.json with `{"strikes": true}` must not be silently
1524
- accepted as the integer 1. Python's `bool` is a subclass of `int`, so a bare
1525
- `isinstance(value, int)` guard lets a malformed payload disable strike
1526
- behavior without warning. The reader must explicitly exclude bool values."""
1527
- strike_path, _override_path, _enabled_path = readability_state_paths_enabled
1528
- strike_path.write_text('{"strikes": true}')
1529
- assert hook_module._read_strike_count() == 0
1530
-
1531
-
1532
- def test_loosens_used_rejects_boolean_value(readability_state_paths_enabled) -> None:
1533
- """`{"loosens_used": true}` must read as the default 0, not coerce the bool
1534
- to 1 via the `isinstance(x, int)` quirk that accepts bool."""
1535
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1536
- override_path.write_text('{"loosens_used": true}')
1537
- assert hook_module._read_loosens_used() == 0
1538
-
1539
-
1540
- def test_readability_thresholds_reject_boolean_values(readability_state_paths_enabled) -> None:
1541
- """A threshold field set to a boolean must fall back to the default integer,
1542
- not silently coerce True to 1 or False to 0 via Python's bool-is-int quirk."""
1543
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
1544
- override_path.write_text(
1545
- '{"flesch_min": true, "max_sentence_words": false, "avg_sentence_words": true}'
1546
- )
1547
- thresholds = hook_module._load_readability_thresholds()
1548
- assert thresholds.flesch_min == hook_module.DEFAULT_READABILITY_THRESHOLDS.flesch_min
1549
- assert thresholds.max_sentence_words == hook_module.DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
1550
- assert thresholds.avg_sentence_words == hook_module.DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
1551
-
1552
-
1553
- def test_single_use_helper_constants_are_inlined() -> None:
1554
- """`_vowel_set`, `_sentence_split_pattern`, and `_all_cli_flag_tokens` each
1555
- had exactly one consumer in production. The file-global-constants rule
1556
- requires either a second caller or a move out of module scope; inlining
1557
- into the single consumer is the chosen resolution. Pin that the three
1558
- names are no longer module attributes so they cannot drift back."""
1559
- for each_name in ("_vowel_set", "_sentence_split_pattern", "_all_cli_flag_tokens"):
1560
- assert not hasattr(hook_module, each_name), (
1561
- f"{each_name} must be inlined into its single consumer, not "
1562
- "carried as a file-global constant."
1563
- )
1564
-
1565
-
1566
- def test_readability_violation_strings_match_agent_doc_format() -> None:
1567
- """The agent SKILL example shows the canonical readability message format
1568
- (`Readability: longest sentence is N words (maximum 28); split or rewrite
1569
- the longest sentence`). The hook's `_evaluate_readability_metrics` must
1570
- emit the same `maximum N` / `split or rewrite` wording so users see the
1571
- exact form documented in the agent file."""
1572
- text_with_long_sentence = (
1573
- "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu "
1574
- "nu xi omicron pi rho sigma tau upsilon phi chi psi omega aleph "
1575
- "beth gimel daleth he waw zayin heth teth yodh kaph lamedh mem nun."
1576
- )
1577
- messages_via_eval = hook_module._evaluate_readability_metrics(
1578
- text_with_long_sentence, hook_module.DEFAULT_READABILITY_THRESHOLDS
1579
- )
1580
- joined_messages = "\n".join(messages_via_eval)
1581
- assert "(maximum" in joined_messages, (
1582
- f"Readability messages must use `maximum N` wording (matching agent doc); "
1583
- f"got: {joined_messages!r}"
1584
- )
1585
- assert "split or rewrite the longest sentence" in joined_messages, (
1586
- f"Longest-sentence message must end with `split or rewrite the longest sentence`; "
1587
- f"got: {joined_messages!r}"
1588
- )
1589
-
1590
-
1591
- def test_long_body_without_heavy_headers_still_classifies_heavy() -> None:
1592
- """The Heavy required-header check in `validate_pr_body` only runs when
1593
- `_compute_pr_body_shape` returns HEAVY. Previously the classifier required
1594
- BOTH length >= 500 chars AND >= 2 heavy detection headers, which meant a
1595
- long body missing the required headers entirely was classified Standard
1596
- and silently bypassed the missing-header enforcement. Length alone must
1597
- drive the HEAVY classification so the validator can enforce the rule."""
1598
- long_body_with_no_heavy_headers = (
1599
- "Refactors the request-pipeline batcher to coalesce idempotent calls "
1600
- "before the network round-trip. The change touches the dispatcher, the "
1601
- "retry loop, the error normalizer, and three downstream consumers. "
1602
- "Every test in the integration suite continues to pass without "
1603
- "modification because the public contract is unchanged.\n\n"
1604
- "The new coalescer reads a per-call digest, looks up an in-flight slot "
1605
- "indexed by that digest, and appends the caller's promise to the slot "
1606
- "instead of dispatching a duplicate request. Once the network response "
1607
- "arrives, every queued promise resolves with the same value. Error "
1608
- "responses propagate to every queued promise so retry logic stays "
1609
- "consistent with the prior contract.\n"
1610
- )
1611
- assert (
1612
- hook_module._count_substantive_prose_chars(long_body_with_no_heavy_headers)
1613
- >= hook_module.HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION
1614
- )
1615
- assert hook_module._compute_pr_body_shape(long_body_with_no_heavy_headers) == hook_module.HEAVY_SHAPE
1616
-
1617
-
1618
- def test_validate_heavy_body_without_required_headers_blocks() -> None:
1619
- """End-to-end: a long body without `## Problem|Summary` or `## Test plan|...`
1620
- must trip the Heavy missing-header violation. Previously the classifier
1621
- bypassed Heavy classification because the body lacked the headers we were
1622
- trying to require — a circular self-bypass."""
1623
- long_body_missing_heavy_headers = (
1624
- "Refactors the request-pipeline batcher to coalesce idempotent calls "
1625
- "before the network round-trip. The change touches the dispatcher, the "
1626
- "retry loop, the error normalizer, and three downstream consumers. "
1627
- "Every test in the integration suite continues to pass without "
1628
- "modification because the public contract is unchanged.\n\n"
1629
- "The new coalescer reads a per-call digest, looks up an in-flight slot "
1630
- "indexed by that digest, and appends the caller's promise to the slot "
1631
- "instead of dispatching a duplicate request. Once the network response "
1632
- "arrives, every queued promise resolves with the same value. Error "
1633
- "responses propagate to every queued promise so retry logic stays "
1634
- "consistent with the prior contract.\n"
1635
- )
1636
- violations = validate_pr_body(long_body_missing_heavy_headers)
1637
- assert any("heavy" in each_violation.lower() for each_violation in violations), (
1638
- f"Long body missing Heavy headers must trip the required-header check; got {violations!r}"
1639
- )
1640
-
1641
-
1642
- def test_compute_pr_body_shape_uses_named_shape_constants() -> None:
1643
- """`_compute_pr_body_shape` returns the centralised shape names rather than
1644
- inline string literals. Confirm the constants flow through end-to-end."""
1645
- trivial_body = "Bump bun to 1.3.14."
1646
- assert hook_module._compute_pr_body_shape(trivial_body) == hook_module.TRIVIAL_SHAPE
1647
-
1648
-
1649
- def test_compute_flesch_reading_ease_uses_named_constants() -> None:
1650
- """`_compute_flesch_reading_ease` must reference the named Flesch constants
1651
- rather than embed the magic literals 206.835 / 1.015 / 84.6 / 100.0 inline.
1652
- Smoke-test the empty-input path returns the perfect-score default."""
1653
- perfect_score = hook_module._compute_flesch_reading_ease("")
1654
- assert perfect_score == hook_module.FLESCH_PERFECT_SCORE
1655
- perfect_score_no_words = hook_module._compute_flesch_reading_ease(" ")
1656
- assert perfect_score_no_words == hook_module.FLESCH_PERFECT_SCORE
1657
-
1658
-
1659
- def test_iter_section_headers_docstring_matches_actual_pattern() -> None:
1660
- """`_iter_section_headers` uses `HEADING_LINE_PATTERN = ^#+`, so it returns
1661
- every ATX heading level (`#`, `##`, `###`...), not just `##`. The docstring
1662
- must describe that actual contract so callers cannot be misled."""
1663
- docstring = hook_module._iter_section_headers.__doc__ or ""
1664
- assert "every ATX heading" in docstring or "any heading level" in docstring, (
1665
- f"_iter_section_headers docstring must document that it matches every "
1666
- f"heading level (`HEADING_LINE_PATTERN` is `^#+`); got: {docstring!r}"
1667
- )
1668
-
1669
-
1670
- def test_extract_readability_target_text_strips_fences_before_finding_header() -> None:
1671
- """`_extract_readability_target_text` must strip fenced code blocks before
1672
- searching for the first structural header. Otherwise a fenced example like
1673
- ```\\n## Problem\\n``` is matched as the first header and the intro / section
1674
- boundaries collapse to bogus values."""
1675
- body = (
1676
- "Intro paragraph that should be the intro for readability analysis.\n\n"
1677
- "```\n## Problem\n```\n\n"
1678
- "## RealHeader\n\n"
1679
- "Real first-section prose for readability measurement.\n"
1680
- )
1681
- target_text = hook_module._extract_readability_target_text(body)
1682
- assert "Intro paragraph" in target_text, (
1683
- f"Intro paragraph must survive; got {target_text!r}"
1684
- )
1685
- assert "Real first-section prose" in target_text, (
1686
- f"First real section prose must follow; got {target_text!r}"
1687
- )
1688
-
1689
-
1690
- @pytest.fixture
1691
- def readability_state_paths_enabled(tmp_path, monkeypatch):
1692
- """Redirect the three readability state files to per-test temp paths while keeping
1693
- readability enabled. The autouse `_isolate_readability_state` fixture disables
1694
- readability by default for unrelated tests; tests exercising strike-counter or
1695
- dispatch behavior need it ON, so this fixture re-points the three state paths
1696
- WITHOUT stubbing _is_readability_enabled.
1697
-
1698
- Returns:
1699
- Tuple of (strike_path, override_path, enabled_path).
1700
- """
1701
- strike_path = tmp_path / "strikes.json"
1702
- override_path = tmp_path / "overrides.json"
1703
- enabled_path = tmp_path / "enabled.json"
1704
- monkeypatch.setattr(hook_module, "READABILITY_STATE_FILE", strike_path)
1705
- monkeypatch.setattr(hook_module, "READABILITY_THRESHOLD_OVERRIDE_FILE", override_path)
1706
- monkeypatch.setattr(hook_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
1707
- return strike_path, override_path, enabled_path