claude-dev-env 1.49.1 → 1.50.1
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/audit-rubrics/category_rubrics/category-a-api-contracts.md +17 -3
- package/audit-rubrics/prompts/category-a-api-contracts.md +17 -2
- package/docs/CODE_RULES.md +6 -1
- 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_enforcer.py +386 -32
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +2 -2
- 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/test_code_rules_enforcer.py +65 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +136 -5
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
- 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/hooks_constants/blocking_check_limits.py +2 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
- package/package.json +1 -1
- package/hooks/blocking/test_md_to_html_blocker.py +0 -772
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Tests for check_docstring_args_match_signature — Args:-vs-signature drift.
|
|
2
|
+
|
|
3
|
+
A documented ``Args:`` entry naming a parameter the signature lacks is the
|
|
4
|
+
stale residue of a rename. Only the ``Args:`` section is validated; ``Raises:``
|
|
5
|
+
is left alone because callee-propagated exceptions cause false positives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_enforcer_module() -> ModuleType:
|
|
16
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
17
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
18
|
+
assert spec is not None
|
|
19
|
+
assert spec.loader is not None
|
|
20
|
+
module = importlib.util.module_from_spec(spec)
|
|
21
|
+
spec.loader.exec_module(module)
|
|
22
|
+
return module
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_docstring_args_match_signature(content: str, file_path: str) -> list[str]:
|
|
29
|
+
return code_rules_enforcer.check_docstring_args_match_signature(content, file_path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
33
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
PRODUCTION_FILE_PATH = "/project/src/services.py"
|
|
37
|
+
TEST_FILE_PATH = "/project/src/test_services.py"
|
|
38
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _function_with_stale_arg() -> str:
|
|
42
|
+
return (
|
|
43
|
+
"def fetch_user(account_id: int) -> str:\n"
|
|
44
|
+
' """Look up a user by id.\n'
|
|
45
|
+
"\n"
|
|
46
|
+
" Args:\n"
|
|
47
|
+
" user_id: The user identifier.\n"
|
|
48
|
+
"\n"
|
|
49
|
+
" Returns:\n"
|
|
50
|
+
" The user name.\n"
|
|
51
|
+
' """\n'
|
|
52
|
+
" lookup = _registry.get(account_id)\n"
|
|
53
|
+
" if not lookup:\n"
|
|
54
|
+
" return ''\n"
|
|
55
|
+
" return lookup.name\n"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_should_flag_documented_arg_not_in_signature() -> None:
|
|
60
|
+
issues = check_docstring_args_match_signature(_function_with_stale_arg(), PRODUCTION_FILE_PATH)
|
|
61
|
+
assert any("user_id" in each for each in issues), (
|
|
62
|
+
f"Expected stale 'user_id' flag, got: {issues!r}"
|
|
63
|
+
)
|
|
64
|
+
assert len(issues) == 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_not_flag_when_args_match_signature() -> None:
|
|
68
|
+
source = (
|
|
69
|
+
"def fetch_user(user_id: int) -> str:\n"
|
|
70
|
+
' """Look up a user by id.\n'
|
|
71
|
+
"\n"
|
|
72
|
+
" Args:\n"
|
|
73
|
+
" user_id: The user identifier.\n"
|
|
74
|
+
"\n"
|
|
75
|
+
" Returns:\n"
|
|
76
|
+
" The user name.\n"
|
|
77
|
+
' """\n'
|
|
78
|
+
" lookup = _registry.get(user_id)\n"
|
|
79
|
+
" if not lookup:\n"
|
|
80
|
+
" return ''\n"
|
|
81
|
+
" return lookup.name\n"
|
|
82
|
+
)
|
|
83
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
84
|
+
assert issues == [], f"Matching Args must not be flagged, got: {issues!r}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_should_handle_parenthesized_type_in_arg_entry() -> None:
|
|
88
|
+
source = (
|
|
89
|
+
"def fetch_user(account_id: int) -> str:\n"
|
|
90
|
+
' """Look up a user by id.\n'
|
|
91
|
+
"\n"
|
|
92
|
+
" Args:\n"
|
|
93
|
+
" user_id (int): The user identifier.\n"
|
|
94
|
+
"\n"
|
|
95
|
+
" Returns:\n"
|
|
96
|
+
" The user name.\n"
|
|
97
|
+
' """\n'
|
|
98
|
+
" lookup = _registry.get(account_id)\n"
|
|
99
|
+
" if not lookup:\n"
|
|
100
|
+
" return ''\n"
|
|
101
|
+
" return lookup.name\n"
|
|
102
|
+
)
|
|
103
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
104
|
+
assert any("user_id" in each for each in issues), (
|
|
105
|
+
f"Parenthesized-type entry must still be parsed, got: {issues!r}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_should_count_vararg_and_kwarg_as_real_parameters() -> None:
|
|
110
|
+
source = (
|
|
111
|
+
"def collect(first: int, *extra_values, **options) -> int:\n"
|
|
112
|
+
' """Collect values.\n'
|
|
113
|
+
"\n"
|
|
114
|
+
" Args:\n"
|
|
115
|
+
" first: The leading value.\n"
|
|
116
|
+
" extra_values: Additional positional values.\n"
|
|
117
|
+
" options: Keyword overrides.\n"
|
|
118
|
+
' """\n'
|
|
119
|
+
" total = first\n"
|
|
120
|
+
" for each_value in extra_values:\n"
|
|
121
|
+
" total += each_value\n"
|
|
122
|
+
" return total\n"
|
|
123
|
+
)
|
|
124
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
125
|
+
assert issues == [], f"vararg/kwarg must count as parameters, got: {issues!r}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_should_not_flag_self_documented_when_method_uses_self() -> None:
|
|
129
|
+
source = (
|
|
130
|
+
"class Service:\n"
|
|
131
|
+
" def fetch(self, user_id: int) -> int:\n"
|
|
132
|
+
' """Fetch a record.\n'
|
|
133
|
+
"\n"
|
|
134
|
+
" Args:\n"
|
|
135
|
+
" user_id: The user identifier.\n"
|
|
136
|
+
' """\n'
|
|
137
|
+
" record = self._registry.get(user_id)\n"
|
|
138
|
+
" if record is None:\n"
|
|
139
|
+
" return 0\n"
|
|
140
|
+
" return record\n"
|
|
141
|
+
)
|
|
142
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
143
|
+
assert issues == [], f"self-using method must not be flagged, got: {issues!r}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_stop_parsing_args_at_next_section() -> None:
|
|
147
|
+
source = (
|
|
148
|
+
"def fetch_user(user_id: int) -> str:\n"
|
|
149
|
+
' """Look up a user by id.\n'
|
|
150
|
+
"\n"
|
|
151
|
+
" Args:\n"
|
|
152
|
+
" user_id: The user identifier.\n"
|
|
153
|
+
"\n"
|
|
154
|
+
" Raises:\n"
|
|
155
|
+
" LookupError: When missing.\n"
|
|
156
|
+
' """\n'
|
|
157
|
+
" lookup = _registry.get(user_id)\n"
|
|
158
|
+
" if not lookup:\n"
|
|
159
|
+
" raise LookupError('missing')\n"
|
|
160
|
+
" return lookup.name\n"
|
|
161
|
+
)
|
|
162
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
163
|
+
assert issues == [], f"Raises entries past Args must be ignored, got: {issues!r}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_should_skip_private_function() -> None:
|
|
167
|
+
source = (
|
|
168
|
+
"def _fetch(account_id: int) -> int:\n"
|
|
169
|
+
' """Fetch internally.\n'
|
|
170
|
+
"\n"
|
|
171
|
+
" Args:\n"
|
|
172
|
+
" user_id: stale name.\n"
|
|
173
|
+
' """\n'
|
|
174
|
+
" value = _registry.get(account_id)\n"
|
|
175
|
+
" return value\n"
|
|
176
|
+
)
|
|
177
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
178
|
+
assert issues == [], f"Private functions exempt, got: {issues!r}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_should_skip_short_function() -> None:
|
|
182
|
+
source = (
|
|
183
|
+
"def fetch(account_id: int) -> int:\n"
|
|
184
|
+
' """Args:\n'
|
|
185
|
+
" user_id: stale.\n"
|
|
186
|
+
' """\n'
|
|
187
|
+
" return account_id\n"
|
|
188
|
+
)
|
|
189
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
190
|
+
assert issues == [], f"Trivial-body functions exempt, got: {issues!r}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_should_skip_test_file() -> None:
|
|
194
|
+
issues = check_docstring_args_match_signature(_function_with_stale_arg(), TEST_FILE_PATH)
|
|
195
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_should_skip_hook_infrastructure() -> None:
|
|
199
|
+
issues = check_docstring_args_match_signature(
|
|
200
|
+
_function_with_stale_arg(), HOOK_INFRASTRUCTURE_PATH
|
|
201
|
+
)
|
|
202
|
+
assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
206
|
+
issues = check_docstring_args_match_signature("def fetch(\n", PRODUCTION_FILE_PATH)
|
|
207
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_validate_content_surfaces_args_drift() -> None:
|
|
211
|
+
issues = validate_content(_function_with_stale_arg(), PRODUCTION_FILE_PATH, old_content="")
|
|
212
|
+
matching_issues = [each for each in issues if "user_id" in each and "Args" in each]
|
|
213
|
+
assert matching_issues, (
|
|
214
|
+
f"Expected validate_content to surface the Args drift issue, got: {issues!r}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_should_not_flag_deeper_indented_continuation_line() -> None:
|
|
219
|
+
source = (
|
|
220
|
+
"def fetch_user(user_id: int) -> str:\n"
|
|
221
|
+
' """Look up a user by id.\n'
|
|
222
|
+
"\n"
|
|
223
|
+
" Args:\n"
|
|
224
|
+
" user_id: The user identifier. Example mapping:\n"
|
|
225
|
+
" shadow_key: not a parameter.\n"
|
|
226
|
+
"\n"
|
|
227
|
+
" Returns:\n"
|
|
228
|
+
" The user name.\n"
|
|
229
|
+
' """\n'
|
|
230
|
+
" lookup = _registry.get(user_id)\n"
|
|
231
|
+
" if not lookup:\n"
|
|
232
|
+
" return ''\n"
|
|
233
|
+
" return lookup.name\n"
|
|
234
|
+
)
|
|
235
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
236
|
+
assert issues == [], f"Continuation lines must not be parsed as args, got: {issues!r}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_should_not_flag_documented_kwargs_keys() -> None:
|
|
240
|
+
source = (
|
|
241
|
+
"def configure(timeout: int, **overrides) -> None:\n"
|
|
242
|
+
' """Configure the client.\n'
|
|
243
|
+
"\n"
|
|
244
|
+
" Args:\n"
|
|
245
|
+
" timeout: Seconds to wait.\n"
|
|
246
|
+
" max_retries: A documented keyword override.\n"
|
|
247
|
+
"\n"
|
|
248
|
+
" Returns:\n"
|
|
249
|
+
" None.\n"
|
|
250
|
+
' """\n'
|
|
251
|
+
" settings = dict(overrides)\n"
|
|
252
|
+
" settings['timeout'] = timeout\n"
|
|
253
|
+
" return None\n"
|
|
254
|
+
)
|
|
255
|
+
issues = check_docstring_args_match_signature(source, PRODUCTION_FILE_PATH)
|
|
256
|
+
assert issues == [], f"**kwargs-key docs must not be flagged, got: {issues!r}"
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"""Tests for ``check_function_length``.
|
|
2
2
|
|
|
3
|
-
Functions whose
|
|
4
|
-
inclusive
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Functions whose executable span (signature line through last body statement,
|
|
4
|
+
inclusive, minus the leading docstring lines of the function and of every
|
|
5
|
+
function or class nested within it) is at or above
|
|
6
|
+
``FUNCTION_LENGTH_BLOCKING_THRESHOLD`` (60) block the write (small-function
|
|
7
|
+
basis: Robert C. Martin, Clean Code Ch. 3 "Functions"; Google Python Style
|
|
8
|
+
Guide ~40-line function review hint — a measure of executable complexity,
|
|
9
|
+
paired with the Guide's complete-docstring mandate for public APIs). Executable
|
|
10
|
+
spans below the threshold pass silently, whatever the docstring adds to the
|
|
11
|
+
full declared span; the issue message keeps reporting the full declared span so
|
|
12
|
+
the commit gate's span recovery holds.
|
|
8
13
|
|
|
9
14
|
Cited SYNTHESIS evidence: pa#143 F4, F9, F14 (three recurrences in one PR);
|
|
10
15
|
pa#136 F20.
|
|
@@ -208,3 +213,129 @@ def test_reports_only_in_scope_violation_among_untouched_ones() -> None:
|
|
|
208
213
|
)
|
|
209
214
|
assert any("target_function" in each_issue for each_issue in issues)
|
|
210
215
|
assert not any("leading_" in each_issue for each_issue in issues)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_docstring_function_source(
|
|
219
|
+
name: str, docstring_line_count: int, body_line_count: int
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Build a function whose leading docstring spans ``docstring_line_count + 2``
|
|
222
|
+
source lines (opening summary line, the counted filler lines, closing quotes)
|
|
223
|
+
followed by ``body_line_count`` executable statements."""
|
|
224
|
+
docstring_lines = [
|
|
225
|
+
' """Documented helper.',
|
|
226
|
+
*(
|
|
227
|
+
f" documentation line {each_index}."
|
|
228
|
+
for each_index in range(docstring_line_count)
|
|
229
|
+
),
|
|
230
|
+
' """',
|
|
231
|
+
]
|
|
232
|
+
all_source_lines = [
|
|
233
|
+
f"def {name}() -> None:",
|
|
234
|
+
*docstring_lines,
|
|
235
|
+
*(
|
|
236
|
+
f" statement_{each_index} = {each_index}"
|
|
237
|
+
for each_index in range(body_line_count)
|
|
238
|
+
),
|
|
239
|
+
]
|
|
240
|
+
return "\n".join(all_source_lines) + "\n"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_docstring_heavy_function_with_small_body_passes() -> None:
|
|
244
|
+
"""A complete Google-style docstring must not push a small-bodied function
|
|
245
|
+
over the gate: the threshold measures executable lines only."""
|
|
246
|
+
source = _build_docstring_function_source(
|
|
247
|
+
"documented_compact_helper",
|
|
248
|
+
docstring_line_count=hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
249
|
+
body_line_count=5,
|
|
250
|
+
)
|
|
251
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
252
|
+
assert issues == [], f"docstring lines must not count toward the gate, got: {issues!r}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_oversized_executable_body_blocks_despite_docstring() -> None:
|
|
256
|
+
"""A docstring does not acquit a genuinely large executable body, and the
|
|
257
|
+
issue message reports the full declared span so the commit gate's
|
|
258
|
+
``function_length_span_range`` recovery keeps covering the whole function."""
|
|
259
|
+
docstring_line_count = 10
|
|
260
|
+
body_line_count = hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
|
|
261
|
+
source = _build_docstring_function_source(
|
|
262
|
+
"documented_oversized_helper",
|
|
263
|
+
docstring_line_count=docstring_line_count,
|
|
264
|
+
body_line_count=body_line_count,
|
|
265
|
+
)
|
|
266
|
+
full_declared_span = 1 + (docstring_line_count + 2) + body_line_count
|
|
267
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
268
|
+
assert any("documented_oversized_helper" in each_issue for each_issue in issues)
|
|
269
|
+
assert any(f"is {full_declared_span} lines" in each_issue for each_issue in issues)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_executable_span_boundary_sits_one_below_threshold() -> None:
|
|
273
|
+
"""With the docstring excluded, an executable span of THRESHOLD - 1 passes
|
|
274
|
+
even though the full declared span sits far above the threshold."""
|
|
275
|
+
source = _build_docstring_function_source(
|
|
276
|
+
"documented_boundary_helper",
|
|
277
|
+
docstring_line_count=20,
|
|
278
|
+
body_line_count=hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 2,
|
|
279
|
+
)
|
|
280
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
281
|
+
assert issues == []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_builder_zero_docstring_line_count_keeps_span_contract() -> None:
|
|
285
|
+
"""The builder's docstring-span contract (``docstring_line_count + 2``) holds
|
|
286
|
+
at the zero boundary, so hand-computed span oracles in tests cannot drift."""
|
|
287
|
+
source = _build_docstring_function_source(
|
|
288
|
+
"documented_minimal_helper", docstring_line_count=0, body_line_count=3
|
|
289
|
+
)
|
|
290
|
+
expected_total_lines = 1 + (0 + 2) + 3
|
|
291
|
+
assert len(source.splitlines()) == expected_total_lines
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_builder_zero_body_line_count_keeps_span_contract() -> None:
|
|
295
|
+
"""The builder's span contract holds at the zero-body boundary, so a
|
|
296
|
+
docstring-only function's hand-computed span oracle cannot drift."""
|
|
297
|
+
source = _build_docstring_function_source(
|
|
298
|
+
"documented_bodyless_helper", docstring_line_count=5, body_line_count=0
|
|
299
|
+
)
|
|
300
|
+
expected_total_lines = 1 + (5 + 2) + 0
|
|
301
|
+
assert len(source.splitlines()) == expected_total_lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_nested_function_docstring_does_not_count_toward_outer() -> None:
|
|
305
|
+
"""A nested helper's docstring is documentation too: the outer function's
|
|
306
|
+
executable span excludes every leading docstring within its declared span."""
|
|
307
|
+
nested_docstring_filler = "\n".join(
|
|
308
|
+
f" nested documentation line {each_index}."
|
|
309
|
+
for each_index in range(hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD)
|
|
310
|
+
)
|
|
311
|
+
source = (
|
|
312
|
+
"def outer_documented_orchestrator() -> None:\n"
|
|
313
|
+
" def nested_documented_helper() -> None:\n"
|
|
314
|
+
' """Documented nested helper.\n'
|
|
315
|
+
f"{nested_docstring_filler}\n"
|
|
316
|
+
' """\n'
|
|
317
|
+
" nested_statement = 1\n"
|
|
318
|
+
" outer_statement = 2\n"
|
|
319
|
+
)
|
|
320
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
321
|
+
assert issues == [], f"nested docstring lines must not count toward the gate, got: {issues!r}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_nested_class_docstring_does_not_count_toward_outer() -> None:
|
|
325
|
+
"""A nested class's docstring is documentation too: the outer function's
|
|
326
|
+
executable span excludes leading docstrings of nested classes as well."""
|
|
327
|
+
nested_class_docstring_filler = "\n".join(
|
|
328
|
+
f" nested class documentation line {each_index}."
|
|
329
|
+
for each_index in range(hook_module.FUNCTION_LENGTH_BLOCKING_THRESHOLD)
|
|
330
|
+
)
|
|
331
|
+
source = (
|
|
332
|
+
"def outer_class_documented_orchestrator() -> None:\n"
|
|
333
|
+
" class NestedDocumentedConfig:\n"
|
|
334
|
+
' """Documented nested class.\n'
|
|
335
|
+
f"{nested_class_docstring_filler}\n"
|
|
336
|
+
' """\n'
|
|
337
|
+
" nested_field = 1\n"
|
|
338
|
+
" outer_statement = 2\n"
|
|
339
|
+
)
|
|
340
|
+
issues = check_function_length(source, PRODUCTION_FILE_PATH)
|
|
341
|
+
assert issues == [], f"nested class docstring lines must not count toward the gate, got: {issues!r}"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Tests for check_ignored_must_check_return — discarded must-check outcomes.
|
|
2
|
+
|
|
3
|
+
A bare-statement call to a function in ALL_MUST_CHECK_RETURN_FUNCTION_NAMES
|
|
4
|
+
discards the only failure signal it produces. An assigned or branched-on
|
|
5
|
+
call is exempt; only bare ``ast.Expr`` calls are flagged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_enforcer_module() -> ModuleType:
|
|
16
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
17
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
18
|
+
assert spec is not None
|
|
19
|
+
assert spec.loader is not None
|
|
20
|
+
module = importlib.util.module_from_spec(spec)
|
|
21
|
+
spec.loader.exec_module(module)
|
|
22
|
+
return module
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_ignored_must_check_return(content: str, file_path: str) -> list[str]:
|
|
29
|
+
return code_rules_enforcer.check_ignored_must_check_return(content, file_path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
33
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
PRODUCTION_FILE_PATH = "/project/src/clicker.py"
|
|
37
|
+
TEST_FILE_PATH = "/project/src/test_clicker.py"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_should_flag_bare_find_and_click_call() -> None:
|
|
41
|
+
source = "def step() -> None:\n find_and_click('#submit')\n"
|
|
42
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
43
|
+
assert any("find_and_click" in each for each in issues), (
|
|
44
|
+
f"Expected discarded-return flag for find_and_click, got: {issues!r}"
|
|
45
|
+
)
|
|
46
|
+
assert len(issues) == 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_should_flag_bare_write_outcome_call() -> None:
|
|
50
|
+
source = "def step() -> None:\n write_outcome('done')\n"
|
|
51
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
52
|
+
assert any("write_outcome" in each for each in issues), (
|
|
53
|
+
f"Expected discarded-return flag for write_outcome, got: {issues!r}"
|
|
54
|
+
)
|
|
55
|
+
assert len(issues) == 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_should_flag_attribute_call_with_must_check_name() -> None:
|
|
59
|
+
source = "def step() -> None:\n self.find_and_click('#submit')\n"
|
|
60
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
61
|
+
assert len(issues) == 1, f"Attribute call terminal name must be resolved, got: {issues!r}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_not_flag_assigned_find_and_click() -> None:
|
|
65
|
+
source = "def step() -> None:\n clicked = find_and_click('#submit')\n print(clicked)\n"
|
|
66
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
67
|
+
assert issues == [], f"Assigned call must not be flagged, got: {issues!r}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_not_flag_branched_find_and_click() -> None:
|
|
71
|
+
source = "def step() -> None:\n if find_and_click('#submit'):\n pass\n"
|
|
72
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
73
|
+
assert issues == [], f"Branched-on call must not be flagged, got: {issues!r}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_should_flag_bare_awaited_find_and_click_call() -> None:
|
|
77
|
+
source = "async def step() -> None:\n await find_and_click('#x')\n"
|
|
78
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
79
|
+
assert any("find_and_click" in each for each in issues), (
|
|
80
|
+
f"Expected discarded-return flag for awaited find_and_click, got: {issues!r}"
|
|
81
|
+
)
|
|
82
|
+
assert len(issues) == 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_not_flag_assigned_awaited_find_and_click() -> None:
|
|
86
|
+
source = (
|
|
87
|
+
"async def step() -> None:\n"
|
|
88
|
+
" clicked = await find_and_click('#x')\n"
|
|
89
|
+
" print(clicked)\n"
|
|
90
|
+
)
|
|
91
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
92
|
+
assert issues == [], f"Assigned awaited call must not be flagged, got: {issues!r}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_should_not_flag_branched_awaited_find_and_click() -> None:
|
|
96
|
+
source = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
|
|
97
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
98
|
+
assert issues == [], f"Branched-on awaited call must not be flagged, got: {issues!r}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_should_not_flag_unrelated_bare_call() -> None:
|
|
102
|
+
source = "def step() -> None:\n print('hello')\n"
|
|
103
|
+
issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
|
|
104
|
+
assert issues == [], f"Unrelated call must not be flagged, got: {issues!r}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_skip_test_file() -> None:
|
|
108
|
+
source = "def step() -> None:\n find_and_click('#submit')\n"
|
|
109
|
+
issues = check_ignored_must_check_return(source, TEST_FILE_PATH)
|
|
110
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
114
|
+
issues = check_ignored_must_check_return("def step(\n", PRODUCTION_FILE_PATH)
|
|
115
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_validate_content_surfaces_discarded_return() -> None:
|
|
119
|
+
source = "def step() -> None:\n find_and_click('#submit')\n"
|
|
120
|
+
issues = validate_content(source, PRODUCTION_FILE_PATH, old_content="")
|
|
121
|
+
matching_issues = [each for each in issues if "find_and_click" in each]
|
|
122
|
+
assert matching_issues, (
|
|
123
|
+
f"Expected validate_content to surface the discarded-return issue, got: {issues!r}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
EDIT_FULL_MODULE_SOURCE = (
|
|
128
|
+
"async def step() -> None:\n"
|
|
129
|
+
" await find_and_click('#x')\n"
|
|
130
|
+
)
|
|
131
|
+
AWAITED_CALL_LINE_NUMBER = 2
|
|
132
|
+
UNCHANGED_LINE_NUMBER = 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_flag_when_changed_line_covers_the_bare_await() -> None:
|
|
136
|
+
all_changed_lines = {AWAITED_CALL_LINE_NUMBER}
|
|
137
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
138
|
+
EDIT_FULL_MODULE_SOURCE,
|
|
139
|
+
PRODUCTION_FILE_PATH,
|
|
140
|
+
all_changed_lines,
|
|
141
|
+
False,
|
|
142
|
+
)
|
|
143
|
+
assert len(issues) == 1, (
|
|
144
|
+
f"An Edit touching the bare await line must surface exactly one issue, got: {issues!r}"
|
|
145
|
+
)
|
|
146
|
+
assert "find_and_click" in issues[0]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_not_flag_when_changed_line_excludes_the_bare_await() -> None:
|
|
150
|
+
all_changed_lines = {UNCHANGED_LINE_NUMBER}
|
|
151
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
152
|
+
EDIT_FULL_MODULE_SOURCE,
|
|
153
|
+
PRODUCTION_FILE_PATH,
|
|
154
|
+
all_changed_lines,
|
|
155
|
+
False,
|
|
156
|
+
)
|
|
157
|
+
assert issues == [], (
|
|
158
|
+
f"A pre-existing violation on an unedited line must not block the edit, got: {issues!r}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
PRE_EXISTING_BARE_CALL_COUNT = 5
|
|
163
|
+
EDITED_BARE_CALL_LINE_NUMBER = PRE_EXISTING_BARE_CALL_COUNT + 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _build_module_with_pre_existing_violations_before_the_edit() -> str:
|
|
167
|
+
all_signature_lines = ["async def step() -> None:"]
|
|
168
|
+
all_pre_existing_call_lines = [
|
|
169
|
+
f" await find_and_click('#x{each_index}')"
|
|
170
|
+
for each_index in range(PRE_EXISTING_BARE_CALL_COUNT)
|
|
171
|
+
]
|
|
172
|
+
edited_call_line = " await find_and_click('#edited')"
|
|
173
|
+
all_lines = all_signature_lines + all_pre_existing_call_lines + [edited_call_line]
|
|
174
|
+
return "\n".join(all_lines) + "\n"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_should_flag_edited_line_even_when_cap_worth_of_violations_precede_it() -> None:
|
|
178
|
+
source = _build_module_with_pre_existing_violations_before_the_edit()
|
|
179
|
+
all_changed_lines = {EDITED_BARE_CALL_LINE_NUMBER}
|
|
180
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
181
|
+
source,
|
|
182
|
+
PRODUCTION_FILE_PATH,
|
|
183
|
+
all_changed_lines,
|
|
184
|
+
False,
|
|
185
|
+
)
|
|
186
|
+
assert len(issues) == 1, (
|
|
187
|
+
"Collecting every violation before scoping must surface the edited-line "
|
|
188
|
+
f"violation even with a cap's worth of earlier out-of-scope calls, got: {issues!r}"
|
|
189
|
+
)
|
|
190
|
+
assert f"Line {EDITED_BARE_CALL_LINE_NUMBER}:" in issues[0], (
|
|
191
|
+
f"The single issue must name the edited line {EDITED_BARE_CALL_LINE_NUMBER}, got: {issues!r}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_module_with_more_than_cap_bare_calls() -> tuple[str, int]:
|
|
196
|
+
bare_call_count = code_rules_enforcer.MAX_IGNORED_MUST_CHECK_RETURN_ISSUES + 3
|
|
197
|
+
all_signature_lines = ["async def step() -> None:"]
|
|
198
|
+
all_call_lines = [
|
|
199
|
+
f" await find_and_click('#x{each_index}')"
|
|
200
|
+
for each_index in range(bare_call_count)
|
|
201
|
+
]
|
|
202
|
+
source = "\n".join(all_signature_lines + all_call_lines) + "\n"
|
|
203
|
+
return source, bare_call_count
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_deferred_scope_returns_every_violation_uncapped() -> None:
|
|
207
|
+
source, bare_call_count = _build_module_with_more_than_cap_bare_calls()
|
|
208
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
209
|
+
source,
|
|
210
|
+
PRODUCTION_FILE_PATH,
|
|
211
|
+
None,
|
|
212
|
+
True,
|
|
213
|
+
)
|
|
214
|
+
assert len(issues) == bare_call_count, (
|
|
215
|
+
"With defer_scope_to_caller=True the gate must see every violation uncapped "
|
|
216
|
+
f"so it can scope by added line, got: {issues!r}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_terminal_scope_caps_violations_at_the_module_limit() -> None:
|
|
221
|
+
source, _ = _build_module_with_more_than_cap_bare_calls()
|
|
222
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
223
|
+
source,
|
|
224
|
+
PRODUCTION_FILE_PATH,
|
|
225
|
+
None,
|
|
226
|
+
False,
|
|
227
|
+
)
|
|
228
|
+
assert len(issues) == code_rules_enforcer.MAX_IGNORED_MUST_CHECK_RETURN_ISSUES, (
|
|
229
|
+
"The terminal hook path with all_changed_lines=None must cap at the module "
|
|
230
|
+
f"limit, got: {issues!r}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
WRAPPED_CALL_OPEN_PAREN_LINE_NUMBER = 2
|
|
235
|
+
WRAPPED_CALL_ARGUMENT_LINE_NUMBER = 3
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_should_flag_when_changed_line_covers_a_later_line_of_a_wrapped_call() -> None:
|
|
239
|
+
source = (
|
|
240
|
+
"def step() -> None:\n"
|
|
241
|
+
" find_and_click(\n"
|
|
242
|
+
" '#submit',\n"
|
|
243
|
+
" )\n"
|
|
244
|
+
)
|
|
245
|
+
all_changed_lines = {WRAPPED_CALL_ARGUMENT_LINE_NUMBER}
|
|
246
|
+
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
247
|
+
source,
|
|
248
|
+
PRODUCTION_FILE_PATH,
|
|
249
|
+
all_changed_lines,
|
|
250
|
+
False,
|
|
251
|
+
)
|
|
252
|
+
assert len(issues) == 1, (
|
|
253
|
+
"Editing a later line of a multi-line bare must-check call must still flag it "
|
|
254
|
+
f"because the violation span covers the whole call, got: {issues!r}"
|
|
255
|
+
)
|
|
256
|
+
assert "find_and_click" in issues[0]
|