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
|
@@ -1,2669 +0,0 @@
|
|
|
1
|
-
"""Tests covering file-global constant reference resolution edge cases.
|
|
2
|
-
|
|
3
|
-
Loop2-C: class-decorator usage of a module-level constant must count as a
|
|
4
|
-
caller so the single-caller rule fires correctly.
|
|
5
|
-
|
|
6
|
-
Loop2-D: module-scope usages must register as a distinct caller bucket so
|
|
7
|
-
the "zero function references" exemption does not swallow real references.
|
|
8
|
-
|
|
9
|
-
Loop1-1: scope-bounded assertion collection — nested function/class bodies
|
|
10
|
-
inside compound statements must not have their assertions attributed to the
|
|
11
|
-
enclosing test function.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import ast
|
|
17
|
-
import importlib.util
|
|
18
|
-
import io
|
|
19
|
-
import json
|
|
20
|
-
import sys
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
from types import ModuleType
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _load_enforcer_module() -> ModuleType:
|
|
26
|
-
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
27
|
-
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
28
|
-
assert spec is not None
|
|
29
|
-
assert spec.loader is not None
|
|
30
|
-
module = importlib.util.module_from_spec(spec)
|
|
31
|
-
spec.loader.exec_module(module)
|
|
32
|
-
return module
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
code_rules_enforcer = _load_enforcer_module()
|
|
36
|
-
|
|
37
|
-
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
38
|
-
_HOOKS_TREE_DIR = _BLOCKING_DIR.parent
|
|
39
|
-
if str(_BLOCKING_DIR) not in sys.path:
|
|
40
|
-
sys.path.insert(0, str(_BLOCKING_DIR))
|
|
41
|
-
if str(_HOOKS_TREE_DIR) not in sys.path:
|
|
42
|
-
sys.path.insert(0, str(_HOOKS_TREE_DIR))
|
|
43
|
-
|
|
44
|
-
from code_rules_path_utils import is_config_file as path_utils_is_config_file # noqa: E402
|
|
45
|
-
from hooks_constants.banned_identifiers_constants import ( # noqa: E402
|
|
46
|
-
ALL_BANNED_IDENTIFIERS as config_all_banned_identifiers,
|
|
47
|
-
BANNED_IDENTIFIER_MESSAGE_SUFFIX as config_banned_identifier_message_suffix,
|
|
48
|
-
BANNED_IDENTIFIER_SKIP_ADVISORY as config_banned_identifier_skip_advisory,
|
|
49
|
-
MAX_BANNED_IDENTIFIER_ISSUES as config_max_banned_identifier_issues,
|
|
50
|
-
)
|
|
51
|
-
from hooks_constants.hardcoded_user_path_constants import ( # noqa: E402
|
|
52
|
-
HARDCODED_USER_PATH_GUIDANCE as config_hardcoded_user_path_guidance,
|
|
53
|
-
HARDCODED_USER_PATH_PATTERN as config_hardcoded_user_path_pattern,
|
|
54
|
-
MAX_HARDCODED_USER_PATH_ISSUES as config_max_hardcoded_user_path_issues,
|
|
55
|
-
)
|
|
56
|
-
from hooks_constants.stuttering_check_config import ( # noqa: E402
|
|
57
|
-
MAX_STUTTERING_PREFIX_ISSUES as config_max_stuttering_prefix_issues,
|
|
58
|
-
STUTTERING_ALL_PREFIX_PATTERN as config_stuttering_all_prefix_pattern,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
PRODUCTION_FILE_PATH = "packages/claude-dev-env/hooks/blocking/example_production.py"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_should_treat_repo_relative_hook_path_as_hook_infrastructure() -> None:
|
|
65
|
-
relative_hook_path = "packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py"
|
|
66
|
-
assert code_rules_enforcer.is_hook_infrastructure(relative_hook_path) is True
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_should_treat_backslash_repo_relative_hook_path_as_hook_infrastructure() -> None:
|
|
70
|
-
relative_hook_path = "packages\\claude-dev-env\\hooks\\blocking\\code_rules_enforcer.py"
|
|
71
|
-
assert code_rules_enforcer.is_hook_infrastructure(relative_hook_path) is True
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_should_not_treat_unrelated_repo_relative_path_as_hook_infrastructure() -> None:
|
|
75
|
-
relative_source_path = "packages/claude-dev-env/skills/bugteam/scripts/runner.py"
|
|
76
|
-
assert code_rules_enforcer.is_hook_infrastructure(relative_source_path) is False
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_should_exempt_repo_relative_hook_file_from_function_length() -> None:
|
|
80
|
-
body_lines = "\n".join(f" bound_{each_index} = {each_index}" for each_index in range(70))
|
|
81
|
-
grown_function_source = "def grown_function() -> None:\n" + body_lines + "\n"
|
|
82
|
-
relative_hook_path = "packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py"
|
|
83
|
-
assert code_rules_enforcer.check_function_length(grown_function_source, relative_hook_path) == []
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_should_expose_all_banned_identifiers_from_config() -> None:
|
|
87
|
-
expected_banned_identifiers = frozenset({
|
|
88
|
-
"result", "data", "output", "response", "value", "item", "temp",
|
|
89
|
-
"argv", "args", "kwargs", "argc",
|
|
90
|
-
})
|
|
91
|
-
actual_banned_identifiers = getattr(
|
|
92
|
-
code_rules_enforcer, "ALL_BANNED_IDENTIFIERS", None
|
|
93
|
-
)
|
|
94
|
-
assert actual_banned_identifiers is not None, (
|
|
95
|
-
"Renamed constant ALL_BANNED_IDENTIFIERS must be importable from "
|
|
96
|
-
"config/banned_identifiers_constants.py and re-exposed on the "
|
|
97
|
-
f"enforcer module, got: {actual_banned_identifiers!r}"
|
|
98
|
-
)
|
|
99
|
-
assert expected_banned_identifiers <= actual_banned_identifiers, (
|
|
100
|
-
"ALL_BANNED_IDENTIFIERS must contain every expected banned identifier; "
|
|
101
|
-
f"missing: {expected_banned_identifiers - actual_banned_identifiers!r}"
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def test_should_source_banned_identifier_companion_constants_from_config() -> None:
|
|
106
|
-
assert (
|
|
107
|
-
code_rules_enforcer.MAX_BANNED_IDENTIFIER_ISSUES
|
|
108
|
-
is config_max_banned_identifier_issues
|
|
109
|
-
)
|
|
110
|
-
assert (
|
|
111
|
-
code_rules_enforcer.BANNED_IDENTIFIER_MESSAGE_SUFFIX
|
|
112
|
-
is config_banned_identifier_message_suffix
|
|
113
|
-
)
|
|
114
|
-
assert (
|
|
115
|
-
code_rules_enforcer.BANNED_IDENTIFIER_SKIP_ADVISORY
|
|
116
|
-
is config_banned_identifier_skip_advisory
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def test_should_reexport_hardcoded_user_path_pattern_from_config() -> None:
|
|
121
|
-
assert code_rules_enforcer.HARDCODED_USER_PATH_PATTERN is config_hardcoded_user_path_pattern
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_should_reexport_max_hardcoded_user_path_issues_from_config() -> None:
|
|
125
|
-
assert code_rules_enforcer.MAX_HARDCODED_USER_PATH_ISSUES == config_max_hardcoded_user_path_issues
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_should_reexport_hardcoded_user_path_guidance_from_config() -> None:
|
|
129
|
-
assert code_rules_enforcer.HARDCODED_USER_PATH_GUIDANCE == config_hardcoded_user_path_guidance
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def test_should_reexport_all_banned_identifiers_from_config() -> None:
|
|
133
|
-
assert code_rules_enforcer.ALL_BANNED_IDENTIFIERS is config_all_banned_identifiers
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def test_should_flag_constant_used_only_in_class_level_decorator() -> None:
|
|
137
|
-
source = (
|
|
138
|
-
"TIMEOUT = 5\n"
|
|
139
|
-
"\n"
|
|
140
|
-
"def register(value):\n"
|
|
141
|
-
" def wrap(cls):\n"
|
|
142
|
-
" return cls\n"
|
|
143
|
-
" return wrap\n"
|
|
144
|
-
"\n"
|
|
145
|
-
"@register(TIMEOUT)\n"
|
|
146
|
-
"class Foo:\n"
|
|
147
|
-
" pass\n"
|
|
148
|
-
)
|
|
149
|
-
issues = code_rules_enforcer.check_file_global_constants_use_count(
|
|
150
|
-
source, PRODUCTION_FILE_PATH
|
|
151
|
-
)
|
|
152
|
-
assert any(
|
|
153
|
-
"TIMEOUT" in issue and "only 1 function/method" in issue for issue in issues
|
|
154
|
-
), f"Expected class-decorator usage to register as a caller, got: {issues}"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def test_should_flag_constant_used_once_at_module_scope_and_once_in_function() -> None:
|
|
158
|
-
source = "UPPER = 1\nSHADOW = UPPER\n\ndef lonely_caller():\n return UPPER\n"
|
|
159
|
-
issues = code_rules_enforcer.check_file_global_constants_use_count(
|
|
160
|
-
source, PRODUCTION_FILE_PATH
|
|
161
|
-
)
|
|
162
|
-
assert issues == [], (
|
|
163
|
-
f"Expected module-scope + function usage to count as 2 distinct callers, got: {issues}"
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
UNUSED_OPTIONAL_PRODUCTION_FILE_PATH = "packages/app/services/feature.py"
|
|
168
|
-
UNUSED_OPTIONAL_TEST_FILE_PATH = "packages/app/tests/test_feature.py"
|
|
169
|
-
UNUSED_OPTIONAL_CONFIG_FILE_PATH = "packages/app/config/constants.py"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def test_should_flag_optional_param_never_varied_in_file() -> None:
|
|
173
|
-
source = (
|
|
174
|
-
"def build_url(path: str, prefix: str = '/api') -> str:\n"
|
|
175
|
-
" return f'{prefix}{path}'\n"
|
|
176
|
-
"\n"
|
|
177
|
-
"def call_first() -> str:\n"
|
|
178
|
-
" return build_url('/users')\n"
|
|
179
|
-
"\n"
|
|
180
|
-
"def call_second() -> str:\n"
|
|
181
|
-
" return build_url('/items')\n"
|
|
182
|
-
)
|
|
183
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
184
|
-
source, UNUSED_OPTIONAL_PRODUCTION_FILE_PATH
|
|
185
|
-
)
|
|
186
|
-
assert any("prefix" in issue for issue in issues), (
|
|
187
|
-
f"Expected 'prefix' flagged as never-varied, got: {issues}"
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def test_should_not_flag_when_param_is_varied_at_call_site() -> None:
|
|
192
|
-
source = (
|
|
193
|
-
"def build_url(path: str, prefix: str = '/api') -> str:\n"
|
|
194
|
-
" return f'{prefix}{path}'\n"
|
|
195
|
-
"\n"
|
|
196
|
-
"def call_with_default() -> str:\n"
|
|
197
|
-
" return build_url('/users')\n"
|
|
198
|
-
"\n"
|
|
199
|
-
"def call_with_override() -> str:\n"
|
|
200
|
-
" return build_url('/items', prefix='/v2')\n"
|
|
201
|
-
)
|
|
202
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
203
|
-
source, UNUSED_OPTIONAL_PRODUCTION_FILE_PATH
|
|
204
|
-
)
|
|
205
|
-
assert not any("prefix" in issue for issue in issues), (
|
|
206
|
-
f"Expected 'prefix' not flagged when varied, got: {issues}"
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def test_should_not_flag_unused_optional_in_test_files() -> None:
|
|
211
|
-
source = (
|
|
212
|
-
"def build_url(path: str, prefix: str = '/api') -> str:\n"
|
|
213
|
-
" return f'{prefix}{path}'\n"
|
|
214
|
-
"\n"
|
|
215
|
-
"def call_first() -> str:\n"
|
|
216
|
-
" return build_url('/users')\n"
|
|
217
|
-
)
|
|
218
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
219
|
-
source, UNUSED_OPTIONAL_TEST_FILE_PATH
|
|
220
|
-
)
|
|
221
|
-
assert issues == [], f"Expected no issues in test file, got: {issues}"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def test_should_not_flag_unused_optional_in_config_files() -> None:
|
|
225
|
-
source = (
|
|
226
|
-
"def build_url(path: str, prefix: str = '/api') -> str:\n"
|
|
227
|
-
" return f'{prefix}{path}'\n"
|
|
228
|
-
"\n"
|
|
229
|
-
"def call_first() -> str:\n"
|
|
230
|
-
" return build_url('/users')\n"
|
|
231
|
-
)
|
|
232
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
233
|
-
source, UNUSED_OPTIONAL_CONFIG_FILE_PATH
|
|
234
|
-
)
|
|
235
|
-
assert issues == [], f"Expected no issues in config file, got: {issues}"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def test_should_not_flag_when_no_same_file_call_sites_exist() -> None:
|
|
239
|
-
source = (
|
|
240
|
-
"def build_url(path: str, prefix: str = '/api') -> str:\n"
|
|
241
|
-
" return f'{prefix}{path}'\n"
|
|
242
|
-
)
|
|
243
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
244
|
-
source, UNUSED_OPTIONAL_PRODUCTION_FILE_PATH
|
|
245
|
-
)
|
|
246
|
-
assert issues == [], (
|
|
247
|
-
f"Expected no issues when no same-file call sites, got: {issues}"
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def test_should_include_line_number_and_param_name_in_issue() -> None:
|
|
252
|
-
source = (
|
|
253
|
-
"def fetch(url: str, timeout: int = 30) -> str:\n"
|
|
254
|
-
" return get(url, timeout=timeout)\n"
|
|
255
|
-
"\n"
|
|
256
|
-
"def run_fetch() -> str:\n"
|
|
257
|
-
" return fetch('http://example.com')\n"
|
|
258
|
-
)
|
|
259
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
260
|
-
source, UNUSED_OPTIONAL_PRODUCTION_FILE_PATH
|
|
261
|
-
)
|
|
262
|
-
assert any("Line 1" in issue and "timeout" in issue for issue in issues), (
|
|
263
|
-
f"Expected issue with line number and param name, got: {issues}"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def test_should_flag_when_every_call_passes_the_exact_default() -> None:
|
|
268
|
-
source = (
|
|
269
|
-
"def fetch(url: str, timeout: int = 30) -> str:\n"
|
|
270
|
-
" return get(url, timeout=timeout)\n"
|
|
271
|
-
"\n"
|
|
272
|
-
"def run_fetch() -> str:\n"
|
|
273
|
-
" return fetch('http://example.com', timeout=30)\n"
|
|
274
|
-
)
|
|
275
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
276
|
-
source, UNUSED_OPTIONAL_PRODUCTION_FILE_PATH
|
|
277
|
-
)
|
|
278
|
-
assert any("timeout" in issue for issue in issues), (
|
|
279
|
-
f"Expected 'timeout' flagged when every call passes the exact default, got: {issues}"
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
INCOMPLETE_MOCK_TEST_FILE_PATH = "packages/app/tests/test_orders.py"
|
|
284
|
-
INCOMPLETE_MOCK_PRODUCTION_FILE_PATH = "packages/app/services/orders.py"
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def test_should_advise_when_mock_missing_accessed_field(capsys: object) -> None:
|
|
288
|
-
source = (
|
|
289
|
-
"mock_order = {'id': 1}\n"
|
|
290
|
-
"\n"
|
|
291
|
-
"def test_order_total() -> None:\n"
|
|
292
|
-
" total = mock_order['total']\n"
|
|
293
|
-
" assert total > 0\n"
|
|
294
|
-
)
|
|
295
|
-
code_rules_enforcer.check_incomplete_mocks(source, INCOMPLETE_MOCK_TEST_FILE_PATH)
|
|
296
|
-
captured = getattr(capsys, "readouterr")()
|
|
297
|
-
assert "mock_order" in captured.err and "total" in captured.err, (
|
|
298
|
-
f"Expected advisory about missing 'total' field, got: {captured.err!r}"
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def test_should_not_advise_when_mock_has_all_accessed_fields(capsys: object) -> None:
|
|
303
|
-
source = (
|
|
304
|
-
"mock_order = {'id': 1, 'total': 50}\n"
|
|
305
|
-
"\n"
|
|
306
|
-
"def test_order_total() -> None:\n"
|
|
307
|
-
" total = mock_order['total']\n"
|
|
308
|
-
" assert total > 0\n"
|
|
309
|
-
)
|
|
310
|
-
code_rules_enforcer.check_incomplete_mocks(source, INCOMPLETE_MOCK_TEST_FILE_PATH)
|
|
311
|
-
captured = getattr(capsys, "readouterr")()
|
|
312
|
-
assert "mock_order" not in captured.err, (
|
|
313
|
-
f"Expected no advisory when all fields present, got: {captured.err!r}"
|
|
314
|
-
)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def test_should_not_advise_for_incomplete_mocks_in_production_files(capsys: object) -> None:
|
|
318
|
-
source = (
|
|
319
|
-
"mock_order = {'id': 1}\n"
|
|
320
|
-
"\n"
|
|
321
|
-
"def run_order() -> None:\n"
|
|
322
|
-
" total = mock_order['total']\n"
|
|
323
|
-
)
|
|
324
|
-
code_rules_enforcer.check_incomplete_mocks(source, INCOMPLETE_MOCK_PRODUCTION_FILE_PATH)
|
|
325
|
-
captured = getattr(capsys, "readouterr")()
|
|
326
|
-
assert "mock_order" not in captured.err, (
|
|
327
|
-
f"Expected no advisory in production file, got: {captured.err!r}"
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
def test_should_advise_for_attribute_access_on_mock_object(capsys: object) -> None:
|
|
332
|
-
source = (
|
|
333
|
-
"class MockUser:\n"
|
|
334
|
-
" pass\n"
|
|
335
|
-
"\n"
|
|
336
|
-
"mock_user = MockUser()\n"
|
|
337
|
-
"mock_user.name = 'Alice'\n"
|
|
338
|
-
"\n"
|
|
339
|
-
"def test_user_email() -> None:\n"
|
|
340
|
-
" email = mock_user.email\n"
|
|
341
|
-
" assert email\n"
|
|
342
|
-
)
|
|
343
|
-
code_rules_enforcer.check_incomplete_mocks(source, INCOMPLETE_MOCK_TEST_FILE_PATH)
|
|
344
|
-
captured = getattr(capsys, "readouterr")()
|
|
345
|
-
assert "mock_user" in captured.err and "email" in captured.err, (
|
|
346
|
-
f"Expected advisory about missing 'email' attribute, got: {captured.err!r}"
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
DUPLICATED_FORMAT_PRODUCTION_FILE_PATH = "packages/app/services/api_client.py"
|
|
351
|
-
DUPLICATED_FORMAT_TEST_FILE_PATH = "packages/app/tests/test_api_client.py"
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def test_should_advise_when_fstring_skeleton_appears_three_or_more_times(capsys: object) -> None:
|
|
355
|
-
source = (
|
|
356
|
-
"def get_user(user_id: str) -> str:\n"
|
|
357
|
-
" return f'/api/{user_id}'\n"
|
|
358
|
-
"\n"
|
|
359
|
-
"def get_order(order_id: str) -> str:\n"
|
|
360
|
-
" return f'/api/{order_id}'\n"
|
|
361
|
-
"\n"
|
|
362
|
-
"def get_product(product_id: str) -> str:\n"
|
|
363
|
-
" return f'/api/{product_id}'\n"
|
|
364
|
-
)
|
|
365
|
-
code_rules_enforcer.check_duplicated_format_patterns(
|
|
366
|
-
source, DUPLICATED_FORMAT_PRODUCTION_FILE_PATH
|
|
367
|
-
)
|
|
368
|
-
captured = getattr(capsys, "readouterr")()
|
|
369
|
-
assert "/api/" in captured.err and "3" in captured.err, (
|
|
370
|
-
f"Expected advisory for repeated /api/<x> pattern, got: {captured.err!r}"
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def test_should_not_advise_when_fstring_skeleton_appears_fewer_than_three_times(capsys: object) -> None:
|
|
375
|
-
source = (
|
|
376
|
-
"def get_user(user_id: str) -> str:\n"
|
|
377
|
-
" return f'/api/{user_id}'\n"
|
|
378
|
-
"\n"
|
|
379
|
-
"def get_order(order_id: str) -> str:\n"
|
|
380
|
-
" return f'/api/{order_id}'\n"
|
|
381
|
-
)
|
|
382
|
-
code_rules_enforcer.check_duplicated_format_patterns(
|
|
383
|
-
source, DUPLICATED_FORMAT_PRODUCTION_FILE_PATH
|
|
384
|
-
)
|
|
385
|
-
captured = getattr(capsys, "readouterr")()
|
|
386
|
-
assert "/api/" not in captured.err, (
|
|
387
|
-
f"Expected no advisory for pattern appearing only twice, got: {captured.err!r}"
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def test_should_not_advise_for_duplicated_format_patterns_in_test_files(capsys: object) -> None:
|
|
392
|
-
source = (
|
|
393
|
-
"def test_user() -> None:\n"
|
|
394
|
-
" url_a = f'/api/{1}'\n"
|
|
395
|
-
" url_b = f'/api/{2}'\n"
|
|
396
|
-
" url_c = f'/api/{3}'\n"
|
|
397
|
-
)
|
|
398
|
-
code_rules_enforcer.check_duplicated_format_patterns(
|
|
399
|
-
source, DUPLICATED_FORMAT_TEST_FILE_PATH
|
|
400
|
-
)
|
|
401
|
-
captured = getattr(capsys, "readouterr")()
|
|
402
|
-
assert "/api/" not in captured.err, (
|
|
403
|
-
f"Expected no advisory in test file, got: {captured.err!r}"
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def test_should_advise_with_distinct_skeletons(capsys: object) -> None:
|
|
408
|
-
source = (
|
|
409
|
-
"def first(team: str, user: str) -> str:\n"
|
|
410
|
-
" return f'/teams/{team}/users/{user}'\n"
|
|
411
|
-
"\n"
|
|
412
|
-
"def second(team: str, role: str) -> str:\n"
|
|
413
|
-
" return f'/teams/{team}/users/{role}'\n"
|
|
414
|
-
"\n"
|
|
415
|
-
"def third(team: str, admin: str) -> str:\n"
|
|
416
|
-
" return f'/teams/{team}/users/{admin}'\n"
|
|
417
|
-
)
|
|
418
|
-
code_rules_enforcer.check_duplicated_format_patterns(
|
|
419
|
-
source, DUPLICATED_FORMAT_PRODUCTION_FILE_PATH
|
|
420
|
-
)
|
|
421
|
-
captured = getattr(capsys, "readouterr")()
|
|
422
|
-
assert "/teams/" in captured.err, (
|
|
423
|
-
f"Expected advisory for repeated /teams/<x>/users/<x> pattern, got: {captured.err!r}"
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def test_build_fstring_skeleton_preserves_literal_interp_substring() -> None:
|
|
428
|
-
joined_str_expression = ast.parse("f'PREFIX INTERP {value} SUFFIX'", mode="eval").body
|
|
429
|
-
assert isinstance(joined_str_expression, ast.JoinedStr)
|
|
430
|
-
skeleton = code_rules_enforcer._build_fstring_skeleton(joined_str_expression)
|
|
431
|
-
assert skeleton == "PREFIX INTERP <x> SUFFIX", (
|
|
432
|
-
"Literal 'INTERP' text inside an f-string must survive skeleton building — "
|
|
433
|
-
f"only interpolation slots should become '<x>'. Got: {skeleton!r}"
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
CONSTANT_EQUALITY_TEST_FILE_PATH = "packages/app/tests/test_constants.py"
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def test_should_not_flag_two_named_constants_compared_to_each_other() -> None:
|
|
441
|
-
source = (
|
|
442
|
-
"FOO = 'a'\n"
|
|
443
|
-
"BAR = 'b'\n"
|
|
444
|
-
"\n"
|
|
445
|
-
"def test_constants_differ() -> None:\n"
|
|
446
|
-
" assert FOO == BAR\n"
|
|
447
|
-
)
|
|
448
|
-
issues = code_rules_enforcer.check_constant_equality_tests(
|
|
449
|
-
source, CONSTANT_EQUALITY_TEST_FILE_PATH
|
|
450
|
-
)
|
|
451
|
-
assert issues == [], (
|
|
452
|
-
f"Expected no flag when both sides are named constants, got: {issues}"
|
|
453
|
-
)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
def test_should_flag_named_constant_compared_to_literal() -> None:
|
|
457
|
-
source = (
|
|
458
|
-
"FOO = 'a'\n"
|
|
459
|
-
"\n"
|
|
460
|
-
"def test_foo_value() -> None:\n"
|
|
461
|
-
" assert FOO == 'literal'\n"
|
|
462
|
-
)
|
|
463
|
-
issues = code_rules_enforcer.check_constant_equality_tests(
|
|
464
|
-
source, CONSTANT_EQUALITY_TEST_FILE_PATH
|
|
465
|
-
)
|
|
466
|
-
assert any("constant-value test" in issue for issue in issues), (
|
|
467
|
-
f"Expected flag when UPPER_SNAKE compared to literal, got: {issues}"
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
NESTED_FUNCTION_PRODUCTION_FILE_PATH = "packages/app/services/nested.py"
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def test_should_not_flag_nested_function_optional_param() -> None:
|
|
475
|
-
source = (
|
|
476
|
-
"def outer() -> None:\n"
|
|
477
|
-
" def inner(timeout: int = 30) -> None:\n"
|
|
478
|
-
" pass\n"
|
|
479
|
-
" inner()\n"
|
|
480
|
-
" inner()\n"
|
|
481
|
-
)
|
|
482
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
483
|
-
source, NESTED_FUNCTION_PRODUCTION_FILE_PATH
|
|
484
|
-
)
|
|
485
|
-
assert not any("timeout" in issue for issue in issues), (
|
|
486
|
-
f"Expected nested function 'timeout' not flagged, got: {issues}"
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
def test_should_advise_when_mock_defined_inside_test_function_is_incomplete(
|
|
491
|
-
capsys: object,
|
|
492
|
-
) -> None:
|
|
493
|
-
source = (
|
|
494
|
-
"def test_thing() -> None:\n"
|
|
495
|
-
" mock_user = {'name': 'x'}\n"
|
|
496
|
-
" assert mock_user['email'] == 'y'\n"
|
|
497
|
-
)
|
|
498
|
-
code_rules_enforcer.check_incomplete_mocks(source, INCOMPLETE_MOCK_TEST_FILE_PATH)
|
|
499
|
-
captured = getattr(capsys, "readouterr")()
|
|
500
|
-
assert "mock_user" in captured.err and "email" in captured.err, (
|
|
501
|
-
f"Expected advisory for mock defined inside test function, got: {captured.err!r}"
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
def test_should_emit_advisories_for_incomplete_mocks_and_format_patterns_via_validate_content(
|
|
506
|
-
capsys: object,
|
|
507
|
-
) -> None:
|
|
508
|
-
incomplete_mock_source = (
|
|
509
|
-
"mock_order = {'id': 1}\n"
|
|
510
|
-
"\n"
|
|
511
|
-
"def test_order_total() -> None:\n"
|
|
512
|
-
" total = mock_order['total']\n"
|
|
513
|
-
" assert total > 0\n"
|
|
514
|
-
)
|
|
515
|
-
code_rules_enforcer.validate_content(
|
|
516
|
-
incomplete_mock_source, INCOMPLETE_MOCK_TEST_FILE_PATH
|
|
517
|
-
)
|
|
518
|
-
captured = getattr(capsys, "readouterr")()
|
|
519
|
-
assert "mock_order" in captured.err and "total" in captured.err, (
|
|
520
|
-
f"Expected incomplete-mock advisory from validate_content, got: {captured.err!r}"
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
repeated_pattern_source = (
|
|
524
|
-
"def get_user(user_id: str) -> str:\n"
|
|
525
|
-
" return f'/api/{user_id}'\n"
|
|
526
|
-
"\n"
|
|
527
|
-
"def get_order(order_id: str) -> str:\n"
|
|
528
|
-
" return f'/api/{order_id}'\n"
|
|
529
|
-
"\n"
|
|
530
|
-
"def get_product(product_id: str) -> str:\n"
|
|
531
|
-
" return f'/api/{product_id}'\n"
|
|
532
|
-
)
|
|
533
|
-
code_rules_enforcer.validate_content(
|
|
534
|
-
repeated_pattern_source, DUPLICATED_FORMAT_PRODUCTION_FILE_PATH
|
|
535
|
-
)
|
|
536
|
-
captured = getattr(capsys, "readouterr")()
|
|
537
|
-
assert "/api/" in captured.err and "3" in captured.err, (
|
|
538
|
-
f"Expected duplicated-format advisory from validate_content, got: {captured.err!r}"
|
|
539
|
-
)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
SCOPE_KEYED_MOCK_TEST_FILE_PATH = "packages/app/tests/test_scope_mocks.py"
|
|
543
|
-
KWARGS_EXPANSION_PRODUCTION_FILE_PATH = "packages/app/services/fetcher.py"
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def test_should_check_each_scope_mock_against_its_own_field_set(capsys: object) -> None:
|
|
547
|
-
"""Same mock_user name in two test functions with different field sets.
|
|
548
|
-
|
|
549
|
-
First function defines mock_user with only 'id'; accesses 'email' — should warn.
|
|
550
|
-
Second function defines mock_user with 'id' and 'email'; accesses 'email' — no warn.
|
|
551
|
-
The second definition must NOT overwrite the first scope's tracking.
|
|
552
|
-
"""
|
|
553
|
-
source = (
|
|
554
|
-
"def test_first_scope() -> None:\n"
|
|
555
|
-
" mock_user = {'id': 1}\n"
|
|
556
|
-
" email = mock_user['email']\n"
|
|
557
|
-
"\n"
|
|
558
|
-
"def test_second_scope() -> None:\n"
|
|
559
|
-
" mock_user = {'id': 2, 'email': 'b@b.com'}\n"
|
|
560
|
-
" email = mock_user['email']\n"
|
|
561
|
-
)
|
|
562
|
-
code_rules_enforcer.check_incomplete_mocks(source, SCOPE_KEYED_MOCK_TEST_FILE_PATH)
|
|
563
|
-
captured = getattr(capsys, "readouterr")()
|
|
564
|
-
advisory_lines = [
|
|
565
|
-
line for line in captured.err.splitlines() if "mock_user" in line and "email" in line
|
|
566
|
-
]
|
|
567
|
-
assert len(advisory_lines) == 1, (
|
|
568
|
-
f"Expected exactly 1 advisory (first scope missing email), got: {captured.err!r}"
|
|
569
|
-
)
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def test_should_emit_exactly_one_advisory_for_repeated_accesses_to_same_missing_field(
|
|
573
|
-
capsys: object,
|
|
574
|
-
) -> None:
|
|
575
|
-
"""mock_user accessed 5 times for 'email' but email is missing — emit exactly one advisory."""
|
|
576
|
-
source = (
|
|
577
|
-
"def test_repeated_access() -> None:\n"
|
|
578
|
-
" mock_user = {'id': 1}\n"
|
|
579
|
-
" _ = mock_user['email']\n"
|
|
580
|
-
" _ = mock_user['email']\n"
|
|
581
|
-
" _ = mock_user['email']\n"
|
|
582
|
-
" _ = mock_user['email']\n"
|
|
583
|
-
" _ = mock_user['email']\n"
|
|
584
|
-
)
|
|
585
|
-
code_rules_enforcer.check_incomplete_mocks(source, SCOPE_KEYED_MOCK_TEST_FILE_PATH)
|
|
586
|
-
captured = getattr(capsys, "readouterr")()
|
|
587
|
-
advisory_lines = [
|
|
588
|
-
line for line in captured.err.splitlines() if "mock_user" in line and "email" in line
|
|
589
|
-
]
|
|
590
|
-
assert len(advisory_lines) == 1, (
|
|
591
|
-
f"Expected exactly 1 advisory for 5 repeated accesses to missing 'email', got: {captured.err!r}"
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
def test_should_not_flag_optional_param_when_only_call_site_uses_kwargs_expansion() -> None:
|
|
596
|
-
"""A call using **defaults passes unknown values — the param must NOT be flagged."""
|
|
597
|
-
source = (
|
|
598
|
-
"def fetch(url: str, timeout: int = 30) -> str:\n"
|
|
599
|
-
" return url\n"
|
|
600
|
-
"\n"
|
|
601
|
-
"def run() -> str:\n"
|
|
602
|
-
" defaults = {'timeout': 30}\n"
|
|
603
|
-
" return fetch('http://example.com', **defaults)\n"
|
|
604
|
-
)
|
|
605
|
-
issues = code_rules_enforcer.check_unused_optional_parameters(
|
|
606
|
-
source, KWARGS_EXPANSION_PRODUCTION_FILE_PATH
|
|
607
|
-
)
|
|
608
|
-
assert not any("timeout" in issue for issue in issues), (
|
|
609
|
-
f"Expected 'timeout' NOT flagged when call uses **kwargs expansion, got: {issues}"
|
|
610
|
-
)
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
MODULE_LEVEL_MOCK_TEST_FILE_PATH = "packages/app/tests/test_module_level.py"
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
def test_should_emit_exactly_one_advisory_for_module_level_mock_with_missing_field(
|
|
617
|
-
capsys: object,
|
|
618
|
-
) -> None:
|
|
619
|
-
"""Module-level mock_user with one missing field access should produce ONE advisory.
|
|
620
|
-
|
|
621
|
-
Finding 4: ast.walk() already yields the root Module node, so
|
|
622
|
-
[module_tree, *ast.walk(module_tree)] iterates the module twice and
|
|
623
|
-
previously produced two identical advisories for module-level mocks.
|
|
624
|
-
"""
|
|
625
|
-
source = (
|
|
626
|
-
"mock_user = {'name': 'Alice'}\n"
|
|
627
|
-
"\n"
|
|
628
|
-
"def test_email_present() -> None:\n"
|
|
629
|
-
" email = mock_user['email']\n"
|
|
630
|
-
" assert email\n"
|
|
631
|
-
)
|
|
632
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
633
|
-
captured = getattr(capsys, "readouterr")()
|
|
634
|
-
advisory_lines = [
|
|
635
|
-
line for line in captured.err.splitlines() if "mock_user" in line and "email" in line
|
|
636
|
-
]
|
|
637
|
-
assert len(advisory_lines) == 1, (
|
|
638
|
-
f"Expected exactly 1 advisory for module-level mock missing 'email', got: {captured.err!r}"
|
|
639
|
-
)
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def test_is_config_file_rejects_filename_only_config_pattern() -> None:
|
|
643
|
-
"""Paths where 'config' appears only in the filename (not as a directory segment) must return False."""
|
|
644
|
-
assert code_rules_enforcer.is_config_file("scripts/db/config.py") is False, (
|
|
645
|
-
"scripts/db/config.py — filename is config.py but parent dir is db, must be False"
|
|
646
|
-
)
|
|
647
|
-
assert code_rules_enforcer.is_config_file("lib/myconfig.py") is False, (
|
|
648
|
-
"lib/myconfig.py — config appears only in the filename stem, must be False"
|
|
649
|
-
)
|
|
650
|
-
assert code_rules_enforcer.is_config_file("src/app_config.py") is False, (
|
|
651
|
-
"src/app_config.py — config appears only in the filename stem, must be False"
|
|
652
|
-
)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
def test_is_config_file_via_path_utils_returns_same_results_as_enforcer() -> None:
|
|
656
|
-
"""is_config_file from code_rules_path_utils must agree with the enforcer on all sample paths."""
|
|
657
|
-
all_sample_paths = [
|
|
658
|
-
"scripts/db/config.py",
|
|
659
|
-
"config/timing.py",
|
|
660
|
-
"settings.py",
|
|
661
|
-
]
|
|
662
|
-
for each_path in all_sample_paths:
|
|
663
|
-
enforcer_result = code_rules_enforcer.is_config_file(each_path)
|
|
664
|
-
path_utils_result = path_utils_is_config_file(each_path)
|
|
665
|
-
assert enforcer_result == path_utils_result, (
|
|
666
|
-
f"is_config_file diverged for {each_path!r}: "
|
|
667
|
-
f"enforcer={enforcer_result}, code_rules_path_utils={path_utils_result}"
|
|
668
|
-
)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def test_is_exempt_for_advisory_scan_returns_true_for_config_file() -> None:
|
|
672
|
-
assert code_rules_enforcer._is_exempt_for_advisory_scan("project/config/constants.py") is True
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def test_is_exempt_for_advisory_scan_returns_true_for_test_file() -> None:
|
|
676
|
-
assert code_rules_enforcer._is_exempt_for_advisory_scan("test_example.py") is True
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
def test_is_exempt_for_advisory_scan_returns_true_for_workflow_registry() -> None:
|
|
680
|
-
assert code_rules_enforcer._is_exempt_for_advisory_scan("app/workflow/states.py") is True
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def test_is_exempt_for_advisory_scan_returns_true_for_migration() -> None:
|
|
684
|
-
assert code_rules_enforcer._is_exempt_for_advisory_scan("app/migrations/0001_initial.py") is True
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
def test_is_exempt_for_advisory_scan_returns_false_for_production_file() -> None:
|
|
688
|
-
assert code_rules_enforcer._is_exempt_for_advisory_scan("packages/myapp/some_module.py") is False
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
def test_scan_function_body_constants_finds_upper_snake_in_function() -> None:
|
|
692
|
-
source = (
|
|
693
|
-
"def fetch():\n"
|
|
694
|
-
" MAX_RETRIES = 3\n"
|
|
695
|
-
" for attempt in range(MAX_RETRIES):\n"
|
|
696
|
-
" pass\n"
|
|
697
|
-
)
|
|
698
|
-
advisory_issues = code_rules_enforcer._scan_function_body_constants(source)
|
|
699
|
-
assert any("MAX_RETRIES" in issue for issue in advisory_issues)
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
def test_scan_function_body_constants_does_not_flag_module_level() -> None:
|
|
703
|
-
source = "MAX_RETRIES = 3\n\ndef fetch():\n pass\n"
|
|
704
|
-
advisory_issues = code_rules_enforcer._scan_function_body_constants(source)
|
|
705
|
-
assert advisory_issues == []
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
def test_advisory_should_not_flag_class_attribute_after_method_def() -> None:
|
|
709
|
-
source_with_class_attribute_after_method = (
|
|
710
|
-
"class ExampleModel:\n"
|
|
711
|
-
" def method_a(self) -> None:\n"
|
|
712
|
-
" pass\n"
|
|
713
|
-
"\n"
|
|
714
|
-
" TABLE_NAME = \"example\"\n"
|
|
715
|
-
)
|
|
716
|
-
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
717
|
-
source_with_class_attribute_after_method,
|
|
718
|
-
"example_module.py",
|
|
719
|
-
)
|
|
720
|
-
assert advisory_issues == [], (
|
|
721
|
-
"Class-level TABLE_NAME attribute must not be flagged as function-local"
|
|
722
|
-
)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
def test_advisory_should_still_flag_actual_method_body_constant() -> None:
|
|
726
|
-
source_with_method_body_constant = (
|
|
727
|
-
"class ExampleModel:\n"
|
|
728
|
-
" def method_a(self) -> None:\n"
|
|
729
|
-
" MAXIMUM_RETRIES = 3\n"
|
|
730
|
-
" return None\n"
|
|
731
|
-
)
|
|
732
|
-
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
733
|
-
source_with_method_body_constant,
|
|
734
|
-
"example_module.py",
|
|
735
|
-
)
|
|
736
|
-
assert len(advisory_issues) == 1, (
|
|
737
|
-
"Method-body UPPER_SNAKE constant must still surface as advisory"
|
|
738
|
-
)
|
|
739
|
-
assert "MAXIMUM_RETRIES" in advisory_issues[0]
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
def test_advisory_should_flag_annotated_function_body_constant() -> None:
|
|
743
|
-
source_with_annotated_function_body_constant = (
|
|
744
|
-
"def example_function() -> None:\n"
|
|
745
|
-
" MAXIMUM_RETRIES: int = 3\n"
|
|
746
|
-
" return None\n"
|
|
747
|
-
)
|
|
748
|
-
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
749
|
-
source_with_annotated_function_body_constant,
|
|
750
|
-
"example_module.py",
|
|
751
|
-
)
|
|
752
|
-
assert len(advisory_issues) == 1, (
|
|
753
|
-
"Annotated function-body UPPER_SNAKE constant (PEP 526) must surface as advisory"
|
|
754
|
-
)
|
|
755
|
-
assert "MAXIMUM_RETRIES" in advisory_issues[0]
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
|
|
759
|
-
source_with_nested_def = (
|
|
760
|
-
"def outer():\n"
|
|
761
|
-
" OUTER_CONST = 1\n"
|
|
762
|
-
" def inner():\n"
|
|
763
|
-
" INNER_CONST = 2\n"
|
|
764
|
-
" ANOTHER_OUTER = 3\n"
|
|
765
|
-
)
|
|
766
|
-
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
767
|
-
source_with_nested_def,
|
|
768
|
-
"example_module.py",
|
|
769
|
-
)
|
|
770
|
-
flagged_names = " ".join(advisory_issues)
|
|
771
|
-
assert "OUTER_CONST" in flagged_names, (
|
|
772
|
-
"OUTER_CONST before nested def must be flagged"
|
|
773
|
-
)
|
|
774
|
-
assert "INNER_CONST" in flagged_names, (
|
|
775
|
-
"INNER_CONST inside nested def must be flagged"
|
|
776
|
-
)
|
|
777
|
-
assert "ANOTHER_OUTER" in flagged_names, (
|
|
778
|
-
"ANOTHER_OUTER after nested def must be flagged — this is the regression case"
|
|
779
|
-
)
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
def test_should_not_leak_shadowed_nested_assignment_into_outer_mock_known_fields(
|
|
783
|
-
capsys: object,
|
|
784
|
-
) -> None:
|
|
785
|
-
"""Assignment collector must skip nested scopes that shadow the mock name.
|
|
786
|
-
|
|
787
|
-
The access collector uses _walk_scope_skipping_shadowed; the assignment
|
|
788
|
-
collector must do the same, otherwise attribute assignments inside a
|
|
789
|
-
nested function that redefines mock_user leak into the outer mock's
|
|
790
|
-
known-fields set and suppress advisories for genuinely missing fields.
|
|
791
|
-
"""
|
|
792
|
-
source = (
|
|
793
|
-
"mock_user = {'id': 1}\n"
|
|
794
|
-
"outer_value = mock_user['email']\n"
|
|
795
|
-
"\n"
|
|
796
|
-
"def test_inner() -> None:\n"
|
|
797
|
-
" mock_user = {'id': 2}\n"
|
|
798
|
-
" mock_user.email = 'shadowed@example.com'\n"
|
|
799
|
-
)
|
|
800
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
801
|
-
captured = getattr(capsys, "readouterr")()
|
|
802
|
-
advisory_lines = [
|
|
803
|
-
line for line in captured.err.splitlines() if "mock_user" in line and "email" in line
|
|
804
|
-
]
|
|
805
|
-
assert len(advisory_lines) == 1, (
|
|
806
|
-
"Expected outer mock's missing 'email' advisory to fire even when a shadowing "
|
|
807
|
-
f"nested function assigns mock_user.email, got: {captured.err!r}"
|
|
808
|
-
)
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
def test_should_treat_annotated_assignment_as_shadowing_in_nested_scope(
|
|
812
|
-
capsys: object,
|
|
813
|
-
) -> None:
|
|
814
|
-
"""AnnAssign must shadow just like Assign.
|
|
815
|
-
|
|
816
|
-
When a nested scope re-binds the mock variable via an annotated
|
|
817
|
-
assignment (``mock_user: dict = {...}``), accesses inside that nested
|
|
818
|
-
scope belong to the inner mock, not the outer one. If the shadow
|
|
819
|
-
detector ignores AnnAssign, inner accesses leak out and cause
|
|
820
|
-
spurious advisories against the outer mock for fields it never sees.
|
|
821
|
-
"""
|
|
822
|
-
source = (
|
|
823
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
824
|
-
"outer_value = mock_user['name']\n"
|
|
825
|
-
"\n"
|
|
826
|
-
"def test_inner() -> None:\n"
|
|
827
|
-
" mock_user: dict = {'id': 2, 'timezone': 'UTC'}\n"
|
|
828
|
-
" inner_value = mock_user['timezone']\n"
|
|
829
|
-
)
|
|
830
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
831
|
-
captured = getattr(capsys, "readouterr")()
|
|
832
|
-
leaked_advisories = [
|
|
833
|
-
line
|
|
834
|
-
for line in captured.err.splitlines()
|
|
835
|
-
if "mock_user" in line and "timezone" in line
|
|
836
|
-
]
|
|
837
|
-
assert leaked_advisories == [], (
|
|
838
|
-
"Expected no advisory on the outer mock for 'timezone' — that field is "
|
|
839
|
-
"accessed only inside a nested scope that re-binds mock_user via an "
|
|
840
|
-
f"annotated assignment, got: {captured.err!r}"
|
|
841
|
-
)
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
def _assert_inner_field_did_not_leak(
|
|
845
|
-
captured_stderr: str,
|
|
846
|
-
inner_only_field_name: str,
|
|
847
|
-
binding_form_description: str,
|
|
848
|
-
) -> None:
|
|
849
|
-
leaked_advisories = [
|
|
850
|
-
line
|
|
851
|
-
for line in captured_stderr.splitlines()
|
|
852
|
-
if "mock_user" in line and inner_only_field_name in line
|
|
853
|
-
]
|
|
854
|
-
assert leaked_advisories == [], (
|
|
855
|
-
f"Expected no advisory on the outer mock for {inner_only_field_name!r} — "
|
|
856
|
-
f"that field is accessed only inside a nested scope that re-binds "
|
|
857
|
-
f"mock_user via {binding_form_description}, got: {captured_stderr!r}"
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
def test_should_treat_assignment_inside_if_block_as_shadowing(capsys: object) -> None:
|
|
862
|
-
"""Binding inside an ``if`` block must shadow the outer mock name.
|
|
863
|
-
|
|
864
|
-
Python binds a name locally when it is assigned *anywhere* in the
|
|
865
|
-
function body, including inside a branch. A shadow detector that only
|
|
866
|
-
inspects the top-level statements misses this form.
|
|
867
|
-
"""
|
|
868
|
-
source = (
|
|
869
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
870
|
-
"outer_value = mock_user['name']\n"
|
|
871
|
-
"\n"
|
|
872
|
-
"def test_inner() -> None:\n"
|
|
873
|
-
" if True:\n"
|
|
874
|
-
" mock_user = {'id': 2, 'timezone': 'UTC'}\n"
|
|
875
|
-
" inner_value = mock_user['timezone']\n"
|
|
876
|
-
)
|
|
877
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
878
|
-
captured = getattr(capsys, "readouterr")()
|
|
879
|
-
_assert_inner_field_did_not_leak(
|
|
880
|
-
captured.err, "timezone", "an assignment nested inside an if-block"
|
|
881
|
-
)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
def test_should_treat_for_loop_target_as_shadowing(capsys: object) -> None:
|
|
885
|
-
"""A ``for`` loop target binds the name locally and must shadow."""
|
|
886
|
-
source = (
|
|
887
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
888
|
-
"outer_value = mock_user['name']\n"
|
|
889
|
-
"\n"
|
|
890
|
-
"def test_inner() -> None:\n"
|
|
891
|
-
" for mock_user in [{'id': 2, 'timezone': 'UTC'}]:\n"
|
|
892
|
-
" inner_value = mock_user['timezone']\n"
|
|
893
|
-
)
|
|
894
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
895
|
-
captured = getattr(capsys, "readouterr")()
|
|
896
|
-
_assert_inner_field_did_not_leak(
|
|
897
|
-
captured.err, "timezone", "a for-loop target"
|
|
898
|
-
)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
def test_should_treat_try_except_handler_name_as_shadowing(capsys: object) -> None:
|
|
902
|
-
"""An ``except ... as mock_user`` handler binds the name locally."""
|
|
903
|
-
source = (
|
|
904
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
905
|
-
"outer_value = mock_user['name']\n"
|
|
906
|
-
"\n"
|
|
907
|
-
"def test_inner() -> None:\n"
|
|
908
|
-
" try:\n"
|
|
909
|
-
" raise ValueError({'id': 2, 'timezone': 'UTC'})\n"
|
|
910
|
-
" except ValueError as mock_user:\n"
|
|
911
|
-
" inner_value = mock_user['timezone']\n"
|
|
912
|
-
)
|
|
913
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
914
|
-
captured = getattr(capsys, "readouterr")()
|
|
915
|
-
_assert_inner_field_did_not_leak(
|
|
916
|
-
captured.err, "timezone", "an except-handler binding"
|
|
917
|
-
)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
def test_should_treat_walrus_expression_as_shadowing(capsys: object) -> None:
|
|
921
|
-
"""A named-expression walrus binding inside a condition must shadow."""
|
|
922
|
-
source = (
|
|
923
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
924
|
-
"outer_value = mock_user['name']\n"
|
|
925
|
-
"\n"
|
|
926
|
-
"def test_inner() -> None:\n"
|
|
927
|
-
" if (mock_user := {'id': 2, 'timezone': 'UTC'}):\n"
|
|
928
|
-
" inner_value = mock_user['timezone']\n"
|
|
929
|
-
)
|
|
930
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
931
|
-
captured = getattr(capsys, "readouterr")()
|
|
932
|
-
_assert_inner_field_did_not_leak(
|
|
933
|
-
captured.err, "timezone", "a walrus named-expression"
|
|
934
|
-
)
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
def test_should_treat_function_parameter_as_shadowing(capsys: object) -> None:
|
|
938
|
-
"""A parameter named like the mock variable must shadow the outer binding."""
|
|
939
|
-
source = (
|
|
940
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
941
|
-
"outer_value = mock_user['name']\n"
|
|
942
|
-
"\n"
|
|
943
|
-
"def test_inner(mock_user: dict) -> None:\n"
|
|
944
|
-
" inner_value = mock_user['timezone']\n"
|
|
945
|
-
)
|
|
946
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
947
|
-
captured = getattr(capsys, "readouterr")()
|
|
948
|
-
_assert_inner_field_did_not_leak(
|
|
949
|
-
captured.err, "timezone", "a function parameter of the same name"
|
|
950
|
-
)
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
def test_should_treat_import_asname_as_shadowing(capsys: object) -> None:
|
|
954
|
-
"""An ``import ... as mock_user`` must shadow the outer mock name."""
|
|
955
|
-
source = (
|
|
956
|
-
"mock_user = {'id': 1, 'name': 'outer'}\n"
|
|
957
|
-
"outer_value = mock_user['name']\n"
|
|
958
|
-
"\n"
|
|
959
|
-
"def test_inner() -> None:\n"
|
|
960
|
-
" import collections as mock_user\n"
|
|
961
|
-
" inner_value = mock_user['timezone']\n"
|
|
962
|
-
)
|
|
963
|
-
code_rules_enforcer.check_incomplete_mocks(source, MODULE_LEVEL_MOCK_TEST_FILE_PATH)
|
|
964
|
-
captured = getattr(capsys, "readouterr")()
|
|
965
|
-
_assert_inner_field_did_not_leak(
|
|
966
|
-
captured.err, "timezone", "an import-asname binding"
|
|
967
|
-
)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
def test_should_not_advise_when_duplicated_fstring_literal_is_short(capsys: object) -> None:
|
|
971
|
-
"""Short logger-prefix style f-strings must not emit a duplication advisory.
|
|
972
|
-
|
|
973
|
-
A three-times-repeated ``f"Got {x}"`` has only four characters of literal
|
|
974
|
-
text (``"Got "``). Flagging such short fragments creates noise for common
|
|
975
|
-
logging prefixes. The heuristic requires a minimum amount of structural
|
|
976
|
-
literal text before an advisory fires.
|
|
977
|
-
"""
|
|
978
|
-
source = (
|
|
979
|
-
"def first(value: str) -> str:\n"
|
|
980
|
-
" return f'Got {value}'\n"
|
|
981
|
-
"\n"
|
|
982
|
-
"def second(value: str) -> str:\n"
|
|
983
|
-
" return f'Got {value}'\n"
|
|
984
|
-
"\n"
|
|
985
|
-
"def third(value: str) -> str:\n"
|
|
986
|
-
" return f'Got {value}'\n"
|
|
987
|
-
)
|
|
988
|
-
code_rules_enforcer.check_duplicated_format_patterns(
|
|
989
|
-
source, DUPLICATED_FORMAT_PRODUCTION_FILE_PATH
|
|
990
|
-
)
|
|
991
|
-
captured = getattr(capsys, "readouterr")()
|
|
992
|
-
assert "Got" not in captured.err, (
|
|
993
|
-
"Expected no advisory for a short repeated f-string literal fragment, "
|
|
994
|
-
f"got: {captured.err!r}"
|
|
995
|
-
)
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
def test_should_still_advise_when_duplicated_fstring_literal_is_long(capsys: object) -> None:
|
|
999
|
-
"""Longer duplicated f-string skeletons must continue to fire.
|
|
1000
|
-
|
|
1001
|
-
The short-literal heuristic must not regress the existing
|
|
1002
|
-
``/api/<x>`` and ``/teams/<x>/users/<x>`` advisories — those path
|
|
1003
|
-
skeletons carry enough structural literal text to warrant a helper.
|
|
1004
|
-
"""
|
|
1005
|
-
source = (
|
|
1006
|
-
"def get_user(user_id: str) -> str:\n"
|
|
1007
|
-
" return f'/api/{user_id}'\n"
|
|
1008
|
-
"\n"
|
|
1009
|
-
"def get_order(order_id: str) -> str:\n"
|
|
1010
|
-
" return f'/api/{order_id}'\n"
|
|
1011
|
-
"\n"
|
|
1012
|
-
"def get_product(product_id: str) -> str:\n"
|
|
1013
|
-
" return f'/api/{product_id}'\n"
|
|
1014
|
-
)
|
|
1015
|
-
code_rules_enforcer.check_duplicated_format_patterns(
|
|
1016
|
-
source, DUPLICATED_FORMAT_PRODUCTION_FILE_PATH
|
|
1017
|
-
)
|
|
1018
|
-
captured = getattr(capsys, "readouterr")()
|
|
1019
|
-
assert "/api/" in captured.err, (
|
|
1020
|
-
"Expected the existing /api/<x> path-shape advisory to still fire, "
|
|
1021
|
-
f"got: {captured.err!r}"
|
|
1022
|
-
)
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
LOOP_NAMING_PRODUCTION_FILE_PATH = "packages/app/services/loop_naming.py"
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
def test_check_loop_variable_naming_flags_missing_each_prefix() -> None:
|
|
1029
|
-
source = (
|
|
1030
|
-
"def consume() -> None:\n"
|
|
1031
|
-
" for marker in []:\n"
|
|
1032
|
-
" return None\n"
|
|
1033
|
-
)
|
|
1034
|
-
issues = code_rules_enforcer.check_loop_variable_naming(
|
|
1035
|
-
source, LOOP_NAMING_PRODUCTION_FILE_PATH
|
|
1036
|
-
)
|
|
1037
|
-
assert any("marker" in each_issue for each_issue in issues), (
|
|
1038
|
-
f"Expected 'marker' loop variable flagged, got: {issues}"
|
|
1039
|
-
)
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
INLINE_LITERAL_PRODUCTION_FILE_PATH = "packages/app/services/inline_literal.py"
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
def test_check_inline_literal_collections_flags_three_string_set_in_function() -> None:
|
|
1046
|
-
source = (
|
|
1047
|
-
"def is_known(value: str) -> bool:\n"
|
|
1048
|
-
" return value in {'true', 'false', 'none'}\n"
|
|
1049
|
-
)
|
|
1050
|
-
issues = code_rules_enforcer.check_inline_literal_collections(
|
|
1051
|
-
source, INLINE_LITERAL_PRODUCTION_FILE_PATH
|
|
1052
|
-
)
|
|
1053
|
-
assert len(issues) == 1, f"Expected 3-element string set flagged, got: {issues}"
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
STRING_MAGIC_PRODUCTION_FILE_PATH = "packages/app/services/string_magic.py"
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
def test_check_string_literal_magic_flags_env_var_name() -> None:
|
|
1060
|
-
source = (
|
|
1061
|
-
"import os\n"
|
|
1062
|
-
"\n"
|
|
1063
|
-
"def fetch_secret() -> str:\n"
|
|
1064
|
-
" return os.environ['STRIPE_SECRET']\n"
|
|
1065
|
-
)
|
|
1066
|
-
issues = code_rules_enforcer.check_string_literal_magic(
|
|
1067
|
-
source, STRING_MAGIC_PRODUCTION_FILE_PATH
|
|
1068
|
-
)
|
|
1069
|
-
assert any("STRIPE_SECRET" in each_issue for each_issue in issues), (
|
|
1070
|
-
f"Expected env-var name flagged, got: {issues}"
|
|
1071
|
-
)
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
CONSTANTS_OUTSIDE_CONFIG_PRODUCTION_FILE_PATH = "packages/app/services/encoding.py"
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
def test_check_constants_outside_config_flags_annotated_assignment() -> None:
|
|
1078
|
-
source = "TEXT_FILE_ENCODING: str = 'utf-8'\n"
|
|
1079
|
-
issues = code_rules_enforcer.check_constants_outside_config(
|
|
1080
|
-
source, CONSTANTS_OUTSIDE_CONFIG_PRODUCTION_FILE_PATH
|
|
1081
|
-
)
|
|
1082
|
-
assert any("TEXT_FILE_ENCODING" in each_issue for each_issue in issues), (
|
|
1083
|
-
f"Expected annotated UPPER_SNAKE assignment flagged, got: {issues}"
|
|
1084
|
-
)
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
def test_check_constants_outside_config_reports_more_than_three_constants() -> None:
|
|
1088
|
-
source = (
|
|
1089
|
-
"ALPHA_VALUE = 1\n"
|
|
1090
|
-
"BETA_VALUE = 2\n"
|
|
1091
|
-
"GAMMA_VALUE = 3\n"
|
|
1092
|
-
"DELTA_VALUE = 4\n"
|
|
1093
|
-
"EPSILON_VALUE = 5\n"
|
|
1094
|
-
"\n"
|
|
1095
|
-
"def consumer() -> int:\n"
|
|
1096
|
-
" return ALPHA_VALUE + BETA_VALUE\n"
|
|
1097
|
-
)
|
|
1098
|
-
issues = code_rules_enforcer.check_constants_outside_config(
|
|
1099
|
-
source, CONSTANTS_OUTSIDE_CONFIG_PRODUCTION_FILE_PATH
|
|
1100
|
-
)
|
|
1101
|
-
expected_constant_count = 5
|
|
1102
|
-
assert len(issues) == expected_constant_count, (
|
|
1103
|
-
f"Expected all {expected_constant_count} constants reported, got {len(issues)}: {issues}"
|
|
1104
|
-
)
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
def test_stuttering_collection_prefix_flags_function_name_loop1_1() -> None:
|
|
1108
|
-
source = "def all_all_process() -> None:\n return None\n"
|
|
1109
|
-
issues = code_rules_enforcer.check_stuttering_collection_prefix(
|
|
1110
|
-
source, "packages/app/services/foo.py"
|
|
1111
|
-
)
|
|
1112
|
-
assert any("all_all_process" in each_issue for each_issue in issues), (
|
|
1113
|
-
f"loop1-1: stuttering function name must be flagged, got: {issues}"
|
|
1114
|
-
)
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
def test_stuttering_collection_prefix_flags_with_as_binding_loop3_1() -> None:
|
|
1118
|
-
source = "def f() -> None:\n with open('x') as all_all_context:\n pass\n"
|
|
1119
|
-
issues = code_rules_enforcer.check_stuttering_collection_prefix(
|
|
1120
|
-
source, "packages/app/services/foo.py"
|
|
1121
|
-
)
|
|
1122
|
-
assert any("all_all_context" in each_issue for each_issue in issues), (
|
|
1123
|
-
f"loop3-1: stuttering with-as binding must be flagged, got: {issues}"
|
|
1124
|
-
)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
def test_stuttering_collection_prefix_flags_except_as_binding_loop3_1() -> None:
|
|
1128
|
-
source = (
|
|
1129
|
-
"def f() -> None:\n"
|
|
1130
|
-
" try:\n"
|
|
1131
|
-
" pass\n"
|
|
1132
|
-
" except Exception as all_all_error:\n"
|
|
1133
|
-
" pass\n"
|
|
1134
|
-
)
|
|
1135
|
-
issues = code_rules_enforcer.check_stuttering_collection_prefix(
|
|
1136
|
-
source, "packages/app/services/foo.py"
|
|
1137
|
-
)
|
|
1138
|
-
assert any("all_all_error" in each_issue for each_issue in issues), (
|
|
1139
|
-
f"loop3-1: stuttering except-as binding must be flagged, got: {issues}"
|
|
1140
|
-
)
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
def test_stuttering_constants_live_under_config_subpackage() -> None:
|
|
1144
|
-
"""Stuttering-prefix constants must be sourced from the hooks-tree config package.
|
|
1145
|
-
|
|
1146
|
-
Per CODE_RULES, module-level UPPER_SNAKE constants must live under a
|
|
1147
|
-
directory segment named ``config``. This test pins the move so the
|
|
1148
|
-
constants cannot regress to inline definition at the enforcer module's
|
|
1149
|
-
top level. The enforcer's own bootstrap inserts the hooks tree onto
|
|
1150
|
-
``sys.path`` so ``config.stuttering_check_config`` resolves at runtime.
|
|
1151
|
-
"""
|
|
1152
|
-
assert (
|
|
1153
|
-
code_rules_enforcer.STUTTERING_ALL_PREFIX_PATTERN
|
|
1154
|
-
is config_stuttering_all_prefix_pattern
|
|
1155
|
-
), "Enforcer must reuse the hooks-tree config STUTTERING_ALL_PREFIX_PATTERN object"
|
|
1156
|
-
assert (
|
|
1157
|
-
code_rules_enforcer.MAX_STUTTERING_PREFIX_ISSUES
|
|
1158
|
-
== config_max_stuttering_prefix_issues
|
|
1159
|
-
), "Enforcer must reuse the hooks-tree config MAX_STUTTERING_PREFIX_ISSUES value"
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
SYS_PATH_INSERT_PRODUCTION_FILE_PATH = "packages/app/services/loader.py"
|
|
1163
|
-
SYS_PATH_INSERT_HOOK_INFRASTRUCTURE_FILE_PATH = "/repo/.claude/hooks/blocking/some_hook.py"
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
def test_sys_path_insert_should_flag_mismatched_guard_path() -> None:
|
|
1167
|
-
source = (
|
|
1168
|
-
"import sys\n"
|
|
1169
|
-
'if "wrong_path" not in sys.path:\n'
|
|
1170
|
-
' sys.path.insert(0, "actual_path")\n'
|
|
1171
|
-
)
|
|
1172
|
-
issues = code_rules_enforcer.check_sys_path_insert_deduplication_guard(
|
|
1173
|
-
source, SYS_PATH_INSERT_PRODUCTION_FILE_PATH
|
|
1174
|
-
)
|
|
1175
|
-
assert any("sys.path.insert" in each_issue for each_issue in issues), (
|
|
1176
|
-
"Guard testing a different value than what is inserted must be flagged, "
|
|
1177
|
-
f"got: {issues}"
|
|
1178
|
-
)
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
def test_sys_path_insert_should_not_flag_matching_guard_path() -> None:
|
|
1182
|
-
source = (
|
|
1183
|
-
"import sys\n"
|
|
1184
|
-
'if "correct_path" not in sys.path:\n'
|
|
1185
|
-
' sys.path.insert(0, "correct_path")\n'
|
|
1186
|
-
)
|
|
1187
|
-
issues = code_rules_enforcer.check_sys_path_insert_deduplication_guard(
|
|
1188
|
-
source, SYS_PATH_INSERT_PRODUCTION_FILE_PATH
|
|
1189
|
-
)
|
|
1190
|
-
assert issues == [], (
|
|
1191
|
-
f"Guard testing the same value that is inserted must not be flagged, got: {issues}"
|
|
1192
|
-
)
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
def test_sys_path_insert_should_not_flag_guarded_insert_in_class_body() -> None:
|
|
1196
|
-
source = (
|
|
1197
|
-
"import sys\n"
|
|
1198
|
-
"class Configurator:\n"
|
|
1199
|
-
" target = '/some/path'\n"
|
|
1200
|
-
" if target not in sys.path:\n"
|
|
1201
|
-
" sys.path.insert(0, target)\n"
|
|
1202
|
-
)
|
|
1203
|
-
issues = code_rules_enforcer.check_sys_path_insert_deduplication_guard(
|
|
1204
|
-
source, SYS_PATH_INSERT_PRODUCTION_FILE_PATH
|
|
1205
|
-
)
|
|
1206
|
-
assert issues == [], (
|
|
1207
|
-
f"Guarded sys.path.insert directly in a class body must not be flagged, got: {issues}"
|
|
1208
|
-
)
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
def test_sys_path_insert_should_skip_hook_infrastructure_files() -> None:
|
|
1212
|
-
source = "import sys\nsys.path.insert(0, '/some/path')\n"
|
|
1213
|
-
issues = code_rules_enforcer.check_sys_path_insert_deduplication_guard(
|
|
1214
|
-
source, SYS_PATH_INSERT_HOOK_INFRASTRUCTURE_FILE_PATH
|
|
1215
|
-
)
|
|
1216
|
-
assert issues == [], (
|
|
1217
|
-
f"Hook infrastructure files are exempt from this rule, got: {issues}"
|
|
1218
|
-
)
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
def test_validate_content_honors_empty_full_file_content_for_thin_wrapper_check() -> None:
|
|
1222
|
-
"""An empty `full_file_content` must not be silently replaced with the pre-edit fragment.
|
|
1223
|
-
|
|
1224
|
-
Regression for loop1-8: the `or` short-circuit at the thin-wrapper call
|
|
1225
|
-
site treated `""` identically to `None`, so an Edit collapsing a file to
|
|
1226
|
-
empty was scanned against the pre-edit fragment instead of the empty
|
|
1227
|
-
post-edit content. Mirror the canonical idiom at line 3438.
|
|
1228
|
-
"""
|
|
1229
|
-
pre_edit_fragment_with_imports_only = (
|
|
1230
|
-
"from real_module import do_thing\n__all__ = ['do_thing']\n"
|
|
1231
|
-
)
|
|
1232
|
-
issues = code_rules_enforcer.validate_content(
|
|
1233
|
-
pre_edit_fragment_with_imports_only,
|
|
1234
|
-
"/project/src/aliases.py",
|
|
1235
|
-
full_file_content="",
|
|
1236
|
-
)
|
|
1237
|
-
assert not any("thin wrapper" in each.lower() for each in issues), (
|
|
1238
|
-
f"empty post-edit file must not be flagged as a thin wrapper, got: {issues!r}"
|
|
1239
|
-
)
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
def test_isolation_check_does_not_flag_expanduser_without_tilde_argument() -> None:
|
|
1243
|
-
"""expanduser of a tilde-free string does not probe HOME and must not fire."""
|
|
1244
|
-
source = (
|
|
1245
|
-
"import os\n"
|
|
1246
|
-
"def test_resolves_relative() -> None:\n"
|
|
1247
|
-
" target = os.path.expanduser('relative/path')\n"
|
|
1248
|
-
" assert target\n"
|
|
1249
|
-
)
|
|
1250
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1251
|
-
source, "/project/src/test_module.py"
|
|
1252
|
-
)
|
|
1253
|
-
assert issues == [], f"tilde-free expanduser must not be flagged, got: {issues!r}"
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
def test_isolation_check_flags_expanduser_with_tilde_argument() -> None:
|
|
1257
|
-
"""expanduser of a leading-tilde string resolves HOME and must fire."""
|
|
1258
|
-
source = (
|
|
1259
|
-
"import os\n"
|
|
1260
|
-
"def test_reads_home() -> None:\n"
|
|
1261
|
-
" target = os.path.expanduser('~/.config/x')\n"
|
|
1262
|
-
" assert target\n"
|
|
1263
|
-
)
|
|
1264
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1265
|
-
source, "/project/src/test_module.py"
|
|
1266
|
-
)
|
|
1267
|
-
assert any("expanduser" in each_issue for each_issue in issues)
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
def test_isolation_check_flags_path_constructor_expanduser_method() -> None:
|
|
1271
|
-
"""`Path('~/x').expanduser()` expands the home directory through the bound
|
|
1272
|
-
Path object and must fire even though it bypasses the static probe chain."""
|
|
1273
|
-
source = (
|
|
1274
|
-
"from pathlib import Path\n"
|
|
1275
|
-
"def test_reads_dotfile() -> None:\n"
|
|
1276
|
-
" target = Path('~/x').expanduser()\n"
|
|
1277
|
-
" target.read_text()\n"
|
|
1278
|
-
)
|
|
1279
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1280
|
-
source, "/project/src/test_module.py"
|
|
1281
|
-
)
|
|
1282
|
-
assert any("expanduser" in each_issue for each_issue in issues)
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
def test_isolation_check_flags_aliased_path_constructor_expanduser_method() -> None:
|
|
1286
|
-
"""`from pathlib import Path as P` then `P('~/x').expanduser()` resolves the
|
|
1287
|
-
constructor through alias canonicalization and must fire."""
|
|
1288
|
-
source = (
|
|
1289
|
-
"from pathlib import Path as P\n"
|
|
1290
|
-
"def test_reads_dotfile() -> None:\n"
|
|
1291
|
-
" target = P('~/x').expanduser()\n"
|
|
1292
|
-
" target.read_text()\n"
|
|
1293
|
-
)
|
|
1294
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1295
|
-
source, "/project/src/test_module.py"
|
|
1296
|
-
)
|
|
1297
|
-
assert any("expanduser" in each_issue for each_issue in issues)
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
def test_isolation_check_flags_tempfile_named_temporary_file() -> None:
|
|
1301
|
-
"""`tempfile.NamedTemporaryFile()` allocates in the shared temp dir and must
|
|
1302
|
-
fire as a temp-isolation probe."""
|
|
1303
|
-
source = (
|
|
1304
|
-
"import tempfile\n"
|
|
1305
|
-
"def test_writes_named_temp() -> None:\n"
|
|
1306
|
-
" handle = tempfile.NamedTemporaryFile()\n"
|
|
1307
|
-
" handle.write(b'x')\n"
|
|
1308
|
-
)
|
|
1309
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1310
|
-
source, "/project/src/test_module.py"
|
|
1311
|
-
)
|
|
1312
|
-
assert any("NamedTemporaryFile" in each_issue for each_issue in issues)
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
def test_isolation_check_exempts_tempfile_factory_with_explicit_dir() -> None:
|
|
1316
|
-
"""A tempfile factory given an explicit `dir=` argument allocates under the
|
|
1317
|
-
supplied sandbox, so it must not fire as a shared-temp isolation probe."""
|
|
1318
|
-
source = (
|
|
1319
|
-
"import tempfile\n"
|
|
1320
|
-
"def test_writes_named_temp(tmp_path) -> None:\n"
|
|
1321
|
-
" handle = tempfile.NamedTemporaryFile(dir=tmp_path)\n"
|
|
1322
|
-
" handle.write(b'x')\n"
|
|
1323
|
-
)
|
|
1324
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1325
|
-
source, "/project/src/test_module.py"
|
|
1326
|
-
)
|
|
1327
|
-
assert issues == []
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
def test_isolation_check_flags_tempfile_factory_with_dir_constant_none() -> None:
|
|
1331
|
-
"""`dir=None` selects the default shared temp directory, so the factory
|
|
1332
|
-
still allocates from shared temp and must fire."""
|
|
1333
|
-
source = (
|
|
1334
|
-
"import tempfile\n"
|
|
1335
|
-
"def test_writes_named_temp() -> None:\n"
|
|
1336
|
-
" handle = tempfile.NamedTemporaryFile(dir=None)\n"
|
|
1337
|
-
" handle.write(b'x')\n"
|
|
1338
|
-
)
|
|
1339
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1340
|
-
source, "/project/src/test_module.py"
|
|
1341
|
-
)
|
|
1342
|
-
assert any("NamedTemporaryFile" in each_issue for each_issue in issues)
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
def test_isolation_check_flags_tempfile_factory_with_dir_getenv_tmpdir() -> None:
|
|
1346
|
-
"""`dir=os.getenv('TMPDIR')` resolves to a shared-temp env source, so the
|
|
1347
|
-
factory still allocates from shared temp and must fire."""
|
|
1348
|
-
source = (
|
|
1349
|
-
"import os\n"
|
|
1350
|
-
"import tempfile\n"
|
|
1351
|
-
"def test_makes_temp_dir() -> None:\n"
|
|
1352
|
-
" holder = tempfile.mkdtemp(dir=os.getenv('TMPDIR'))\n"
|
|
1353
|
-
" print(holder)\n"
|
|
1354
|
-
)
|
|
1355
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1356
|
-
source, "/project/src/test_module.py"
|
|
1357
|
-
)
|
|
1358
|
-
assert any("mkdtemp" in each_issue for each_issue in issues)
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
def test_isolation_check_exempts_tempfile_factory_with_dir_tmp_path() -> None:
|
|
1362
|
-
"""`dir=tmp_path` allocates under the pytest sandbox, so the factory is
|
|
1363
|
-
isolated and must not fire."""
|
|
1364
|
-
source = (
|
|
1365
|
-
"import tempfile\n"
|
|
1366
|
-
"def test_makes_temp_dir(tmp_path) -> None:\n"
|
|
1367
|
-
" holder = tempfile.mkdtemp(dir=tmp_path)\n"
|
|
1368
|
-
" print(holder)\n"
|
|
1369
|
-
)
|
|
1370
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1371
|
-
source, "/project/src/test_module.py"
|
|
1372
|
-
)
|
|
1373
|
-
assert issues == []
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
def test_isolation_check_flags_class_level_probe_in_nested_class_body() -> None:
|
|
1377
|
-
"""A Path.home() initializer in a nested class body runs at class-creation
|
|
1378
|
-
time during the test, so it must fire."""
|
|
1379
|
-
source = (
|
|
1380
|
-
"from pathlib import Path\n"
|
|
1381
|
-
"def test_defines_inner_class() -> None:\n"
|
|
1382
|
-
" class Inner:\n"
|
|
1383
|
-
" root = Path.home()\n"
|
|
1384
|
-
" assert Inner is not None\n"
|
|
1385
|
-
)
|
|
1386
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1387
|
-
source, "/project/src/test_module.py"
|
|
1388
|
-
)
|
|
1389
|
-
assert any("Path.home" in each_issue for each_issue in issues)
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
def test_isolation_check_flags_from_os_import_path_expanduser() -> None:
|
|
1393
|
-
"""`from os import path` binds `path` to `os.path`, so `path.expanduser`
|
|
1394
|
-
must resolve to the canonical `os.path.expanduser` probe and fire."""
|
|
1395
|
-
source = (
|
|
1396
|
-
"from os import path\n"
|
|
1397
|
-
"def test_reads_dotfile() -> None:\n"
|
|
1398
|
-
" target = path.expanduser('~/.config/x')\n"
|
|
1399
|
-
" open(target).read()\n"
|
|
1400
|
-
)
|
|
1401
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1402
|
-
source, "/project/src/test_module.py"
|
|
1403
|
-
)
|
|
1404
|
-
assert any("expanduser" in each_issue for each_issue in issues)
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
def test_isolation_check_flags_expandvars_with_windows_percent_userprofile() -> None:
|
|
1408
|
-
"""expandvars expands Windows `%USERPROFILE%` percent syntax, so a percent
|
|
1409
|
-
reference to a home env var must fire."""
|
|
1410
|
-
source = (
|
|
1411
|
-
"import os\n"
|
|
1412
|
-
"def test_expands_userprofile() -> None:\n"
|
|
1413
|
-
" target = os.path.expandvars('%USERPROFILE%\\\\.cfg')\n"
|
|
1414
|
-
" open(target).read()\n"
|
|
1415
|
-
)
|
|
1416
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1417
|
-
source, "/project/src/test_module.py"
|
|
1418
|
-
)
|
|
1419
|
-
assert any("expandvars" in each_issue for each_issue in issues)
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
def test_isolation_check_ignores_expandvars_with_unrelated_windows_percent_var() -> None:
|
|
1423
|
-
"""A percent reference to an unrelated env var does not probe HOME/TMP and
|
|
1424
|
-
must not fire."""
|
|
1425
|
-
source = (
|
|
1426
|
-
"import os\n"
|
|
1427
|
-
"def test_expands_unrelated() -> None:\n"
|
|
1428
|
-
" token = os.path.expandvars('%MY_APP_TOKEN%')\n"
|
|
1429
|
-
" print(token)\n"
|
|
1430
|
-
)
|
|
1431
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1432
|
-
source, "/project/src/test_module.py"
|
|
1433
|
-
)
|
|
1434
|
-
assert issues == []
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
def test_isolation_check_flags_environ_get_via_local_binding() -> None:
|
|
1438
|
-
"""`e = os.environ` then `e.get('HOME')` reads HOME through a local alias
|
|
1439
|
-
and must fire just like the subscript `e['HOME']` form."""
|
|
1440
|
-
source = (
|
|
1441
|
-
"import os\n"
|
|
1442
|
-
"def test_resolves_home() -> None:\n"
|
|
1443
|
-
" e = os.environ\n"
|
|
1444
|
-
" home = e.get('HOME')\n"
|
|
1445
|
-
" print(home)\n"
|
|
1446
|
-
)
|
|
1447
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1448
|
-
source, "/project/src/test_module.py"
|
|
1449
|
-
)
|
|
1450
|
-
assert any("HOME" in each_issue for each_issue in issues)
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
def test_isolation_check_scopes_path_bindings_to_their_own_test() -> None:
|
|
1454
|
-
"""A `p = Path('~/x')` binding in one test must not make an unrelated
|
|
1455
|
-
`p.expanduser()` in a sibling test a finding; bindings are per-test."""
|
|
1456
|
-
source = (
|
|
1457
|
-
"from pathlib import Path\n"
|
|
1458
|
-
"def test_a() -> None:\n"
|
|
1459
|
-
" p = Path('~/x')\n"
|
|
1460
|
-
" p.expanduser()\n"
|
|
1461
|
-
"def test_b(p) -> None:\n"
|
|
1462
|
-
" p.expanduser()\n"
|
|
1463
|
-
)
|
|
1464
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1465
|
-
source, "/project/src/test_module.py"
|
|
1466
|
-
)
|
|
1467
|
-
assert any("test_a" in each_issue for each_issue in issues)
|
|
1468
|
-
assert not any("test_b" in each_issue for each_issue in issues)
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
def test_isolation_check_scopes_environ_bindings_to_their_own_test() -> None:
|
|
1472
|
-
"""An `e = os.environ` binding in one test must not make an unrelated
|
|
1473
|
-
`e['HOME']` in a sibling test a finding; bindings are per-test."""
|
|
1474
|
-
source = (
|
|
1475
|
-
"import os\n"
|
|
1476
|
-
"def test_a() -> None:\n"
|
|
1477
|
-
" e = os.environ\n"
|
|
1478
|
-
" home = e['HOME']\n"
|
|
1479
|
-
" print(home)\n"
|
|
1480
|
-
"def test_b(e) -> None:\n"
|
|
1481
|
-
" home = e['HOME']\n"
|
|
1482
|
-
" print(home)\n"
|
|
1483
|
-
)
|
|
1484
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1485
|
-
source, "/project/src/test_module.py"
|
|
1486
|
-
)
|
|
1487
|
-
assert any("test_a" in each_issue for each_issue in issues)
|
|
1488
|
-
assert not any("test_b" in each_issue for each_issue in issues)
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
def test_isolation_check_ignores_path_constructor_expanduser_with_tilde_free_argument() -> None:
|
|
1492
|
-
"""`Path('/tmp/x').expanduser()` carries no leading tilde, so it expands no
|
|
1493
|
-
home directory and must stay symmetric with `os.path.expanduser` of a
|
|
1494
|
-
tilde-free literal — neither fires."""
|
|
1495
|
-
source = (
|
|
1496
|
-
"from pathlib import Path\n"
|
|
1497
|
-
"def test_resolves_absolute() -> None:\n"
|
|
1498
|
-
" target = Path('/tmp/x').expanduser()\n"
|
|
1499
|
-
" target.read_text()\n"
|
|
1500
|
-
)
|
|
1501
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1502
|
-
source, "/project/src/test_module.py"
|
|
1503
|
-
)
|
|
1504
|
-
assert issues == []
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
def test_isolation_check_ignores_static_pathlib_expanduser_with_dynamic_argument() -> None:
|
|
1508
|
-
"""`pathlib.Path.expanduser(some_path)` with a non-constant argument cannot
|
|
1509
|
-
be inspected for a leading tilde, so it follows the conservative rule and
|
|
1510
|
-
does not fire — symmetric with `os.path.expanduser(some_path)`."""
|
|
1511
|
-
source = (
|
|
1512
|
-
"import pathlib\n"
|
|
1513
|
-
"def test_resolves_dynamic(some_path) -> None:\n"
|
|
1514
|
-
" target = pathlib.Path.expanduser(some_path)\n"
|
|
1515
|
-
" target.read_text()\n"
|
|
1516
|
-
)
|
|
1517
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1518
|
-
source, "/project/src/test_module.py"
|
|
1519
|
-
)
|
|
1520
|
-
assert issues == []
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
def test_isolation_check_flags_path_home_via_function_local_class_alias() -> None:
|
|
1524
|
-
"""`path_class = Path` then `path_class.home()` reaches the real home
|
|
1525
|
-
directory through a per-test class alias and must fire just like the bare
|
|
1526
|
-
`Path.home()` form."""
|
|
1527
|
-
source = (
|
|
1528
|
-
"from pathlib import Path\n"
|
|
1529
|
-
"def test_reads_home() -> None:\n"
|
|
1530
|
-
" path_class = Path\n"
|
|
1531
|
-
" home_dir = path_class.home()\n"
|
|
1532
|
-
" (home_dir / '.myapp').write_text('x')\n"
|
|
1533
|
-
)
|
|
1534
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1535
|
-
source, "/project/src/test_module.py"
|
|
1536
|
-
)
|
|
1537
|
-
assert any("home" in each_issue.lower() for each_issue in issues)
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
def test_isolation_check_flags_getenv_via_function_local_callable_alias() -> None:
|
|
1541
|
-
"""`read_env = os.getenv` then `read_env('HOME')` reads HOME through a
|
|
1542
|
-
per-test callable alias and must fire just like the bare `os.getenv('HOME')`
|
|
1543
|
-
form."""
|
|
1544
|
-
source = (
|
|
1545
|
-
"import os\n"
|
|
1546
|
-
"def test_reads_home() -> None:\n"
|
|
1547
|
-
" read_env = os.getenv\n"
|
|
1548
|
-
" home = read_env('HOME')\n"
|
|
1549
|
-
" print(home)\n"
|
|
1550
|
-
)
|
|
1551
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1552
|
-
source, "/project/src/test_module.py"
|
|
1553
|
-
)
|
|
1554
|
-
assert any("HOME" in each_issue for each_issue in issues)
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
def test_isolation_check_flags_tempfile_spooled_temporary_file() -> None:
|
|
1558
|
-
"""`tempfile.SpooledTemporaryFile()` allocates in the shared temp dir and
|
|
1559
|
-
must fire as a temp-isolation probe alongside the other tempfile factories."""
|
|
1560
|
-
source = (
|
|
1561
|
-
"import tempfile\n"
|
|
1562
|
-
"def test_writes_spooled_temp() -> None:\n"
|
|
1563
|
-
" handle = tempfile.SpooledTemporaryFile()\n"
|
|
1564
|
-
" handle.write(b'x')\n"
|
|
1565
|
-
)
|
|
1566
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1567
|
-
source, "/project/src/test_module.py"
|
|
1568
|
-
)
|
|
1569
|
-
assert any("SpooledTemporaryFile" in each_issue for each_issue in issues)
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
def test_isolation_check_flags_tempfile_gettempdirb() -> None:
|
|
1573
|
-
"""`tempfile.gettempdirb()` returns the shared temp dir as bytes and must
|
|
1574
|
-
fire just like the string-returning `tempfile.gettempdir()`."""
|
|
1575
|
-
source = (
|
|
1576
|
-
"import tempfile\n"
|
|
1577
|
-
"def test_resolves_temp_bytes() -> None:\n"
|
|
1578
|
-
" base = tempfile.gettempdirb()\n"
|
|
1579
|
-
" print(base)\n"
|
|
1580
|
-
)
|
|
1581
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1582
|
-
source, "/project/src/test_module.py"
|
|
1583
|
-
)
|
|
1584
|
-
assert any("gettempdirb" in each_issue for each_issue in issues)
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
def test_isolation_check_flags_module_level_from_os_import_environ_subscript() -> None:
|
|
1588
|
-
"""A module-level `from os import environ` binds `environ` to `os.environ`,
|
|
1589
|
-
so `environ['HOME']` inside a test must fire even without a per-test
|
|
1590
|
-
local binding."""
|
|
1591
|
-
source = (
|
|
1592
|
-
"from os import environ\n"
|
|
1593
|
-
"def test_resolves_home() -> None:\n"
|
|
1594
|
-
" home = environ['HOME']\n"
|
|
1595
|
-
" print(home)\n"
|
|
1596
|
-
)
|
|
1597
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1598
|
-
source, "/project/src/test_module.py"
|
|
1599
|
-
)
|
|
1600
|
-
assert any("HOME" in each_issue for each_issue in issues)
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
def test_isolation_check_reports_probes_in_source_order_on_new_file() -> None:
|
|
1604
|
-
"""On a new file (``all_changed_lines is None``) every probe is in scope and
|
|
1605
|
-
reported in source order — none dropped by the cap, which now trims only
|
|
1606
|
-
out-of-scope advisory noise."""
|
|
1607
|
-
probe_count = 20
|
|
1608
|
-
repeated_probes = "\n".join(
|
|
1609
|
-
f" p{each_index} = Path.home()" for each_index in range(probe_count)
|
|
1610
|
-
)
|
|
1611
|
-
source = (
|
|
1612
|
-
f"from pathlib import Path\ndef test_many_probes() -> None:\n{repeated_probes}\n"
|
|
1613
|
-
)
|
|
1614
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1615
|
-
source, "/project/src/test_module.py"
|
|
1616
|
-
)
|
|
1617
|
-
first_probe_line_number = 3
|
|
1618
|
-
reported_line_numbers = [
|
|
1619
|
-
int(each_issue.split(":", maxsplit=1)[0].removeprefix("Line ").strip())
|
|
1620
|
-
for each_issue in issues
|
|
1621
|
-
]
|
|
1622
|
-
expected_line_numbers = [
|
|
1623
|
-
first_probe_line_number + each_offset for each_offset in range(probe_count)
|
|
1624
|
-
]
|
|
1625
|
-
assert reported_line_numbers == expected_line_numbers
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
def test_exempt_comment_rejects_noqa_prefixed_prose_lacking_boundary() -> None:
|
|
1629
|
-
"""A comment body that merely starts with `noqa` followed by non-boundary
|
|
1630
|
-
characters is not a real noqa directive and must stay subject to the
|
|
1631
|
-
no-new-comments rule."""
|
|
1632
|
-
source = "x = compute() # noqa-but-not-really: explanation\n"
|
|
1633
|
-
issues = code_rules_enforcer.check_comments_python(source)
|
|
1634
|
-
assert issues
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
def test_exempt_comment_keeps_bare_and_coded_noqa_exempt() -> None:
|
|
1638
|
-
"""A bare `# noqa` and a coded `# noqa: E501` remain exempt under the
|
|
1639
|
-
tightened boundary rule."""
|
|
1640
|
-
bare_source = "x = compute() # noqa\n"
|
|
1641
|
-
coded_source = "x = compute() # noqa: E501\n"
|
|
1642
|
-
assert code_rules_enforcer.check_comments_python(bare_source) == []
|
|
1643
|
-
assert code_rules_enforcer.check_comments_python(coded_source) == []
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
def test_exempt_comment_keeps_colon_terminated_markers_without_trailing_space() -> None:
|
|
1647
|
-
"""A colon-terminated marker (`pylint:`, `type:`, `pragma:`) is self-bounded
|
|
1648
|
-
by its own colon, so the directive stays exempt even when the next character
|
|
1649
|
-
follows the colon immediately."""
|
|
1650
|
-
pylint_source = "import os # pylint:disable=unused-import\n"
|
|
1651
|
-
type_ignore_source = "x = compute() # type:ignore\n"
|
|
1652
|
-
pragma_source = "x = compute() # pragma:no-cover\n"
|
|
1653
|
-
assert code_rules_enforcer.check_comments_python(pylint_source) == []
|
|
1654
|
-
assert code_rules_enforcer.check_comments_python(type_ignore_source) == []
|
|
1655
|
-
assert code_rules_enforcer.check_comments_python(pragma_source) == []
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
def test_exempt_comment_still_flags_noqa_glued_to_prose_without_boundary() -> None:
|
|
1659
|
-
"""The colon-terminated allowance must not loosen the boundary rule for
|
|
1660
|
-
markers that do not end in a colon: `# noqaFOO` still lacks a real boundary
|
|
1661
|
-
after `noqa` and stays subject to the no-new-comments rule."""
|
|
1662
|
-
source = "x = compute() # noqaFOO\n"
|
|
1663
|
-
assert code_rules_enforcer.check_comments_python(source)
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
def test_banned_noun_word_skips_non_aliased_upstream_import() -> None:
|
|
1667
|
-
"""A non-aliased upstream import the author cannot rename
|
|
1668
|
-
(`from typing import ItemsView`) must not be flagged, while an
|
|
1669
|
-
author-coined alias still is."""
|
|
1670
|
-
production_path = "packages/myapp/services/customer_pipeline.py"
|
|
1671
|
-
upstream_issues = code_rules_enforcer.check_banned_noun_word_boundary(
|
|
1672
|
-
"from typing import ItemsView\n", production_path
|
|
1673
|
-
)
|
|
1674
|
-
aliased_issues = code_rules_enforcer.check_banned_noun_word_boundary(
|
|
1675
|
-
"import legacy_helper as cached_response\n", production_path
|
|
1676
|
-
)
|
|
1677
|
-
assert upstream_issues == []
|
|
1678
|
-
assert any("cached_response" in each_issue for each_issue in aliased_issues)
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
def test_function_length_message_does_not_cite_file_length_section() -> None:
|
|
1682
|
-
"""The blocking message must cite a function-length basis, not the
|
|
1683
|
-
advisory file-length section (CODE_RULES §6.5)."""
|
|
1684
|
-
assert "6.5" not in code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX
|
|
1685
|
-
assert "Clean Code" in code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
def _function_node_named(source: str, function_name: str) -> ast.FunctionDef:
|
|
1689
|
-
syntax_tree = ast.parse(source)
|
|
1690
|
-
for each_node in syntax_tree.body:
|
|
1691
|
-
if isinstance(each_node, ast.FunctionDef) and each_node.name == function_name:
|
|
1692
|
-
return each_node
|
|
1693
|
-
raise AssertionError(f"no function named {function_name!r} in source")
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
def test_collect_pathlib_path_bindings_only_sees_the_scope_node_function() -> None:
|
|
1697
|
-
"""The Path-binding collector must scope its walk to the function node it
|
|
1698
|
-
is given. A `p = Path('~/x')` binding in test_a must not appear when the
|
|
1699
|
-
collector is handed test_b's node (test_b never binds `p` to a Path)."""
|
|
1700
|
-
source = (
|
|
1701
|
-
"from pathlib import Path\n"
|
|
1702
|
-
"def test_a() -> None:\n"
|
|
1703
|
-
" p = Path('~/x')\n"
|
|
1704
|
-
" p.expanduser()\n"
|
|
1705
|
-
"def test_b(p) -> None:\n"
|
|
1706
|
-
" p.expanduser()\n"
|
|
1707
|
-
)
|
|
1708
|
-
syntax_tree = ast.parse(source)
|
|
1709
|
-
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
1710
|
-
test_a_node = _function_node_named(source, "test_a")
|
|
1711
|
-
test_b_node = _function_node_named(source, "test_b")
|
|
1712
|
-
|
|
1713
|
-
test_a_bindings = code_rules_enforcer._collect_pathlib_path_local_binding_names(
|
|
1714
|
-
test_a_node, alias_map
|
|
1715
|
-
)
|
|
1716
|
-
test_b_bindings = code_rules_enforcer._collect_pathlib_path_local_binding_names(
|
|
1717
|
-
test_b_node, alias_map
|
|
1718
|
-
)
|
|
1719
|
-
|
|
1720
|
-
assert "p" in test_a_bindings
|
|
1721
|
-
assert "p" not in test_b_bindings
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
def test_collect_os_environ_bindings_only_sees_the_scope_node_function() -> None:
|
|
1725
|
-
"""The environ-binding collector must scope its walk to the function node
|
|
1726
|
-
it is given. An `e = os.environ` binding in test_a must not appear when the
|
|
1727
|
-
collector is handed test_b's node (test_b never binds `e`)."""
|
|
1728
|
-
source = (
|
|
1729
|
-
"import os\n"
|
|
1730
|
-
"def test_a() -> None:\n"
|
|
1731
|
-
" e = os.environ\n"
|
|
1732
|
-
" home = e['HOME']\n"
|
|
1733
|
-
" print(home)\n"
|
|
1734
|
-
"def test_b(e) -> None:\n"
|
|
1735
|
-
" home = e['HOME']\n"
|
|
1736
|
-
" print(home)\n"
|
|
1737
|
-
)
|
|
1738
|
-
syntax_tree = ast.parse(source)
|
|
1739
|
-
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
1740
|
-
test_a_node = _function_node_named(source, "test_a")
|
|
1741
|
-
test_b_node = _function_node_named(source, "test_b")
|
|
1742
|
-
|
|
1743
|
-
test_a_bindings = code_rules_enforcer._collect_os_environ_local_binding_names(
|
|
1744
|
-
test_a_node, alias_map
|
|
1745
|
-
)
|
|
1746
|
-
test_b_bindings = code_rules_enforcer._collect_os_environ_local_binding_names(
|
|
1747
|
-
test_b_node, alias_map
|
|
1748
|
-
)
|
|
1749
|
-
|
|
1750
|
-
assert "e" in test_a_bindings
|
|
1751
|
-
assert "e" not in test_b_bindings
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
def test_function_local_from_os_import_environ_does_not_leak_into_sibling_test() -> None:
|
|
1755
|
-
"""bugbot-1: a function-local `from os import environ` in test_a binds
|
|
1756
|
-
`environ` only for test_a's runtime. A sibling test_b that references the
|
|
1757
|
-
bare name `environ` without importing it must not be flagged, while the
|
|
1758
|
-
test that actually imports and probes HOME (test_a) must be flagged."""
|
|
1759
|
-
source = (
|
|
1760
|
-
"def test_a() -> None:\n"
|
|
1761
|
-
" from os import environ\n"
|
|
1762
|
-
" home = environ['HOME']\n"
|
|
1763
|
-
" print(home)\n"
|
|
1764
|
-
"def test_b() -> None:\n"
|
|
1765
|
-
" home = environ['HOME']\n"
|
|
1766
|
-
" print(home)\n"
|
|
1767
|
-
)
|
|
1768
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1769
|
-
source, "/project/src/test_module.py"
|
|
1770
|
-
)
|
|
1771
|
-
assert any("test_a" in each_issue for each_issue in issues), (
|
|
1772
|
-
f"test_a's own function-local environ import must be flagged, got: {issues!r}"
|
|
1773
|
-
)
|
|
1774
|
-
assert not any("test_b" in each_issue for each_issue in issues), (
|
|
1775
|
-
"test_b references bare `environ` it never imports, so the function-local "
|
|
1776
|
-
f"import in test_a must not leak into it, got: {issues!r}"
|
|
1777
|
-
)
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
def test_function_local_aliased_module_import_does_not_leak_into_sibling_test() -> None:
|
|
1781
|
-
"""bugbot-1 sibling: a function-local `import os as o` in test_a aliases
|
|
1782
|
-
`o` only for test_a. test_b referencing `o.getenv('HOME')` without its own
|
|
1783
|
-
import must not be flagged; test_a's own probe must be flagged."""
|
|
1784
|
-
source = (
|
|
1785
|
-
"def test_a() -> None:\n"
|
|
1786
|
-
" import os as o\n"
|
|
1787
|
-
" home = o.getenv('HOME')\n"
|
|
1788
|
-
" print(home)\n"
|
|
1789
|
-
"def test_b() -> None:\n"
|
|
1790
|
-
" home = o.getenv('HOME')\n"
|
|
1791
|
-
" print(home)\n"
|
|
1792
|
-
)
|
|
1793
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1794
|
-
source, "/project/src/test_module.py"
|
|
1795
|
-
)
|
|
1796
|
-
assert any("test_a" in each_issue for each_issue in issues), (
|
|
1797
|
-
f"test_a's own function-local aliased import must be flagged, got: {issues!r}"
|
|
1798
|
-
)
|
|
1799
|
-
assert not any("test_b" in each_issue for each_issue in issues), (
|
|
1800
|
-
"test_b references alias `o` it never bound, so the function-local "
|
|
1801
|
-
f"import in test_a must not leak into it, got: {issues!r}"
|
|
1802
|
-
)
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
def test_build_alias_map_excludes_function_local_imports() -> None:
|
|
1806
|
-
"""bugbot-1: the module-wide alias canonicalization map must be built only
|
|
1807
|
-
from top-level imports. A function-local `import os as o` and a
|
|
1808
|
-
function-local `from os import environ` must not appear in the shared map."""
|
|
1809
|
-
source = (
|
|
1810
|
-
"import tempfile as module_temp\n"
|
|
1811
|
-
"def test_a() -> None:\n"
|
|
1812
|
-
" import os as o\n"
|
|
1813
|
-
" from os import environ\n"
|
|
1814
|
-
" print(o, environ)\n"
|
|
1815
|
-
)
|
|
1816
|
-
syntax_tree = ast.parse(source)
|
|
1817
|
-
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
1818
|
-
assert alias_map.get("module_temp") == "tempfile", (
|
|
1819
|
-
f"top-level alias must be recorded, got: {alias_map!r}"
|
|
1820
|
-
)
|
|
1821
|
-
assert "o" not in alias_map, (
|
|
1822
|
-
f"function-local `import os as o` must not leak into the module map, got: {alias_map!r}"
|
|
1823
|
-
)
|
|
1824
|
-
assert "environ" not in alias_map, (
|
|
1825
|
-
f"function-local `from os import environ` must not leak into the module map, got: {alias_map!r}"
|
|
1826
|
-
)
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
def test_module_level_from_os_import_environ_still_flags_every_referencing_test() -> None:
|
|
1830
|
-
"""bugbot-1 guard: a genuine module-level `from os import environ` binds the
|
|
1831
|
-
name for the whole module, so every test that probes HOME through it must
|
|
1832
|
-
still be flagged. The per-function scoping must not suppress this case."""
|
|
1833
|
-
source = (
|
|
1834
|
-
"from os import environ\n"
|
|
1835
|
-
"def test_a() -> None:\n"
|
|
1836
|
-
" print(environ['HOME'])\n"
|
|
1837
|
-
"def test_b() -> None:\n"
|
|
1838
|
-
" print(environ['HOME'])\n"
|
|
1839
|
-
)
|
|
1840
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1841
|
-
source, "/project/src/test_module.py"
|
|
1842
|
-
)
|
|
1843
|
-
assert any("test_a" in each_issue for each_issue in issues)
|
|
1844
|
-
assert any("test_b" in each_issue for each_issue in issues), (
|
|
1845
|
-
f"module-level import must flag every probing test, got: {issues!r}"
|
|
1846
|
-
)
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
def test_build_alias_map_excludes_class_body_imports() -> None:
|
|
1850
|
-
"""A probe alias imported inside a class body binds only inside that class
|
|
1851
|
-
scope, so it must not enter the module-wide alias canonicalization map. A
|
|
1852
|
-
genuine module-level alias in the same source must still be recorded."""
|
|
1853
|
-
source = (
|
|
1854
|
-
"import tempfile as module_temp\n"
|
|
1855
|
-
"class TestAlpha:\n"
|
|
1856
|
-
" import tempfile as t\n"
|
|
1857
|
-
" def test_alpha_probe(self) -> None:\n"
|
|
1858
|
-
" assert self.t is not None\n"
|
|
1859
|
-
)
|
|
1860
|
-
syntax_tree = ast.parse(source)
|
|
1861
|
-
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
1862
|
-
assert alias_map.get("module_temp") == "tempfile", (
|
|
1863
|
-
f"top-level alias must be recorded, got: {alias_map!r}"
|
|
1864
|
-
)
|
|
1865
|
-
assert "t" not in alias_map, (
|
|
1866
|
-
f"class-body `import tempfile as t` must not leak into the module map, got: {alias_map!r}"
|
|
1867
|
-
)
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
def test_class_body_aliased_import_does_not_leak_into_sibling_test() -> None:
|
|
1871
|
-
"""A class-body `import tempfile as t` aliases `t` only inside that class.
|
|
1872
|
-
A sibling top-level test taking `t` as a parameter and calling `t.mkdtemp()`
|
|
1873
|
-
must not be flagged, since the class-scoped alias never enters the
|
|
1874
|
-
module-wide map."""
|
|
1875
|
-
source = (
|
|
1876
|
-
"class TestAlpha:\n"
|
|
1877
|
-
" import tempfile as t\n"
|
|
1878
|
-
" def test_alpha_probe(self) -> None:\n"
|
|
1879
|
-
" assert self.t is not None\n"
|
|
1880
|
-
"def test_sibling(t) -> None:\n"
|
|
1881
|
-
" t.mkdtemp()\n"
|
|
1882
|
-
)
|
|
1883
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
1884
|
-
source, "/project/src/test_module.py"
|
|
1885
|
-
)
|
|
1886
|
-
assert not any("test_sibling" in each_issue for each_issue in issues), (
|
|
1887
|
-
"class-body alias must not leak into a sibling test through the "
|
|
1888
|
-
f"module-wide map, got: {issues!r}"
|
|
1889
|
-
)
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
def test_build_alias_map_records_module_top_level_but_excludes_function_and_class_imports() -> None:
|
|
1893
|
-
"""Only true module-top-level imports enter the alias map. Imports lexically
|
|
1894
|
-
inside a function body or a class body are excluded, while a module-level
|
|
1895
|
-
try-guarded optional import is still recorded module-wide."""
|
|
1896
|
-
source = (
|
|
1897
|
-
"try:\n"
|
|
1898
|
-
" import tempfile as guarded_temp\n"
|
|
1899
|
-
"except ImportError:\n"
|
|
1900
|
-
" guarded_temp = None\n"
|
|
1901
|
-
"def test_function_local() -> None:\n"
|
|
1902
|
-
" import tempfile as function_temp\n"
|
|
1903
|
-
" assert function_temp is not None\n"
|
|
1904
|
-
"class TestBeta:\n"
|
|
1905
|
-
" import tempfile as class_temp\n"
|
|
1906
|
-
" def test_beta_probe(self) -> None:\n"
|
|
1907
|
-
" assert self.class_temp is not None\n"
|
|
1908
|
-
)
|
|
1909
|
-
syntax_tree = ast.parse(source)
|
|
1910
|
-
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
1911
|
-
assert alias_map.get("guarded_temp") == "tempfile", (
|
|
1912
|
-
f"module-level try-guarded alias must be recorded, got: {alias_map!r}"
|
|
1913
|
-
)
|
|
1914
|
-
assert "function_temp" not in alias_map, (
|
|
1915
|
-
f"function-local alias must not enter the module map, got: {alias_map!r}"
|
|
1916
|
-
)
|
|
1917
|
-
assert "class_temp" not in alias_map, (
|
|
1918
|
-
f"class-body alias must not enter the module map, got: {alias_map!r}"
|
|
1919
|
-
)
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
def _oversized_function_source(name: str) -> str:
|
|
1923
|
-
body_line_count = code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
|
|
1924
|
-
body_lines = [
|
|
1925
|
-
f" bound_{each_index} = {each_index}" for each_index in range(body_line_count)
|
|
1926
|
-
]
|
|
1927
|
-
return f"def {name}() -> None:\n" + "\n".join(body_lines) + "\n"
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
def test_function_length_edit_does_not_block_untouched_long_function() -> None:
|
|
1931
|
-
"""loop5-1: editing a short region of a file that already contains an
|
|
1932
|
-
untouched oversized function must not produce a blocking function-length
|
|
1933
|
-
violation at the PreToolUse layer."""
|
|
1934
|
-
untouched_long_function = _oversized_function_source("untouched_long")
|
|
1935
|
-
short_helper_before = "def short_helper() -> int:\n return 1\n"
|
|
1936
|
-
short_helper_after = "def short_helper() -> int:\n return 2\n"
|
|
1937
|
-
prior_full_file = untouched_long_function + "\n" + short_helper_before
|
|
1938
|
-
post_edit_full_file = untouched_long_function + "\n" + short_helper_after
|
|
1939
|
-
issues = code_rules_enforcer.validate_content(
|
|
1940
|
-
short_helper_after,
|
|
1941
|
-
"/project/src/edited_module.py",
|
|
1942
|
-
old_content=short_helper_before,
|
|
1943
|
-
full_file_content=post_edit_full_file,
|
|
1944
|
-
prior_full_file_content=prior_full_file,
|
|
1945
|
-
)
|
|
1946
|
-
assert not any(
|
|
1947
|
-
"untouched_long" in each_issue for each_issue in issues
|
|
1948
|
-
), f"untouched long function must not block on an unrelated edit, got: {issues!r}"
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
def test_function_length_edit_blocks_function_grown_on_changed_lines() -> None:
|
|
1952
|
-
"""loop5-1: when the edit itself grows a function past the threshold, the
|
|
1953
|
-
function-length violation must still block at the PreToolUse layer."""
|
|
1954
|
-
short_function_before = "def grows_now() -> int:\n return 1\n"
|
|
1955
|
-
grown_function_after = _oversized_function_source("grows_now")
|
|
1956
|
-
prior_full_file = short_function_before
|
|
1957
|
-
post_edit_full_file = grown_function_after
|
|
1958
|
-
issues = code_rules_enforcer.validate_content(
|
|
1959
|
-
grown_function_after,
|
|
1960
|
-
"/project/src/edited_module.py",
|
|
1961
|
-
old_content=short_function_before,
|
|
1962
|
-
full_file_content=post_edit_full_file,
|
|
1963
|
-
prior_full_file_content=prior_full_file,
|
|
1964
|
-
)
|
|
1965
|
-
assert any(
|
|
1966
|
-
"grows_now" in each_issue for each_issue in issues
|
|
1967
|
-
), f"function grown past threshold on changed lines must block, got: {issues!r}"
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
def test_isolation_edit_does_not_block_untouched_probe() -> None:
|
|
1971
|
-
"""loop5-3: editing a short region of a test file that already contains an
|
|
1972
|
-
untouched HOME probe must not block at the PreToolUse layer."""
|
|
1973
|
-
untouched_probe_function = (
|
|
1974
|
-
"def test_reads_home() -> None:\n"
|
|
1975
|
-
" target_path = Path.home()\n"
|
|
1976
|
-
" assert target_path\n"
|
|
1977
|
-
)
|
|
1978
|
-
short_test_before = "def test_addition() -> None:\n assert 1 + 1 == 2\n"
|
|
1979
|
-
short_test_after = "def test_addition() -> None:\n assert 2 + 2 == 4\n"
|
|
1980
|
-
header = "from pathlib import Path\n"
|
|
1981
|
-
prior_full_file = header + untouched_probe_function + "\n" + short_test_before
|
|
1982
|
-
post_edit_full_file = header + untouched_probe_function + "\n" + short_test_after
|
|
1983
|
-
issues = code_rules_enforcer.validate_content(
|
|
1984
|
-
short_test_after,
|
|
1985
|
-
"/project/src/test_edited_module.py",
|
|
1986
|
-
old_content=short_test_before,
|
|
1987
|
-
full_file_content=post_edit_full_file,
|
|
1988
|
-
prior_full_file_content=prior_full_file,
|
|
1989
|
-
)
|
|
1990
|
-
assert not any(
|
|
1991
|
-
"test_reads_home" in each_issue for each_issue in issues
|
|
1992
|
-
), f"untouched isolation probe must not block on an unrelated edit, got: {issues!r}"
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
def test_isolation_edit_blocks_probe_added_on_changed_lines() -> None:
|
|
1996
|
-
"""loop5-3: when the edit introduces a HOME probe, the isolation violation
|
|
1997
|
-
must still block at the PreToolUse layer."""
|
|
1998
|
-
test_before = "def test_writes() -> None:\n assert True\n"
|
|
1999
|
-
test_after = (
|
|
2000
|
-
"def test_writes() -> None:\n"
|
|
2001
|
-
" target_path = Path.home()\n"
|
|
2002
|
-
" assert target_path\n"
|
|
2003
|
-
)
|
|
2004
|
-
header = "from pathlib import Path\n"
|
|
2005
|
-
prior_full_file = header + test_before
|
|
2006
|
-
post_edit_full_file = header + test_after
|
|
2007
|
-
issues = code_rules_enforcer.validate_content(
|
|
2008
|
-
test_after,
|
|
2009
|
-
"/project/src/test_edited_module.py",
|
|
2010
|
-
old_content=test_before,
|
|
2011
|
-
full_file_content=post_edit_full_file,
|
|
2012
|
-
prior_full_file_content=prior_full_file,
|
|
2013
|
-
)
|
|
2014
|
-
assert any(
|
|
2015
|
-
"test_writes" in each_issue and "Path.home" in each_issue
|
|
2016
|
-
for each_issue in issues
|
|
2017
|
-
), f"isolation probe added on changed lines must block, got: {issues!r}"
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
def test_isolation_edit_blocks_probe_unisolated_by_signature_line_change() -> None:
|
|
2021
|
-
"""Removing the ``monkeypatch`` fixture from a test's signature line
|
|
2022
|
-
un-isolates a HOME probe in its unchanged body; the violation must block
|
|
2023
|
-
because the enclosing function's span covers the changed signature line."""
|
|
2024
|
-
test_before = (
|
|
2025
|
-
"def test_reads_home(monkeypatch) -> None:\n"
|
|
2026
|
-
" target_path = Path.home()\n"
|
|
2027
|
-
" assert target_path\n"
|
|
2028
|
-
)
|
|
2029
|
-
test_after = (
|
|
2030
|
-
"def test_reads_home() -> None:\n"
|
|
2031
|
-
" target_path = Path.home()\n"
|
|
2032
|
-
" assert target_path\n"
|
|
2033
|
-
)
|
|
2034
|
-
header = "from pathlib import Path\n"
|
|
2035
|
-
prior_full_file = header + test_before
|
|
2036
|
-
post_edit_full_file = header + test_after
|
|
2037
|
-
issues = code_rules_enforcer.validate_content(
|
|
2038
|
-
test_after,
|
|
2039
|
-
"/project/src/test_edited_module.py",
|
|
2040
|
-
old_content=test_before,
|
|
2041
|
-
full_file_content=post_edit_full_file,
|
|
2042
|
-
prior_full_file_content=prior_full_file,
|
|
2043
|
-
)
|
|
2044
|
-
assert any(
|
|
2045
|
-
"test_reads_home" in each_issue and "Path.home" in each_issue
|
|
2046
|
-
for each_issue in issues
|
|
2047
|
-
), f"signature-line change that un-isolates a probe must block, got: {issues!r}"
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
def test_isolation_message_carries_enclosing_function_definition_span() -> None:
|
|
2051
|
-
"""The isolation message must carry the enclosing test's definition line
|
|
2052
|
-
and line span so the commit gate can scope by the same function span the
|
|
2053
|
-
enforcer uses, while keeping the ``Line N:`` probe-line prefix intact."""
|
|
2054
|
-
header = "from pathlib import Path\n"
|
|
2055
|
-
test_body = (
|
|
2056
|
-
"def test_reads_home() -> None:\n"
|
|
2057
|
-
" target_path = Path.home()\n"
|
|
2058
|
-
" assert target_path\n"
|
|
2059
|
-
)
|
|
2060
|
-
source = header + test_body
|
|
2061
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2062
|
-
source, "/project/src/test_module.py"
|
|
2063
|
-
)
|
|
2064
|
-
definition_line = 2
|
|
2065
|
-
function_span = 3
|
|
2066
|
-
expected_span_fragment = (
|
|
2067
|
-
f"(defined at line {definition_line}, spanning {function_span} lines)"
|
|
2068
|
-
)
|
|
2069
|
-
assert any(
|
|
2070
|
-
each_issue.startswith("Line ") and expected_span_fragment in each_issue
|
|
2071
|
-
for each_issue in issues
|
|
2072
|
-
), f"isolation message must carry the def-line + span fragment, got: {issues!r}"
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
def test_function_length_reports_only_in_scope_violation_on_terminal_edit() -> None:
|
|
2076
|
-
"""A terminal diff-scoped Edit reports only the function whose changed-line
|
|
2077
|
-
span grew past the threshold; untouched oversized functions earlier in the
|
|
2078
|
-
file are out of scope and dropped, regardless of how many precede it."""
|
|
2079
|
-
leading_function_count = 6
|
|
2080
|
-
leading_functions = "\n".join(
|
|
2081
|
-
_oversized_function_source(f"leading_long_{each_index}")
|
|
2082
|
-
for each_index in range(leading_function_count)
|
|
2083
|
-
)
|
|
2084
|
-
short_target_before = "def target_function() -> int:\n return 1\n"
|
|
2085
|
-
grown_target_after = _oversized_function_source("target_function")
|
|
2086
|
-
prior_full_file = leading_functions + "\n" + short_target_before
|
|
2087
|
-
post_edit_full_file = leading_functions + "\n" + grown_target_after
|
|
2088
|
-
issues = code_rules_enforcer.validate_content(
|
|
2089
|
-
grown_target_after,
|
|
2090
|
-
"/project/src/many_functions.py",
|
|
2091
|
-
old_content=short_target_before,
|
|
2092
|
-
full_file_content=post_edit_full_file,
|
|
2093
|
-
prior_full_file_content=prior_full_file,
|
|
2094
|
-
)
|
|
2095
|
-
function_length_issues = [
|
|
2096
|
-
each_issue for each_issue in issues if "defined at line" in each_issue
|
|
2097
|
-
]
|
|
2098
|
-
assert any(
|
|
2099
|
-
"target_function" in each_issue for each_issue in function_length_issues
|
|
2100
|
-
), f"in-scope grown function must still block, got: {issues!r}"
|
|
2101
|
-
assert not any(
|
|
2102
|
-
"leading_long_" in each_issue for each_issue in function_length_issues
|
|
2103
|
-
), f"untouched functions must stay out of scope, got: {function_length_issues!r}"
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
def test_new_file_write_reports_every_in_scope_long_function_uncapped() -> None:
|
|
2107
|
-
"""loop7-bugbot: a new-file Write passes ``all_changed_lines is None``; every
|
|
2108
|
-
line was just authored and is in scope, so every long function is reported
|
|
2109
|
-
with no ceiling on the count."""
|
|
2110
|
-
function_count = 6
|
|
2111
|
-
all_functions = "\n".join(
|
|
2112
|
-
_oversized_function_source(f"new_long_{each_index}")
|
|
2113
|
-
for each_index in range(function_count)
|
|
2114
|
-
)
|
|
2115
|
-
issues = code_rules_enforcer.validate_content(
|
|
2116
|
-
all_functions,
|
|
2117
|
-
"/project/src/freshly_written_module.py",
|
|
2118
|
-
old_content="",
|
|
2119
|
-
)
|
|
2120
|
-
function_length_issues = [
|
|
2121
|
-
each_issue for each_issue in issues if "defined at line" in each_issue
|
|
2122
|
-
]
|
|
2123
|
-
assert len(function_length_issues) == function_count, (
|
|
2124
|
-
"every long function in a new file is in scope and must be reported, "
|
|
2125
|
-
f"got: {function_length_issues!r}"
|
|
2126
|
-
)
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
def test_new_file_write_reports_every_in_scope_isolation_probe_uncapped() -> None:
|
|
2130
|
-
"""loop7-bugbot: a new test file Write passes ``all_changed_lines is None``;
|
|
2131
|
-
every HOME probe is in scope, so each one is reported with no count ceiling."""
|
|
2132
|
-
probe_count = 6
|
|
2133
|
-
probing_tests = "".join(
|
|
2134
|
-
f"def test_probe_{each_index}() -> None:\n"
|
|
2135
|
-
f" home_dir_{each_index} = Path.home()\n"
|
|
2136
|
-
f" assert home_dir_{each_index}\n"
|
|
2137
|
-
for each_index in range(probe_count)
|
|
2138
|
-
)
|
|
2139
|
-
source = "from pathlib import Path\n" + probing_tests
|
|
2140
|
-
issues = code_rules_enforcer.validate_content(
|
|
2141
|
-
source,
|
|
2142
|
-
"/project/src/test_freshly_written_module.py",
|
|
2143
|
-
old_content="",
|
|
2144
|
-
)
|
|
2145
|
-
home_probe_issues = [
|
|
2146
|
-
each_issue for each_issue in issues if "Path.home" in each_issue
|
|
2147
|
-
]
|
|
2148
|
-
assert len(home_probe_issues) == probe_count, (
|
|
2149
|
-
"every HOME probe in a new test file is in scope and must be reported, "
|
|
2150
|
-
f"got: {home_probe_issues!r}"
|
|
2151
|
-
)
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
def test_banned_noun_word_defers_scope_to_caller_when_requested() -> None:
|
|
2155
|
-
"""loop7-P1: when the gate sets the deferral flag, the banned-noun check must
|
|
2156
|
-
return every violation so ``split_violations_by_scope`` can scope by added
|
|
2157
|
-
line before reporting the in-scope set."""
|
|
2158
|
-
binding_count = 5
|
|
2159
|
-
source = "".join(
|
|
2160
|
-
f"BINDING_{each_index}_RESULT_PATH = {each_index}\n"
|
|
2161
|
-
for each_index in range(binding_count)
|
|
2162
|
-
)
|
|
2163
|
-
issues = code_rules_enforcer.check_banned_noun_word_boundary(
|
|
2164
|
-
source,
|
|
2165
|
-
"/project/src/many_nouns.py",
|
|
2166
|
-
defer_scope_to_caller=True,
|
|
2167
|
-
)
|
|
2168
|
-
assert len(issues) == binding_count, (
|
|
2169
|
-
"deferral must return every banned-noun violation, "
|
|
2170
|
-
f"got: {issues!r}"
|
|
2171
|
-
)
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
def test_banned_noun_word_keeps_in_scope_binding_among_untouched_ones() -> None:
|
|
2175
|
-
"""loop7-P1: an Edit whose changed line introduces a banned-noun identifier
|
|
2176
|
-
among several pre-existing untouched ones must still report the new in-scope
|
|
2177
|
-
binding while leaving the untouched bindings out of scope."""
|
|
2178
|
-
leading_count = 5
|
|
2179
|
-
leading_bindings = "".join(
|
|
2180
|
-
f"LEADING_{each_index}_RESULT_PATH = {each_index}\n"
|
|
2181
|
-
for each_index in range(leading_count)
|
|
2182
|
-
)
|
|
2183
|
-
target_before = "PLACEHOLDER_NAME = 0\n"
|
|
2184
|
-
target_after = "INTRODUCED_RESULT_PATH = 0\n"
|
|
2185
|
-
prior_full_file = leading_bindings + target_before
|
|
2186
|
-
post_edit_full_file = leading_bindings + target_after
|
|
2187
|
-
issues = code_rules_enforcer.validate_content(
|
|
2188
|
-
target_after,
|
|
2189
|
-
"/project/src/many_nouns.py",
|
|
2190
|
-
old_content=target_before,
|
|
2191
|
-
full_file_content=post_edit_full_file,
|
|
2192
|
-
prior_full_file_content=prior_full_file,
|
|
2193
|
-
)
|
|
2194
|
-
assert any(
|
|
2195
|
-
"INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
|
|
2196
|
-
), f"in-scope banned-noun past the cap window must still block, got: {issues!r}"
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
def test_module_import_inside_top_level_try_is_retained_in_alias_map() -> None:
|
|
2200
|
-
"""loop7-P2 (2566): a module-level ``try: import os as o`` is genuinely
|
|
2201
|
-
module-scoped; its alias must enter the shared canonicalization map so a
|
|
2202
|
-
later ``o.path.expanduser('~')`` inside a test is flagged."""
|
|
2203
|
-
source = (
|
|
2204
|
-
"try:\n"
|
|
2205
|
-
" import os as o\n"
|
|
2206
|
-
"except ImportError:\n"
|
|
2207
|
-
" o = None\n"
|
|
2208
|
-
"def test_reads_home() -> None:\n"
|
|
2209
|
-
" discovered = o.path.expanduser('~')\n"
|
|
2210
|
-
" assert discovered\n"
|
|
2211
|
-
)
|
|
2212
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2213
|
-
source, "/project/src/test_optional_import.py"
|
|
2214
|
-
)
|
|
2215
|
-
assert any(
|
|
2216
|
-
"test_reads_home" in each_issue for each_issue in issues
|
|
2217
|
-
), f"module import nested in top-level try must be retained, got: {issues!r}"
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
def test_direct_module_aliased_import_is_retained_in_alias_map() -> None:
|
|
2221
|
-
"""loop7-P2 (2566): a plain top-level ``import os as o`` must still resolve so
|
|
2222
|
-
``o.path.expanduser('~')`` inside a test is flagged."""
|
|
2223
|
-
source = (
|
|
2224
|
-
"import os as o\n"
|
|
2225
|
-
"def test_reads_home() -> None:\n"
|
|
2226
|
-
" discovered = o.path.expanduser('~')\n"
|
|
2227
|
-
" assert discovered\n"
|
|
2228
|
-
)
|
|
2229
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2230
|
-
source, "/project/src/test_direct_import.py"
|
|
2231
|
-
)
|
|
2232
|
-
assert any(
|
|
2233
|
-
"test_reads_home" in each_issue for each_issue in issues
|
|
2234
|
-
), f"direct module aliased import must resolve, got: {issues!r}"
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
def test_function_local_import_does_not_enter_shared_alias_map() -> None:
|
|
2238
|
-
"""loop7-P2 (2566): an import inside one test must not canonicalize a
|
|
2239
|
-
same-named reference in a sibling test that never imported it."""
|
|
2240
|
-
source = (
|
|
2241
|
-
"def test_imports_locally() -> None:\n"
|
|
2242
|
-
" import os as o\n"
|
|
2243
|
-
" assert o\n"
|
|
2244
|
-
"def test_sibling_uses_o() -> None:\n"
|
|
2245
|
-
" o = make_unrelated_object()\n"
|
|
2246
|
-
" discovered = o.path.expanduser('~')\n"
|
|
2247
|
-
" assert discovered\n"
|
|
2248
|
-
)
|
|
2249
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2250
|
-
source, "/project/src/test_local_import_scope.py"
|
|
2251
|
-
)
|
|
2252
|
-
assert not any(
|
|
2253
|
-
"test_sibling_uses_o" in each_issue for each_issue in issues
|
|
2254
|
-
), f"function-local import must not leak to a sibling test, got: {issues!r}"
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
def test_import_inside_nested_helper_does_not_leak_to_outer_test_overlay() -> None:
|
|
2258
|
-
"""loop7-P2 (2690): an import inside a standalone nested helper runs in its own
|
|
2259
|
-
callable scope; its alias must not enter the outer test's overlay and flag a
|
|
2260
|
-
sibling reference in the outer body."""
|
|
2261
|
-
source = (
|
|
2262
|
-
"def test_outer() -> None:\n"
|
|
2263
|
-
" def nested_helper() -> None:\n"
|
|
2264
|
-
" import os as o\n"
|
|
2265
|
-
" assert o\n"
|
|
2266
|
-
" o = make_unrelated_object()\n"
|
|
2267
|
-
" discovered = o.path.expanduser('~')\n"
|
|
2268
|
-
" assert discovered\n"
|
|
2269
|
-
)
|
|
2270
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2271
|
-
source, "/project/src/test_nested_helper_scope.py"
|
|
2272
|
-
)
|
|
2273
|
-
assert not any(
|
|
2274
|
-
"test_outer" in each_issue for each_issue in issues
|
|
2275
|
-
), f"nested-helper import must not leak to the outer test, got: {issues!r}"
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
def test_environ_binding_inside_nested_helper_does_not_leak_to_outer_test() -> None:
|
|
2279
|
-
"""loop7-P2 (2690 sibling): an ``os.environ`` binding inside a standalone
|
|
2280
|
-
nested helper runs in its own scope; a same-named outer reference must not be
|
|
2281
|
-
attributed to that binding."""
|
|
2282
|
-
source = (
|
|
2283
|
-
"import os\n"
|
|
2284
|
-
"def test_outer() -> None:\n"
|
|
2285
|
-
" def nested_helper() -> None:\n"
|
|
2286
|
-
" captured = os.environ\n"
|
|
2287
|
-
" assert captured\n"
|
|
2288
|
-
" captured = make_unrelated_mapping()\n"
|
|
2289
|
-
" discovered = captured['HOME']\n"
|
|
2290
|
-
" assert discovered\n"
|
|
2291
|
-
)
|
|
2292
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2293
|
-
source, "/project/src/test_environ_nested_scope.py"
|
|
2294
|
-
)
|
|
2295
|
-
assert not any(
|
|
2296
|
-
"test_outer" in each_issue for each_issue in issues
|
|
2297
|
-
), f"nested-helper environ binding must not leak to the outer test, got: {issues!r}"
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
def test_pathlib_binding_inside_nested_helper_does_not_leak_to_outer_test() -> None:
|
|
2301
|
-
"""loop7-P2 (2690 sibling): a home-tilde ``Path('~')`` binding inside a
|
|
2302
|
-
standalone nested helper runs in its own scope; a same-named outer
|
|
2303
|
-
``.expanduser()`` call must not be attributed to that binding."""
|
|
2304
|
-
source = (
|
|
2305
|
-
"from pathlib import Path\n"
|
|
2306
|
-
"def test_outer() -> None:\n"
|
|
2307
|
-
" def nested_helper() -> None:\n"
|
|
2308
|
-
" candidate = Path('~/config')\n"
|
|
2309
|
-
" assert candidate\n"
|
|
2310
|
-
" candidate = make_unrelated_path()\n"
|
|
2311
|
-
" discovered = candidate.expanduser()\n"
|
|
2312
|
-
" assert discovered\n"
|
|
2313
|
-
)
|
|
2314
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2315
|
-
source, "/project/src/test_pathlib_nested_scope.py"
|
|
2316
|
-
)
|
|
2317
|
-
assert not any(
|
|
2318
|
-
"test_outer" in each_issue for each_issue in issues
|
|
2319
|
-
), f"nested-helper pathlib binding must not leak to the outer test, got: {issues!r}"
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
def test_banned_noun_edit_drops_untouched_out_of_scope_binding() -> None:
|
|
2323
|
-
"""An Edit that touches none of the banned-noun bindings reports nothing —
|
|
2324
|
-
the check now routes through the reconstructed effective content and the
|
|
2325
|
-
edit's changed lines, exactly like check_function_length, so an untouched
|
|
2326
|
-
binding outside the edit hunk must not block."""
|
|
2327
|
-
leading = "".join(
|
|
2328
|
-
f"LEADING_{each_index}_RESULT_PATH = {each_index}\n" for each_index in range(5)
|
|
2329
|
-
)
|
|
2330
|
-
edited_tail = "def compute_total() -> int:\n running_sum = 0\n return running_sum\n"
|
|
2331
|
-
prior_full_file = leading + "def compute_total() -> int:\n running_sum = 0\n return 0\n"
|
|
2332
|
-
post_edit_full_file = leading + edited_tail
|
|
2333
|
-
issues = code_rules_enforcer.validate_content(
|
|
2334
|
-
edited_tail,
|
|
2335
|
-
"/project/src/many_nouns.py",
|
|
2336
|
-
old_content="def compute_total() -> int:\n running_sum = 0\n return 0\n",
|
|
2337
|
-
full_file_content=post_edit_full_file,
|
|
2338
|
-
prior_full_file_content=prior_full_file,
|
|
2339
|
-
)
|
|
2340
|
-
assert not any(
|
|
2341
|
-
"RESULT_PATH" in each_issue for each_issue in issues
|
|
2342
|
-
), f"untouched banned-noun bindings must stay out of scope, got: {issues!r}"
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
def test_banned_noun_edit_keeps_touched_binding_in_scope() -> None:
|
|
2346
|
-
"""An Edit whose changed line introduces a banned-noun binding reports it,
|
|
2347
|
-
using the reconstructed effective content and the edit's changed lines."""
|
|
2348
|
-
leading = "".join(
|
|
2349
|
-
f"LEADING_{each_index}_VALUE_PATH = {each_index}\n" for each_index in range(5)
|
|
2350
|
-
)
|
|
2351
|
-
prior_tail = "PLACEHOLDER_NAME = 0\n"
|
|
2352
|
-
edited_tail = "INTRODUCED_RESULT_PATH = 0\n"
|
|
2353
|
-
prior_full_file = leading + prior_tail
|
|
2354
|
-
post_edit_full_file = leading + edited_tail
|
|
2355
|
-
issues = code_rules_enforcer.validate_content(
|
|
2356
|
-
edited_tail,
|
|
2357
|
-
"/project/src/introduces_noun.py",
|
|
2358
|
-
old_content=prior_tail,
|
|
2359
|
-
full_file_content=post_edit_full_file,
|
|
2360
|
-
prior_full_file_content=prior_full_file,
|
|
2361
|
-
)
|
|
2362
|
-
assert any(
|
|
2363
|
-
"INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
|
|
2364
|
-
), f"introduced banned-noun binding must block, got: {issues!r}"
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
def test_banned_noun_message_carries_binding_line_span() -> None:
|
|
2368
|
-
"""A banned-noun binding carries its own binding line as a one-line span so
|
|
2369
|
-
the commit gate reconstructs it through the same shared span mechanism the
|
|
2370
|
-
other diff-scoped checks use, while keeping the Line N: prefix intact. The
|
|
2371
|
-
binding-line granularity matches the companion exact-match
|
|
2372
|
-
check_banned_identifiers and avoids re-flagging a pre-existing binding when
|
|
2373
|
-
an unrelated line of its enclosing function is edited."""
|
|
2374
|
-
source = (
|
|
2375
|
-
"def aggregate() -> list[int]:\n"
|
|
2376
|
-
" canned_results = [1, 2, 3]\n"
|
|
2377
|
-
" return canned_results\n"
|
|
2378
|
-
)
|
|
2379
|
-
issues = code_rules_enforcer.check_banned_noun_word_boundary(
|
|
2380
|
-
source, "/project/src/has_noun.py"
|
|
2381
|
-
)
|
|
2382
|
-
binding_line = 2
|
|
2383
|
-
expected_fragment = f"(binding span at line {binding_line}, spanning 1 lines)"
|
|
2384
|
-
assert any(
|
|
2385
|
-
each_issue.startswith(f"Line {binding_line}:") and expected_fragment in each_issue
|
|
2386
|
-
for each_issue in issues
|
|
2387
|
-
), f"banned-noun message must carry the binding-line span fragment, got: {issues!r}"
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
def test_banned_noun_message_module_level_binding_spans_one_line() -> None:
|
|
2391
|
-
"""A module-level banned-noun binding spans its own binding line alone
|
|
2392
|
-
(span 1)."""
|
|
2393
|
-
source = "SAFE_OUTPUT_PATH = '/var/run/x'\n"
|
|
2394
|
-
issues = code_rules_enforcer.check_banned_noun_word_boundary(
|
|
2395
|
-
source, "/project/src/module_noun.py"
|
|
2396
|
-
)
|
|
2397
|
-
expected_fragment = "(binding span at line 1, spanning 1 lines)"
|
|
2398
|
-
assert any(expected_fragment in each_issue for each_issue in issues), (
|
|
2399
|
-
f"module-level banned-noun span must be one line, got: {issues!r}"
|
|
2400
|
-
)
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
def test_banned_noun_edit_does_not_reflag_param_when_unrelated_body_line_changes() -> None:
|
|
2404
|
-
"""Editing a body line of a function that already has a banned-noun
|
|
2405
|
-
parameter must not re-flag that pre-existing parameter: the binding-line
|
|
2406
|
-
span keeps the parameter out of scope unless its own declaration line is in
|
|
2407
|
-
the changed set."""
|
|
2408
|
-
prior_full_file = (
|
|
2409
|
-
"def transform(canned_results: int) -> int:\n"
|
|
2410
|
-
" midpoint = canned_results\n"
|
|
2411
|
-
" return midpoint\n"
|
|
2412
|
-
)
|
|
2413
|
-
post_edit_full_file = (
|
|
2414
|
-
"def transform(canned_results: int) -> int:\n"
|
|
2415
|
-
" midpoint = canned_results + 1\n"
|
|
2416
|
-
" return midpoint\n"
|
|
2417
|
-
)
|
|
2418
|
-
issues = code_rules_enforcer.validate_content(
|
|
2419
|
-
" midpoint = canned_results + 1\n",
|
|
2420
|
-
"/project/src/has_param.py",
|
|
2421
|
-
old_content=" midpoint = canned_results\n",
|
|
2422
|
-
full_file_content=post_edit_full_file,
|
|
2423
|
-
prior_full_file_content=prior_full_file,
|
|
2424
|
-
)
|
|
2425
|
-
assert not any(
|
|
2426
|
-
"canned_results" in each_issue for each_issue in issues
|
|
2427
|
-
), f"pre-existing param must not re-flag on unrelated body edit, got: {issues!r}"
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
def test_unreadable_prior_yields_no_prior_and_no_reconstruction() -> None:
|
|
2431
|
-
"""When the on-disk prior cannot be read for an Edit, the prior/post helper
|
|
2432
|
-
returns (None, None): a missing prior must not be fabricated as an empty
|
|
2433
|
-
string that would diff every line as changed and defeat edit scoping."""
|
|
2434
|
-
missing_path = "/project/src/does_not_exist_anywhere.py"
|
|
2435
|
-
prior_content, post_edit_content = code_rules_enforcer.prior_and_post_edit_content(
|
|
2436
|
-
missing_path,
|
|
2437
|
-
old_string="placeholder = 0\n",
|
|
2438
|
-
new_string="placeholder = 1\n",
|
|
2439
|
-
)
|
|
2440
|
-
assert prior_content is None
|
|
2441
|
-
assert post_edit_content is None
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
def test_readable_prior_yields_consistent_prior_and_reconstruction(tmp_path) -> None:
|
|
2445
|
-
"""When the prior reads cleanly, the helper returns the same prior content it
|
|
2446
|
-
reconstructed the post-edit view from, so the two never diverge across two
|
|
2447
|
-
independent reads."""
|
|
2448
|
-
source_file = tmp_path / "module.py"
|
|
2449
|
-
original = "alpha = 1\nbeta = 2\n"
|
|
2450
|
-
source_file.write_text(original, encoding="utf-8")
|
|
2451
|
-
prior_content, post_edit_content = code_rules_enforcer.prior_and_post_edit_content(
|
|
2452
|
-
str(source_file),
|
|
2453
|
-
old_string="beta = 2\n",
|
|
2454
|
-
new_string="beta = 3\n",
|
|
2455
|
-
)
|
|
2456
|
-
assert prior_content == original
|
|
2457
|
-
assert post_edit_content == "alpha = 1\nbeta = 3\n"
|
|
2458
|
-
changed = code_rules_enforcer.changed_line_numbers(prior_content, post_edit_content)
|
|
2459
|
-
assert changed == {2}
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
def _run_main_with_edit_payload(
|
|
2463
|
-
file_path: str,
|
|
2464
|
-
old_string: str,
|
|
2465
|
-
new_string: str,
|
|
2466
|
-
monkeypatch: object,
|
|
2467
|
-
capsys: object,
|
|
2468
|
-
) -> str:
|
|
2469
|
-
"""Drive ``main()`` through its stdin entry point for an Edit and return stdout.
|
|
2470
|
-
|
|
2471
|
-
Args:
|
|
2472
|
-
file_path: The on-disk path the Edit targets.
|
|
2473
|
-
old_string: The Edit's ``old_string`` fragment.
|
|
2474
|
-
new_string: The Edit's ``new_string`` fragment.
|
|
2475
|
-
monkeypatch: The pytest fixture used to redirect ``sys.stdin``.
|
|
2476
|
-
capsys: The pytest fixture used to capture the deny payload on stdout.
|
|
2477
|
-
|
|
2478
|
-
Returns:
|
|
2479
|
-
The captured stdout, which holds the deny payload when violations fire.
|
|
2480
|
-
"""
|
|
2481
|
-
edit_payload = json.dumps(
|
|
2482
|
-
{
|
|
2483
|
-
"tool_name": "Edit",
|
|
2484
|
-
"tool_input": {
|
|
2485
|
-
"file_path": file_path,
|
|
2486
|
-
"old_string": old_string,
|
|
2487
|
-
"new_string": new_string,
|
|
2488
|
-
},
|
|
2489
|
-
}
|
|
2490
|
-
)
|
|
2491
|
-
getattr(monkeypatch, "setattr")(code_rules_enforcer.sys, "stdin", io.StringIO(edit_payload))
|
|
2492
|
-
try:
|
|
2493
|
-
code_rules_enforcer.main()
|
|
2494
|
-
except SystemExit:
|
|
2495
|
-
pass
|
|
2496
|
-
captured = getattr(capsys, "readouterr")()
|
|
2497
|
-
return captured.out
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
|
|
2501
|
-
tmp_path_factory: object, monkeypatch: object, capsys: object,
|
|
2502
|
-
) -> None:
|
|
2503
|
-
"""When an Edit's old_string is absent from the file, ``prior_and_post_edit_content``
|
|
2504
|
-
yields ``(None, None)``; ``main()`` must analyze the real on-disk file whole-file
|
|
2505
|
-
rather than the new_string fragment, so an oversized function elsewhere in the
|
|
2506
|
-
file is still reported with its true line numbers."""
|
|
2507
|
-
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
2508
|
-
untouched_long_function = _oversized_function_source("untouched_long")
|
|
2509
|
-
short_helper = "def short_helper() -> int:\n return 1\n"
|
|
2510
|
-
on_disk_content = untouched_long_function + "\n" + short_helper
|
|
2511
|
-
source_file = production_directory / "edited_module.py"
|
|
2512
|
-
source_file.write_text(on_disk_content, encoding="utf-8")
|
|
2513
|
-
absent_fragment_old = "def absent_function() -> int:\n return 0\n"
|
|
2514
|
-
short_fragment_new = "def absent_function() -> int:\n return 2\n"
|
|
2515
|
-
stdout = _run_main_with_edit_payload(
|
|
2516
|
-
str(source_file), absent_fragment_old, short_fragment_new, monkeypatch, capsys,
|
|
2517
|
-
)
|
|
2518
|
-
assert "untouched_long" in stdout, (
|
|
2519
|
-
"an unreconstructable Edit must fall back to whole-file on-disk analysis, "
|
|
2520
|
-
f"so the oversized function is still reported; got stdout: {stdout!r}"
|
|
2521
|
-
)
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
def test_edit_with_unreadable_file_does_not_analyze_fragment_as_whole_file(
|
|
2525
|
-
tmp_path_factory: object, monkeypatch: object, capsys: object,
|
|
2526
|
-
) -> None:
|
|
2527
|
-
"""When the on-disk file cannot be read, no well-defined post-edit content
|
|
2528
|
-
exists; ``main()`` must exit cleanly rather than analyze the new_string
|
|
2529
|
-
fragment as if it were the whole file, so the fragment's own function-length
|
|
2530
|
-
violation does not surface as a deny payload."""
|
|
2531
|
-
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
2532
|
-
missing_path = str(production_directory / "never_created.py")
|
|
2533
|
-
oversized_fragment_old = "def grows() -> int:\n return 0\n"
|
|
2534
|
-
oversized_fragment_new = _oversized_function_source("grows")
|
|
2535
|
-
stdout = _run_main_with_edit_payload(
|
|
2536
|
-
missing_path,
|
|
2537
|
-
oversized_fragment_old,
|
|
2538
|
-
oversized_fragment_new,
|
|
2539
|
-
monkeypatch,
|
|
2540
|
-
capsys,
|
|
2541
|
-
)
|
|
2542
|
-
assert stdout == "", (
|
|
2543
|
-
"an unreadable Edit target has no well-defined whole-file content, so the "
|
|
2544
|
-
f"fragment must not be analyzed as the whole file; got stdout: {stdout!r}"
|
|
2545
|
-
)
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
def test_isolation_check_exempts_usefixtures_monkeypatch_decorator() -> None:
|
|
2549
|
-
"""A test isolated via ``@pytest.mark.usefixtures("monkeypatch")`` injects the
|
|
2550
|
-
monkeypatch fixture without a signature parameter and must be exempt from the
|
|
2551
|
-
HOME/TMP probe, mirroring the signature-parameter suppression."""
|
|
2552
|
-
source = (
|
|
2553
|
-
"import os\n"
|
|
2554
|
-
"import pytest\n"
|
|
2555
|
-
"@pytest.mark.usefixtures('monkeypatch')\n"
|
|
2556
|
-
"def test_reads_home() -> None:\n"
|
|
2557
|
-
" home = os.environ['HOME']\n"
|
|
2558
|
-
" print(home)\n"
|
|
2559
|
-
)
|
|
2560
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2561
|
-
source, "/project/src/test_module.py"
|
|
2562
|
-
)
|
|
2563
|
-
assert issues == [], (
|
|
2564
|
-
"a test decorated with usefixtures('monkeypatch') is isolated and must "
|
|
2565
|
-
f"not be flagged; got: {issues!r}"
|
|
2566
|
-
)
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
def test_isolation_check_still_flags_usefixtures_without_monkeypatch() -> None:
|
|
2570
|
-
"""``@pytest.mark.usefixtures("tmp_path")`` does not inject monkeypatch, so a
|
|
2571
|
-
HOME probe in its body must still be flagged."""
|
|
2572
|
-
source = (
|
|
2573
|
-
"import os\n"
|
|
2574
|
-
"import pytest\n"
|
|
2575
|
-
"@pytest.mark.usefixtures('tmp_path')\n"
|
|
2576
|
-
"def test_reads_home() -> None:\n"
|
|
2577
|
-
" home = os.environ['HOME']\n"
|
|
2578
|
-
" print(home)\n"
|
|
2579
|
-
)
|
|
2580
|
-
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
2581
|
-
source, "/project/src/test_module.py"
|
|
2582
|
-
)
|
|
2583
|
-
assert any("HOME" in each_issue for each_issue in issues), (
|
|
2584
|
-
"usefixtures('tmp_path') does not intercept env reads, so the HOME probe "
|
|
2585
|
-
f"must still be flagged; got: {issues!r}"
|
|
2586
|
-
)
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
def test_banned_noun_word_boundary_flags_plural_results_identifier() -> None:
|
|
2590
|
-
"""A plural banned noun ('results') embedded in an identifier must flag.
|
|
2591
|
-
|
|
2592
|
-
``ALL_BANNED_NOUN_WORDS`` contains plural forms (results, outputs,
|
|
2593
|
-
responses, values, items) in addition to the singular nouns, so an
|
|
2594
|
-
identifier such as ``canned_results`` is flagged even though no singular
|
|
2595
|
-
exact-match identifier appears.
|
|
2596
|
-
"""
|
|
2597
|
-
source = "canned_results = []\n"
|
|
2598
|
-
issues = code_rules_enforcer.check_banned_noun_word_boundary(
|
|
2599
|
-
source, "/project/src/pipeline.py"
|
|
2600
|
-
)
|
|
2601
|
-
assert any("canned_results" in each_issue for each_issue in issues), (
|
|
2602
|
-
"a plural banned-noun identifier must be flagged by the word-boundary "
|
|
2603
|
-
f"check; got: {issues!r}"
|
|
2604
|
-
)
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
def test_ignored_must_check_return_flags_bare_awaited_call() -> None:
|
|
2608
|
-
"""A bare ``await find_and_click(...)`` statement discards its only failure signal.
|
|
2609
|
-
|
|
2610
|
-
The curated must-check functions are async, so the common real call site is a
|
|
2611
|
-
bare ``await``-wrapped call. Unwrapping ``ast.Await`` before the Call check is
|
|
2612
|
-
required for this case to be flagged.
|
|
2613
|
-
"""
|
|
2614
|
-
source = "async def step() -> None:\n await find_and_click('#x')\n"
|
|
2615
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
2616
|
-
source, "/project/src/clicker.py"
|
|
2617
|
-
)
|
|
2618
|
-
assert any("find_and_click" in each_issue for each_issue in issues), (
|
|
2619
|
-
f"a bare awaited must-check call must be flagged; got: {issues!r}"
|
|
2620
|
-
)
|
|
2621
|
-
assert len(issues) == 1
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
def test_ignored_must_check_return_exempts_consumed_awaited_call() -> None:
|
|
2625
|
-
"""An assigned or branched-on awaited must-check call consumes its outcome."""
|
|
2626
|
-
assigned = "async def step() -> None:\n clicked = await find_and_click('#x')\n print(clicked)\n"
|
|
2627
|
-
branched = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
|
|
2628
|
-
assert (
|
|
2629
|
-
code_rules_enforcer.check_ignored_must_check_return(assigned, "/project/src/clicker.py")
|
|
2630
|
-
== []
|
|
2631
|
-
)
|
|
2632
|
-
assert (
|
|
2633
|
-
code_rules_enforcer.check_ignored_must_check_return(branched, "/project/src/clicker.py")
|
|
2634
|
-
== []
|
|
2635
|
-
)
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
def test_ignored_must_check_return_flags_edited_line_past_a_cap_of_earlier_violations() -> None:
|
|
2639
|
-
"""The cap must apply after scoping so the edited-line violation is never dropped.
|
|
2640
|
-
|
|
2641
|
-
Collecting only a cap's worth of violations in ``ast.walk`` order, then scoping,
|
|
2642
|
-
fills the cap with earlier out-of-scope calls and discards the edited-line one —
|
|
2643
|
-
the very violation the scoped enforcer exists to block. Every violation must be
|
|
2644
|
-
collected before scoping so the edited line survives the diff filter.
|
|
2645
|
-
"""
|
|
2646
|
-
pre_existing_call_count = 5
|
|
2647
|
-
edited_call_line_number = pre_existing_call_count + 2
|
|
2648
|
-
all_pre_existing_call_lines = [
|
|
2649
|
-
f" await find_and_click('#x{each_index}')"
|
|
2650
|
-
for each_index in range(pre_existing_call_count)
|
|
2651
|
-
]
|
|
2652
|
-
all_lines = (
|
|
2653
|
-
["async def step() -> None:"]
|
|
2654
|
-
+ all_pre_existing_call_lines
|
|
2655
|
-
+ [" await find_and_click('#edited')"]
|
|
2656
|
-
)
|
|
2657
|
-
source = "\n".join(all_lines) + "\n"
|
|
2658
|
-
issues = code_rules_enforcer.check_ignored_must_check_return(
|
|
2659
|
-
source,
|
|
2660
|
-
"/project/src/clicker.py",
|
|
2661
|
-
{edited_call_line_number},
|
|
2662
|
-
False,
|
|
2663
|
-
)
|
|
2664
|
-
assert len(issues) == 1, (
|
|
2665
|
-
f"the edited-line violation must survive a cap's worth of earlier calls; got: {issues!r}"
|
|
2666
|
-
)
|
|
2667
|
-
assert f"Line {edited_call_line_number}:" in issues[0], (
|
|
2668
|
-
f"the single issue must name the edited line {edited_call_line_number}; got: {issues!r}"
|
|
2669
|
-
)
|