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.
Files changed (203) hide show
  1. package/_shared/advisor/reference/sol-rung.md +5 -2
  2. package/_shared/advisor/reference/warm-up.md +1 -0
  3. package/_shared/advisor/scripts/codex_sol_advisor.py +96 -31
  4. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +5 -0
  5. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +173 -11
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/test_code_rules_gate.py +0 -26
  8. package/agents/CLAUDE.md +1 -2
  9. package/agents/pr-description-writer.md +11 -81
  10. package/bin/install.mjs +48 -9
  11. package/bin/install.prune.test.mjs +26 -0
  12. package/bin/install.settings-defaults.test.mjs +60 -33
  13. package/bin/install.test.mjs +2 -4
  14. package/commands/CLAUDE.md +1 -0
  15. package/commands/sr-loop.md +52 -0
  16. package/docs/CLAUDE.md +0 -1
  17. package/docs/references/CLAUDE.md +0 -1
  18. package/hooks/CLAUDE.md +1 -1
  19. package/hooks/blocking/CLAUDE.md +2 -27
  20. package/hooks/blocking/config/CLAUDE.md +1 -13
  21. package/hooks/blocking/conventional_pr_title_gate.py +1 -2
  22. package/hooks/blocking/send_user_file_open_locally_blocker.py +1 -1
  23. package/hooks/blocking/stop_dispatcher.py +4 -4
  24. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -3
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +6 -7
  26. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +4 -3
  27. package/hooks/blocking/test_shared_stdin_adoption.py +0 -2
  28. package/hooks/diagnostic/CLAUDE.md +3 -3
  29. package/hooks/diagnostic/hook_log_extractor.py +2 -36
  30. package/hooks/diagnostic/hook_log_stop_wrapper.py +6 -155
  31. package/hooks/diagnostic/test_hook_log_extractor.py +8 -21
  32. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +3 -331
  33. package/hooks/git-hooks/git_hooks_constants/__init__.py +0 -6
  34. package/hooks/git-hooks/pre_push.py +3 -85
  35. package/hooks/git-hooks/test_pre_push.py +0 -130
  36. package/hooks/hooks.json +0 -35
  37. package/hooks/hooks_constants/CLAUDE.md +0 -9
  38. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -11
  39. package/hooks/hooks_constants/convergence_branch_constants.py +0 -1
  40. package/hooks/hooks_constants/fable_spawn_gate_constants.py +3 -4
  41. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -8
  42. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +1 -6
  43. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -26
  45. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +0 -5
  46. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -1
  47. package/hooks/lifecycle/CLAUDE.md +1 -3
  48. package/package.json +1 -1
  49. package/rules/CLAUDE.md +0 -2
  50. package/rules/gh-cli-conventions.md +0 -1
  51. package/rules/git-workflow.md +1 -3
  52. package/scripts/CLAUDE.md +0 -1
  53. package/scripts/_code_review_test_support.py +0 -95
  54. package/scripts/dev_env_scripts_constants/code_review_constants.py +9 -98
  55. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +1 -44
  56. package/scripts/invoke_code_review.py +8 -451
  57. package/scripts/test_invoke_code_review.py +5 -61
  58. package/scripts/test_invoke_code_review_cli.py +1 -45
  59. package/scripts/test_invoke_code_review_contract.py +1 -118
  60. package/settings.json +1 -10
  61. package/skills/CLAUDE.md +8 -0
  62. package/skills/autoconverge/reference/CLAUDE.md +1 -1
  63. package/skills/autoconverge/reference/convergence.md +7 -7
  64. package/skills/autoconverge/reference/gotchas.md +3 -10
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +53 -1388
  66. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +4 -3
  67. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +33 -493
  68. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +15 -97
  69. package/skills/autoconverge/workflow/converge.mjs +201 -238
  70. package/skills/autoconverge/workflow/converge.precatch.test.mjs +1 -1
  71. package/skills/bugteam/CONSTRAINTS.md +1 -1
  72. package/skills/bugteam/SKILL.md +9 -0
  73. package/skills/bugteam/reference/audit-and-teammates.md +10 -6
  74. package/skills/bugteam/test_skill_additions.py +9 -0
  75. package/skills/comments/SKILL.md +65 -0
  76. package/skills/descriptions/SKILL.md +51 -0
  77. package/skills/emergencies/SKILL.md +42 -0
  78. package/skills/imagegen/SKILL.md +21 -0
  79. package/skills/imagegen/scripts/config/__init__.py +1 -0
  80. package/skills/imagegen/scripts/config/constants.py +28 -0
  81. package/skills/imagegen/scripts/imagegen.py +45 -0
  82. package/skills/imagegen/scripts/imagegen_core.py +528 -0
  83. package/skills/imagegen/scripts/test_imagegen.py +204 -0
  84. package/skills/orchestrator/SKILL.md +2 -4
  85. package/skills/plan-to-pr/SKILL.md +6 -7
  86. package/skills/plan-to-pr/reference/final-validation-tasks.md +1 -1
  87. package/skills/plan-to-pr/reference/process-inventory.md +2 -2
  88. package/skills/plan-to-pr/reference/review-loop.md +2 -2
  89. package/skills/plan-to-pr/reference/run-record.schema.json +1 -1
  90. package/skills/plan-to-pr/reference/task-seeds.md +1 -1
  91. package/skills/plan-to-pr/reference/task-ticket.md +3 -4
  92. package/skills/plan-to-pr/scripts/config/constants.py +1 -2
  93. package/skills/plan-to-pr/scripts/test_validate_protocol.py +1 -1
  94. package/skills/plan-to-pr/test_skill_contract.py +2 -2
  95. package/skills/pr-converge/SKILL.md +13 -16
  96. package/skills/pr-converge/reference/progress-checklist.md +6 -5
  97. package/skills/pr-converge/reference/state-schema.md +1 -27
  98. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  99. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +3 -3
  100. package/skills/pr-loop-lifecycle/SKILL.md +1 -1
  101. package/skills/pr-loop-lifecycle/reference/teardown-publish-permissions.md +1 -1
  102. package/skills/prototype/SKILL.md +4 -4
  103. package/skills/prototype/reference/honest-limitations.md +4 -4
  104. package/skills/prototype/reference/promotion-tasks.md +2 -2
  105. package/skills/prototype/workflows/promotion.md +4 -6
  106. package/skills/reviews/SKILL.md +85 -0
  107. package/skills/small-cl/SKILL.md +52 -0
  108. package/skills/usage-pause/SKILL.md +1 -1
  109. package/skills/usage-pause/scripts/resolve_usage_window.py +32 -4
  110. package/skills/usage-pause/scripts/test_resolve_usage_window.py +26 -0
  111. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +3 -1
  112. package/agents/code-verifier.md +0 -74
  113. package/docs/PR_DESCRIPTION_GUIDE.md +0 -157
  114. package/docs/references/code-review-enforcement.md +0 -132
  115. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +0 -53
  116. package/hooks/blocking/code_review_gate_deny.py +0 -74
  117. package/hooks/blocking/code_review_pr_create_gate.py +0 -198
  118. package/hooks/blocking/code_review_push_gate.py +0 -145
  119. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +0 -348
  120. package/hooks/blocking/code_review_stamp_store.py +0 -233
  121. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +0 -7
  122. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +0 -15
  123. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +0 -212
  124. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +0 -138
  125. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +0 -49
  126. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +0 -38
  127. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +0 -483
  128. package/hooks/blocking/config/code_review_enforcement_constants.py +0 -143
  129. package/hooks/blocking/config/test_code_review_enforcement_constants.py +0 -166
  130. package/hooks/blocking/config/verified_commit_constants.py +0 -160
  131. package/hooks/blocking/config/verified_commit_context_constants.py +0 -21
  132. package/hooks/blocking/config/verified_commit_gate_output_constants.py +0 -14
  133. package/hooks/blocking/conftest.py +0 -123
  134. package/hooks/blocking/convergence_gate_blocker.py +0 -310
  135. package/hooks/blocking/pr_converge_bugteam_enforcer.py +0 -170
  136. package/hooks/blocking/pr_description_body_audit.py +0 -148
  137. package/hooks/blocking/pr_description_command_parser.py +0 -237
  138. package/hooks/blocking/pr_description_enforcer.py +0 -227
  139. package/hooks/blocking/pr_description_pr_number.py +0 -155
  140. package/hooks/blocking/pr_description_proof_of_work.py +0 -385
  141. package/hooks/blocking/pr_description_readability.py +0 -364
  142. package/hooks/blocking/reviewer_spawn_gate.py +0 -182
  143. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +0 -62
  144. package/hooks/blocking/test_code_review_gate_deny.py +0 -54
  145. package/hooks/blocking/test_code_review_pr_create_gate.py +0 -199
  146. package/hooks/blocking/test_code_review_push_gate.py +0 -205
  147. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +0 -199
  148. package/hooks/blocking/test_code_review_stamp_store.py +0 -205
  149. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +0 -710
  150. package/hooks/blocking/test_code_verifier_tools_contract.py +0 -28
  151. package/hooks/blocking/test_convergence_gate_blocker.py +0 -428
  152. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +0 -311
  153. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +0 -184
  154. package/hooks/blocking/test_pr_description_enforcer.py +0 -221
  155. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +0 -247
  156. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +0 -493
  157. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +0 -366
  158. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +0 -159
  159. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +0 -175
  160. package/hooks/blocking/test_pr_description_enforcer_readability.py +0 -443
  161. package/hooks/blocking/test_pr_description_proof_of_work.py +0 -313
  162. package/hooks/blocking/test_reviewer_spawn_gate.py +0 -230
  163. package/hooks/blocking/test_verdict_directory_write_blocker.py +0 -804
  164. package/hooks/blocking/test_verification_verdict_store.py +0 -974
  165. package/hooks/blocking/test_verified_commit_config_bootstrap.py +0 -67
  166. package/hooks/blocking/test_verified_commit_docs_delta.py +0 -176
  167. package/hooks/blocking/test_verified_commit_gate.py +0 -581
  168. package/hooks/blocking/test_verified_commit_gate_additional_context.py +0 -134
  169. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +0 -131
  170. package/hooks/blocking/test_verifier_verdict_minter.py +0 -299
  171. package/hooks/blocking/tests/test_verified_commit_gate.py +0 -41
  172. package/hooks/blocking/verdict_directory_write_blocker.py +0 -687
  173. package/hooks/blocking/verification_verdict_store.py +0 -1039
  174. package/hooks/blocking/verified_commit_config_bootstrap.py +0 -63
  175. package/hooks/blocking/verified_commit_gate.py +0 -113
  176. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +0 -28
  177. package/hooks/blocking/verified_commit_gate_parts/__init__.py +0 -1
  178. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +0 -174
  179. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +0 -53
  180. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +0 -80
  181. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +0 -170
  182. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +0 -217
  183. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +0 -10
  184. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +0 -94
  185. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +0 -17
  186. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +0 -38
  187. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +0 -71
  188. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +0 -96
  189. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +0 -167
  190. package/hooks/blocking/verifier_verdict_minter.py +0 -280
  191. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +0 -64
  192. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +0 -53
  193. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_constants.py +0 -55
  194. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_state.py +0 -67
  195. package/hooks/hooks_constants/pr_description_enforcer_constants.py +0 -167
  196. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -107
  197. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +0 -41
  198. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +0 -292
  199. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +0 -198
  200. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +0 -283
  201. package/rules/proof-of-work-pr-comments.md +0 -22
  202. package/rules/verified-commit-gate-skip.md +0 -4
  203. package/scripts/Show-Asset.ps1 +0 -106
@@ -1,175 +0,0 @@
1
- """Entry-flow tests for the proof-of-work gates in pr_description_enforcer.
2
-
3
- Each test drives hook main() with a real Bash PreToolUse payload on stdin,
4
- mirroring test_pr_description_enforcer.py. The single gh subprocess boundary
5
- in pr_description_proof_of_work is patched with a fake runner, so command
6
- recognition, body extraction, and the proof audit run the production paths.
7
- """
8
-
9
- import contextlib
10
- import importlib.util
11
- import io
12
- import json
13
- import pathlib
14
- import sys
15
- from unittest.mock import patch
16
-
17
- import pytest
18
-
19
- _HOOK_DIR = pathlib.Path(__file__).parent
20
- _HOOKS_ROOT = _HOOK_DIR.parent
21
- if str(_HOOKS_ROOT) not in sys.path:
22
- sys.path.insert(0, str(_HOOKS_ROOT))
23
- if str(_HOOK_DIR) not in sys.path:
24
- sys.path.insert(0, str(_HOOK_DIR))
25
-
26
- from blocking import pr_description_proof_of_work as proof_module # noqa: E402
27
- from blocking import pr_description_readability as readability_module # noqa: E402
28
-
29
- hook_spec = importlib.util.spec_from_file_location(
30
- "pr_description_enforcer_proof_gate",
31
- _HOOK_DIR / "pr_description_enforcer.py",
32
- )
33
- assert hook_spec is not None
34
- assert hook_spec.loader is not None
35
- hook_module = importlib.util.module_from_spec(hook_spec)
36
- hook_spec.loader.exec_module(hook_module)
37
-
38
-
39
- @pytest.fixture(autouse=True)
40
- def _isolate_readability_state(
41
- tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
42
- ) -> None:
43
- """Redirect the three readability state files to per-test temp paths.
44
-
45
- The enabled file is written with enabled=False so readability scoring
46
- stays out of these entry-flow tests and the live state directory is
47
- never touched.
48
- """
49
- per_test_state_dir = tmp_path_factory.mktemp("proof_gate_readability_state")
50
- monkeypatch.setattr(
51
- readability_module, "READABILITY_STATE_FILE", per_test_state_dir / "strikes.json"
52
- )
53
- monkeypatch.setattr(
54
- readability_module,
55
- "READABILITY_THRESHOLD_OVERRIDE_FILE",
56
- per_test_state_dir / "overrides.json",
57
- )
58
- enabled_path = per_test_state_dir / "enabled.json"
59
- enabled_path.write_text(json.dumps({"enabled": False}))
60
- monkeypatch.setattr(readability_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
61
-
62
-
63
- PASSING_PROOF_COMMENT_BODY = (
64
- "## Summary\n\n"
65
- "Recolored the launcher icons and checked every produced asset by hand "
66
- "against the palette the plan names.\n\n"
67
- "## Verification\n\n"
68
- "```\n"
69
- "C:\\Python313\\python.exe scripts/recolor.py --input themes/dawn\n"
70
- "```\n\n"
71
- "| Artifact | Measured |\n"
72
- "| --- | --- |\n"
73
- "| icons.zip | 48 files, 412,993 bytes |\n\n"
74
- "This advances phase 2 of issue #12.\n\n"
75
- "Known gap: the offline proof cannot show the on-device rendering; the "
76
- "store preview covers that.\n"
77
- )
78
-
79
- ORDINARY_COMMENT_BODY = (
80
- "The retry logic here mirrors what the uploader does for chunked "
81
- "transfers, so the two stay in step."
82
- )
83
-
84
-
85
- def _run_hook_main_with_command(command: str) -> str:
86
- hook_input_json = json.dumps({"tool_name": "Bash", "tool_input": {"command": command}})
87
- captured_stdout = io.StringIO()
88
- with (
89
- patch("sys.stdin", io.StringIO(hook_input_json)),
90
- patch("sys.stdout", captured_stdout),
91
- contextlib.suppress(SystemExit),
92
- ):
93
- hook_module.main()
94
- return captured_stdout.getvalue()
95
-
96
-
97
- def _install_fake_gh_responses(monkeypatch, all_comment_bodies, all_diff_names):
98
- all_comment_records = [{"body": each_body} for each_body in all_comment_bodies]
99
-
100
- def _fake_run_gh_command(all_gh_arguments):
101
- if all_gh_arguments and all_gh_arguments[0] == "api":
102
- return json.dumps([all_comment_records])
103
- if "--name-only" in all_gh_arguments:
104
- return "\n".join(all_diff_names)
105
- if list(all_gh_arguments[:2]) == ["pr", "view"]:
106
- return json.dumps({"number": 123})
107
- if list(all_gh_arguments[:2]) == ["pr", "diff"]:
108
- return ""
109
- return None
110
-
111
- monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
112
-
113
-
114
- def test_main_blocks_gh_pr_ready_without_proof_comment(monkeypatch: pytest.MonkeyPatch) -> None:
115
- _install_fake_gh_responses(monkeypatch, [], ["src/parser.py"])
116
- decision_output = _run_hook_main_with_command("gh pr ready 123")
117
- assert "deny" in decision_output
118
- assert "proof" in decision_output.lower()
119
-
120
-
121
- def test_main_allows_gh_pr_ready_with_passing_proof_comment(
122
- monkeypatch: pytest.MonkeyPatch,
123
- ) -> None:
124
- _install_fake_gh_responses(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"])
125
- decision_output = _run_hook_main_with_command("gh pr ready 123")
126
- assert "deny" not in decision_output
127
-
128
-
129
- def test_main_allows_complete_proof_comment_post(
130
- tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
131
- ) -> None:
132
- _install_fake_gh_responses(monkeypatch, [], ["src/parser.py"])
133
- body_file = tmp_path / "proof.md"
134
- body_file.write_text(PASSING_PROOF_COMMENT_BODY)
135
- decision_output = _run_hook_main_with_command(f"gh pr comment 123 --body-file {body_file}")
136
- assert "deny" not in decision_output
137
-
138
-
139
- def test_main_blocks_proof_comment_missing_visual_on_visual_change(
140
- tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
141
- ) -> None:
142
- _install_fake_gh_responses(monkeypatch, [], ["assets/icon.png"])
143
- body_file = tmp_path / "proof.md"
144
- body_file.write_text(PASSING_PROOF_COMMENT_BODY)
145
- decision_output = _run_hook_main_with_command(f"gh pr comment 123 --body-file {body_file}")
146
- assert "deny" in decision_output
147
- assert "visual" in decision_output
148
-
149
-
150
- def test_main_leaves_ordinary_comment_untouched(
151
- tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
152
- ) -> None:
153
- all_recorded_calls: list[list[str]] = []
154
-
155
- def _recording_run_gh_command(all_gh_arguments):
156
- all_recorded_calls.append(list(all_gh_arguments))
157
-
158
- monkeypatch.setattr(proof_module, "_run_gh_command", _recording_run_gh_command)
159
- body_file = tmp_path / "comment.md"
160
- body_file.write_text(ORDINARY_COMMENT_BODY)
161
- decision_output = _run_hook_main_with_command(f"gh pr comment 123 --body-file {body_file}")
162
- assert "deny" not in decision_output
163
- assert all_recorded_calls == []
164
-
165
-
166
- def test_main_allows_gh_pr_ready_undo_without_queries(monkeypatch: pytest.MonkeyPatch) -> None:
167
- all_recorded_calls: list[list[str]] = []
168
-
169
- def _recording_run_gh_command(all_gh_arguments):
170
- all_recorded_calls.append(list(all_gh_arguments))
171
-
172
- monkeypatch.setattr(proof_module, "_run_gh_command", _recording_run_gh_command)
173
- decision_output = _run_hook_main_with_command("gh pr ready 123 --undo")
174
- assert "deny" not in decision_output
175
- assert all_recorded_calls == []
@@ -1,443 +0,0 @@
1
- """Unit tests for pr-description-enforcer readability scoring and state."""
2
-
3
- import importlib.util
4
- import io
5
- import json
6
- import pathlib
7
- import sys
8
-
9
- import pytest
10
-
11
- _HOOK_DIR = pathlib.Path(__file__).parent
12
- _HOOKS_ROOT = _HOOK_DIR.parent
13
- if str(_HOOKS_ROOT) not in sys.path:
14
- sys.path.insert(0, str(_HOOKS_ROOT))
15
- if str(_HOOK_DIR) not in sys.path:
16
- sys.path.insert(0, str(_HOOK_DIR))
17
-
18
- from blocking import pr_description_readability as readability_module
19
-
20
- hook_spec = importlib.util.spec_from_file_location(
21
- "pr_description_enforcer",
22
- _HOOK_DIR / "pr_description_enforcer.py",
23
- )
24
- assert hook_spec is not None
25
- assert hook_spec.loader is not None
26
- hook_module = importlib.util.module_from_spec(hook_spec)
27
- hook_spec.loader.exec_module(hook_module)
28
- validate_pr_body = hook_module.validate_pr_body
29
-
30
-
31
- @pytest.fixture(autouse=True)
32
- def _isolate_readability_state(tmp_path_factory, monkeypatch):
33
- """Redirect the three readability state files to per-test temp paths for every test.
34
-
35
- The enabled file is written with enabled=False, which isolates every test from
36
- the live state directory by setting a readability-off baseline. Tests that
37
- exercise readability scoring re-enable it via the `readability_state_paths_enabled`
38
- fixture, which re-points READABILITY_ENABLED_STATE_FILE at a fresh path whose
39
- missing enabled file defaults to enabled.
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
- @pytest.fixture
52
- def readability_state_paths_enabled(tmp_path, monkeypatch):
53
- """Redirect the three readability state files to per-test temp paths while keeping
54
- readability enabled. The autouse `_isolate_readability_state` fixture disables
55
- readability by default for unrelated tests; tests exercising strike-counter or
56
- dispatch behavior need it ON, so this fixture re-points the three state paths
57
- WITHOUT stubbing _is_readability_enabled.
58
-
59
- Returns:
60
- Tuple of (strike_path, override_path, enabled_path).
61
- """
62
- strike_path = tmp_path / "strikes.json"
63
- override_path = tmp_path / "overrides.json"
64
- enabled_path = tmp_path / "enabled.json"
65
- monkeypatch.setattr(readability_module, "READABILITY_STATE_FILE", strike_path)
66
- monkeypatch.setattr(readability_module, "READABILITY_THRESHOLD_OVERRIDE_FILE", override_path)
67
- monkeypatch.setattr(readability_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
68
- return strike_path, override_path, enabled_path
69
-
70
-
71
- def _readability_failing_body() -> str:
72
- """A Heavy-classified body whose intro sentence dramatically exceeds the
73
- max-sentence-words threshold. Wraps the long sentence in `## Problem` and
74
- `## Test plan` headers so the Heavy required-header check is satisfied
75
- and only the readability violation fires; otherwise the missing-header
76
- violations would inflate the result list and mask readability regressions
77
- behind broad `any()` substring matches."""
78
- return (
79
- "## Problem\n\n"
80
- "Adds a multi-step coordination protocol that traverses the entire "
81
- "request lifecycle through every middleware layer in the system, ensuring that "
82
- "downstream consumers observe a perfectly consistent ordering guarantee across "
83
- "all participating subsystems including the queueing component and the storage "
84
- "subsystem and the notification dispatch path that fans out to subscribers "
85
- "across every channel registered against the tenant scope including email and "
86
- "push and webhook delivery surfaces simultaneously in one transactional unit.\n\n"
87
- "## Test plan\n\n"
88
- "- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
89
- )
90
-
91
-
92
- def test_readability_strike_one_emits_metric_violation(readability_state_paths_enabled) -> None:
93
- body = _readability_failing_body()
94
- violations = validate_pr_body(body)
95
- assert any(
96
- "readability" in each_violation.lower() or "sentence" in each_violation.lower()
97
- for each_violation in violations
98
- )
99
- assert not any("--readability-loosen" in each_violation for each_violation in violations)
100
- assert readability_module._read_strike_count() == 1
101
-
102
-
103
- def test_readability_strike_two_still_metric_violation(readability_state_paths_enabled) -> None:
104
- body = _readability_failing_body()
105
- validate_pr_body(body)
106
- violations = validate_pr_body(body)
107
- assert readability_module._read_strike_count() == 2
108
- assert not any("--readability-loosen" in each_violation for each_violation in violations)
109
-
110
-
111
- def test_readability_strike_three_fires_escape_hatch(readability_state_paths_enabled) -> None:
112
- body = _readability_failing_body()
113
- validate_pr_body(body)
114
- validate_pr_body(body)
115
- violations = validate_pr_body(body)
116
- assert readability_module._read_strike_count() == 3
117
- assert any("--readability-loosen" in each_violation for each_violation in violations)
118
- assert any("--readability-disable" in each_violation for each_violation in violations)
119
- assert any("--readability-reset" in each_violation for each_violation in violations)
120
-
121
-
122
- def test_loosen_cap_errors_on_fourth_invocation(readability_state_paths_enabled) -> None:
123
- assert readability_module._apply_readability_loosen() == "ok"
124
- assert readability_module._apply_readability_loosen() == "ok"
125
- assert readability_module._apply_readability_loosen() == "ok"
126
- fourth_outcome = readability_module._apply_readability_loosen()
127
- assert fourth_outcome == "cap_reached"
128
-
129
-
130
- def test_loosen_flesch_floor_cap_errors(readability_state_paths_enabled) -> None:
131
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
132
- floor_value = readability_module.READABILITY_MIN_FLESCH_FLOOR
133
- payload = {
134
- "flesch_min": floor_value,
135
- "max_sentence_words": 30,
136
- "avg_sentence_words": 20,
137
- "loosens_used": 0,
138
- }
139
- override_path.parent.mkdir(parents=True, exist_ok=True)
140
- override_path.write_text(json.dumps(payload))
141
- assert readability_module._apply_readability_loosen() == "floor_reached"
142
-
143
-
144
- def test_loosen_max_sentence_ceiling_cap_errors(readability_state_paths_enabled) -> None:
145
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
146
- ceiling_value = readability_module.READABILITY_MAX_SENTENCE_WORDS_CEILING
147
- payload = {
148
- "flesch_min": 50,
149
- "max_sentence_words": ceiling_value,
150
- "avg_sentence_words": 20,
151
- "loosens_used": 0,
152
- }
153
- override_path.parent.mkdir(parents=True, exist_ok=True)
154
- override_path.write_text(json.dumps(payload))
155
- assert readability_module._apply_readability_loosen() == "ceiling_reached"
156
-
157
-
158
- def test_loosen_avg_sentence_ceiling_cap_errors(readability_state_paths_enabled) -> None:
159
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
160
- ceiling_value = readability_module.READABILITY_AVG_SENTENCE_WORDS_CEILING
161
- payload = {
162
- "flesch_min": 50,
163
- "max_sentence_words": 30,
164
- "avg_sentence_words": ceiling_value,
165
- "loosens_used": 0,
166
- }
167
- override_path.parent.mkdir(parents=True, exist_ok=True)
168
- override_path.write_text(json.dumps(payload))
169
- assert readability_module._apply_readability_loosen() == "ceiling_reached"
170
-
171
-
172
- def test_threshold_override_file_widens_max_sentence_words(readability_state_paths_enabled) -> None:
173
- """When max_sentence_words override is 50, the loaded thresholds reflect that value."""
174
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
175
- payload = {
176
- "flesch_min": 30,
177
- "max_sentence_words": 50,
178
- "avg_sentence_words": 40,
179
- "loosens_used": 0,
180
- }
181
- override_path.parent.mkdir(parents=True, exist_ok=True)
182
- override_path.write_text(json.dumps(payload))
183
- thresholds = readability_module._load_readability_thresholds()
184
- assert thresholds.max_sentence_words == 50
185
- assert thresholds.flesch_min == 30
186
- assert thresholds.avg_sentence_words == 40
187
-
188
-
189
- def test_loosen_writes_expected_scaled_thresholds(readability_state_paths_enabled) -> None:
190
- """First loosen invocation scales flesch by 0.9 and sentence widths by 10/9."""
191
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
192
- assert readability_module._apply_readability_loosen() == "ok"
193
- written_payload = json.loads(override_path.read_text())
194
- assert written_payload["flesch_min"] == 45
195
- assert written_payload["max_sentence_words"] == 32
196
- assert written_payload["avg_sentence_words"] == 20
197
- assert written_payload["loosens_used"] == 1
198
-
199
-
200
- def test_dispatch_loosen_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
201
- """The loosen handler writes its success message to the supplied output stream."""
202
- output_stream = io.StringIO()
203
- error_stream = io.StringIO()
204
- with pytest.raises(SystemExit) as exit_info:
205
- readability_module._dispatch_cli_flag(
206
- "--readability-loosen",
207
- output_stream=output_stream,
208
- error_stream=error_stream,
209
- )
210
- assert exit_info.value.code == 0
211
- assert "readability thresholds loosened 10%\n" == output_stream.getvalue()
212
- assert error_stream.getvalue() == ""
213
-
214
-
215
- def test_dispatch_loosen_cap_writes_to_error_stream(readability_state_paths_enabled) -> None:
216
- """When the loosen cap is hit, the handler writes the corrective message to error stream."""
217
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
218
- override_path.parent.mkdir(parents=True, exist_ok=True)
219
- override_path.write_text(
220
- json.dumps({"loosens_used": readability_module.READABILITY_LOOSEN_CAP})
221
- )
222
- output_stream = io.StringIO()
223
- error_stream = io.StringIO()
224
- with pytest.raises(SystemExit) as exit_info:
225
- readability_module._dispatch_cli_flag(
226
- "--readability-loosen",
227
- output_stream=output_stream,
228
- error_stream=error_stream,
229
- )
230
- assert exit_info.value.code == 1
231
- assert "loosen cap reached" in error_stream.getvalue()
232
- assert output_stream.getvalue() == ""
233
-
234
-
235
- def test_dispatch_loosen_floor_writes_to_error_stream(readability_state_paths_enabled) -> None:
236
- """When the floor is reached, the handler writes the corrective message to error stream."""
237
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
238
- floor_payload = {
239
- "flesch_min": readability_module.READABILITY_MIN_FLESCH_FLOOR,
240
- "max_sentence_words": 30,
241
- "avg_sentence_words": 20,
242
- "loosens_used": 0,
243
- }
244
- override_path.parent.mkdir(parents=True, exist_ok=True)
245
- override_path.write_text(json.dumps(floor_payload))
246
- output_stream = io.StringIO()
247
- error_stream = io.StringIO()
248
- with pytest.raises(SystemExit) as exit_info:
249
- readability_module._dispatch_cli_flag(
250
- "--readability-loosen",
251
- output_stream=output_stream,
252
- error_stream=error_stream,
253
- )
254
- assert exit_info.value.code == 1
255
- assert "floor/ceiling" in error_stream.getvalue()
256
- assert output_stream.getvalue() == ""
257
-
258
-
259
- def test_dispatch_reset_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
260
- """The reset handler writes its success message to the supplied output stream."""
261
- output_stream = io.StringIO()
262
- error_stream = io.StringIO()
263
- with pytest.raises(SystemExit) as exit_info:
264
- readability_module._dispatch_cli_flag(
265
- "--readability-reset",
266
- output_stream=output_stream,
267
- error_stream=error_stream,
268
- )
269
- assert exit_info.value.code == 0
270
- assert "readability strike counter and override thresholds reset\n" == output_stream.getvalue()
271
- assert error_stream.getvalue() == ""
272
-
273
-
274
- def test_dispatch_disable_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
275
- """The disable handler writes its success message to the supplied output stream."""
276
- output_stream = io.StringIO()
277
- error_stream = io.StringIO()
278
- with pytest.raises(SystemExit) as exit_info:
279
- readability_module._dispatch_cli_flag(
280
- "--readability-disable",
281
- output_stream=output_stream,
282
- error_stream=error_stream,
283
- )
284
- assert exit_info.value.code == 0
285
- assert "readability check disabled\n" == output_stream.getvalue()
286
- assert error_stream.getvalue() == ""
287
-
288
-
289
- def test_dispatch_enable_writes_success_to_output_stream(readability_state_paths_enabled) -> None:
290
- """The enable handler writes its success message to the supplied output stream."""
291
- output_stream = io.StringIO()
292
- error_stream = io.StringIO()
293
- with pytest.raises(SystemExit) as exit_info:
294
- readability_module._dispatch_cli_flag(
295
- "--readability-enable",
296
- output_stream=output_stream,
297
- error_stream=error_stream,
298
- )
299
- assert exit_info.value.code == 0
300
- assert "readability check enabled\n" == output_stream.getvalue()
301
- assert error_stream.getvalue() == ""
302
-
303
-
304
- def test_read_strike_count_clamps_negative_to_zero(readability_state_paths_enabled) -> None:
305
- """A corrupted strike-count JSON state with a negative integer must not
306
- silently bypass escalation. Reads clamp to >= 0 so subsequent increments
307
- walk the strike threshold from a sane baseline."""
308
- strike_path, _override_path, _enabled_path = readability_state_paths_enabled
309
- strike_path.parent.mkdir(parents=True, exist_ok=True)
310
- strike_path.write_text(json.dumps({"strikes": -5}))
311
- assert readability_module._read_strike_count() == 0, "negative strikes must clamp to 0"
312
-
313
-
314
- def test_increment_strike_count_clamps_negative_starting_value(
315
- readability_state_paths_enabled,
316
- ) -> None:
317
- """`_increment_strike_count` must not propagate a corrupted negative
318
- starting value. The new count after one increment from a negative
319
- baseline is exactly 1, not (negative + 1)."""
320
- strike_path, _override_path, _enabled_path = readability_state_paths_enabled
321
- strike_path.parent.mkdir(parents=True, exist_ok=True)
322
- strike_path.write_text(json.dumps({"strikes": -3}))
323
- new_count_after_increment = readability_module._increment_strike_count()
324
- assert new_count_after_increment == 1, (
325
- f"increment from negative starting value must clamp first; got {new_count_after_increment}"
326
- )
327
-
328
-
329
- def test_read_loosens_used_clamps_negative_to_zero(readability_state_paths_enabled) -> None:
330
- """A corrupted `loosens_used` JSON state with a negative integer must
331
- not silently bypass the loosen cap. Reads clamp to >= 0 so the cap
332
- check enforces the documented ceiling."""
333
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
334
- override_path.parent.mkdir(parents=True, exist_ok=True)
335
- override_path.write_text(json.dumps({"loosens_used": -2}))
336
- assert readability_module._read_loosens_used() == 0, "negative loosens_used must clamp to 0"
337
-
338
-
339
- def test_strike_count_rejects_boolean_value_as_strikes(readability_state_paths_enabled) -> None:
340
- """A corrupted strikes.json with `{"strikes": true}` must not be silently
341
- accepted as the integer 1. Python's `bool` is a subclass of `int`, so a bare
342
- `isinstance(value, int)` guard lets a malformed payload disable strike
343
- behavior without warning. The reader must explicitly exclude bool values."""
344
- strike_path, _override_path, _enabled_path = readability_state_paths_enabled
345
- strike_path.write_text('{"strikes": true}')
346
- assert readability_module._read_strike_count() == 0
347
-
348
-
349
- def test_loosens_used_rejects_boolean_value(readability_state_paths_enabled) -> None:
350
- """`{"loosens_used": true}` must read as the default 0, not coerce the bool
351
- to 1 via the `isinstance(x, int)` quirk that accepts bool."""
352
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
353
- override_path.write_text('{"loosens_used": true}')
354
- assert readability_module._read_loosens_used() == 0
355
-
356
-
357
- def test_readability_thresholds_reject_boolean_values(readability_state_paths_enabled) -> None:
358
- """A threshold field set to a boolean must fall back to the default integer,
359
- not silently coerce True to 1 or False to 0 via Python's bool-is-int quirk."""
360
- _strike_path, override_path, _enabled_path = readability_state_paths_enabled
361
- override_path.write_text(
362
- '{"flesch_min": true, "max_sentence_words": false, "avg_sentence_words": true}'
363
- )
364
- thresholds = readability_module._load_readability_thresholds()
365
- assert thresholds.flesch_min == readability_module.DEFAULT_READABILITY_THRESHOLDS.flesch_min
366
- assert (
367
- thresholds.max_sentence_words
368
- == readability_module.DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
369
- )
370
- assert (
371
- thresholds.avg_sentence_words
372
- == readability_module.DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
373
- )
374
-
375
-
376
- def test_readability_violation_strings_match_agent_doc_format() -> None:
377
- """The agent SKILL example shows the canonical readability message format
378
- (`Readability: longest sentence is N words (maximum 28); split or rewrite
379
- the longest sentence`). The hook's `_evaluate_readability_metrics` must
380
- emit the same `maximum N` / `split or rewrite` wording so users see the
381
- exact form documented in the agent file."""
382
- text_with_long_sentence = (
383
- "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu "
384
- "nu xi omicron pi rho sigma tau upsilon phi chi psi omega aleph "
385
- "beth gimel daleth he waw zayin heth teth yodh kaph lamedh mem nun."
386
- )
387
- messages_via_eval = readability_module._evaluate_readability_metrics(
388
- text_with_long_sentence, readability_module.DEFAULT_READABILITY_THRESHOLDS
389
- )
390
- joined_messages = "\n".join(messages_via_eval)
391
- assert "(maximum" in joined_messages, (
392
- f"Readability messages must use `maximum N` wording (matching agent doc); "
393
- f"got: {joined_messages!r}"
394
- )
395
- assert "split or rewrite the longest sentence" in joined_messages, (
396
- f"Longest-sentence message must end with `split or rewrite the longest sentence`; "
397
- f"got: {joined_messages!r}"
398
- )
399
-
400
-
401
- def test_compute_flesch_reading_ease_uses_named_constants() -> None:
402
- """`_compute_flesch_reading_ease` must reference the named Flesch constants
403
- rather than embed the magic literals 206.835 / 1.015 / 84.6 / 100.0 inline.
404
- Smoke-test the empty-input path returns the perfect-score default."""
405
- perfect_score = readability_module._compute_flesch_reading_ease("")
406
- assert perfect_score == readability_module.FLESCH_PERFECT_SCORE
407
- perfect_score_no_words = readability_module._compute_flesch_reading_ease(" ")
408
- assert perfect_score_no_words == readability_module.FLESCH_PERFECT_SCORE
409
-
410
-
411
- def test_extract_readability_target_text_strips_fences_before_finding_header() -> None:
412
- """`_extract_readability_target_text` must strip fenced code blocks before
413
- searching for the first structural header. Otherwise a fenced example like
414
- ```\\n## Problem\\n``` is matched as the first header and the intro / section
415
- boundaries collapse to bogus values."""
416
- body = (
417
- "Intro paragraph that should be the intro for readability analysis.\n\n"
418
- "```\n## Problem\n```\n\n"
419
- "## RealHeader\n\n"
420
- "Real first-section prose for readability measurement.\n"
421
- )
422
- target_text = readability_module._extract_readability_target_text(body)
423
- assert "Intro paragraph" in target_text, f"Intro paragraph must survive; got {target_text!r}"
424
- assert "Real first-section prose" in target_text, (
425
- f"First real section prose must follow; got {target_text!r}"
426
- )
427
-
428
-
429
- def test_single_use_helper_constants_are_inlined() -> None:
430
- """`_vowel_set`, `_sentence_split_pattern`, and `_all_cli_flag_tokens` each
431
- had exactly one consumer in production. The file-global-constants rule
432
- requires either a second caller or a move out of module scope; inlining
433
- into the single consumer is the chosen resolution. Pin that the three
434
- names are no longer module attributes so they cannot drift back."""
435
- for each_name in ("_vowel_set", "_sentence_split_pattern", "_all_cli_flag_tokens"):
436
- assert not hasattr(readability_module, each_name), (
437
- f"{each_name} must be inlined into its single consumer, not "
438
- "carried as a file-global constant."
439
- )
440
- assert not hasattr(hook_module, each_name), (
441
- f"{each_name} must be inlined into its single consumer, not "
442
- "carried as a file-global constant."
443
- )