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