claude-dev-env 1.50.0 → 1.50.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_annotations_length.py +167 -0
- package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
- package/hooks/blocking/code_rules_comments.py +337 -0
- package/hooks/blocking/code_rules_constants_config.py +252 -0
- package/hooks/blocking/code_rules_docstrings.py +308 -0
- package/hooks/blocking/code_rules_enforcer.py +98 -5765
- package/hooks/blocking/code_rules_imports_logging.py +276 -0
- package/hooks/blocking/code_rules_magic_values.py +180 -0
- package/hooks/blocking/code_rules_mock_completeness.py +295 -0
- package/hooks/blocking/code_rules_naming_collection.py +264 -0
- package/hooks/blocking/code_rules_optional_params.py +288 -0
- package/hooks/blocking/code_rules_paths_syspath.py +186 -0
- package/hooks/blocking/code_rules_probe_chains.py +305 -0
- package/hooks/blocking/code_rules_probe_detection.py +257 -0
- package/hooks/blocking/code_rules_probe_recording.py +225 -0
- package/hooks/blocking/code_rules_scope_binding.py +151 -0
- package/hooks/blocking/code_rules_shared.py +301 -0
- package/hooks/blocking/code_rules_string_magic.py +207 -0
- package/hooks/blocking/code_rules_test_assertions.py +226 -0
- package/hooks/blocking/code_rules_test_branching_except.py +181 -0
- package/hooks/blocking/code_rules_test_isolation.py +341 -0
- package/hooks/blocking/code_rules_type_escape.py +341 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
- package/hooks/blocking/code_rules_unused_imports.py +256 -0
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/tdd_enforcer.py +31 -0
- package/hooks/blocking/test_code_rules_constants_config.py +26 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
- package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
- package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
- package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
- package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
- package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
- package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
- package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/blocking/test_tdd_enforcer.py +116 -0
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
- package/hooks/blocking/test_md_to_html_blocker.py +0 -810
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Behavior tests for the code_rules_test_isolation code-rules check module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
11
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
13
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
14
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
15
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
16
|
+
|
|
17
|
+
from code_rules_test_isolation import ( # noqa: E402
|
|
18
|
+
_build_alias_canonicalization_map,
|
|
19
|
+
_collect_os_environ_local_binding_names,
|
|
20
|
+
_collect_pathlib_path_local_binding_names,
|
|
21
|
+
check_tests_use_isolated_filesystem_paths,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
code_rules_enforcer = SimpleNamespace(
|
|
25
|
+
_build_alias_canonicalization_map=_build_alias_canonicalization_map,
|
|
26
|
+
_collect_os_environ_local_binding_names=_collect_os_environ_local_binding_names,
|
|
27
|
+
_collect_pathlib_path_local_binding_names=_collect_pathlib_path_local_binding_names,
|
|
28
|
+
check_tests_use_isolated_filesystem_paths=check_tests_use_isolated_filesystem_paths,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _function_node_named(source: str, function_name: str) -> ast.FunctionDef:
|
|
33
|
+
syntax_tree = ast.parse(source)
|
|
34
|
+
for each_node in syntax_tree.body:
|
|
35
|
+
if isinstance(each_node, ast.FunctionDef) and each_node.name == function_name:
|
|
36
|
+
return each_node
|
|
37
|
+
raise AssertionError(f"no function named {function_name!r} in source")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_isolation_check_ignores_path_constructor_expanduser_with_tilde_free_argument() -> None:
|
|
41
|
+
"""`Path('/tmp/x').expanduser()` carries no leading tilde, so it expands no
|
|
42
|
+
home directory and must stay symmetric with `os.path.expanduser` of a
|
|
43
|
+
tilde-free literal — neither fires."""
|
|
44
|
+
source = (
|
|
45
|
+
"from pathlib import Path\n"
|
|
46
|
+
"def test_resolves_absolute() -> None:\n"
|
|
47
|
+
" target = Path('/tmp/x').expanduser()\n"
|
|
48
|
+
" target.read_text()\n"
|
|
49
|
+
)
|
|
50
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
51
|
+
source, "/project/src/test_module.py"
|
|
52
|
+
)
|
|
53
|
+
assert issues == []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_isolation_check_ignores_static_pathlib_expanduser_with_dynamic_argument() -> None:
|
|
57
|
+
"""`pathlib.Path.expanduser(some_path)` with a non-constant argument cannot
|
|
58
|
+
be inspected for a leading tilde, so it follows the conservative rule and
|
|
59
|
+
does not fire — symmetric with `os.path.expanduser(some_path)`."""
|
|
60
|
+
source = (
|
|
61
|
+
"import pathlib\n"
|
|
62
|
+
"def test_resolves_dynamic(some_path) -> None:\n"
|
|
63
|
+
" target = pathlib.Path.expanduser(some_path)\n"
|
|
64
|
+
" target.read_text()\n"
|
|
65
|
+
)
|
|
66
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
67
|
+
source, "/project/src/test_module.py"
|
|
68
|
+
)
|
|
69
|
+
assert issues == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_isolation_check_flags_path_home_via_function_local_class_alias() -> None:
|
|
73
|
+
"""`path_class = Path` then `path_class.home()` reaches the real home
|
|
74
|
+
directory through a per-test class alias and must fire just like the bare
|
|
75
|
+
`Path.home()` form."""
|
|
76
|
+
source = (
|
|
77
|
+
"from pathlib import Path\n"
|
|
78
|
+
"def test_reads_home() -> None:\n"
|
|
79
|
+
" path_class = Path\n"
|
|
80
|
+
" home_dir = path_class.home()\n"
|
|
81
|
+
" (home_dir / '.myapp').write_text('x')\n"
|
|
82
|
+
)
|
|
83
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
84
|
+
source, "/project/src/test_module.py"
|
|
85
|
+
)
|
|
86
|
+
assert any("home" in each_issue.lower() for each_issue in issues)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_isolation_check_flags_getenv_via_function_local_callable_alias() -> None:
|
|
90
|
+
"""`read_env = os.getenv` then `read_env('HOME')` reads HOME through a
|
|
91
|
+
per-test callable alias and must fire just like the bare `os.getenv('HOME')`
|
|
92
|
+
form."""
|
|
93
|
+
source = (
|
|
94
|
+
"import os\n"
|
|
95
|
+
"def test_reads_home() -> None:\n"
|
|
96
|
+
" read_env = os.getenv\n"
|
|
97
|
+
" home = read_env('HOME')\n"
|
|
98
|
+
" print(home)\n"
|
|
99
|
+
)
|
|
100
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
101
|
+
source, "/project/src/test_module.py"
|
|
102
|
+
)
|
|
103
|
+
assert any("HOME" in each_issue for each_issue in issues)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_isolation_check_flags_tempfile_spooled_temporary_file() -> None:
|
|
107
|
+
"""`tempfile.SpooledTemporaryFile()` allocates in the shared temp dir and
|
|
108
|
+
must fire as a temp-isolation probe alongside the other tempfile factories."""
|
|
109
|
+
source = (
|
|
110
|
+
"import tempfile\n"
|
|
111
|
+
"def test_writes_spooled_temp() -> None:\n"
|
|
112
|
+
" handle = tempfile.SpooledTemporaryFile()\n"
|
|
113
|
+
" handle.write(b'x')\n"
|
|
114
|
+
)
|
|
115
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
116
|
+
source, "/project/src/test_module.py"
|
|
117
|
+
)
|
|
118
|
+
assert any("SpooledTemporaryFile" in each_issue for each_issue in issues)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_isolation_check_flags_tempfile_gettempdirb() -> None:
|
|
122
|
+
"""`tempfile.gettempdirb()` returns the shared temp dir as bytes and must
|
|
123
|
+
fire just like the string-returning `tempfile.gettempdir()`."""
|
|
124
|
+
source = (
|
|
125
|
+
"import tempfile\n"
|
|
126
|
+
"def test_resolves_temp_bytes() -> None:\n"
|
|
127
|
+
" base = tempfile.gettempdirb()\n"
|
|
128
|
+
" print(base)\n"
|
|
129
|
+
)
|
|
130
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
131
|
+
source, "/project/src/test_module.py"
|
|
132
|
+
)
|
|
133
|
+
assert any("gettempdirb" in each_issue for each_issue in issues)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_isolation_check_flags_module_level_from_os_import_environ_subscript() -> None:
|
|
137
|
+
"""A module-level `from os import environ` binds `environ` to `os.environ`,
|
|
138
|
+
so `environ['HOME']` inside a test must fire even without a per-test
|
|
139
|
+
local binding."""
|
|
140
|
+
source = (
|
|
141
|
+
"from os import environ\n"
|
|
142
|
+
"def test_resolves_home() -> None:\n"
|
|
143
|
+
" home = environ['HOME']\n"
|
|
144
|
+
" print(home)\n"
|
|
145
|
+
)
|
|
146
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
147
|
+
source, "/project/src/test_module.py"
|
|
148
|
+
)
|
|
149
|
+
assert any("HOME" in each_issue for each_issue in issues)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_isolation_check_reports_probes_in_source_order_on_new_file() -> None:
|
|
153
|
+
"""On a new file (``all_changed_lines is None``) every probe is in scope and
|
|
154
|
+
reported in source order — none dropped by the cap, which now trims only
|
|
155
|
+
out-of-scope advisory noise."""
|
|
156
|
+
probe_count = 20
|
|
157
|
+
repeated_probes = "\n".join(
|
|
158
|
+
f" p{each_index} = Path.home()" for each_index in range(probe_count)
|
|
159
|
+
)
|
|
160
|
+
source = (
|
|
161
|
+
f"from pathlib import Path\ndef test_many_probes() -> None:\n{repeated_probes}\n"
|
|
162
|
+
)
|
|
163
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
164
|
+
source, "/project/src/test_module.py"
|
|
165
|
+
)
|
|
166
|
+
first_probe_line_number = 3
|
|
167
|
+
reported_line_numbers = [
|
|
168
|
+
int(each_issue.split(":", maxsplit=1)[0].removeprefix("Line ").strip())
|
|
169
|
+
for each_issue in issues
|
|
170
|
+
]
|
|
171
|
+
expected_line_numbers = [
|
|
172
|
+
first_probe_line_number + each_offset for each_offset in range(probe_count)
|
|
173
|
+
]
|
|
174
|
+
assert reported_line_numbers == expected_line_numbers
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_collect_pathlib_path_bindings_only_sees_the_scope_node_function() -> None:
|
|
178
|
+
"""The Path-binding collector must scope its walk to the function node it
|
|
179
|
+
is given. A `p = Path('~/x')` binding in test_a must not appear when the
|
|
180
|
+
collector is handed test_b's node (test_b never binds `p` to a Path)."""
|
|
181
|
+
source = (
|
|
182
|
+
"from pathlib import Path\n"
|
|
183
|
+
"def test_a() -> None:\n"
|
|
184
|
+
" p = Path('~/x')\n"
|
|
185
|
+
" p.expanduser()\n"
|
|
186
|
+
"def test_b(p) -> None:\n"
|
|
187
|
+
" p.expanduser()\n"
|
|
188
|
+
)
|
|
189
|
+
syntax_tree = ast.parse(source)
|
|
190
|
+
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
191
|
+
test_a_node = _function_node_named(source, "test_a")
|
|
192
|
+
test_b_node = _function_node_named(source, "test_b")
|
|
193
|
+
|
|
194
|
+
test_a_bindings = code_rules_enforcer._collect_pathlib_path_local_binding_names(
|
|
195
|
+
test_a_node, alias_map
|
|
196
|
+
)
|
|
197
|
+
test_b_bindings = code_rules_enforcer._collect_pathlib_path_local_binding_names(
|
|
198
|
+
test_b_node, alias_map
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
assert "p" in test_a_bindings
|
|
202
|
+
assert "p" not in test_b_bindings
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_collect_os_environ_bindings_only_sees_the_scope_node_function() -> None:
|
|
206
|
+
"""The environ-binding collector must scope its walk to the function node
|
|
207
|
+
it is given. An `e = os.environ` binding in test_a must not appear when the
|
|
208
|
+
collector is handed test_b's node (test_b never binds `e`)."""
|
|
209
|
+
source = (
|
|
210
|
+
"import os\n"
|
|
211
|
+
"def test_a() -> None:\n"
|
|
212
|
+
" e = os.environ\n"
|
|
213
|
+
" home = e['HOME']\n"
|
|
214
|
+
" print(home)\n"
|
|
215
|
+
"def test_b(e) -> None:\n"
|
|
216
|
+
" home = e['HOME']\n"
|
|
217
|
+
" print(home)\n"
|
|
218
|
+
)
|
|
219
|
+
syntax_tree = ast.parse(source)
|
|
220
|
+
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
221
|
+
test_a_node = _function_node_named(source, "test_a")
|
|
222
|
+
test_b_node = _function_node_named(source, "test_b")
|
|
223
|
+
|
|
224
|
+
test_a_bindings = code_rules_enforcer._collect_os_environ_local_binding_names(
|
|
225
|
+
test_a_node, alias_map
|
|
226
|
+
)
|
|
227
|
+
test_b_bindings = code_rules_enforcer._collect_os_environ_local_binding_names(
|
|
228
|
+
test_b_node, alias_map
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
assert "e" in test_a_bindings
|
|
232
|
+
assert "e" not in test_b_bindings
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_function_local_from_os_import_environ_does_not_leak_into_sibling_test() -> None:
|
|
236
|
+
"""bugbot-1: a function-local `from os import environ` in test_a binds
|
|
237
|
+
`environ` only for test_a's runtime. A sibling test_b that references the
|
|
238
|
+
bare name `environ` without importing it must not be flagged, while the
|
|
239
|
+
test that actually imports and probes HOME (test_a) must be flagged."""
|
|
240
|
+
source = (
|
|
241
|
+
"def test_a() -> None:\n"
|
|
242
|
+
" from os import environ\n"
|
|
243
|
+
" home = environ['HOME']\n"
|
|
244
|
+
" print(home)\n"
|
|
245
|
+
"def test_b() -> None:\n"
|
|
246
|
+
" home = environ['HOME']\n"
|
|
247
|
+
" print(home)\n"
|
|
248
|
+
)
|
|
249
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
250
|
+
source, "/project/src/test_module.py"
|
|
251
|
+
)
|
|
252
|
+
assert any("test_a" in each_issue for each_issue in issues), (
|
|
253
|
+
f"test_a's own function-local environ import must be flagged, got: {issues!r}"
|
|
254
|
+
)
|
|
255
|
+
assert not any("test_b" in each_issue for each_issue in issues), (
|
|
256
|
+
"test_b references bare `environ` it never imports, so the function-local "
|
|
257
|
+
f"import in test_a must not leak into it, got: {issues!r}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_function_local_aliased_module_import_does_not_leak_into_sibling_test() -> None:
|
|
262
|
+
"""bugbot-1 sibling: a function-local `import os as o` in test_a aliases
|
|
263
|
+
`o` only for test_a. test_b referencing `o.getenv('HOME')` without its own
|
|
264
|
+
import must not be flagged; test_a's own probe must be flagged."""
|
|
265
|
+
source = (
|
|
266
|
+
"def test_a() -> None:\n"
|
|
267
|
+
" import os as o\n"
|
|
268
|
+
" home = o.getenv('HOME')\n"
|
|
269
|
+
" print(home)\n"
|
|
270
|
+
"def test_b() -> None:\n"
|
|
271
|
+
" home = o.getenv('HOME')\n"
|
|
272
|
+
" print(home)\n"
|
|
273
|
+
)
|
|
274
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
275
|
+
source, "/project/src/test_module.py"
|
|
276
|
+
)
|
|
277
|
+
assert any("test_a" in each_issue for each_issue in issues), (
|
|
278
|
+
f"test_a's own function-local aliased import must be flagged, got: {issues!r}"
|
|
279
|
+
)
|
|
280
|
+
assert not any("test_b" in each_issue for each_issue in issues), (
|
|
281
|
+
"test_b references alias `o` it never bound, so the function-local "
|
|
282
|
+
f"import in test_a must not leak into it, got: {issues!r}"
|
|
283
|
+
)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Behavior tests for the code_rules_test_isolation code-rules check module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
11
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
13
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
14
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
15
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
16
|
+
|
|
17
|
+
from code_rules_test_isolation import ( # noqa: E402
|
|
18
|
+
_build_alias_canonicalization_map,
|
|
19
|
+
check_tests_use_isolated_filesystem_paths,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
code_rules_enforcer = SimpleNamespace(
|
|
23
|
+
_build_alias_canonicalization_map=_build_alias_canonicalization_map,
|
|
24
|
+
check_tests_use_isolated_filesystem_paths=check_tests_use_isolated_filesystem_paths,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_build_alias_map_excludes_function_local_imports() -> None:
|
|
29
|
+
"""bugbot-1: the module-wide alias canonicalization map must be built only
|
|
30
|
+
from top-level imports. A function-local `import os as o` and a
|
|
31
|
+
function-local `from os import environ` must not appear in the shared map."""
|
|
32
|
+
source = (
|
|
33
|
+
"import tempfile as module_temp\n"
|
|
34
|
+
"def test_a() -> None:\n"
|
|
35
|
+
" import os as o\n"
|
|
36
|
+
" from os import environ\n"
|
|
37
|
+
" print(o, environ)\n"
|
|
38
|
+
)
|
|
39
|
+
syntax_tree = ast.parse(source)
|
|
40
|
+
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
41
|
+
assert alias_map.get("module_temp") == "tempfile", (
|
|
42
|
+
f"top-level alias must be recorded, got: {alias_map!r}"
|
|
43
|
+
)
|
|
44
|
+
assert "o" not in alias_map, (
|
|
45
|
+
f"function-local `import os as o` must not leak into the module map, got: {alias_map!r}"
|
|
46
|
+
)
|
|
47
|
+
assert "environ" not in alias_map, (
|
|
48
|
+
f"function-local `from os import environ` must not leak into the module map, got: {alias_map!r}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_module_level_from_os_import_environ_still_flags_every_referencing_test() -> None:
|
|
53
|
+
"""bugbot-1 guard: a genuine module-level `from os import environ` binds the
|
|
54
|
+
name for the whole module, so every test that probes HOME through it must
|
|
55
|
+
still be flagged. The per-function scoping must not suppress this case."""
|
|
56
|
+
source = (
|
|
57
|
+
"from os import environ\n"
|
|
58
|
+
"def test_a() -> None:\n"
|
|
59
|
+
" print(environ['HOME'])\n"
|
|
60
|
+
"def test_b() -> None:\n"
|
|
61
|
+
" print(environ['HOME'])\n"
|
|
62
|
+
)
|
|
63
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
64
|
+
source, "/project/src/test_module.py"
|
|
65
|
+
)
|
|
66
|
+
assert any("test_a" in each_issue for each_issue in issues)
|
|
67
|
+
assert any("test_b" in each_issue for each_issue in issues), (
|
|
68
|
+
f"module-level import must flag every probing test, got: {issues!r}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_build_alias_map_excludes_class_body_imports() -> None:
|
|
73
|
+
"""A probe alias imported inside a class body binds only inside that class
|
|
74
|
+
scope, so it must not enter the module-wide alias canonicalization map. A
|
|
75
|
+
genuine module-level alias in the same source must still be recorded."""
|
|
76
|
+
source = (
|
|
77
|
+
"import tempfile as module_temp\n"
|
|
78
|
+
"class TestAlpha:\n"
|
|
79
|
+
" import tempfile as t\n"
|
|
80
|
+
" def test_alpha_probe(self) -> None:\n"
|
|
81
|
+
" assert self.t is not None\n"
|
|
82
|
+
)
|
|
83
|
+
syntax_tree = ast.parse(source)
|
|
84
|
+
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
85
|
+
assert alias_map.get("module_temp") == "tempfile", (
|
|
86
|
+
f"top-level alias must be recorded, got: {alias_map!r}"
|
|
87
|
+
)
|
|
88
|
+
assert "t" not in alias_map, (
|
|
89
|
+
f"class-body `import tempfile as t` must not leak into the module map, got: {alias_map!r}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_class_body_aliased_import_does_not_leak_into_sibling_test() -> None:
|
|
94
|
+
"""A class-body `import tempfile as t` aliases `t` only inside that class.
|
|
95
|
+
A sibling top-level test taking `t` as a parameter and calling `t.mkdtemp()`
|
|
96
|
+
must not be flagged, since the class-scoped alias never enters the
|
|
97
|
+
module-wide map."""
|
|
98
|
+
source = (
|
|
99
|
+
"class TestAlpha:\n"
|
|
100
|
+
" import tempfile as t\n"
|
|
101
|
+
" def test_alpha_probe(self) -> None:\n"
|
|
102
|
+
" assert self.t is not None\n"
|
|
103
|
+
"def test_sibling(t) -> None:\n"
|
|
104
|
+
" t.mkdtemp()\n"
|
|
105
|
+
)
|
|
106
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
107
|
+
source, "/project/src/test_module.py"
|
|
108
|
+
)
|
|
109
|
+
assert not any("test_sibling" in each_issue for each_issue in issues), (
|
|
110
|
+
"class-body alias must not leak into a sibling test through the "
|
|
111
|
+
f"module-wide map, got: {issues!r}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_build_alias_map_records_module_top_level_but_excludes_function_and_class_imports() -> None:
|
|
116
|
+
"""Only true module-top-level imports enter the alias map. Imports lexically
|
|
117
|
+
inside a function body or a class body are excluded, while a module-level
|
|
118
|
+
try-guarded optional import is still recorded module-wide."""
|
|
119
|
+
source = (
|
|
120
|
+
"try:\n"
|
|
121
|
+
" import tempfile as guarded_temp\n"
|
|
122
|
+
"except ImportError:\n"
|
|
123
|
+
" guarded_temp = None\n"
|
|
124
|
+
"def test_function_local() -> None:\n"
|
|
125
|
+
" import tempfile as function_temp\n"
|
|
126
|
+
" assert function_temp is not None\n"
|
|
127
|
+
"class TestBeta:\n"
|
|
128
|
+
" import tempfile as class_temp\n"
|
|
129
|
+
" def test_beta_probe(self) -> None:\n"
|
|
130
|
+
" assert self.class_temp is not None\n"
|
|
131
|
+
)
|
|
132
|
+
syntax_tree = ast.parse(source)
|
|
133
|
+
alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
|
|
134
|
+
assert alias_map.get("guarded_temp") == "tempfile", (
|
|
135
|
+
f"module-level try-guarded alias must be recorded, got: {alias_map!r}"
|
|
136
|
+
)
|
|
137
|
+
assert "function_temp" not in alias_map, (
|
|
138
|
+
f"function-local alias must not enter the module map, got: {alias_map!r}"
|
|
139
|
+
)
|
|
140
|
+
assert "class_temp" not in alias_map, (
|
|
141
|
+
f"class-body alias must not enter the module map, got: {alias_map!r}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_isolation_message_carries_enclosing_function_definition_span() -> None:
|
|
146
|
+
"""The isolation message must carry the enclosing test's definition line
|
|
147
|
+
and line span so the commit gate can scope by the same function span the
|
|
148
|
+
enforcer uses, while keeping the ``Line N:`` probe-line prefix intact."""
|
|
149
|
+
header = "from pathlib import Path\n"
|
|
150
|
+
test_body = (
|
|
151
|
+
"def test_reads_home() -> None:\n"
|
|
152
|
+
" target_path = Path.home()\n"
|
|
153
|
+
" assert target_path\n"
|
|
154
|
+
)
|
|
155
|
+
source = header + test_body
|
|
156
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
157
|
+
source, "/project/src/test_module.py"
|
|
158
|
+
)
|
|
159
|
+
definition_line = 2
|
|
160
|
+
function_span = 3
|
|
161
|
+
expected_span_fragment = (
|
|
162
|
+
f"(defined at line {definition_line}, spanning {function_span} lines)"
|
|
163
|
+
)
|
|
164
|
+
assert any(
|
|
165
|
+
each_issue.startswith("Line ") and expected_span_fragment in each_issue
|
|
166
|
+
for each_issue in issues
|
|
167
|
+
), f"isolation message must carry the def-line + span fragment, got: {issues!r}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_module_import_inside_top_level_try_is_retained_in_alias_map() -> None:
|
|
171
|
+
"""loop7-P2 (2566): a module-level ``try: import os as o`` is genuinely
|
|
172
|
+
module-scoped; its alias must enter the shared canonicalization map so a
|
|
173
|
+
later ``o.path.expanduser('~')`` inside a test is flagged."""
|
|
174
|
+
source = (
|
|
175
|
+
"try:\n"
|
|
176
|
+
" import os as o\n"
|
|
177
|
+
"except ImportError:\n"
|
|
178
|
+
" o = None\n"
|
|
179
|
+
"def test_reads_home() -> None:\n"
|
|
180
|
+
" discovered = o.path.expanduser('~')\n"
|
|
181
|
+
" assert discovered\n"
|
|
182
|
+
)
|
|
183
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
184
|
+
source, "/project/src/test_optional_import.py"
|
|
185
|
+
)
|
|
186
|
+
assert any(
|
|
187
|
+
"test_reads_home" in each_issue for each_issue in issues
|
|
188
|
+
), f"module import nested in top-level try must be retained, got: {issues!r}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_direct_module_aliased_import_is_retained_in_alias_map() -> None:
|
|
192
|
+
"""loop7-P2 (2566): a plain top-level ``import os as o`` must still resolve so
|
|
193
|
+
``o.path.expanduser('~')`` inside a test is flagged."""
|
|
194
|
+
source = (
|
|
195
|
+
"import os as o\n"
|
|
196
|
+
"def test_reads_home() -> None:\n"
|
|
197
|
+
" discovered = o.path.expanduser('~')\n"
|
|
198
|
+
" assert discovered\n"
|
|
199
|
+
)
|
|
200
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
201
|
+
source, "/project/src/test_direct_import.py"
|
|
202
|
+
)
|
|
203
|
+
assert any(
|
|
204
|
+
"test_reads_home" in each_issue for each_issue in issues
|
|
205
|
+
), f"direct module aliased import must resolve, got: {issues!r}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_function_local_import_does_not_enter_shared_alias_map() -> None:
|
|
209
|
+
"""loop7-P2 (2566): an import inside one test must not canonicalize a
|
|
210
|
+
same-named reference in a sibling test that never imported it."""
|
|
211
|
+
source = (
|
|
212
|
+
"def test_imports_locally() -> None:\n"
|
|
213
|
+
" import os as o\n"
|
|
214
|
+
" assert o\n"
|
|
215
|
+
"def test_sibling_uses_o() -> None:\n"
|
|
216
|
+
" o = make_unrelated_object()\n"
|
|
217
|
+
" discovered = o.path.expanduser('~')\n"
|
|
218
|
+
" assert discovered\n"
|
|
219
|
+
)
|
|
220
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
221
|
+
source, "/project/src/test_local_import_scope.py"
|
|
222
|
+
)
|
|
223
|
+
assert not any(
|
|
224
|
+
"test_sibling_uses_o" in each_issue for each_issue in issues
|
|
225
|
+
), f"function-local import must not leak to a sibling test, got: {issues!r}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_import_inside_nested_helper_does_not_leak_to_outer_test_overlay() -> None:
|
|
229
|
+
"""loop7-P2 (2690): an import inside a standalone nested helper runs in its own
|
|
230
|
+
callable scope; its alias must not enter the outer test's overlay and flag a
|
|
231
|
+
sibling reference in the outer body."""
|
|
232
|
+
source = (
|
|
233
|
+
"def test_outer() -> None:\n"
|
|
234
|
+
" def nested_helper() -> None:\n"
|
|
235
|
+
" import os as o\n"
|
|
236
|
+
" assert o\n"
|
|
237
|
+
" o = make_unrelated_object()\n"
|
|
238
|
+
" discovered = o.path.expanduser('~')\n"
|
|
239
|
+
" assert discovered\n"
|
|
240
|
+
)
|
|
241
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
242
|
+
source, "/project/src/test_nested_helper_scope.py"
|
|
243
|
+
)
|
|
244
|
+
assert not any(
|
|
245
|
+
"test_outer" in each_issue for each_issue in issues
|
|
246
|
+
), f"nested-helper import must not leak to the outer test, got: {issues!r}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_environ_binding_inside_nested_helper_does_not_leak_to_outer_test() -> None:
|
|
250
|
+
"""loop7-P2 (2690 sibling): an ``os.environ`` binding inside a standalone
|
|
251
|
+
nested helper runs in its own scope; a same-named outer reference must not be
|
|
252
|
+
attributed to that binding."""
|
|
253
|
+
source = (
|
|
254
|
+
"import os\n"
|
|
255
|
+
"def test_outer() -> None:\n"
|
|
256
|
+
" def nested_helper() -> None:\n"
|
|
257
|
+
" captured = os.environ\n"
|
|
258
|
+
" assert captured\n"
|
|
259
|
+
" captured = make_unrelated_mapping()\n"
|
|
260
|
+
" discovered = captured['HOME']\n"
|
|
261
|
+
" assert discovered\n"
|
|
262
|
+
)
|
|
263
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
264
|
+
source, "/project/src/test_environ_nested_scope.py"
|
|
265
|
+
)
|
|
266
|
+
assert not any(
|
|
267
|
+
"test_outer" in each_issue for each_issue in issues
|
|
268
|
+
), f"nested-helper environ binding must not leak to the outer test, got: {issues!r}"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Behavior tests for the code_rules_test_isolation code-rules check module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
|
|
9
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
10
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
11
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
12
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
13
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
14
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
15
|
+
|
|
16
|
+
from code_rules_test_isolation import ( # noqa: E402
|
|
17
|
+
check_tests_use_isolated_filesystem_paths,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
code_rules_enforcer = SimpleNamespace(
|
|
21
|
+
check_tests_use_isolated_filesystem_paths=check_tests_use_isolated_filesystem_paths,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_pathlib_binding_inside_nested_helper_does_not_leak_to_outer_test() -> None:
|
|
26
|
+
"""loop7-P2 (2690 sibling): a home-tilde ``Path('~')`` binding inside a
|
|
27
|
+
standalone nested helper runs in its own scope; a same-named outer
|
|
28
|
+
``.expanduser()`` call must not be attributed to that binding."""
|
|
29
|
+
source = (
|
|
30
|
+
"from pathlib import Path\n"
|
|
31
|
+
"def test_outer() -> None:\n"
|
|
32
|
+
" def nested_helper() -> None:\n"
|
|
33
|
+
" candidate = Path('~/config')\n"
|
|
34
|
+
" assert candidate\n"
|
|
35
|
+
" candidate = make_unrelated_path()\n"
|
|
36
|
+
" discovered = candidate.expanduser()\n"
|
|
37
|
+
" assert discovered\n"
|
|
38
|
+
)
|
|
39
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
40
|
+
source, "/project/src/test_pathlib_nested_scope.py"
|
|
41
|
+
)
|
|
42
|
+
assert not any(
|
|
43
|
+
"test_outer" in each_issue for each_issue in issues
|
|
44
|
+
), f"nested-helper pathlib binding must not leak to the outer test, got: {issues!r}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_isolation_check_exempts_usefixtures_monkeypatch_decorator() -> None:
|
|
48
|
+
"""A test isolated via ``@pytest.mark.usefixtures("monkeypatch")`` injects the
|
|
49
|
+
monkeypatch fixture without a signature parameter and must be exempt from the
|
|
50
|
+
HOME/TMP probe, mirroring the signature-parameter suppression."""
|
|
51
|
+
source = (
|
|
52
|
+
"import os\n"
|
|
53
|
+
"import pytest\n"
|
|
54
|
+
"@pytest.mark.usefixtures('monkeypatch')\n"
|
|
55
|
+
"def test_reads_home() -> None:\n"
|
|
56
|
+
" home = os.environ['HOME']\n"
|
|
57
|
+
" print(home)\n"
|
|
58
|
+
)
|
|
59
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
60
|
+
source, "/project/src/test_module.py"
|
|
61
|
+
)
|
|
62
|
+
assert issues == [], (
|
|
63
|
+
"a test decorated with usefixtures('monkeypatch') is isolated and must "
|
|
64
|
+
f"not be flagged; got: {issues!r}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_isolation_check_still_flags_usefixtures_without_monkeypatch() -> None:
|
|
69
|
+
"""``@pytest.mark.usefixtures("tmp_path")`` does not inject monkeypatch, so a
|
|
70
|
+
HOME probe in its body must still be flagged."""
|
|
71
|
+
source = (
|
|
72
|
+
"import os\n"
|
|
73
|
+
"import pytest\n"
|
|
74
|
+
"@pytest.mark.usefixtures('tmp_path')\n"
|
|
75
|
+
"def test_reads_home() -> None:\n"
|
|
76
|
+
" home = os.environ['HOME']\n"
|
|
77
|
+
" print(home)\n"
|
|
78
|
+
)
|
|
79
|
+
issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
|
|
80
|
+
source, "/project/src/test_module.py"
|
|
81
|
+
)
|
|
82
|
+
assert any("HOME" in each_issue for each_issue in issues), (
|
|
83
|
+
"usefixtures('tmp_path') does not intercept env reads, so the HOME probe "
|
|
84
|
+
f"must still be flagged; got: {issues!r}"
|
|
85
|
+
)
|