claude-dev-env 1.50.0 → 1.50.2
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/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_annotations_length.py +167 -0
- package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
- package/hooks/blocking/code_rules_comments.py +337 -0
- package/hooks/blocking/code_rules_constants_config.py +252 -0
- package/hooks/blocking/code_rules_docstrings.py +308 -0
- package/hooks/blocking/code_rules_enforcer.py +98 -5765
- package/hooks/blocking/code_rules_imports_logging.py +276 -0
- package/hooks/blocking/code_rules_magic_values.py +180 -0
- package/hooks/blocking/code_rules_mock_completeness.py +295 -0
- package/hooks/blocking/code_rules_naming_collection.py +264 -0
- package/hooks/blocking/code_rules_optional_params.py +288 -0
- package/hooks/blocking/code_rules_paths_syspath.py +186 -0
- package/hooks/blocking/code_rules_probe_chains.py +305 -0
- package/hooks/blocking/code_rules_probe_detection.py +257 -0
- package/hooks/blocking/code_rules_probe_recording.py +225 -0
- package/hooks/blocking/code_rules_scope_binding.py +151 -0
- package/hooks/blocking/code_rules_shared.py +301 -0
- package/hooks/blocking/code_rules_string_magic.py +207 -0
- package/hooks/blocking/code_rules_test_assertions.py +226 -0
- package/hooks/blocking/code_rules_test_branching_except.py +181 -0
- package/hooks/blocking/code_rules_test_isolation.py +341 -0
- package/hooks/blocking/code_rules_type_escape.py +341 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
- package/hooks/blocking/code_rules_unused_imports.py +256 -0
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/tdd_enforcer.py +31 -0
- package/hooks/blocking/test_code_rules_constants_config.py +26 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
- package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
- package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
- package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
- package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
- package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
- package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
- package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/blocking/test_tdd_enforcer.py +116 -0
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
- package/hooks/blocking/test_md_to_html_blocker.py +0 -810
|
@@ -0,0 +1,443 @@
|
|
|
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
|
+
)
|
|
@@ -574,3 +574,119 @@ def test_is_inside_dotclaude_segment_helper_matches_only_exact_segments() -> Non
|
|
|
574
574
|
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("C:\\Users\\dev\\.claude\\agent.py") is True
|
|
575
575
|
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/my.claude.helpers.py") is False
|
|
576
576
|
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/app/service.py") is False
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_should_offer_split_family_test_files_as_candidates_for_code_rules_module(
|
|
580
|
+
tmp_path: Path,
|
|
581
|
+
) -> None:
|
|
582
|
+
sandbox = _sandbox(tmp_path)
|
|
583
|
+
production_module = sandbox / "code_rules_magic_values.py"
|
|
584
|
+
string_magic_family_test = sandbox / "test_code_rules_enforcer_split_string_magic.py"
|
|
585
|
+
string_magic_family_test.write_text("def test_string_magic(): pass\n")
|
|
586
|
+
banned_family_test = sandbox / "test_code_rules_enforcer_split_banned.py"
|
|
587
|
+
banned_family_test.write_text("def test_banned(): pass\n")
|
|
588
|
+
|
|
589
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
590
|
+
|
|
591
|
+
assert string_magic_family_test in all_candidates
|
|
592
|
+
assert banned_family_test in all_candidates
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def test_should_keep_plain_stem_candidates_first_for_code_rules_module(
|
|
596
|
+
tmp_path: Path,
|
|
597
|
+
) -> None:
|
|
598
|
+
sandbox = _sandbox(tmp_path)
|
|
599
|
+
production_module = sandbox / "code_rules_magic_values.py"
|
|
600
|
+
family_test = sandbox / "test_code_rules_enforcer_split_string_magic.py"
|
|
601
|
+
family_test.write_text("def test_string_magic(): pass\n")
|
|
602
|
+
|
|
603
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
604
|
+
|
|
605
|
+
assert all_candidates[0] == sandbox / "test_code_rules_magic_values.py"
|
|
606
|
+
assert all_candidates[1] == sandbox / "code_rules_magic_values_test.py"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_should_not_offer_split_family_candidates_for_non_code_rules_module(
|
|
610
|
+
tmp_path: Path,
|
|
611
|
+
) -> None:
|
|
612
|
+
sandbox = _sandbox(tmp_path)
|
|
613
|
+
production_module = sandbox / "orders.py"
|
|
614
|
+
family_test = sandbox / "test_code_rules_enforcer_split_string_magic.py"
|
|
615
|
+
family_test.write_text("def test_string_magic(): pass\n")
|
|
616
|
+
|
|
617
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
618
|
+
|
|
619
|
+
assert family_test not in all_candidates
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def test_should_add_no_split_family_candidates_when_directory_has_none(
|
|
623
|
+
tmp_path: Path,
|
|
624
|
+
) -> None:
|
|
625
|
+
sandbox = _sandbox(tmp_path)
|
|
626
|
+
production_module = sandbox / "code_rules_magic_values.py"
|
|
627
|
+
|
|
628
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
629
|
+
|
|
630
|
+
expected_stem_candidates = [
|
|
631
|
+
sandbox / "test_code_rules_magic_values.py",
|
|
632
|
+
sandbox / "code_rules_magic_values_test.py",
|
|
633
|
+
]
|
|
634
|
+
assert all_candidates == expected_stem_candidates
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def test_should_not_offer_family_candidates_for_code_ruleset_stem(
|
|
638
|
+
tmp_path: Path,
|
|
639
|
+
) -> None:
|
|
640
|
+
sandbox = _sandbox(tmp_path)
|
|
641
|
+
production_module = sandbox / "code_ruleset.py"
|
|
642
|
+
family_test = sandbox / "test_code_rules_enforcer_split_example.py"
|
|
643
|
+
family_test.write_text("def test_detects_example(): pass\n")
|
|
644
|
+
|
|
645
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
646
|
+
|
|
647
|
+
expected_stem_candidates = [
|
|
648
|
+
sandbox / "test_code_ruleset.py",
|
|
649
|
+
sandbox / "code_ruleset_test.py",
|
|
650
|
+
]
|
|
651
|
+
assert all_candidates == expected_stem_candidates
|
|
652
|
+
assert family_test not in all_candidates
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def test_should_allow_code_rules_edit_when_fresh_split_family_sibling_exists(
|
|
656
|
+
tmp_path: Path,
|
|
657
|
+
) -> None:
|
|
658
|
+
sandbox = _sandbox(tmp_path)
|
|
659
|
+
production_module = sandbox / "code_rules_example.py"
|
|
660
|
+
production_module.write_text("def detect() -> None:\n return None\n")
|
|
661
|
+
family_test = sandbox / "test_code_rules_enforcer_split_example_concern.py"
|
|
662
|
+
family_test.write_text("def test_detects_example(): pass\n")
|
|
663
|
+
|
|
664
|
+
payload = _make_edit_payload(
|
|
665
|
+
production_module,
|
|
666
|
+
old_string="return None",
|
|
667
|
+
new_string="return None # adjusted",
|
|
668
|
+
)
|
|
669
|
+
completed = _run_hook_with_payload(payload)
|
|
670
|
+
|
|
671
|
+
assert _decision_from(completed) == "allow"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_should_deny_code_rules_edit_when_split_family_sibling_is_stale(
|
|
675
|
+
tmp_path: Path,
|
|
676
|
+
) -> None:
|
|
677
|
+
sandbox = _sandbox(tmp_path)
|
|
678
|
+
production_module = sandbox / "code_rules_example.py"
|
|
679
|
+
production_module.write_text("def detect() -> None:\n return None\n")
|
|
680
|
+
family_test = sandbox / "test_code_rules_enforcer_split_example_concern.py"
|
|
681
|
+
family_test.write_text("def test_detects_example(): pass\n")
|
|
682
|
+
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
683
|
+
os.utime(family_test, (stale_timestamp, stale_timestamp))
|
|
684
|
+
|
|
685
|
+
payload = _make_edit_payload(
|
|
686
|
+
production_module,
|
|
687
|
+
old_string="return None",
|
|
688
|
+
new_string="return None # adjusted",
|
|
689
|
+
)
|
|
690
|
+
completed = _run_hook_with_payload(payload)
|
|
691
|
+
|
|
692
|
+
assert _decision_from(completed) == "deny"
|
|
@@ -20,6 +20,9 @@ MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES: int = 5
|
|
|
20
20
|
MAX_IGNORED_MUST_CHECK_RETURN_ISSUES: int = 5
|
|
21
21
|
MAX_TYPE_ESCAPE_HATCH_ISSUES: int = 5
|
|
22
22
|
MAX_THIN_WRAPPER_ISSUES: int = 1
|
|
23
|
+
MAX_LOGGING_FSTRING_ISSUES: int = 3
|
|
24
|
+
MAX_WINDOWS_API_NONE_ISSUES: int = 3
|
|
25
|
+
MAX_E2E_TEST_NAMING_ISSUES: int = 3
|
|
23
26
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
|
|
24
27
|
|
|
25
28
|
ALL_BARE_EXCEPT_BANNED_HANDLER_NAMES: frozenset[str] = frozenset({"Exception", "BaseException"})
|
|
@@ -79,6 +79,14 @@ NOT_INSIDE_TYPE_CHECKING_BLOCK = -1
|
|
|
79
79
|
TRIPLE_QUOTE_PARITY_DIVISOR = 2
|
|
80
80
|
TRIPLE_DOUBLE_QUOTE_DELIMITER = '"""'
|
|
81
81
|
TRIPLE_SINGLE_QUOTE_DELIMITER = "'''"
|
|
82
|
+
MAX_MAGIC_VALUE_ISSUES = 3
|
|
83
|
+
STRING_LITERAL_QUOTE_PAIR_LENGTH = 2
|
|
84
|
+
MINIMUM_FSTRING_LITERAL_LENGTH = 2
|
|
85
|
+
MAX_FSTRING_STRUCTURAL_LITERAL_ISSUES = 100
|
|
86
|
+
ALL_ALLOWED_MAGIC_NUMBER_LITERALS: frozenset[str] = frozenset({"0", "1", "-1", "0.0", "1.0"})
|
|
87
|
+
ALL_NON_MAGIC_FSTRING_STRIPPED_VALUES: frozenset[str] = frozenset({"", "True", "False"})
|
|
88
|
+
DUPLICATED_FORMAT_MINIMUM_REPETITION_COUNT = 3
|
|
89
|
+
DUPLICATED_FORMAT_MINIMUM_LITERAL_CHARACTER_COUNT = 5
|
|
82
90
|
FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
|
|
83
91
|
|
|
84
92
|
ALL_COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
|
|
@@ -23,6 +23,10 @@ BLOCKQUOTE_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*>.*$", re.MULTILINE)
|
|
|
23
23
|
TABLE_ROW_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*\|.*\|.*$", re.MULTILINE)
|
|
24
24
|
LINK_TEXT_PATTERN: re.Pattern[str] = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
25
25
|
WHITESPACE_RUN_PATTERN: re.Pattern[str] = re.compile(r"\s+")
|
|
26
|
+
VAGUE_LANGUAGE_PATTERN: re.Pattern[str] = re.compile(
|
|
27
|
+
r"\b(fix(?:ed)? (?:bug|issue|it)|update(?:d)? code|minor changes|various (?:fixes|updates|improvements))\b",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
26
30
|
|
|
27
31
|
SUMMARY_HEADER: str = "## Summary"
|
|
28
32
|
PROBLEM_HEADER: str = "## Problem"
|
|
@@ -37,6 +41,7 @@ ALL_HEAVY_TESTING_HEADERS: frozenset[str] = frozenset(
|
|
|
37
41
|
{TEST_PLAN_HEADER, TESTING_HEADER, TESTS_HEADER, VERIFICATION_HEADER, VALIDATION_HEADER}
|
|
38
42
|
)
|
|
39
43
|
GH_PR_COMMAND_MIN_TOKEN_COUNT: int = 3
|
|
44
|
+
BODY_FILE_STDIN_SENTINEL: str = "-"
|
|
40
45
|
ATOMIC_WRITE_TEMP_SUFFIX: str = ".tmp"
|
|
41
46
|
SELF_CLOSING_REFERENCE_MESSAGE_PREFIX: str = "PR body references its own PR number #"
|
|
42
47
|
SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX: str = (
|
|
@@ -119,6 +124,7 @@ __all__ = [
|
|
|
119
124
|
"ATOMIC_WRITE_TEMP_SUFFIX",
|
|
120
125
|
"BLOCKQUOTE_LINE_PATTERN",
|
|
121
126
|
"BLOCKQUOTE_MARKER_PATTERN",
|
|
127
|
+
"BODY_FILE_STDIN_SENTINEL",
|
|
122
128
|
"BOLD_PAIR_PATTERN",
|
|
123
129
|
"BULLET_MARKER_PATTERN",
|
|
124
130
|
"DEFAULT_READABILITY_THRESHOLDS",
|
|
@@ -154,5 +160,6 @@ __all__ = [
|
|
|
154
160
|
"THIS_PR_OPENING_PATTERN",
|
|
155
161
|
"TRIVIAL_BODY_CHAR_THRESHOLD",
|
|
156
162
|
"TRIVIAL_SHAPE",
|
|
163
|
+
"VAGUE_LANGUAGE_PATTERN",
|
|
157
164
|
"WHITESPACE_RUN_PATTERN",
|
|
158
165
|
]
|