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,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()