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,305 @@
|
|
|
1
|
+
"""TypedDict encode/decode pairing, stub-implementation, and thin-wrapper-module checks."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
9
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
10
|
+
if _blocking_directory not in sys.path:
|
|
11
|
+
sys.path.insert(0, _blocking_directory)
|
|
12
|
+
if _hooks_directory not in sys.path:
|
|
13
|
+
sys.path.insert(0, _hooks_directory)
|
|
14
|
+
|
|
15
|
+
from code_rules_path_utils import ( # noqa: E402
|
|
16
|
+
is_config_file,
|
|
17
|
+
)
|
|
18
|
+
from code_rules_shared import ( # noqa: E402
|
|
19
|
+
_statement_is_docstring,
|
|
20
|
+
_walk_skipping_type_checking_blocks,
|
|
21
|
+
is_hook_infrastructure,
|
|
22
|
+
is_test_file,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
26
|
+
MAX_STUB_IMPLEMENTATION_ISSUES,
|
|
27
|
+
MAX_THIN_WRAPPER_ISSUES,
|
|
28
|
+
MAX_TYPED_DICT_PAIR_ISSUES,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _pascal_to_snake_case(pascal_name: str) -> str:
|
|
32
|
+
pascal_to_snake_word_boundary = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
33
|
+
return pascal_to_snake_word_boundary.sub("_", pascal_name).lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _class_inherits_from_typed_dict(class_node: ast.ClassDef) -> bool:
|
|
37
|
+
for each_base in class_node.bases:
|
|
38
|
+
if isinstance(each_base, ast.Name) and each_base.id == "TypedDict":
|
|
39
|
+
return True
|
|
40
|
+
if isinstance(each_base, ast.Attribute) and each_base.attr == "TypedDict":
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _collect_typed_dict_class_names(parsed_tree: ast.AST) -> list[tuple[str, int]]:
|
|
46
|
+
typed_dict_entries: list[tuple[str, int]] = []
|
|
47
|
+
for each_statement in parsed_tree.body:
|
|
48
|
+
if isinstance(each_statement, ast.ClassDef) and _class_inherits_from_typed_dict(each_statement):
|
|
49
|
+
typed_dict_entries.append((each_statement.name, each_statement.lineno))
|
|
50
|
+
return typed_dict_entries
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _collect_module_function_names(parsed_tree: ast.AST) -> set[str]:
|
|
54
|
+
module_function_names: set[str] = set()
|
|
55
|
+
for each_statement in parsed_tree.body:
|
|
56
|
+
if isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
57
|
+
module_function_names.add(each_statement.name)
|
|
58
|
+
return module_function_names
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_init_file(file_path: str) -> bool:
|
|
62
|
+
return file_path.replace("\\", "/").rsplit("/", 1)[-1] == "__init__.py"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _statement_is_dunder_all_assignment(statement_node: ast.stmt) -> bool:
|
|
66
|
+
if isinstance(statement_node, ast.Assign):
|
|
67
|
+
for each_target in statement_node.targets:
|
|
68
|
+
if isinstance(each_target, ast.Name) and each_target.id == "__all__":
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
if isinstance(statement_node, ast.AnnAssign):
|
|
72
|
+
target = statement_node.target
|
|
73
|
+
return isinstance(target, ast.Name) and target.id == "__all__"
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _statement_is_import_or_reexport(statement_node: ast.stmt) -> bool:
|
|
78
|
+
if isinstance(statement_node, (ast.Import, ast.ImportFrom)):
|
|
79
|
+
return True
|
|
80
|
+
if _statement_is_dunder_all_assignment(statement_node):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_thin_wrapper_files(content: str, file_path: str) -> list[str]:
|
|
86
|
+
"""Flag non-`__init__.py` modules that are only imports + `__all__`.
|
|
87
|
+
|
|
88
|
+
A re-export-only wrapper outside `__init__.py` forces callers through an
|
|
89
|
+
indirection layer with no payload of its own. Callers should import from
|
|
90
|
+
the real module. `__init__.py` is the canonical re-export surface and is
|
|
91
|
+
exempt; test files, hook infrastructure, and `config/` are also exempt.
|
|
92
|
+
"""
|
|
93
|
+
if (
|
|
94
|
+
is_test_file(file_path)
|
|
95
|
+
or is_hook_infrastructure(file_path)
|
|
96
|
+
or is_config_file(file_path)
|
|
97
|
+
or _is_init_file(file_path)
|
|
98
|
+
):
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
parsed_tree = ast.parse(content)
|
|
103
|
+
except SyntaxError:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
body_statements = list(parsed_tree.body)
|
|
107
|
+
if not body_statements:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
statements_after_docstring = (
|
|
111
|
+
body_statements[1:]
|
|
112
|
+
if _statement_is_docstring(body_statements[0])
|
|
113
|
+
else body_statements
|
|
114
|
+
)
|
|
115
|
+
if not statements_after_docstring:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
for each_statement in statements_after_docstring:
|
|
119
|
+
if not _statement_is_import_or_reexport(each_statement):
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
issues = [
|
|
123
|
+
f"Line 1: {file_path}: thin wrapper file — module body is only imports (optionally with __all__); "
|
|
124
|
+
"callers should import from the real module instead of going through this indirection"
|
|
125
|
+
]
|
|
126
|
+
return issues[:MAX_THIN_WRAPPER_ISSUES]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_typed_dict_encode_decode(content: str, file_path: str) -> list[str]:
|
|
130
|
+
"""Flag TypedDict declarations missing companion `_encode_*` / `_decode_*` functions."""
|
|
131
|
+
if (
|
|
132
|
+
is_test_file(file_path)
|
|
133
|
+
or is_hook_infrastructure(file_path)
|
|
134
|
+
or _is_init_file(file_path)
|
|
135
|
+
):
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
parsed_tree = ast.parse(content)
|
|
140
|
+
except SyntaxError:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
typed_dict_entries = _collect_typed_dict_class_names(parsed_tree)
|
|
144
|
+
if not typed_dict_entries:
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
module_function_names = _collect_module_function_names(parsed_tree)
|
|
148
|
+
|
|
149
|
+
issues: list[str] = []
|
|
150
|
+
for each_typed_dict_name, each_typed_dict_line in typed_dict_entries:
|
|
151
|
+
snake_name = _pascal_to_snake_case(each_typed_dict_name)
|
|
152
|
+
encoder_function_name = f"_encode_{snake_name}"
|
|
153
|
+
decoder_function_name = f"_decode_{snake_name}"
|
|
154
|
+
is_encoder_present = encoder_function_name in module_function_names
|
|
155
|
+
is_decoder_present = decoder_function_name in module_function_names
|
|
156
|
+
if is_encoder_present and is_decoder_present:
|
|
157
|
+
continue
|
|
158
|
+
missing_companions: list[str] = []
|
|
159
|
+
if not is_encoder_present:
|
|
160
|
+
missing_companions.append(encoder_function_name)
|
|
161
|
+
if not is_decoder_present:
|
|
162
|
+
missing_companions.append(decoder_function_name)
|
|
163
|
+
issues.append(
|
|
164
|
+
f"Line {each_typed_dict_line}: TypedDict '{each_typed_dict_name}' missing companion "
|
|
165
|
+
f"{' and '.join(missing_companions)} — add explicit encode/decode functions"
|
|
166
|
+
)
|
|
167
|
+
if len(issues) >= MAX_TYPED_DICT_PAIR_ISSUES:
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
return issues
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _function_decorator_is_abstractmethod(decorator_node: ast.expr) -> bool:
|
|
174
|
+
if isinstance(decorator_node, ast.Name) and decorator_node.id == "abstractmethod":
|
|
175
|
+
return True
|
|
176
|
+
if isinstance(decorator_node, ast.Attribute) and decorator_node.attr == "abstractmethod":
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _function_is_abstract(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
182
|
+
return any(
|
|
183
|
+
_function_decorator_is_abstractmethod(each_decorator)
|
|
184
|
+
for each_decorator in function_node.decorator_list
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _function_is_overload(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
189
|
+
for each_decorator in function_node.decorator_list:
|
|
190
|
+
if isinstance(each_decorator, ast.Name) and each_decorator.id == "overload":
|
|
191
|
+
return True
|
|
192
|
+
if isinstance(each_decorator, ast.Attribute) and each_decorator.attr == "overload":
|
|
193
|
+
return True
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _class_is_protocol(class_node: ast.ClassDef) -> bool:
|
|
198
|
+
for each_base in class_node.bases:
|
|
199
|
+
if isinstance(each_base, ast.Name) and each_base.id == "Protocol":
|
|
200
|
+
return True
|
|
201
|
+
if isinstance(each_base, ast.Attribute) and each_base.attr == "Protocol":
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _class_inherits_from_protocol_or_abc(class_node: ast.ClassDef) -> bool:
|
|
207
|
+
for each_base in class_node.bases:
|
|
208
|
+
if isinstance(each_base, ast.Name) and each_base.id in {"Protocol", "ABC"}:
|
|
209
|
+
return True
|
|
210
|
+
if isinstance(each_base, ast.Attribute) and each_base.attr in {"Protocol", "ABC"}:
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _statement_is_pass(statement_node: ast.stmt) -> bool:
|
|
216
|
+
return isinstance(statement_node, ast.Pass)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _statement_is_ellipsis(statement_node: ast.stmt) -> bool:
|
|
220
|
+
return (
|
|
221
|
+
isinstance(statement_node, ast.Expr)
|
|
222
|
+
and isinstance(statement_node.value, ast.Constant)
|
|
223
|
+
and statement_node.value.value is Ellipsis
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _statement_is_raise_not_implemented(statement_node: ast.stmt) -> bool:
|
|
228
|
+
if not isinstance(statement_node, ast.Raise):
|
|
229
|
+
return False
|
|
230
|
+
raised_expression = statement_node.exc
|
|
231
|
+
if raised_expression is None:
|
|
232
|
+
return False
|
|
233
|
+
if isinstance(raised_expression, ast.Name) and raised_expression.id == "NotImplementedError":
|
|
234
|
+
return True
|
|
235
|
+
if (
|
|
236
|
+
isinstance(raised_expression, ast.Call)
|
|
237
|
+
and isinstance(raised_expression.func, ast.Name)
|
|
238
|
+
and raised_expression.func.id == "NotImplementedError"
|
|
239
|
+
):
|
|
240
|
+
return True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _function_body_is_stub(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
245
|
+
body_statements = list(function_node.body)
|
|
246
|
+
if body_statements and _statement_is_docstring(body_statements[0]):
|
|
247
|
+
body_statements = body_statements[1:]
|
|
248
|
+
if len(body_statements) != 1:
|
|
249
|
+
return False
|
|
250
|
+
sole_statement = body_statements[0]
|
|
251
|
+
return (
|
|
252
|
+
_statement_is_pass(sole_statement)
|
|
253
|
+
or _statement_is_ellipsis(sole_statement)
|
|
254
|
+
or _statement_is_raise_not_implemented(sole_statement)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def check_stub_implementations(content: str, file_path: str) -> list[str]:
|
|
259
|
+
"""Flag production functions whose body is only pass/.../raise NotImplementedError.
|
|
260
|
+
|
|
261
|
+
Stubs ship as placeholders that the rest of the system depends on but the
|
|
262
|
+
function does not deliver. ABC/Protocol abstract methods are exempt — they
|
|
263
|
+
are placeholders BY contract, not by oversight.
|
|
264
|
+
"""
|
|
265
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
parsed_tree = ast.parse(content)
|
|
270
|
+
except SyntaxError:
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
abstract_class_function_ids: set[int] = set()
|
|
274
|
+
for each_node in ast.walk(parsed_tree):
|
|
275
|
+
if isinstance(each_node, ast.ClassDef) and _class_inherits_from_protocol_or_abc(each_node):
|
|
276
|
+
is_protocol = _class_is_protocol(each_node)
|
|
277
|
+
for each_class_member in each_node.body:
|
|
278
|
+
if not isinstance(each_class_member, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
279
|
+
continue
|
|
280
|
+
if is_protocol or _function_is_abstract(each_class_member):
|
|
281
|
+
abstract_class_function_ids.add(id(each_class_member))
|
|
282
|
+
|
|
283
|
+
stub_function_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
284
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
285
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
286
|
+
continue
|
|
287
|
+
if _function_is_abstract(each_node) or _function_is_overload(each_node):
|
|
288
|
+
continue
|
|
289
|
+
if id(each_node) in abstract_class_function_ids:
|
|
290
|
+
continue
|
|
291
|
+
if _function_body_is_stub(each_node):
|
|
292
|
+
stub_function_nodes.append(each_node)
|
|
293
|
+
|
|
294
|
+
stub_function_nodes.sort(key=lambda each_function: each_function.lineno)
|
|
295
|
+
|
|
296
|
+
issues: list[str] = []
|
|
297
|
+
for each_function in stub_function_nodes:
|
|
298
|
+
issues.append(
|
|
299
|
+
f"Line {each_function.lineno}: Function '{each_function.name}' is a stub "
|
|
300
|
+
"(pass/.../raise NotImplementedError) — implement or remove"
|
|
301
|
+
)
|
|
302
|
+
if len(issues) >= MAX_STUB_IMPLEMENTATION_ISSUES:
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
return issues
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Unused module-level import check and its import-range and type-checking-gate helpers."""
|
|
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_scope_binding import ( # noqa: E402
|
|
15
|
+
_attribute_root_name_if_loaded,
|
|
16
|
+
_collect_string_annotation_names,
|
|
17
|
+
_load_name_is_shadowed,
|
|
18
|
+
)
|
|
19
|
+
from code_rules_shared import ( # noqa: E402
|
|
20
|
+
_build_parent_map,
|
|
21
|
+
is_migration_file,
|
|
22
|
+
is_test_file,
|
|
23
|
+
is_workflow_registry_file,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
27
|
+
ALL_TYPING_MODULE_NAMES,
|
|
28
|
+
MAX_UNUSED_IMPORT_ISSUES,
|
|
29
|
+
TYPE_CHECKING_IDENTIFIER,
|
|
30
|
+
UNUSED_IMPORT_GUIDANCE,
|
|
31
|
+
line_suppresses_unused_import_via_noqa,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _import_alias_pairs(
|
|
36
|
+
import_node: ast.Import | ast.ImportFrom,
|
|
37
|
+
) -> list[tuple[str, int, int | None]]:
|
|
38
|
+
"""Return (binding_name, alias_line, from_keyword_line) for each name introduced.
|
|
39
|
+
|
|
40
|
+
The from-keyword line is None for plain `import X` statements; for
|
|
41
|
+
`from X import (...)` it carries the line of the `from` keyword so
|
|
42
|
+
callers can honor a `# noqa` placed on the opening line of a
|
|
43
|
+
multi-line import block.
|
|
44
|
+
"""
|
|
45
|
+
bindings: list[tuple[str, int, int | None]] = []
|
|
46
|
+
from_keyword_line = import_node.lineno if isinstance(import_node, ast.ImportFrom) else None
|
|
47
|
+
for each_alias in import_node.names:
|
|
48
|
+
if each_alias.name == "*":
|
|
49
|
+
continue
|
|
50
|
+
binding_name = each_alias.asname if each_alias.asname else each_alias.name.split(".")[0]
|
|
51
|
+
alias_line = each_alias.lineno or import_node.lineno
|
|
52
|
+
bindings.append((binding_name, alias_line, from_keyword_line))
|
|
53
|
+
return bindings
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _import_statement_line_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
57
|
+
ranges: list[tuple[int, int]] = []
|
|
58
|
+
for each_node in tree.body:
|
|
59
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
60
|
+
start_line = each_node.lineno
|
|
61
|
+
end_line = each_node.end_lineno or each_node.lineno
|
|
62
|
+
ranges.append((start_line, end_line))
|
|
63
|
+
return ranges
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _line_number_falls_in_import_ranges(
|
|
67
|
+
line_number: int,
|
|
68
|
+
all_import_line_ranges: list[tuple[int, int]],
|
|
69
|
+
) -> bool:
|
|
70
|
+
for each_start, each_end in all_import_line_ranges:
|
|
71
|
+
if each_start <= line_number <= each_end:
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _type_checking_guard_aliases(tree: ast.Module) -> tuple[set[str], set[str]]:
|
|
77
|
+
all_type_checking_names = {TYPE_CHECKING_IDENTIFIER}
|
|
78
|
+
all_type_checking_module_aliases = set(ALL_TYPING_MODULE_NAMES)
|
|
79
|
+
for each_statement in tree.body:
|
|
80
|
+
if isinstance(each_statement, ast.Import):
|
|
81
|
+
for each_alias in each_statement.names:
|
|
82
|
+
if each_alias.name in ALL_TYPING_MODULE_NAMES:
|
|
83
|
+
all_type_checking_module_aliases.add(
|
|
84
|
+
each_alias.asname or each_alias.name
|
|
85
|
+
)
|
|
86
|
+
elif isinstance(each_statement, ast.ImportFrom):
|
|
87
|
+
if each_statement.module not in ALL_TYPING_MODULE_NAMES:
|
|
88
|
+
continue
|
|
89
|
+
for each_alias in each_statement.names:
|
|
90
|
+
if each_alias.name == TYPE_CHECKING_IDENTIFIER:
|
|
91
|
+
all_type_checking_names.add(each_alias.asname or each_alias.name)
|
|
92
|
+
return all_type_checking_names, all_type_checking_module_aliases
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _expression_guards_type_checking_block(
|
|
96
|
+
test_expression: ast.expr,
|
|
97
|
+
all_type_checking_names: set[str],
|
|
98
|
+
all_type_checking_module_aliases: set[str],
|
|
99
|
+
) -> bool:
|
|
100
|
+
if isinstance(test_expression, ast.Name):
|
|
101
|
+
return test_expression.id in all_type_checking_names
|
|
102
|
+
if isinstance(test_expression, ast.Attribute):
|
|
103
|
+
if test_expression.attr != TYPE_CHECKING_IDENTIFIER:
|
|
104
|
+
return False
|
|
105
|
+
receiver = test_expression.value
|
|
106
|
+
return (
|
|
107
|
+
isinstance(receiver, ast.Name)
|
|
108
|
+
and receiver.id in all_type_checking_module_aliases
|
|
109
|
+
)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _module_body_declares_type_checking_gate(tree: ast.Module) -> bool:
|
|
114
|
+
(
|
|
115
|
+
all_type_checking_names,
|
|
116
|
+
all_type_checking_module_aliases,
|
|
117
|
+
) = _type_checking_guard_aliases(tree)
|
|
118
|
+
return any(
|
|
119
|
+
isinstance(each_statement, ast.If)
|
|
120
|
+
and _expression_guards_type_checking_block(
|
|
121
|
+
each_statement.test,
|
|
122
|
+
all_type_checking_names,
|
|
123
|
+
all_type_checking_module_aliases,
|
|
124
|
+
)
|
|
125
|
+
for each_statement in tree.body
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _collect_load_names_outside_import_ranges(
|
|
130
|
+
tree: ast.Module,
|
|
131
|
+
all_import_line_ranges: list[tuple[int, int]],
|
|
132
|
+
) -> set[str]:
|
|
133
|
+
parent_by_node_id = _build_parent_map(tree)
|
|
134
|
+
referenced_names: set[str] = set()
|
|
135
|
+
for each_node in ast.walk(tree):
|
|
136
|
+
if isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Load):
|
|
137
|
+
line_number = each_node.lineno
|
|
138
|
+
if line_number is None or _line_number_falls_in_import_ranges(
|
|
139
|
+
line_number,
|
|
140
|
+
all_import_line_ranges,
|
|
141
|
+
):
|
|
142
|
+
continue
|
|
143
|
+
if _load_name_is_shadowed(each_node, each_node.id, parent_by_node_id):
|
|
144
|
+
continue
|
|
145
|
+
referenced_names.add(each_node.id)
|
|
146
|
+
elif isinstance(each_node, ast.Attribute) and isinstance(
|
|
147
|
+
each_node.ctx, ast.Load
|
|
148
|
+
):
|
|
149
|
+
line_number = each_node.lineno
|
|
150
|
+
if line_number is None or _line_number_falls_in_import_ranges(
|
|
151
|
+
line_number,
|
|
152
|
+
all_import_line_ranges,
|
|
153
|
+
):
|
|
154
|
+
continue
|
|
155
|
+
root_name = _attribute_root_name_if_loaded(each_node)
|
|
156
|
+
if root_name is not None and not _load_name_is_shadowed(
|
|
157
|
+
root_name,
|
|
158
|
+
root_name.id,
|
|
159
|
+
parent_by_node_id,
|
|
160
|
+
):
|
|
161
|
+
referenced_names.add(root_name.id)
|
|
162
|
+
referenced_names.update(_collect_string_annotation_names(tree))
|
|
163
|
+
return referenced_names
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _module_declares_dunder_all(tree: ast.Module) -> bool:
|
|
167
|
+
"""Return True when the module body assigns or annotates ``__all__``."""
|
|
168
|
+
return any(
|
|
169
|
+
(
|
|
170
|
+
isinstance(each_node, ast.Assign)
|
|
171
|
+
and any(
|
|
172
|
+
isinstance(each_target, ast.Name) and each_target.id == "__all__"
|
|
173
|
+
for each_target in each_node.targets
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
or (
|
|
177
|
+
isinstance(each_node, ast.AnnAssign)
|
|
178
|
+
and isinstance(each_node.target, ast.Name)
|
|
179
|
+
and each_node.target.id == "__all__"
|
|
180
|
+
)
|
|
181
|
+
for each_node in tree.body
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_unused_module_level_imports(
|
|
186
|
+
content: str,
|
|
187
|
+
file_path: str,
|
|
188
|
+
full_file_content: str | None = None,
|
|
189
|
+
) -> list[str]:
|
|
190
|
+
"""Flag module-level imports that are never referenced in the rest of the file.
|
|
191
|
+
|
|
192
|
+
References are detected from AST ``Name`` / ``Attribute`` loads outside import
|
|
193
|
+
statements so mentions in comments or string literals do not count. Files
|
|
194
|
+
declaring ``__all__`` (including annotated assignments) are skipped. Files
|
|
195
|
+
whose module body includes ``if TYPE_CHECKING:`` (or
|
|
196
|
+
``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
|
|
197
|
+
``# noqa`` or an explicit ``F401`` code in the noqa list only.
|
|
198
|
+
|
|
199
|
+
When ``full_file_content`` is provided, ``content`` is treated as an Edit
|
|
200
|
+
fragment containing the imports being added or replaced, while the
|
|
201
|
+
``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
|
|
202
|
+
against ``full_file_content`` (the post-edit file as it will look once the
|
|
203
|
+
Edit applies). This prevents false-positive flags on imports added in the
|
|
204
|
+
same Edit as their consumers.
|
|
205
|
+
"""
|
|
206
|
+
if is_test_file(file_path):
|
|
207
|
+
return []
|
|
208
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
209
|
+
return []
|
|
210
|
+
try:
|
|
211
|
+
fragment_tree = ast.parse(content)
|
|
212
|
+
except SyntaxError:
|
|
213
|
+
return []
|
|
214
|
+
reference_source = full_file_content if full_file_content is not None else content
|
|
215
|
+
try:
|
|
216
|
+
reference_tree = ast.parse(reference_source)
|
|
217
|
+
except SyntaxError:
|
|
218
|
+
return []
|
|
219
|
+
if _module_declares_dunder_all(reference_tree):
|
|
220
|
+
return []
|
|
221
|
+
if _module_body_declares_type_checking_gate(reference_tree):
|
|
222
|
+
return []
|
|
223
|
+
fragment_lines = content.splitlines()
|
|
224
|
+
reference_import_ranges = _import_statement_line_ranges(reference_tree)
|
|
225
|
+
referenced_names = _collect_load_names_outside_import_ranges(
|
|
226
|
+
reference_tree,
|
|
227
|
+
reference_import_ranges,
|
|
228
|
+
)
|
|
229
|
+
import_bindings: list[tuple[str, int, int | None]] = []
|
|
230
|
+
for each_node in fragment_tree.body:
|
|
231
|
+
if isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
232
|
+
if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
|
|
233
|
+
continue
|
|
234
|
+
for each_binding in _import_alias_pairs(each_node):
|
|
235
|
+
import_bindings.append(each_binding)
|
|
236
|
+
issues: list[str] = []
|
|
237
|
+
for each_name, each_line_number, each_from_keyword_line in import_bindings:
|
|
238
|
+
if 1 <= each_line_number <= len(fragment_lines):
|
|
239
|
+
if line_suppresses_unused_import_via_noqa(fragment_lines[each_line_number - 1]):
|
|
240
|
+
continue
|
|
241
|
+
if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
|
|
242
|
+
fragment_lines
|
|
243
|
+
):
|
|
244
|
+
if line_suppresses_unused_import_via_noqa(
|
|
245
|
+
fragment_lines[each_from_keyword_line - 1]
|
|
246
|
+
):
|
|
247
|
+
continue
|
|
248
|
+
if each_name in referenced_names:
|
|
249
|
+
continue
|
|
250
|
+
issues.append(
|
|
251
|
+
f"Line {each_line_number}: unused module-level import {each_name!r}"
|
|
252
|
+
f" — {UNUSED_IMPORT_GUIDANCE}"
|
|
253
|
+
)
|
|
254
|
+
if len(issues) >= MAX_UNUSED_IMPORT_ISSUES:
|
|
255
|
+
break
|
|
256
|
+
return issues
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Session-scoped cleanup fixture for the md_to_html_blocker test suites.
|
|
2
|
+
|
|
3
|
+
The md_to_html_blocker suites share one lazily-created sandbox parent
|
|
4
|
+
directory under the home directory. This fixture tears that sandbox down once
|
|
5
|
+
the session ends so the suites leave no residue regardless of which split file
|
|
6
|
+
pytest collects first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
|
|
16
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
18
|
+
|
|
19
|
+
from _md_to_html_blocker_test_support import ( # noqa: E402
|
|
20
|
+
_force_rmtree,
|
|
21
|
+
_get_sandbox_parent_directory,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
26
|
+
def _cleanup_sandbox_parent_directory():
|
|
27
|
+
yield
|
|
28
|
+
if _get_sandbox_parent_directory.cache_info().currsize:
|
|
29
|
+
_force_rmtree(_get_sandbox_parent_directory())
|
|
30
|
+
_get_sandbox_parent_directory.cache_clear()
|