claude-dev-env 1.50.0 → 1.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/hooks/blocking/_gh_body_arg_utils.py +67 -11
  2. package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
  3. package/hooks/blocking/code_rules_annotations_length.py +167 -0
  4. package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
  5. package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
  6. package/hooks/blocking/code_rules_comments.py +337 -0
  7. package/hooks/blocking/code_rules_constants_config.py +252 -0
  8. package/hooks/blocking/code_rules_docstrings.py +308 -0
  9. package/hooks/blocking/code_rules_enforcer.py +98 -5765
  10. package/hooks/blocking/code_rules_imports_logging.py +276 -0
  11. package/hooks/blocking/code_rules_magic_values.py +180 -0
  12. package/hooks/blocking/code_rules_mock_completeness.py +295 -0
  13. package/hooks/blocking/code_rules_naming_collection.py +264 -0
  14. package/hooks/blocking/code_rules_optional_params.py +288 -0
  15. package/hooks/blocking/code_rules_paths_syspath.py +186 -0
  16. package/hooks/blocking/code_rules_probe_chains.py +305 -0
  17. package/hooks/blocking/code_rules_probe_detection.py +257 -0
  18. package/hooks/blocking/code_rules_probe_recording.py +225 -0
  19. package/hooks/blocking/code_rules_scope_binding.py +151 -0
  20. package/hooks/blocking/code_rules_shared.py +301 -0
  21. package/hooks/blocking/code_rules_string_magic.py +207 -0
  22. package/hooks/blocking/code_rules_test_assertions.py +226 -0
  23. package/hooks/blocking/code_rules_test_branching_except.py +181 -0
  24. package/hooks/blocking/code_rules_test_isolation.py +341 -0
  25. package/hooks/blocking/code_rules_type_escape.py +341 -0
  26. package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
  27. package/hooks/blocking/code_rules_unused_imports.py +256 -0
  28. package/hooks/blocking/conftest.py +30 -0
  29. package/hooks/blocking/pr_description_body_audit.py +148 -0
  30. package/hooks/blocking/pr_description_command_parser.py +233 -0
  31. package/hooks/blocking/pr_description_enforcer.py +36 -825
  32. package/hooks/blocking/pr_description_pr_number.py +153 -0
  33. package/hooks/blocking/pr_description_readability.py +366 -0
  34. package/hooks/blocking/tdd_enforcer.py +31 -0
  35. package/hooks/blocking/test_code_rules_constants_config.py +26 -0
  36. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
  38. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
  40. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
  41. package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
  42. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
  43. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
  44. package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
  46. package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
  47. package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
  48. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
  51. package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
  52. package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
  53. package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
  54. package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
  55. package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
  56. package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
  57. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
  58. package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
  59. package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
  60. package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
  61. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
  62. package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
  63. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
  64. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
  65. package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
  66. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
  67. package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
  68. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
  69. package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
  70. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
  71. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
  72. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
  73. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
  74. package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
  75. package/hooks/blocking/test_tdd_enforcer.py +116 -0
  76. package/hooks/hooks_constants/blocking_check_limits.py +3 -0
  77. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  78. package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
  79. package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
  80. package/package.json +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
  82. package/hooks/blocking/test_md_to_html_blocker.py +0 -810
@@ -0,0 +1,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]