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,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
+ )