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.
Files changed (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. 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
  ]
@@ -2,3 +2,4 @@
2
2
 
3
3
  MAX_SYS_PATH_INSERT_ISSUES: int = 25
4
4
  SYS_PATH_INSERT_GUIDANCE: str = "guard with `if <path> not in sys.path:` to avoid pushing the same entry on every reload"
5
+ SYS_PATH_INSERT_MINIMUM_ARGUMENT_COUNT: int = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.50.0",
3
+ "version": "1.50.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {