claude-dev-env 1.50.1 → 1.50.3
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/_shared/pr-loop/audit-contract.md +3 -3
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +3 -2
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +5 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/docs/CODE_RULES.md +1 -1
- 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 -5807
- 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/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 +18 -13
- 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_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/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +13 -7
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +21 -11
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +92 -0
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +20 -48
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/audit-contract.md +4 -4
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/findbugs/SKILL.md +21 -12
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/qbug/SKILL.md +5 -5
- package/skills/qbug/test_qbug_skill_audit_schema.py +13 -23
- package/skills/refine/SKILL.md +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
|
@@ -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
|