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,247 @@
|
|
|
1
|
+
"""Unit tests for pr-description-enforcer body markdown and shape helpers."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import re as _re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
9
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
10
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
12
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pr_description_enforcer_constants import ALL_HEAVY_OPENING_HEADERS
|
|
16
|
+
|
|
17
|
+
body_audit_spec = importlib.util.spec_from_file_location(
|
|
18
|
+
"pr_description_body_audit",
|
|
19
|
+
_HOOK_DIR / "pr_description_body_audit.py",
|
|
20
|
+
)
|
|
21
|
+
assert body_audit_spec is not None
|
|
22
|
+
assert body_audit_spec.loader is not None
|
|
23
|
+
hook_module = importlib.util.module_from_spec(body_audit_spec)
|
|
24
|
+
body_audit_spec.loader.exec_module(hook_module)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_heavy_body(opening_header: str, testing_header: str) -> str:
|
|
28
|
+
intro_text = (
|
|
29
|
+
"Adds shape-aware validation across the pr-description-enforcer pipeline. "
|
|
30
|
+
"The change unifies the body audit with the Anthropic claude-code style "
|
|
31
|
+
"so heavy PRs carry both an opening header and a testing header."
|
|
32
|
+
)
|
|
33
|
+
return (
|
|
34
|
+
f"{intro_text}\n\n"
|
|
35
|
+
f"{opening_header}\n\n"
|
|
36
|
+
"The earlier flow rejected too many valid bodies on equivalence checks "
|
|
37
|
+
"across the three shape categories described in the guide. The fix "
|
|
38
|
+
"restructures the path around shape detection and surfaces the missing "
|
|
39
|
+
"category in the block message so the agent can correct it on first try.\n\n"
|
|
40
|
+
f"{testing_header}\n\n"
|
|
41
|
+
"- `pytest packages/claude-dev-env/hooks/blocking/test_pr_description_enforcer.py`\n"
|
|
42
|
+
"- Manual smoke test against the implementation PR with a sample heavy body\n"
|
|
43
|
+
"- Run the readability check across the full corpus to confirm thresholds hold\n"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_compute_pr_body_shape_trivial() -> None:
|
|
48
|
+
"""A short single-sentence body with zero headers classifies as Trivial."""
|
|
49
|
+
body = "Pin third-party GitHub Actions references to immutable commit SHAs."
|
|
50
|
+
assert hook_module._compute_pr_body_shape(body) == "trivial"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_compute_pr_body_shape_standard() -> None:
|
|
54
|
+
"""A medium body with one ## header below the Heavy threshold classifies as Standard."""
|
|
55
|
+
body = (
|
|
56
|
+
"Adds a timestamp check to prevent background data pulls from overwriting "
|
|
57
|
+
"recent local edits. The pull engine compares the last-modified marker "
|
|
58
|
+
"before deciding whether to apply a remote record.\n\n"
|
|
59
|
+
"## Changes\n\n"
|
|
60
|
+
"- `pullEngine.ts`: compare lastModified before overwriting\n"
|
|
61
|
+
"- `pullEngine.test.ts`: 3 new cases\n"
|
|
62
|
+
)
|
|
63
|
+
assert hook_module._compute_pr_body_shape(body) == "standard"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_compute_pr_body_shape_heavy() -> None:
|
|
67
|
+
"""A long body with two Heavy-detection headers classifies as Heavy."""
|
|
68
|
+
body = _build_heavy_body("## Problem", "## Test plan")
|
|
69
|
+
assert hook_module._compute_pr_body_shape(body) == "heavy"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_first_non_empty_line_helper_is_removed() -> None:
|
|
73
|
+
"""`_first_non_empty_line` was the basis of the prior ceremony-on-Trivial
|
|
74
|
+
check, which now uses `_iter_section_headers`. The helper has no remaining
|
|
75
|
+
call sites; pin its removal so it cannot drift back as dead code."""
|
|
76
|
+
assert not hasattr(hook_module, "_first_non_empty_line"), (
|
|
77
|
+
"_first_non_empty_line must be removed; the ceremony-on-Trivial check "
|
|
78
|
+
"now reads through _iter_section_headers instead."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_strip_leading_hash_lines_helper_is_removed() -> None:
|
|
83
|
+
"""The unused leading-hash stripper must not exist as a module attribute."""
|
|
84
|
+
assert not hasattr(hook_module, "_strip_leading_hash_lines")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_strip_markdown_ceremony_returns_stripped_prose() -> None:
|
|
88
|
+
"""The shared markdown stripper removes fences, inline code, blockquotes,
|
|
89
|
+
headings, bullets, bold, emphasis, and Markdown link targets, leaving the
|
|
90
|
+
underlying prose intact."""
|
|
91
|
+
body = "\n".join(
|
|
92
|
+
[
|
|
93
|
+
"# Heading text",
|
|
94
|
+
"> blockquoted content",
|
|
95
|
+
"- bullet content",
|
|
96
|
+
"**bold body**",
|
|
97
|
+
"*emphasized body*",
|
|
98
|
+
"[link label](https://example.com)",
|
|
99
|
+
"`inline code body`",
|
|
100
|
+
"```",
|
|
101
|
+
"fenced code body",
|
|
102
|
+
"```",
|
|
103
|
+
"plain prose line",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
stripped = hook_module.strip_markdown_ceremony(body)
|
|
107
|
+
assert "Heading text" not in stripped
|
|
108
|
+
assert "blockquoted content" in stripped
|
|
109
|
+
assert "bullet content" in stripped
|
|
110
|
+
assert "bold body" in stripped
|
|
111
|
+
assert "emphasized body" in stripped
|
|
112
|
+
assert "link label" in stripped
|
|
113
|
+
assert "plain prose line" in stripped
|
|
114
|
+
assert "inline code body" not in stripped
|
|
115
|
+
assert "fenced code body" not in stripped
|
|
116
|
+
assert "https://example.com" not in stripped
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_strip_markdown_ceremony_used_by_substantive_prose_count() -> None:
|
|
120
|
+
"""_count_substantive_prose_chars is consistent with the shared stripper:
|
|
121
|
+
its returned count matches len of the whitespace-collapsed stripped body."""
|
|
122
|
+
body = "# Heading\n\nA single paragraph of prose with **bold** and `code` words."
|
|
123
|
+
stripped = hook_module.strip_markdown_ceremony(body)
|
|
124
|
+
collapsed = _re.sub(r"\s+", " ", stripped).strip()
|
|
125
|
+
assert hook_module._count_substantive_prose_chars(body) == len(collapsed)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_shape_classifier_uses_substantive_chars_not_raw_length() -> None:
|
|
129
|
+
"""Shape classifier and ceremony-on-Trivial check must agree on the metric used
|
|
130
|
+
against TRIVIAL_BODY_CHAR_THRESHOLD. A body whose raw length passes the
|
|
131
|
+
threshold but whose substantive prose does not (e.g. tiny prose with a large
|
|
132
|
+
fenced code block) is genuinely Trivial in shape -- not Standard."""
|
|
133
|
+
tiny_prose_with_large_code_fence = "Done.\n\n```\n" + ("x" * 300) + "\n```"
|
|
134
|
+
assert len(tiny_prose_with_large_code_fence) >= hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
|
|
135
|
+
assert (
|
|
136
|
+
hook_module._count_substantive_prose_chars(tiny_prose_with_large_code_fence)
|
|
137
|
+
< hook_module.TRIVIAL_BODY_CHAR_THRESHOLD
|
|
138
|
+
)
|
|
139
|
+
assert hook_module._compute_pr_body_shape(tiny_prose_with_large_code_fence) == "trivial"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_body_contains_any_header_rejects_plural_extension() -> None:
|
|
143
|
+
"""`_body_contains_any_header` must enforce a word boundary after the
|
|
144
|
+
canonical header text. `## Problems` (plural) extends the canonical
|
|
145
|
+
word and must NOT satisfy `## Problem`, otherwise the Heavy
|
|
146
|
+
required-header check is weaker than the documented contract."""
|
|
147
|
+
body_with_plural_extension = "## Problems\n\nDetails follow."
|
|
148
|
+
candidate_set = frozenset({"## Problem"})
|
|
149
|
+
assert not hook_module._body_contains_any_header(body_with_plural_extension, candidate_set), (
|
|
150
|
+
"`## Problems` must NOT satisfy `## Problem` (different header)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_body_contains_any_header_accepts_punctuation_suffix() -> None:
|
|
155
|
+
"""The boundary rule must still accept canonical headers followed by
|
|
156
|
+
non-word punctuation: colon, em-dash, parenthesis, trailing whitespace.
|
|
157
|
+
Reviewers write `## Problem (context)` and `## Test plan: scope` —
|
|
158
|
+
these must continue to satisfy the canonical headers."""
|
|
159
|
+
candidate_set = frozenset({"## Problem"})
|
|
160
|
+
for each_body in [
|
|
161
|
+
"## Problem\n\nDetails.",
|
|
162
|
+
"## Problem:\n\nDetails.",
|
|
163
|
+
"## Problem (context)\n\nDetails.",
|
|
164
|
+
"## Problem — context\n\nDetails.",
|
|
165
|
+
]:
|
|
166
|
+
assert hook_module._body_contains_any_header(each_body, candidate_set), (
|
|
167
|
+
f"`{each_body!r}` must satisfy `## Problem` (punctuation/space follows)"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_body_contains_any_header_rejects_alphanumeric_suffix() -> None:
|
|
172
|
+
"""`## Problem2`, `## ProblemX`, `## Problem_one` are different headers
|
|
173
|
+
and must not match `## Problem`."""
|
|
174
|
+
candidate_set = frozenset({"## Problem"})
|
|
175
|
+
for each_body in [
|
|
176
|
+
"## Problem2\n\nDetails.",
|
|
177
|
+
"## ProblemX\n\nDetails.",
|
|
178
|
+
"## Problem_one\n\nDetails.",
|
|
179
|
+
]:
|
|
180
|
+
assert not hook_module._body_contains_any_header(each_body, candidate_set), (
|
|
181
|
+
f"`{each_body!r}` must NOT satisfy `## Problem` (alphanumeric continuation)"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_iter_section_headers_ignores_headings_inside_fenced_code_blocks() -> None:
|
|
186
|
+
"""Headings nested inside ``` ... ``` fences are example content, not body headers.
|
|
187
|
+
The shape classifier and the Heavy required-header check must agree with the markdown
|
|
188
|
+
stripper -- the body of this very test demonstrates the regression."""
|
|
189
|
+
body = (
|
|
190
|
+
"Intro paragraph that does not classify the body.\n\n```\n## Problem\n## Test plan\n```\n"
|
|
191
|
+
)
|
|
192
|
+
headers = hook_module._iter_section_headers(body)
|
|
193
|
+
assert headers == [], f"Expected zero headers (fenced content), got {headers}"
|
|
194
|
+
assert hook_module._compute_pr_body_shape(body) != "heavy", (
|
|
195
|
+
"Body with only fenced example headers must not classify as heavy"
|
|
196
|
+
)
|
|
197
|
+
assert hook_module._body_contains_any_header(body, ALL_HEAVY_OPENING_HEADERS) is False, (
|
|
198
|
+
"Heavy opening-header check must not see fenced example content"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_long_body_without_heavy_headers_still_classifies_heavy() -> None:
|
|
203
|
+
"""The Heavy required-header check in `validate_pr_body` only runs when
|
|
204
|
+
`_compute_pr_body_shape` returns HEAVY. Previously the classifier required
|
|
205
|
+
BOTH length >= 500 chars AND >= 2 heavy detection headers, which meant a
|
|
206
|
+
long body missing the required headers entirely was classified Standard
|
|
207
|
+
and silently bypassed the missing-header enforcement. Length alone must
|
|
208
|
+
drive the HEAVY classification so the validator can enforce the rule."""
|
|
209
|
+
long_body_with_no_heavy_headers = (
|
|
210
|
+
"Refactors the request-pipeline batcher to coalesce idempotent calls "
|
|
211
|
+
"before the network round-trip. The change touches the dispatcher, the "
|
|
212
|
+
"retry loop, the error normalizer, and three downstream consumers. "
|
|
213
|
+
"Every test in the integration suite continues to pass without "
|
|
214
|
+
"modification because the public contract is unchanged.\n\n"
|
|
215
|
+
"The new coalescer reads a per-call digest, looks up an in-flight slot "
|
|
216
|
+
"indexed by that digest, and appends the caller's promise to the slot "
|
|
217
|
+
"instead of dispatching a duplicate request. Once the network response "
|
|
218
|
+
"arrives, every queued promise resolves with the same value. Error "
|
|
219
|
+
"responses propagate to every queued promise so retry logic stays "
|
|
220
|
+
"consistent with the prior contract.\n"
|
|
221
|
+
)
|
|
222
|
+
assert (
|
|
223
|
+
hook_module._count_substantive_prose_chars(long_body_with_no_heavy_headers)
|
|
224
|
+
>= hook_module.HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION
|
|
225
|
+
)
|
|
226
|
+
assert (
|
|
227
|
+
hook_module._compute_pr_body_shape(long_body_with_no_heavy_headers)
|
|
228
|
+
== hook_module.HEAVY_SHAPE
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_compute_pr_body_shape_uses_named_shape_constants() -> None:
|
|
233
|
+
"""`_compute_pr_body_shape` returns the centralised shape names rather than
|
|
234
|
+
inline string literals. Confirm the constants flow through end-to-end."""
|
|
235
|
+
trivial_body = "Bump bun to 1.3.14."
|
|
236
|
+
assert hook_module._compute_pr_body_shape(trivial_body) == hook_module.TRIVIAL_SHAPE
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_iter_section_headers_docstring_matches_actual_pattern() -> None:
|
|
240
|
+
"""`_iter_section_headers` uses `HEADING_LINE_PATTERN = ^#+`, so it returns
|
|
241
|
+
every ATX heading level (`#`, `##`, `###`...), not just `##`. The docstring
|
|
242
|
+
must describe that actual contract so callers cannot be misled."""
|
|
243
|
+
docstring = hook_module._iter_section_headers.__doc__ or ""
|
|
244
|
+
assert "every ATX heading" in docstring or "any heading level" in docstring, (
|
|
245
|
+
f"_iter_section_headers docstring must document that it matches every "
|
|
246
|
+
f"heading level (`HEADING_LINE_PATTERN` is `^#+`); got: {docstring!r}"
|
|
247
|
+
)
|