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,341 @@
|
|
|
1
|
+
"""Test-isolation check ensuring tests do not probe real home or shared-temp directories."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
8
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
10
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
11
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
12
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
13
|
+
|
|
14
|
+
from code_rules_probe_chains import ( # noqa: E402
|
|
15
|
+
_attribute_chain_resolves_to_os_environ,
|
|
16
|
+
_canonical_probe_prefix_for_value,
|
|
17
|
+
_descend_within_test_scope,
|
|
18
|
+
_node_is_lexically_inside_function_or_class,
|
|
19
|
+
_record_probe_import_aliases,
|
|
20
|
+
)
|
|
21
|
+
from code_rules_probe_detection import ( # noqa: E402
|
|
22
|
+
_pathlib_path_construction_uses_home_tilde,
|
|
23
|
+
)
|
|
24
|
+
from code_rules_probe_recording import ( # noqa: E402
|
|
25
|
+
_collect_pytest_collectable_test_functions,
|
|
26
|
+
_detect_home_or_temp_probes_in_body,
|
|
27
|
+
_function_uses_pytest_isolation_fixture,
|
|
28
|
+
)
|
|
29
|
+
from code_rules_shared import ( # noqa: E402
|
|
30
|
+
_build_parent_map,
|
|
31
|
+
_function_definition_line_span,
|
|
32
|
+
_scope_violations_to_changed_lines,
|
|
33
|
+
is_test_file,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
37
|
+
ALL_CANONICAL_DOTTED_NAMES_BY_BARE_IMPORT,
|
|
38
|
+
OS_ENVIRON_DOTTED_NAME,
|
|
39
|
+
TEST_ISOLATION_MESSAGE_SUFFIX,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_alias_canonicalization_map(syntax_tree: ast.Module) -> dict[str, str]:
|
|
44
|
+
"""Map each module-level probe import local name to its canonical prefix.
|
|
45
|
+
|
|
46
|
+
Resolves both module aliases and bare-imported names so a dotted-call
|
|
47
|
+
chain rooted at any module-level binding rewrites to the canonical form the
|
|
48
|
+
probe set already matches:
|
|
49
|
+
|
|
50
|
+
- ``import os as o`` -> ``o`` resolves to ``os`` (so ``o.getenv`` ->
|
|
51
|
+
``os.getenv`` and ``o.path.expanduser`` -> ``os.path.expanduser``).
|
|
52
|
+
- ``import os.path as op`` -> ``op`` resolves to ``os.path`` (so
|
|
53
|
+
``op.expanduser`` -> ``os.path.expanduser``).
|
|
54
|
+
- ``import pathlib as pl`` -> ``pl`` resolves to ``pathlib``.
|
|
55
|
+
- ``from pathlib import Path as P`` -> ``P`` resolves to ``Path``.
|
|
56
|
+
- ``from os import path`` -> ``path`` resolves to ``os.path`` (so
|
|
57
|
+
``path.expanduser`` -> ``os.path.expanduser``).
|
|
58
|
+
- ``from os.path import expanduser as e`` -> ``e`` resolves to
|
|
59
|
+
``os.path.expanduser``; ``from os import getenv`` -> ``getenv``
|
|
60
|
+
resolves to ``os.getenv``; ``from os import environ`` -> ``environ``
|
|
61
|
+
resolves to ``os.environ``.
|
|
62
|
+
|
|
63
|
+
An import is module-scoped — and enters this shared map — when it is not
|
|
64
|
+
lexically inside any ``FunctionDef``/``AsyncFunctionDef``/``ClassDef`` body.
|
|
65
|
+
That admits top-level imports nested in module-level ``try``/``except``,
|
|
66
|
+
``if``, or ``with`` blocks (the ``try: import os as o except ImportError:``
|
|
67
|
+
optional-import idiom binds ``o`` module-wide) while excluding both
|
|
68
|
+
function-local and class-body imports. A function-local import binds its
|
|
69
|
+
name only inside the function it appears in, and a class-body import binds
|
|
70
|
+
its alias only within the class namespace; neither may enter this shared,
|
|
71
|
+
module-wide map — otherwise a probe import inside one test would
|
|
72
|
+
canonicalize a same-named reference in a sibling test that never imported
|
|
73
|
+
it. Function-local imports are scoped to their own function by
|
|
74
|
+
``_collect_local_probe_alias_bindings``.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
syntax_tree: The parsed module to scan for module-scoped import
|
|
78
|
+
statements.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Mapping from module-level local binding name to its canonical dotted
|
|
82
|
+
prefix.
|
|
83
|
+
"""
|
|
84
|
+
parent_by_child_id = _build_parent_map(syntax_tree)
|
|
85
|
+
all_canonical_names_by_alias: dict[str, str] = {}
|
|
86
|
+
for each_node in ast.walk(syntax_tree):
|
|
87
|
+
if not isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
88
|
+
continue
|
|
89
|
+
if _node_is_lexically_inside_function_or_class(each_node, parent_by_child_id):
|
|
90
|
+
continue
|
|
91
|
+
_record_probe_import_aliases(each_node, all_canonical_names_by_alias)
|
|
92
|
+
return all_canonical_names_by_alias
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _collect_os_environ_local_binding_names(
|
|
96
|
+
scope_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
97
|
+
all_canonical_names_by_alias: dict[str, str],
|
|
98
|
+
) -> set[str]:
|
|
99
|
+
"""Return local names bound to ``os.environ`` within *scope_node*.
|
|
100
|
+
|
|
101
|
+
Scoped to the single test function passed as *scope_node* so a binding in
|
|
102
|
+
one test never attributes a same-named access in a sibling test. Tracks
|
|
103
|
+
``e = os.environ`` style assignments (resolving the right-hand side through
|
|
104
|
+
*all_canonical_names_by_alias* so ``e = o.environ`` with ``import os as o``
|
|
105
|
+
is recognized) and ``from os import environ`` bindings (rare inside a
|
|
106
|
+
function but supported for completeness). Subscript and ``.get(...)`` reads
|
|
107
|
+
on these local names are treated as ``os.environ`` accesses.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
scope_node: The single test function node to scan for bindings.
|
|
111
|
+
all_canonical_names_by_alias: Import-alias map from
|
|
112
|
+
``_build_alias_canonicalization_map``.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Set of local variable names that reference ``os.environ``.
|
|
116
|
+
"""
|
|
117
|
+
environ_bindings: set[str] = set()
|
|
118
|
+
for each_node in _descend_within_test_scope(scope_node):
|
|
119
|
+
if isinstance(each_node, ast.ImportFrom):
|
|
120
|
+
for each_alias in each_node.names:
|
|
121
|
+
canonical_dotted = ALL_CANONICAL_DOTTED_NAMES_BY_BARE_IMPORT.get(
|
|
122
|
+
(each_node.module or "", each_alias.name)
|
|
123
|
+
)
|
|
124
|
+
if canonical_dotted == OS_ENVIRON_DOTTED_NAME:
|
|
125
|
+
environ_bindings.add(each_alias.asname or each_alias.name)
|
|
126
|
+
continue
|
|
127
|
+
if not isinstance(each_node, ast.Assign):
|
|
128
|
+
continue
|
|
129
|
+
if not _attribute_chain_resolves_to_os_environ(each_node.value, all_canonical_names_by_alias):
|
|
130
|
+
continue
|
|
131
|
+
for each_target in each_node.targets:
|
|
132
|
+
if isinstance(each_target, ast.Name):
|
|
133
|
+
environ_bindings.add(each_target.id)
|
|
134
|
+
return environ_bindings
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _collect_pathlib_path_local_binding_names(
|
|
138
|
+
scope_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
139
|
+
all_canonical_names_by_alias: dict[str, str],
|
|
140
|
+
) -> set[str]:
|
|
141
|
+
"""Return local names bound to a home-tilde ``pathlib.Path(...)`` construction.
|
|
142
|
+
|
|
143
|
+
Scoped to the single test function passed as *scope_node* so a binding in
|
|
144
|
+
one test never attributes a same-named ``.expanduser()`` call in a sibling
|
|
145
|
+
test. Tracks ``candidate = Path('~/x')`` style assignments whose first
|
|
146
|
+
constructor argument is a literal string beginning with ``~`` (resolving
|
|
147
|
+
the constructor through *all_canonical_names_by_alias* so an aliased
|
|
148
|
+
``candidate = P('~/x')`` with ``from pathlib import Path as P`` and a
|
|
149
|
+
fully qualified ``candidate = pathlib.Path('~/x')`` are both recognized).
|
|
150
|
+
A later ``candidate.expanduser()`` call on such a name is attributed to a
|
|
151
|
+
home-directory probe. A tilde-free or dynamic constructor argument
|
|
152
|
+
(``Path('/tmp/x')`` / ``Path(some_path)``) expands no home directory and
|
|
153
|
+
is not collected, keeping the instance ``.expanduser()`` form symmetric
|
|
154
|
+
with ``os.path.expanduser`` argument inspection.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
scope_node: The single test function node to scan for bindings.
|
|
158
|
+
all_canonical_names_by_alias: Import-alias map from
|
|
159
|
+
``_build_alias_canonicalization_map``.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Set of local variable names bound to a home-tilde ``pathlib.Path``
|
|
163
|
+
construction.
|
|
164
|
+
"""
|
|
165
|
+
path_bindings: set[str] = set()
|
|
166
|
+
for each_node in _descend_within_test_scope(scope_node):
|
|
167
|
+
if not isinstance(each_node, ast.Assign):
|
|
168
|
+
continue
|
|
169
|
+
if not _pathlib_path_construction_uses_home_tilde(
|
|
170
|
+
each_node.value, all_canonical_names_by_alias
|
|
171
|
+
):
|
|
172
|
+
continue
|
|
173
|
+
for each_target in each_node.targets:
|
|
174
|
+
if isinstance(each_target, ast.Name):
|
|
175
|
+
path_bindings.add(each_target.id)
|
|
176
|
+
return path_bindings
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _collect_local_probe_alias_bindings(
|
|
180
|
+
scope_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
181
|
+
all_canonical_names_by_alias: dict[str, str],
|
|
182
|
+
) -> dict[str, str]:
|
|
183
|
+
"""Return a per-test overlay mapping local names to canonical probe prefixes.
|
|
184
|
+
|
|
185
|
+
Scoped to the single test function passed as *scope_node* so an alias bound
|
|
186
|
+
in one test never resolves a same-named access in a sibling test. Two
|
|
187
|
+
binding forms are tracked, both scoped to this function only:
|
|
188
|
+
|
|
189
|
+
- Function-local imports — ``import os as o``, ``from os import environ``,
|
|
190
|
+
``from pathlib import Path`` — resolved through the same probe-relevant
|
|
191
|
+
filtering ``_build_alias_canonicalization_map`` applies to module-level
|
|
192
|
+
imports. Because the shared module map omits function-local imports, this
|
|
193
|
+
overlay is the only place a probe import inside one test takes effect, and
|
|
194
|
+
it stays confined to that test's body.
|
|
195
|
+
- Rebindings of a probe module, class, or callable to a local name —
|
|
196
|
+
``path_class = Path``, ``read_env = os.getenv``, ``temp_module = tempfile``,
|
|
197
|
+
``path_module = os.path``, ``e = os.environ`` — by resolving each
|
|
198
|
+
right-hand side through *all_canonical_names_by_alias* and keeping only
|
|
199
|
+
those whose canonical prefix is probe-aliasable
|
|
200
|
+
(``ALL_PROBE_ALIASABLE_CANONICAL_PREFIXES``).
|
|
201
|
+
|
|
202
|
+
Merged over the module-level alias map, the overlay lets a later
|
|
203
|
+
``path_class.home()`` / ``read_env('HOME')`` / ``temp_module.mkdtemp()``
|
|
204
|
+
resolve to its canonical probe chain.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
scope_node: The single test function node to scan for alias bindings.
|
|
208
|
+
all_canonical_names_by_alias: Module-level import-alias map from
|
|
209
|
+
``_build_alias_canonicalization_map``.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Mapping from local binding name to its canonical probe prefix.
|
|
213
|
+
"""
|
|
214
|
+
local_alias_canonical_names: dict[str, str] = {}
|
|
215
|
+
for each_node in _descend_within_test_scope(scope_node):
|
|
216
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
217
|
+
_record_probe_import_aliases(each_node, local_alias_canonical_names)
|
|
218
|
+
continue
|
|
219
|
+
if not isinstance(each_node, ast.Assign):
|
|
220
|
+
continue
|
|
221
|
+
canonical_prefix = _canonical_probe_prefix_for_value(
|
|
222
|
+
each_node.value, all_canonical_names_by_alias
|
|
223
|
+
)
|
|
224
|
+
if canonical_prefix is None:
|
|
225
|
+
continue
|
|
226
|
+
for each_target in each_node.targets:
|
|
227
|
+
if isinstance(each_target, ast.Name):
|
|
228
|
+
local_alias_canonical_names[each_target.id] = canonical_prefix
|
|
229
|
+
return local_alias_canonical_names
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_tests_use_isolated_filesystem_paths(
|
|
233
|
+
content: str,
|
|
234
|
+
file_path: str,
|
|
235
|
+
all_changed_lines: set[int] | None = None,
|
|
236
|
+
defer_scope_to_caller: bool = False,
|
|
237
|
+
) -> list[str]:
|
|
238
|
+
"""Flag test functions that probe HOME or TMP without pytest isolation.
|
|
239
|
+
|
|
240
|
+
Pattern class: tests that call ``Path.home()``, ``os.path.expanduser('~')``,
|
|
241
|
+
``os.getenv('HOME'|'USERPROFILE'|'TMPDIR'|…)``, ``os.environ['HOME'|…]``, or
|
|
242
|
+
``tempfile.gettempdir()`` against the real environment leak state across
|
|
243
|
+
the suite and surface as environment-coupled bugs (audit Theme M).
|
|
244
|
+
|
|
245
|
+
Test functions whose signatures take ``monkeypatch`` are treated as
|
|
246
|
+
intentionally isolated and pass — ``monkeypatch.setenv('HOME', ...)``
|
|
247
|
+
can intercept every env-derived probe, and this suppression applies
|
|
248
|
+
uniformly to every probe type below. ``tmp_path`` / ``tmp_path_factory``
|
|
249
|
+
/ ``tmpdir`` / ``tmpdir_factory`` allocate alternative sandbox paths but
|
|
250
|
+
do not intercept env reads, so their presence alone does not suppress
|
|
251
|
+
the check. Module-level helpers and fixtures (any function whose name
|
|
252
|
+
does not start with ``test_`` or ``should_``) are out of scope — only
|
|
253
|
+
pytest-collectable ``def test_*`` / ``async def test_*`` / ``def
|
|
254
|
+
should_*`` module-level or class-method functions are scanned.
|
|
255
|
+
|
|
256
|
+
Covered forms (API surface × access form):
|
|
257
|
+
Probe API surfaces — ``pathlib.Path.home()``,
|
|
258
|
+
``pathlib.Path('~...').expanduser()``, ``os.path.expanduser(arg)``,
|
|
259
|
+
``os.path.expandvars(arg)``, ``os.getenv(name)``,
|
|
260
|
+
``os.environ[name]``, ``os.environ.get(name)``, and the ``tempfile``
|
|
261
|
+
allocators (``gettempdir``, ``gettempdirb``, ``gettempprefix``,
|
|
262
|
+
``mkstemp``, ``mkdtemp``, ``mktemp``, ``NamedTemporaryFile``,
|
|
263
|
+
``TemporaryFile``, ``TemporaryDirectory``, ``SpooledTemporaryFile``).
|
|
264
|
+
Each surface is recognized through four access forms: (1) canonical
|
|
265
|
+
dotted (``os.path.expanduser``), (2) module-level ``from X import
|
|
266
|
+
name`` bare use (``from os import environ; environ['HOME']``),
|
|
267
|
+
(3) module-level aliased import (``import tempfile as tf;
|
|
268
|
+
tf.mkdtemp()``), and (4) a function-local binding tracked per test —
|
|
269
|
+
either a function-local import (``def t(): from os import environ;
|
|
270
|
+
environ['HOME']``) or a local rebinding (``path_class = Path;
|
|
271
|
+
path_class.home()``; ``read_env = os.getenv; read_env('HOME')``). A
|
|
272
|
+
function-local binding never leaks into a sibling test, so a same-named
|
|
273
|
+
bare reference in another test that lacks its own binding does not fire.
|
|
274
|
+
Gating is symmetric across the two ``expanduser`` forms (flag only on a
|
|
275
|
+
leading-``~`` literal) and across the env getters / subscript (flag only
|
|
276
|
+
on a home/temp env-var name). Probes are reported in source-line order
|
|
277
|
+
for every probe type.
|
|
278
|
+
|
|
279
|
+
Out of scope by design (dynamically constructed call targets that no
|
|
280
|
+
AST-level pattern can resolve statically): attribute access through
|
|
281
|
+
``getattr(os, 'environ')``, callable names assembled at runtime by
|
|
282
|
+
string concatenation, and calls built through ``exec``/``eval``. These
|
|
283
|
+
bound the detector to a fixed, documented surface rather than an
|
|
284
|
+
open-ended chase.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
content: The Python source to analyze.
|
|
288
|
+
file_path: The path of the file being checked. The check only fires
|
|
289
|
+
on test files.
|
|
290
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
291
|
+
None to treat the whole file as in scope. When provided, a probe
|
|
292
|
+
blocks when any line of its enclosing test function's declared span
|
|
293
|
+
(signature line through last body line) is among the changed lines,
|
|
294
|
+
so editing the signature to remove an isolation fixture brings an
|
|
295
|
+
unchanged-body probe into scope.
|
|
296
|
+
defer_scope_to_caller: When True, return every probe so the commit/push
|
|
297
|
+
gate's ``split_violations_by_scope`` can scope by added line and
|
|
298
|
+
report the in-scope set.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
A list of issue strings naming each offending probe call. When
|
|
302
|
+
*defer_scope_to_caller* is True every probe is returned for the gate to
|
|
303
|
+
scope; otherwise every probe in scope is returned.
|
|
304
|
+
"""
|
|
305
|
+
if not is_test_file(file_path):
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
syntax_tree = ast.parse(content)
|
|
310
|
+
except SyntaxError:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
all_module_canonical_names_by_alias = _build_alias_canonicalization_map(syntax_tree)
|
|
314
|
+
all_violations_in_source_line_order: list[tuple[range, str]] = []
|
|
315
|
+
for each_node in _collect_pytest_collectable_test_functions(syntax_tree):
|
|
316
|
+
if _function_uses_pytest_isolation_fixture(each_node):
|
|
317
|
+
continue
|
|
318
|
+
all_canonical_names_by_alias = {
|
|
319
|
+
**all_module_canonical_names_by_alias,
|
|
320
|
+
**_collect_local_probe_alias_bindings(each_node, all_module_canonical_names_by_alias),
|
|
321
|
+
}
|
|
322
|
+
all_environ_local_bindings = _collect_os_environ_local_binding_names(each_node, all_canonical_names_by_alias)
|
|
323
|
+
all_path_local_bindings = _collect_pathlib_path_local_binding_names(each_node, all_canonical_names_by_alias)
|
|
324
|
+
line_span = _function_definition_line_span(each_node)
|
|
325
|
+
enclosing_function_span = range(each_node.lineno, each_node.lineno + line_span)
|
|
326
|
+
for each_line, each_probe_label in _detect_home_or_temp_probes_in_body(
|
|
327
|
+
each_node, all_canonical_names_by_alias, all_environ_local_bindings, all_path_local_bindings
|
|
328
|
+
):
|
|
329
|
+
message = (
|
|
330
|
+
f"Line {each_line}: Test {each_node.name!r} "
|
|
331
|
+
f"(defined at line {each_node.lineno}, spanning {line_span} lines) "
|
|
332
|
+
f"probes {each_probe_label} - {TEST_ISOLATION_MESSAGE_SUFFIX}"
|
|
333
|
+
)
|
|
334
|
+
all_violations_in_source_line_order.append(
|
|
335
|
+
(enclosing_function_span, message)
|
|
336
|
+
)
|
|
337
|
+
return _scope_violations_to_changed_lines(
|
|
338
|
+
all_violations_in_source_line_order,
|
|
339
|
+
all_changed_lines,
|
|
340
|
+
defer_scope_to_caller,
|
|
341
|
+
)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Type escape-hatch and boundary-type checks: Any imports, cast(), unjustified type: ignore, and Any in signatures."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
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_comments import ( # noqa: E402
|
|
17
|
+
_comment_tokens,
|
|
18
|
+
)
|
|
19
|
+
from code_rules_shared import ( # noqa: E402
|
|
20
|
+
_collect_annotated_arguments,
|
|
21
|
+
_walk_skipping_type_checking_blocks,
|
|
22
|
+
is_hook_infrastructure,
|
|
23
|
+
is_test_file,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.any_type_config import ( # noqa: E402
|
|
27
|
+
ALL_ANY_ALLOWED_PATTERNS,
|
|
28
|
+
)
|
|
29
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
30
|
+
ALL_BOUNDARY_TYPE_EXEMPT_FILENAMES,
|
|
31
|
+
MAX_BOUNDARY_TYPE_ISSUES,
|
|
32
|
+
MAX_TYPE_ESCAPE_HATCH_ISSUES,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render_annotation_source(annotation_node: ast.expr) -> str:
|
|
37
|
+
"""Return a textual representation of an annotation AST node."""
|
|
38
|
+
unparse_function = getattr(ast, "unparse", None)
|
|
39
|
+
if unparse_function is not None:
|
|
40
|
+
return unparse_function(annotation_node)
|
|
41
|
+
sys.stderr.write(
|
|
42
|
+
"code_rules_enforcer: ast.unparse unavailable on this interpreter; "
|
|
43
|
+
"falling back to ast.dump for Any detection.\n"
|
|
44
|
+
)
|
|
45
|
+
return ast.dump(annotation_node)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _annotation_uses_any(annotation_node: Optional[ast.expr]) -> bool:
|
|
49
|
+
"""Return True when an annotation AST node textually references Any."""
|
|
50
|
+
if annotation_node is None:
|
|
51
|
+
return False
|
|
52
|
+
annotation_source = _render_annotation_source(annotation_node)
|
|
53
|
+
return bool(re.search(r"\bAny\b", annotation_source))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _find_any_annotation_lines(source: str) -> list[int]:
|
|
57
|
+
"""Return line numbers of annotations that textually reference Any."""
|
|
58
|
+
try:
|
|
59
|
+
parsed_tree = ast.parse(source)
|
|
60
|
+
except SyntaxError:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
offending_line_numbers: list[int] = []
|
|
64
|
+
already_reported_lines: set[int] = set()
|
|
65
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
66
|
+
if isinstance(each_node, ast.AnnAssign) and _annotation_uses_any(each_node.annotation):
|
|
67
|
+
if each_node.lineno not in already_reported_lines:
|
|
68
|
+
offending_line_numbers.append(each_node.lineno)
|
|
69
|
+
already_reported_lines.add(each_node.lineno)
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
72
|
+
if _annotation_uses_any(each_node.returns) and each_node.lineno not in already_reported_lines:
|
|
73
|
+
offending_line_numbers.append(each_node.lineno)
|
|
74
|
+
already_reported_lines.add(each_node.lineno)
|
|
75
|
+
for each_argument in _collect_annotated_arguments(each_node):
|
|
76
|
+
if _annotation_uses_any(each_argument.annotation) and each_argument.lineno not in already_reported_lines:
|
|
77
|
+
offending_line_numbers.append(each_argument.lineno)
|
|
78
|
+
already_reported_lines.add(each_argument.lineno)
|
|
79
|
+
return offending_line_numbers
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _find_unjustified_type_ignore_lines(source: str) -> list[int]:
|
|
83
|
+
"""Return line numbers of # type: ignore comments lacking a trailing reason."""
|
|
84
|
+
ignore_pattern = re.compile(r"#\s*type:\s*ignore(?:\[[^\]]*\])?(.*)$")
|
|
85
|
+
minimum_justification_characters = len("xxxxx")
|
|
86
|
+
offending_line_numbers: list[int] = []
|
|
87
|
+
for each_comment_token in _comment_tokens(source):
|
|
88
|
+
matched = ignore_pattern.search(each_comment_token.string)
|
|
89
|
+
if not matched:
|
|
90
|
+
continue
|
|
91
|
+
line_number = each_comment_token.start[0]
|
|
92
|
+
trailing_text = matched.group(1).strip()
|
|
93
|
+
if not trailing_text.startswith("#"):
|
|
94
|
+
offending_line_numbers.append(line_number)
|
|
95
|
+
continue
|
|
96
|
+
justification_text = trailing_text.lstrip("#").strip()
|
|
97
|
+
if len(justification_text) < minimum_justification_characters:
|
|
98
|
+
offending_line_numbers.append(line_number)
|
|
99
|
+
return offending_line_numbers
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_typing_any_imports(source: str) -> list[int]:
|
|
103
|
+
"""Return line numbers of `from typing import ... Any ...` statements."""
|
|
104
|
+
try:
|
|
105
|
+
parsed_tree = ast.parse(source)
|
|
106
|
+
except SyntaxError:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
offending_line_numbers: list[int] = []
|
|
110
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
111
|
+
if not isinstance(each_node, ast.ImportFrom):
|
|
112
|
+
continue
|
|
113
|
+
if each_node.module != "typing":
|
|
114
|
+
continue
|
|
115
|
+
for each_alias in each_node.names:
|
|
116
|
+
if each_alias.name == "Any":
|
|
117
|
+
offending_line_numbers.append(each_node.lineno)
|
|
118
|
+
break
|
|
119
|
+
return offending_line_numbers
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _find_typing_wildcard_imports(source: str) -> list[int]:
|
|
123
|
+
"""Return line numbers of `from typing import *` statements."""
|
|
124
|
+
try:
|
|
125
|
+
parsed_tree = ast.parse(source)
|
|
126
|
+
except SyntaxError:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
offending_line_numbers: list[int] = []
|
|
130
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
131
|
+
if not isinstance(each_node, ast.ImportFrom):
|
|
132
|
+
continue
|
|
133
|
+
if each_node.module != "typing":
|
|
134
|
+
continue
|
|
135
|
+
for each_alias in each_node.names:
|
|
136
|
+
if each_alias.name == "*":
|
|
137
|
+
offending_line_numbers.append(each_node.lineno)
|
|
138
|
+
break
|
|
139
|
+
return offending_line_numbers
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _collect_typing_cast_import_names(source: str) -> frozenset[str]:
|
|
143
|
+
"""Return the set of names bound to typing.cast via `from typing import cast`."""
|
|
144
|
+
try:
|
|
145
|
+
parsed_tree = ast.parse(source)
|
|
146
|
+
except SyntaxError:
|
|
147
|
+
return frozenset()
|
|
148
|
+
|
|
149
|
+
cast_names: set[str] = set()
|
|
150
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
151
|
+
if not isinstance(each_node, ast.ImportFrom):
|
|
152
|
+
continue
|
|
153
|
+
if each_node.module != "typing":
|
|
154
|
+
continue
|
|
155
|
+
for each_alias in each_node.names:
|
|
156
|
+
if each_alias.name == "cast":
|
|
157
|
+
cast_names.add(each_alias.asname or each_alias.name)
|
|
158
|
+
return frozenset(cast_names)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_typing_cast_call(call_node: ast.Call, all_cast_import_names: frozenset[str]) -> bool:
|
|
162
|
+
"""Return True when a Call node represents a typing.cast() or known bare cast()."""
|
|
163
|
+
function_node = call_node.func
|
|
164
|
+
if isinstance(function_node, ast.Attribute) and function_node.attr == "cast":
|
|
165
|
+
if isinstance(function_node.value, ast.Name) and function_node.value.id == "typing":
|
|
166
|
+
return True
|
|
167
|
+
if isinstance(function_node, ast.Name) and function_node.id in all_cast_import_names:
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _find_cast_call_lines(source: str) -> list[int]:
|
|
173
|
+
"""Return line numbers of cast(...) calls (typing.cast or bare cast)."""
|
|
174
|
+
try:
|
|
175
|
+
parsed_tree = ast.parse(source)
|
|
176
|
+
except SyntaxError:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
all_cast_import_names = _collect_typing_cast_import_names(source)
|
|
180
|
+
|
|
181
|
+
offending_line_numbers: list[int] = []
|
|
182
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
183
|
+
if isinstance(each_node, ast.Call) and _is_typing_cast_call(each_node, all_cast_import_names):
|
|
184
|
+
offending_line_numbers.append(each_node.lineno)
|
|
185
|
+
return offending_line_numbers
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _file_path_matches_any_exemption(file_path: str) -> bool:
|
|
189
|
+
filename = file_path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
190
|
+
return filename in {each_pattern.lower() for each_pattern in ALL_ANY_ALLOWED_PATTERNS}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
|
|
194
|
+
"""Flag Any annotations, Any imports, cast() calls, and unjustified # type: ignore."""
|
|
195
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
issues: list[str] = []
|
|
199
|
+
is_any_exempt = _file_path_matches_any_exemption(file_path)
|
|
200
|
+
|
|
201
|
+
if not is_any_exempt:
|
|
202
|
+
any_annotation_issues: list[str] = []
|
|
203
|
+
for each_any_line in _find_any_annotation_lines(content):
|
|
204
|
+
any_annotation_issues.append(f"Line {each_any_line}: Any annotation - replace with explicit type")
|
|
205
|
+
issues.extend(any_annotation_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
206
|
+
|
|
207
|
+
any_import_issues: list[str] = []
|
|
208
|
+
for each_import_line in _find_typing_any_imports(content):
|
|
209
|
+
any_import_issues.append(
|
|
210
|
+
f"Line {each_import_line}: 'from typing import Any' - remove the Any import and use explicit types"
|
|
211
|
+
)
|
|
212
|
+
issues.extend(any_import_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
213
|
+
|
|
214
|
+
wildcard_issues: list[str] = []
|
|
215
|
+
for each_wildcard_line in _find_typing_wildcard_imports(content):
|
|
216
|
+
wildcard_issues.append(
|
|
217
|
+
f"Line {each_wildcard_line}: 'from typing import *' wildcard import - import explicit names instead"
|
|
218
|
+
)
|
|
219
|
+
issues.extend(wildcard_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
220
|
+
|
|
221
|
+
cast_issues: list[str] = []
|
|
222
|
+
for each_cast_line in _find_cast_call_lines(content):
|
|
223
|
+
cast_issues.append(
|
|
224
|
+
f"Line {each_cast_line}: cast() call - escape hatch around the type system; use explicit types or runtime validation"
|
|
225
|
+
)
|
|
226
|
+
issues.extend(cast_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
227
|
+
|
|
228
|
+
type_ignore_issues: list[str] = []
|
|
229
|
+
for each_ignore_line in _find_unjustified_type_ignore_lines(content):
|
|
230
|
+
type_ignore_issues.append(
|
|
231
|
+
f"Line {each_ignore_line}: Unjustified # type: ignore - add trailing '# reason' explaining why"
|
|
232
|
+
)
|
|
233
|
+
issues.extend(type_ignore_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
234
|
+
|
|
235
|
+
return issues
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _annotation_node_references_any(annotation_node: ast.expr | None) -> bool:
|
|
239
|
+
if annotation_node is None:
|
|
240
|
+
return False
|
|
241
|
+
for each_descendant in ast.walk(annotation_node):
|
|
242
|
+
if isinstance(each_descendant, ast.Name) and each_descendant.id == "Any":
|
|
243
|
+
return True
|
|
244
|
+
if isinstance(each_descendant, ast.Attribute) and each_descendant.attr == "Any":
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _file_has_exempt_boundary_filename(file_path: str) -> bool:
|
|
250
|
+
filename = file_path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
251
|
+
return filename in {each_name.lower() for each_name in ALL_BOUNDARY_TYPE_EXEMPT_FILENAMES}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _signature_annotations(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[
|
|
255
|
+
tuple[ast.expr, str, int]
|
|
256
|
+
]:
|
|
257
|
+
collected_annotations: list[tuple[ast.expr, str, int]] = []
|
|
258
|
+
function_name = function_node.name
|
|
259
|
+
for each_argument in function_node.args.args:
|
|
260
|
+
if each_argument.annotation is not None:
|
|
261
|
+
collected_annotations.append(
|
|
262
|
+
(each_argument.annotation, f"{function_name}({each_argument.arg})", each_argument.lineno)
|
|
263
|
+
)
|
|
264
|
+
for each_argument in function_node.args.posonlyargs:
|
|
265
|
+
if each_argument.annotation is not None:
|
|
266
|
+
collected_annotations.append(
|
|
267
|
+
(each_argument.annotation, f"{function_name}({each_argument.arg})", each_argument.lineno)
|
|
268
|
+
)
|
|
269
|
+
for each_argument in function_node.args.kwonlyargs:
|
|
270
|
+
if each_argument.annotation is not None:
|
|
271
|
+
collected_annotations.append(
|
|
272
|
+
(each_argument.annotation, f"{function_name}({each_argument.arg})", each_argument.lineno)
|
|
273
|
+
)
|
|
274
|
+
if function_node.args.vararg is not None and function_node.args.vararg.annotation is not None:
|
|
275
|
+
collected_annotations.append(
|
|
276
|
+
(function_node.args.vararg.annotation, f"{function_name}(*{function_node.args.vararg.arg})", function_node.args.vararg.lineno)
|
|
277
|
+
)
|
|
278
|
+
if function_node.args.kwarg is not None and function_node.args.kwarg.annotation is not None:
|
|
279
|
+
collected_annotations.append(
|
|
280
|
+
(function_node.args.kwarg.annotation, f"{function_name}(**{function_node.args.kwarg.arg})", function_node.args.kwarg.lineno)
|
|
281
|
+
)
|
|
282
|
+
if function_node.returns is not None:
|
|
283
|
+
collected_annotations.append(
|
|
284
|
+
(function_node.returns, f"{function_name} -> return", function_node.returns.lineno)
|
|
285
|
+
)
|
|
286
|
+
return collected_annotations
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _class_attribute_annotations(class_node: ast.ClassDef) -> list[tuple[ast.expr, str, int]]:
|
|
290
|
+
collected_annotations: list[tuple[ast.expr, str, int]] = []
|
|
291
|
+
for each_statement in class_node.body:
|
|
292
|
+
if isinstance(each_statement, ast.AnnAssign) and isinstance(each_statement.target, ast.Name):
|
|
293
|
+
collected_annotations.append(
|
|
294
|
+
(
|
|
295
|
+
each_statement.annotation,
|
|
296
|
+
f"{class_node.name}.{each_statement.target.id}",
|
|
297
|
+
each_statement.lineno,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
return collected_annotations
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def check_boundary_types(content: str, file_path: str) -> list[str]:
|
|
304
|
+
"""Flag `Any` appearing in function signatures or class attribute annotations.
|
|
305
|
+
|
|
306
|
+
Module boundaries (function parameters, return types, class attributes)
|
|
307
|
+
must name the concrete shape they accept and produce. Local variable
|
|
308
|
+
annotations are private and exempt; `protocols.py` and `types.py` are
|
|
309
|
+
interface-declaration files and exempt.
|
|
310
|
+
"""
|
|
311
|
+
if (
|
|
312
|
+
is_test_file(file_path)
|
|
313
|
+
or is_hook_infrastructure(file_path)
|
|
314
|
+
or _file_has_exempt_boundary_filename(file_path)
|
|
315
|
+
):
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
parsed_tree = ast.parse(content)
|
|
320
|
+
except SyntaxError:
|
|
321
|
+
return []
|
|
322
|
+
|
|
323
|
+
issues: list[str] = []
|
|
324
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
325
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
326
|
+
for each_annotation, each_label, each_line_number in _signature_annotations(each_node):
|
|
327
|
+
if _annotation_node_references_any(each_annotation):
|
|
328
|
+
issues.append(
|
|
329
|
+
f"Line {each_line_number}: {each_label} uses Any at module boundary — "
|
|
330
|
+
"name the concrete shape callers receive/produce"
|
|
331
|
+
)
|
|
332
|
+
elif isinstance(each_node, ast.ClassDef):
|
|
333
|
+
for each_annotation, each_label, each_line_number in _class_attribute_annotations(each_node):
|
|
334
|
+
if _annotation_node_references_any(each_annotation):
|
|
335
|
+
issues.append(
|
|
336
|
+
f"Line {each_line_number}: {each_label} uses Any at class boundary — "
|
|
337
|
+
"name the concrete shape this attribute holds"
|
|
338
|
+
)
|
|
339
|
+
if len(issues) >= MAX_BOUNDARY_TYPE_ISSUES:
|
|
340
|
+
break
|
|
341
|
+
return issues[:MAX_BOUNDARY_TYPE_ISSUES]
|