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