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,295 @@
1
+ """Incomplete-mock test-quality check and its scope-shadowing 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_shared import ( # noqa: E402
15
+ is_test_file,
16
+ )
17
+
18
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
19
+ ALL_BUILTIN_DICT_METHOD_NAMES,
20
+ )
21
+
22
+
23
+ def _collect_mock_dict_keys(assign_value: ast.expr) -> set[str] | None:
24
+ """Return the string key set for a dict literal, or None if not a dict literal."""
25
+ if not isinstance(assign_value, ast.Dict):
26
+ return None
27
+ key_names: set[str] = set()
28
+ for each_key in assign_value.keys:
29
+ if isinstance(each_key, ast.Constant) and isinstance(each_key.value, str):
30
+ key_names.add(each_key.value)
31
+ return key_names
32
+
33
+
34
+ def _target_binds_name(target_node: ast.AST, variable_name: str) -> bool:
35
+ """Return True when an assignment target binds variable_name.
36
+
37
+ Handles the recursive assignment target shapes Python permits:
38
+ a bare ``Name``, a ``Tuple`` or ``List`` of targets (including
39
+ nested ones), and a ``Starred`` wrapper around any of the above.
40
+ """
41
+ if isinstance(target_node, ast.Name):
42
+ return target_node.id == variable_name
43
+ if isinstance(target_node, (ast.Tuple, ast.List)):
44
+ return any(_target_binds_name(each_element, variable_name) for each_element in target_node.elts)
45
+ if isinstance(target_node, ast.Starred):
46
+ return _target_binds_name(target_node.value, variable_name)
47
+ return False
48
+
49
+
50
+ def _function_arguments_bind_name(
51
+ arguments_node: ast.arguments,
52
+ variable_name: str,
53
+ ) -> bool:
54
+ """Return True when any parameter slot declares variable_name."""
55
+ all_positional_arguments = list(arguments_node.posonlyargs) + list(arguments_node.args)
56
+ for each_argument in all_positional_arguments + list(arguments_node.kwonlyargs):
57
+ if each_argument.arg == variable_name:
58
+ return True
59
+ if arguments_node.vararg is not None and arguments_node.vararg.arg == variable_name:
60
+ return True
61
+ if arguments_node.kwarg is not None and arguments_node.kwarg.arg == variable_name:
62
+ return True
63
+ return False
64
+
65
+
66
+ def _node_binds_name(node: ast.AST, variable_name: str) -> bool:
67
+ """Return True when a single AST node binds variable_name in its enclosing scope."""
68
+ if isinstance(node, ast.Assign):
69
+ return any(_target_binds_name(each_target, variable_name) for each_target in node.targets)
70
+ if isinstance(node, ast.AnnAssign):
71
+ return _target_binds_name(node.target, variable_name)
72
+ if isinstance(node, ast.AugAssign):
73
+ return _target_binds_name(node.target, variable_name)
74
+ if isinstance(node, (ast.For, ast.AsyncFor)):
75
+ return _target_binds_name(node.target, variable_name)
76
+ if isinstance(node, (ast.With, ast.AsyncWith)):
77
+ for each_item in node.items:
78
+ optional_target = each_item.optional_vars
79
+ if optional_target is not None and _target_binds_name(optional_target, variable_name):
80
+ return True
81
+ return False
82
+ if isinstance(node, ast.ExceptHandler):
83
+ return node.name == variable_name
84
+ if isinstance(node, ast.NamedExpr):
85
+ return _target_binds_name(node.target, variable_name)
86
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
87
+ for each_alias in node.names:
88
+ bound_name = each_alias.asname if each_alias.asname is not None else each_alias.name.split(".")[0]
89
+ if bound_name == variable_name:
90
+ return True
91
+ return False
92
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
93
+ return node.name == variable_name
94
+ return False
95
+
96
+
97
+ def _body_binds_name_recursively(all_body_statements: list[ast.stmt], variable_name: str) -> bool:
98
+ """Return True when any node reachable within all_body_statements binds variable_name.
99
+
100
+ Walks the body using a stack, descending into control-flow constructs
101
+ (if/for/while/try/with) but treating nested function, async-function,
102
+ class, and lambda definitions as opaque: their bodies belong to a
103
+ different scope and do not affect bindings in the enclosing one.
104
+ Function/class definitions themselves still bind their own name in
105
+ the enclosing scope, which is handled by _node_binds_name.
106
+ """
107
+ nodes_to_visit: list[ast.AST] = list(all_body_statements)
108
+ while nodes_to_visit:
109
+ current_node = nodes_to_visit.pop()
110
+ if _node_binds_name(current_node, variable_name):
111
+ return True
112
+ if isinstance(current_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
113
+ continue
114
+ nodes_to_visit.extend(ast.iter_child_nodes(current_node))
115
+ return False
116
+
117
+
118
+ def _scope_shadows_name(
119
+ scope_node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
120
+ variable_name: str,
121
+ ) -> bool:
122
+ """Return True when scope_node locally binds variable_name.
123
+
124
+ Detects every binding form Python treats as a local assignment:
125
+ plain ``Assign``, annotated ``AnnAssign``, augmented ``AugAssign``,
126
+ ``for`` targets, ``with`` as-targets, ``except`` handler names,
127
+ walrus ``NamedExpr`` targets, ``import`` and ``from`` bindings
128
+ (base name or ``as`` alias), nested function/class definitions
129
+ (whose own name binds locally), and function parameters for
130
+ ``FunctionDef`` / ``AsyncFunctionDef`` scopes. Bindings are
131
+ detected at any nesting depth inside control-flow constructs;
132
+ nested function, async-function, class, and lambda bodies are
133
+ treated as opaque because their contents live in a different scope.
134
+ """
135
+ if isinstance(scope_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
136
+ if _function_arguments_bind_name(scope_node.args, variable_name):
137
+ return True
138
+ return _body_binds_name_recursively(list(scope_node.body), variable_name)
139
+
140
+
141
+ def _walk_scope_skipping_shadowed(
142
+ scope_node: ast.AST,
143
+ variable_name: str,
144
+ ) -> list[ast.AST]:
145
+ """Walk all nodes in a scope, skipping nested function/class bodies that shadow variable_name."""
146
+ collected: list[ast.AST] = []
147
+ nodes_to_visit: list[ast.AST] = [scope_node]
148
+ while nodes_to_visit:
149
+ current = nodes_to_visit.pop()
150
+ collected.append(current)
151
+ for each_child in ast.iter_child_nodes(current):
152
+ if (
153
+ isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
154
+ and each_child is not scope_node
155
+ and _scope_shadows_name(each_child, variable_name)
156
+ ):
157
+ continue
158
+ nodes_to_visit.append(each_child)
159
+ return collected
160
+
161
+
162
+ def _collect_mock_field_accesses_in_scope(
163
+ scope_node: ast.AST,
164
+ mock_name: str,
165
+ ) -> list[tuple[str, int]]:
166
+ """Return (field_name, line_number) for attribute or subscript accesses on mock_name within a scope.
167
+
168
+ Skips nested function/class bodies that locally redefine the same mock
169
+ variable to avoid false positives from name shadowing.
170
+ """
171
+ accesses: list[tuple[str, int]] = []
172
+ for each_node in _walk_scope_skipping_shadowed(scope_node, mock_name):
173
+ if isinstance(each_node, ast.Attribute):
174
+ if isinstance(each_node.value, ast.Name) and each_node.value.id == mock_name:
175
+ if isinstance(each_node.ctx, ast.Load):
176
+ if each_node.attr in ALL_BUILTIN_DICT_METHOD_NAMES:
177
+ continue
178
+ accesses.append((each_node.attr, each_node.lineno))
179
+ elif isinstance(each_node, ast.Subscript):
180
+ if isinstance(each_node.value, ast.Name) and each_node.value.id == mock_name:
181
+ if isinstance(each_node.ctx, ast.Load):
182
+ slice_node = each_node.slice
183
+ if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str):
184
+ accesses.append((slice_node.value, each_node.lineno))
185
+ return accesses
186
+
187
+
188
+ def _collect_mock_attribute_assignments_in_scope(
189
+ scope_node: ast.AST,
190
+ mock_name: str,
191
+ ) -> set[str]:
192
+ """Return field names assigned on a mock variable within a scope.
193
+
194
+ Collects both attribute assignments (mock_x.field = ...) and subscript
195
+ assignments with constant string keys (mock_x['field'] = ...).
196
+
197
+ Skips nested function/class bodies that locally redefine the same mock
198
+ variable, mirroring _collect_mock_field_accesses_in_scope so an outer
199
+ mock's known-fields set cannot absorb assignments made on a shadowed
200
+ inner mock of the same name.
201
+ """
202
+ assigned_fields: set[str] = set()
203
+ for each_node in _walk_scope_skipping_shadowed(scope_node, mock_name):
204
+ if not isinstance(each_node, ast.Assign):
205
+ continue
206
+ for each_target in each_node.targets:
207
+ if (
208
+ isinstance(each_target, ast.Attribute)
209
+ and isinstance(each_target.value, ast.Name)
210
+ and each_target.value.id == mock_name
211
+ ):
212
+ assigned_fields.add(each_target.attr)
213
+ elif (
214
+ isinstance(each_target, ast.Subscript)
215
+ and isinstance(each_target.value, ast.Name)
216
+ and each_target.value.id == mock_name
217
+ and isinstance(each_target.slice, ast.Constant)
218
+ and isinstance(each_target.slice.value, str)
219
+ ):
220
+ assigned_fields.add(each_target.slice.value)
221
+ return assigned_fields
222
+
223
+
224
+ def _collect_scoped_mock_definitions(
225
+ module_tree: ast.Module,
226
+ ) -> list[tuple[int, str, set[str], int, ast.AST]]:
227
+ """Return (scope_id, mock_name, declared_keys, definition_line, scope_node) for each mock.
228
+
229
+ Keyed by (scope_node id, variable_name) so the same mock name in two different
230
+ test functions is tracked independently. Scope is the enclosing function node,
231
+ or the module node for module-level assignments.
232
+ """
233
+ scope_definitions: list[tuple[int, str, set[str], int, ast.AST]] = []
234
+ for each_scope in ast.walk(module_tree):
235
+ if not isinstance(each_scope, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Module)):
236
+ continue
237
+ scope_body = each_scope.body
238
+ for each_stmt in scope_body:
239
+ if not isinstance(each_stmt, ast.Assign):
240
+ continue
241
+ for each_target in each_stmt.targets:
242
+ if not isinstance(each_target, ast.Name):
243
+ continue
244
+ target_name = each_target.id
245
+ if not (target_name.startswith("mock_") or target_name.startswith("MOCK_")):
246
+ continue
247
+ mock_keys = _collect_mock_dict_keys(each_stmt.value)
248
+ if mock_keys is not None:
249
+ scope_definitions.append(
250
+ (id(each_scope), target_name, mock_keys, each_stmt.lineno, each_scope)
251
+ )
252
+ elif isinstance(each_stmt.value, ast.Call):
253
+ scope_definitions.append(
254
+ (id(each_scope), target_name, set(), each_stmt.lineno, each_scope)
255
+ )
256
+ return scope_definitions
257
+
258
+
259
+ def check_incomplete_mocks(content: str, file_path: str) -> None:
260
+ """Emit stderr advisories when a mock dict/object is missing fields that are accessed.
261
+
262
+ Scans test files for variables named mock_* or MOCK_* whose value is a dict
263
+ literal. Each mock definition is keyed by (scope_node_id, variable_name) so
264
+ the same name in different test functions is checked independently. Advisories
265
+ are deduplicated per (mock_name, field_name) pair within each scope.
266
+
267
+ This is advisory-only (no return value, no blocking).
268
+ """
269
+ if not is_test_file(file_path):
270
+ return
271
+
272
+ try:
273
+ module_tree = ast.parse(content)
274
+ except SyntaxError:
275
+ return
276
+
277
+ all_scoped_definitions = _collect_scoped_mock_definitions(module_tree)
278
+
279
+ for each_scope_id, each_mock_name, each_declared_keys, each_definition_line, each_scope_node in all_scoped_definitions:
280
+ assigned_attributes = _collect_mock_attribute_assignments_in_scope(each_scope_node, each_mock_name)
281
+ all_known_fields = each_declared_keys | assigned_attributes
282
+ field_accesses = _collect_mock_field_accesses_in_scope(each_scope_node, each_mock_name)
283
+ already_advised: set[tuple[str, str]] = set()
284
+ for each_accessed_field, each_access_line in field_accesses:
285
+ if each_accessed_field in all_known_fields:
286
+ continue
287
+ advisory_key = (each_mock_name, each_accessed_field)
288
+ if advisory_key in already_advised:
289
+ continue
290
+ already_advised.add(advisory_key)
291
+ print(
292
+ f"[CODE_RULES advisory] Line {each_definition_line}: mock {each_mock_name}"
293
+ f" missing field {each_accessed_field} accessed at line {each_access_line}",
294
+ file=sys.stderr,
295
+ )
@@ -0,0 +1,264 @@
1
+ """Collection-prefix, stuttering-prefix, and loop-variable naming checks."""
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_shared import ( # noqa: E402
15
+ _collect_annotated_arguments,
16
+ _collect_target_names,
17
+ is_migration_file,
18
+ is_test_file,
19
+ is_workflow_registry_file,
20
+ )
21
+
22
+ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
23
+ ALL_COLLECTION_TYPE_NAMES,
24
+ ALL_LOOP_INDEX_LETTER_EXEMPTIONS,
25
+ ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES,
26
+ ALL_UNION_TYPING_NAMES,
27
+ BARE_EACH_TOKEN,
28
+ COLLECTION_BY_NAME_PATTERN,
29
+ EACH_PREFIX,
30
+ UPPER_SNAKE_CONSTANT_PATTERN,
31
+ )
32
+ from hooks_constants.stuttering_check_config import ( # noqa: E402
33
+ MAX_STUTTERING_PREFIX_ISSUES,
34
+ STUTTERING_ALL_PREFIX_PATTERN,
35
+ )
36
+ from hooks_constants.stuttering_import_binding_constants import ( # noqa: E402
37
+ AST_LINENO_ATTRIBUTE,
38
+ MODULE_PATH_SEPARATOR,
39
+ WILDCARD_IMPORT_SENTINEL,
40
+ )
41
+
42
+
43
+ def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
44
+ if annotation_node is None:
45
+ return False
46
+ if isinstance(annotation_node, ast.Name):
47
+ return annotation_node.id in ALL_COLLECTION_TYPE_NAMES
48
+ if isinstance(annotation_node, ast.Attribute):
49
+ return annotation_node.attr in ALL_COLLECTION_TYPE_NAMES
50
+ if isinstance(annotation_node, ast.BinOp) and isinstance(annotation_node.op, ast.BitOr):
51
+ return (
52
+ _annotation_names_collection(annotation_node.left)
53
+ or _annotation_names_collection(annotation_node.right)
54
+ )
55
+ if isinstance(annotation_node, ast.Subscript):
56
+ outer_value = annotation_node.value
57
+ is_optional_or_union_subscript = (
58
+ (isinstance(outer_value, ast.Name) and outer_value.id in ALL_UNION_TYPING_NAMES)
59
+ or (isinstance(outer_value, ast.Attribute) and outer_value.attr in ALL_UNION_TYPING_NAMES)
60
+ )
61
+ if is_optional_or_union_subscript:
62
+ slice_node = annotation_node.slice
63
+ if isinstance(slice_node, ast.Tuple):
64
+ return any(
65
+ _annotation_names_collection(each_element)
66
+ for each_element in slice_node.elts
67
+ )
68
+ return _annotation_names_collection(slice_node)
69
+ is_subscript_only_collection_type = (
70
+ (isinstance(outer_value, ast.Name) and outer_value.id in ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES)
71
+ or (isinstance(outer_value, ast.Attribute) and outer_value.attr in ALL_SUBSCRIPT_ONLY_COLLECTION_TYPE_NAMES)
72
+ )
73
+ if is_subscript_only_collection_type:
74
+ return True
75
+ return _annotation_names_collection(outer_value)
76
+ return False
77
+
78
+
79
+ def check_collection_prefix(content: str, file_path: str) -> list[str]:
80
+ if is_test_file(file_path):
81
+ return []
82
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
83
+ return []
84
+ try:
85
+ tree = ast.parse(content)
86
+ except SyntaxError:
87
+ return []
88
+ issues: list[str] = []
89
+ for each_node in tree.body:
90
+ target_name: str | None = None
91
+ target_line = 0
92
+ is_collection_value = False
93
+ if isinstance(each_node, ast.AnnAssign) and isinstance(each_node.target, ast.Name):
94
+ target_name = each_node.target.id
95
+ target_line = each_node.lineno
96
+ is_collection_value = _annotation_names_collection(each_node.annotation)
97
+ elif isinstance(each_node, ast.Assign) and len(each_node.targets) == 1 and isinstance(each_node.targets[0], ast.Name):
98
+ target_name = each_node.targets[0].id
99
+ target_line = each_node.lineno
100
+ is_collection_value = isinstance(each_node.value, (ast.Tuple, ast.List, ast.Set, ast.Dict))
101
+ if target_name is None or not is_collection_value:
102
+ continue
103
+ if not UPPER_SNAKE_CONSTANT_PATTERN.match(target_name):
104
+ continue
105
+ if target_name.startswith("ALL_") or COLLECTION_BY_NAME_PATTERN.match(target_name.lower()):
106
+ continue
107
+ issues.append(
108
+ f"Line {target_line}: Collection constant {target_name} - prefix with ALL_ (CODE_RULES §5)"
109
+ )
110
+ for each_walked_node in ast.walk(tree):
111
+ if not isinstance(each_walked_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
112
+ continue
113
+ for each_arg in _collect_annotated_arguments(each_walked_node):
114
+ if not _annotation_names_collection(each_arg.annotation):
115
+ continue
116
+ if each_arg.arg in {"self", "cls"}:
117
+ continue
118
+ if each_arg.arg.startswith("all_") or COLLECTION_BY_NAME_PATTERN.match(each_arg.arg):
119
+ continue
120
+ issues.append(
121
+ f"Line {each_arg.lineno}: Collection parameter {each_arg.arg} - prefix with all_ (CODE_RULES §5)"
122
+ )
123
+ return issues
124
+
125
+
126
+ def _is_stuttering_all_name(name: str) -> bool:
127
+ return bool(STUTTERING_ALL_PREFIX_PATTERN.match(name))
128
+
129
+
130
+ def _walk_assignment_targets(target: ast.expr) -> list[ast.Name]:
131
+ """Recursively collect ast.Name targets through tuple/list/starred unpacking."""
132
+ if isinstance(target, ast.Name):
133
+ return [target]
134
+ if isinstance(target, (ast.Tuple, ast.List)):
135
+ names: list[ast.Name] = []
136
+ for each_element in target.elts:
137
+ names.extend(_walk_assignment_targets(each_element))
138
+ return names
139
+ if isinstance(target, ast.Starred):
140
+ return _walk_assignment_targets(target.value)
141
+ return []
142
+
143
+
144
+ def _collect_stuttering_name_bindings(tree: ast.Module) -> list[tuple[str, int]]:
145
+ """Return (name, line_number) for bindings whose introduced name stutters all_/ALL_ prefixes.
146
+
147
+ Covers assignments, loops, parameters, walrus targets, comprehensions, with/except
148
+ aliases, import aliases, and class definitions.
149
+ """
150
+ bindings: list[tuple[str, int]] = []
151
+ for each_node in ast.walk(tree):
152
+ if isinstance(each_node, ast.Assign):
153
+ for each_target in each_node.targets:
154
+ for each_name in _walk_assignment_targets(each_target):
155
+ if _is_stuttering_all_name(each_name.id):
156
+ bindings.append((each_name.id, each_name.lineno))
157
+ elif isinstance(each_node, ast.AnnAssign) and isinstance(each_node.target, ast.Name):
158
+ if _is_stuttering_all_name(each_node.target.id):
159
+ bindings.append((each_node.target.id, each_node.target.lineno))
160
+ elif isinstance(each_node, (ast.For, ast.AsyncFor)):
161
+ for each_name in _walk_assignment_targets(each_node.target):
162
+ if _is_stuttering_all_name(each_name.id):
163
+ bindings.append((each_name.id, each_name.lineno))
164
+ elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
165
+ if _is_stuttering_all_name(each_node.name):
166
+ bindings.append((each_node.name, each_node.lineno))
167
+ for each_arg in _collect_annotated_arguments(each_node):
168
+ if _is_stuttering_all_name(each_arg.arg):
169
+ bindings.append((each_arg.arg, each_arg.lineno))
170
+ elif isinstance(each_node, ast.NamedExpr) and isinstance(each_node.target, ast.Name):
171
+ if _is_stuttering_all_name(each_node.target.id):
172
+ bindings.append((each_node.target.id, each_node.target.lineno))
173
+ elif isinstance(each_node, ast.comprehension):
174
+ for each_name in _walk_assignment_targets(each_node.target):
175
+ if _is_stuttering_all_name(each_name.id):
176
+ bindings.append((each_name.id, each_name.lineno))
177
+ elif isinstance(each_node, (ast.With, ast.AsyncWith)):
178
+ for each_with_item in each_node.items:
179
+ if each_with_item.optional_vars is None:
180
+ continue
181
+ for each_name in _walk_assignment_targets(each_with_item.optional_vars):
182
+ if _is_stuttering_all_name(each_name.id):
183
+ bindings.append((each_name.id, each_name.lineno))
184
+ elif isinstance(each_node, ast.ExceptHandler):
185
+ if each_node.name is not None and _is_stuttering_all_name(each_node.name):
186
+ bindings.append((each_node.name, each_node.lineno))
187
+ elif isinstance(each_node, ast.Import):
188
+ for each_alias in each_node.names:
189
+ bound_name = (
190
+ each_alias.asname
191
+ if each_alias.asname is not None
192
+ else each_alias.name.split(MODULE_PATH_SEPARATOR, 1)[0]
193
+ )
194
+ if _is_stuttering_all_name(bound_name):
195
+ line_number = getattr(each_alias, AST_LINENO_ATTRIBUTE, None) or each_node.lineno
196
+ bindings.append((bound_name, line_number))
197
+ elif isinstance(each_node, ast.ImportFrom):
198
+ for each_alias in each_node.names:
199
+ if each_alias.name == WILDCARD_IMPORT_SENTINEL:
200
+ continue
201
+ bound_name = (
202
+ each_alias.asname
203
+ if each_alias.asname is not None
204
+ else each_alias.name
205
+ )
206
+ if _is_stuttering_all_name(bound_name):
207
+ line_number = getattr(each_alias, AST_LINENO_ATTRIBUTE, None) or each_node.lineno
208
+ bindings.append((bound_name, line_number))
209
+ elif isinstance(each_node, ast.ClassDef):
210
+ if _is_stuttering_all_name(each_node.name):
211
+ bindings.append((each_node.name, each_node.lineno))
212
+ return bindings
213
+
214
+
215
+ def check_stuttering_collection_prefix(content: str, file_path: str) -> list[str]:
216
+ """Flag identifiers stuttering the all_/ALL_ collection prefix (e.g., all_all_users)."""
217
+ if is_test_file(file_path):
218
+ return []
219
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
220
+ return []
221
+ try:
222
+ tree = ast.parse(content)
223
+ except SyntaxError:
224
+ return []
225
+ issues: list[str] = []
226
+ for each_name, each_line_number in _collect_stuttering_name_bindings(tree):
227
+ issues.append(
228
+ f"Line {each_line_number}: Stuttering collection prefix {each_name!r}"
229
+ f" - use a single all_/ALL_ prefix (CODE_RULES §5)"
230
+ )
231
+ if len(issues) >= MAX_STUTTERING_PREFIX_ISSUES:
232
+ break
233
+ return issues
234
+
235
+
236
+ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
237
+ if is_test_file(file_path):
238
+ return []
239
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
240
+ return []
241
+ try:
242
+ tree = ast.parse(content)
243
+ except SyntaxError:
244
+ return []
245
+ issues: list[str] = []
246
+ for each_node in ast.walk(tree):
247
+ if not isinstance(each_node, (ast.For, ast.AsyncFor)):
248
+ continue
249
+ for each_name_node in _collect_target_names(each_node.target):
250
+ target_name = each_name_node.id
251
+ if target_name in ALL_LOOP_INDEX_LETTER_EXEMPTIONS:
252
+ continue
253
+ if target_name == BARE_EACH_TOKEN:
254
+ issues.append(
255
+ f"Line {each_name_node.lineno}: loop variable 'each' is a bare token without subject"
256
+ f" - rename to each_<subject> (CODE_RULES §5)"
257
+ )
258
+ continue
259
+ if target_name.startswith(EACH_PREFIX) and len(target_name) > len(EACH_PREFIX):
260
+ continue
261
+ issues.append(
262
+ f"Line {each_name_node.lineno}: loop variable {target_name!r} - prefix with each_ (CODE_RULES §5)"
263
+ )
264
+ return issues