claude-dev-env 2.27.0 → 2.28.1
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/.agents/skills/hitl/SKILL.md +45 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +16 -29
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/ever-shipped-skills.mjs +1 -0
- package/docs/CODE_RULES.md +3 -3
- package/docs/agent-spawn-protocol.md +1 -1
- package/docs/references/prose-style-enforcement.md +5 -9
- package/hooks/blocking/AGENTS.md +3 -13
- package/hooks/blocking/code_rules_docstrings.py +10 -2138
- package/hooks/blocking/code_rules_enforcer.py +0 -121
- package/hooks/blocking/code_rules_imports_logging.py +1 -236
- package/hooks/blocking/code_rules_shared.py +23 -0
- package/hooks/blocking/code_rules_test_layout.py +8 -8
- package/hooks/blocking/config/prose_style_enforcement_constants.py +4 -2
- package/hooks/blocking/config/test_prose_style_enforcement_constants.py +5 -1
- package/hooks/blocking/precommit_code_rules_gate.py +8 -43
- package/hooks/blocking/state_description_blocker.py +1 -7
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +0 -8
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +11 -112
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +1 -18
- package/hooks/blocking/test_code_rules_shared.py +12 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +32 -179
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -1
- package/hooks/blocking/test_state_description_blocker.py +6 -4
- package/hooks/blocking/test_stop_dispatcher.py +5 -29
- package/hooks/hooks_constants/AGENTS.md +1 -6
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/messages.py +0 -2
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +3 -17
- package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/test_messages.py +5 -3
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -2
- package/package.json +1 -1
- package/rules/AGENTS.md +3 -6
- package/rules/claims-as-quotes.md +0 -10
- package/rules/code-standards.md +6 -6
- package/rules/explore-thoroughly.md +0 -1
- package/rules/failure-blast-radius.md +0 -8
- package/rules/falsify-before-green.md +0 -8
- package/rules/file-global-constants.md +2 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +1 -9
- package/rules/hedging-claims.md +2 -6
- package/rules/long-horizon-autonomy.md +1 -1
- package/rules/measurement-denominators.md +0 -9
- package/rules/research-mode.md +0 -6
- package/rules/verify-before-asking.md +0 -5
- package/rules/verify-runtime-state.md +0 -5
- package/scripts/codex_compat_materializer.py +99 -24
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/tests/test_codex_compat_materializer.py +70 -19
- package/scripts/tests/test_engine.py +0 -1
- package/scripts/tests/test_rules.py +0 -1
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/system-prompts/software-engineer.xml +3 -3
- package/hooks/blocking/code_rules_dead_argparse_argument.py +0 -554
- package/hooks/blocking/code_rules_dead_config_field.py +0 -568
- package/hooks/blocking/code_rules_dead_dataclass_field.py +0 -348
- package/hooks/blocking/code_rules_dead_module_constant.py +0 -757
- package/hooks/blocking/code_rules_dead_split_branch.py +0 -225
- package/hooks/blocking/code_rules_mock_completeness.py +0 -295
- package/hooks/blocking/code_rules_scope_binding.py +0 -151
- package/hooks/blocking/code_rules_unused_imports.py +0 -197
- package/hooks/blocking/hedging_language_blocker.py +0 -221
- package/hooks/blocking/intent_only_ending_blocker.py +0 -148
- package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +0 -534
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +0 -846
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +0 -507
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +0 -679
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +0 -133
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +0 -103
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +0 -105
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +0 -425
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +0 -176
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +0 -385
- package/hooks/blocking/test_code_rules_enforcer_docstring_fallback_branch.py +0 -398
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +0 -129
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +0 -93
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +0 -198
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +0 -262
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +0 -93
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +0 -115
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +0 -226
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +0 -207
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +0 -262
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +0 -164
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +0 -188
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +0 -157
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +0 -303
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +0 -111
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +0 -656
- package/hooks/blocking/test_hedging_language_blocker.py +0 -261
- package/hooks/blocking/test_intent_only_ending_blocker.py +0 -209
- package/hooks/hooks_constants/dead_argparse_argument_constants.py +0 -28
- package/hooks/hooks_constants/dead_config_field_constants.py +0 -39
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +0 -25
- package/hooks/hooks_constants/dead_module_constant_constants.py +0 -30
- package/hooks/hooks_constants/hedging_uncertainty_constants.py +0 -42
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +0 -44
- package/rules/conservative-action.md +0 -17
- package/rules/context7.md +0 -8
- package/rules/few-words.md +0 -3
- package/rules/parallel-tools.md +0 -23
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
"""Dead-conditional-branch check for a truthiness test on an always-non-empty split."""
|
|
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_path_utils import ( # noqa: E402
|
|
15
|
-
is_config_file,
|
|
16
|
-
)
|
|
17
|
-
from code_rules_shared import ( # noqa: E402
|
|
18
|
-
_collect_annotated_arguments,
|
|
19
|
-
_walk_skipping_nested_function_defs,
|
|
20
|
-
is_test_file,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
24
|
-
ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES,
|
|
25
|
-
DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX,
|
|
26
|
-
MAX_DEAD_SPLIT_BRANCH_ISSUES,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _is_separator_split_call(call_node: ast.Call) -> bool:
|
|
31
|
-
"""Return True for an ``x.split(sep)`` / ``x.rsplit(sep)`` with a non-empty separator.
|
|
32
|
-
|
|
33
|
-
``str.split`` and ``bytes.split`` return a list holding at least one element
|
|
34
|
-
whenever a separator argument is supplied, so the result is always truthy.
|
|
35
|
-
Requiring a non-empty string or bytes literal separator keeps the guarantee
|
|
36
|
-
airtight: ``split()`` with no argument can return an empty list, and an
|
|
37
|
-
empty separator raises at runtime. The receiver is restricted to a bare
|
|
38
|
-
``Name`` or a string/bytes literal so an intermediate attribute chain such
|
|
39
|
-
as a pandas ``s.str.split(",")`` — whose result is a Series, not a list —
|
|
40
|
-
is not treated as an always-non-empty split.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
call_node: The call expression on the right-hand side of an assignment.
|
|
44
|
-
|
|
45
|
-
Returns:
|
|
46
|
-
True when the call is a separator-bearing split that never returns an
|
|
47
|
-
empty list.
|
|
48
|
-
"""
|
|
49
|
-
function_node = call_node.func
|
|
50
|
-
if not isinstance(function_node, ast.Attribute):
|
|
51
|
-
return False
|
|
52
|
-
if function_node.attr not in ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES:
|
|
53
|
-
return False
|
|
54
|
-
receiver_node = function_node.value
|
|
55
|
-
if isinstance(receiver_node, ast.Constant):
|
|
56
|
-
if not isinstance(receiver_node.value, (str, bytes)):
|
|
57
|
-
return False
|
|
58
|
-
elif not isinstance(receiver_node, ast.Name):
|
|
59
|
-
return False
|
|
60
|
-
if not call_node.args:
|
|
61
|
-
return False
|
|
62
|
-
first_argument = call_node.args[0]
|
|
63
|
-
if not isinstance(first_argument, ast.Constant):
|
|
64
|
-
return False
|
|
65
|
-
separator_value = first_argument.value
|
|
66
|
-
if not isinstance(separator_value, (str, bytes)):
|
|
67
|
-
return False
|
|
68
|
-
return len(separator_value) > 0
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _separator_split_target_names(
|
|
72
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
73
|
-
) -> set[str]:
|
|
74
|
-
"""Return names bound exactly once in the function from a separator split.
|
|
75
|
-
|
|
76
|
-
A name qualifies when its single binding in the function body is an
|
|
77
|
-
assignment whose value is a separator-bearing ``split`` / ``rsplit`` call,
|
|
78
|
-
so the bound list is always truthy. A name bound more than once, or also a
|
|
79
|
-
parameter, is excluded because a later rebinding could make it falsy.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
function_node: The function whose body is inspected.
|
|
83
|
-
|
|
84
|
-
Returns:
|
|
85
|
-
The set of always-truthy split-result names safe to reason about.
|
|
86
|
-
"""
|
|
87
|
-
all_store_counts: dict[str, int] = {}
|
|
88
|
-
all_split_names: set[str] = set()
|
|
89
|
-
for each_statement in function_node.body:
|
|
90
|
-
for each_node in _walk_skipping_nested_function_defs(each_statement):
|
|
91
|
-
if isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Store):
|
|
92
|
-
all_store_counts[each_node.id] = (
|
|
93
|
-
all_store_counts.get(each_node.id, 0) + 1
|
|
94
|
-
)
|
|
95
|
-
if not isinstance(each_node, ast.Assign):
|
|
96
|
-
continue
|
|
97
|
-
if len(each_node.targets) != 1:
|
|
98
|
-
continue
|
|
99
|
-
assignment_target = each_node.targets[0]
|
|
100
|
-
if not isinstance(assignment_target, ast.Name):
|
|
101
|
-
continue
|
|
102
|
-
if isinstance(each_node.value, ast.Call) and _is_separator_split_call(
|
|
103
|
-
each_node.value
|
|
104
|
-
):
|
|
105
|
-
all_split_names.add(assignment_target.id)
|
|
106
|
-
all_parameter_names = {
|
|
107
|
-
each_argument.arg
|
|
108
|
-
for each_argument in _collect_annotated_arguments(function_node)
|
|
109
|
-
}
|
|
110
|
-
return {
|
|
111
|
-
each_name
|
|
112
|
-
for each_name in all_split_names
|
|
113
|
-
if all_store_counts.get(each_name, 0) == 1
|
|
114
|
-
and each_name not in all_parameter_names
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _truthiness_target(
|
|
119
|
-
test_node: ast.expr, all_candidate_names: set[str]
|
|
120
|
-
) -> str | None:
|
|
121
|
-
"""Return the candidate name a bare ``Name`` truthiness test reads, else None."""
|
|
122
|
-
if isinstance(test_node, ast.Name) and test_node.id in all_candidate_names:
|
|
123
|
-
return test_node.id
|
|
124
|
-
return None
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _negated_truthiness_target(
|
|
128
|
-
test_node: ast.expr, all_candidate_names: set[str]
|
|
129
|
-
) -> str | None:
|
|
130
|
-
"""Return the candidate name a ``not Name`` test reads, else None."""
|
|
131
|
-
if not isinstance(test_node, ast.UnaryOp) or not isinstance(test_node.op, ast.Not):
|
|
132
|
-
return None
|
|
133
|
-
operand = test_node.operand
|
|
134
|
-
if isinstance(operand, ast.Name) and operand.id in all_candidate_names:
|
|
135
|
-
return operand.id
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _branch_finding_for_node(
|
|
140
|
-
node: ast.AST, all_candidate_names: set[str]
|
|
141
|
-
) -> tuple[int, str] | None:
|
|
142
|
-
"""Return ``(line, name)`` when a node carries a dead branch, else None.
|
|
143
|
-
|
|
144
|
-
A conditional expression always carries an else arm, so any truthiness test
|
|
145
|
-
on a candidate name is a dead branch. An ``if`` statement only carries a
|
|
146
|
-
dead else when it has one, while an ``if not name`` statement always has a
|
|
147
|
-
dead body.
|
|
148
|
-
"""
|
|
149
|
-
if isinstance(node, ast.IfExp):
|
|
150
|
-
truthy_name = _truthiness_target(node.test, all_candidate_names)
|
|
151
|
-
if truthy_name is not None:
|
|
152
|
-
return node.lineno, truthy_name
|
|
153
|
-
return _negated_branch_finding(node.test, node.lineno, all_candidate_names)
|
|
154
|
-
if isinstance(node, ast.If):
|
|
155
|
-
truthy_name = _truthiness_target(node.test, all_candidate_names)
|
|
156
|
-
if truthy_name is not None and node.orelse:
|
|
157
|
-
return node.lineno, truthy_name
|
|
158
|
-
return _negated_branch_finding(node.test, node.lineno, all_candidate_names)
|
|
159
|
-
return None
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _negated_branch_finding(
|
|
163
|
-
test_node: ast.expr, line_number: int, all_candidate_names: set[str]
|
|
164
|
-
) -> tuple[int, str] | None:
|
|
165
|
-
negated_name = _negated_truthiness_target(test_node, all_candidate_names)
|
|
166
|
-
if negated_name is not None:
|
|
167
|
-
return line_number, negated_name
|
|
168
|
-
return None
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _dead_branch_findings(
|
|
172
|
-
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
173
|
-
all_candidate_names: set[str],
|
|
174
|
-
) -> list[tuple[int, str]]:
|
|
175
|
-
"""Return every ``(line, name)`` dead-branch finding in the function body."""
|
|
176
|
-
all_findings: list[tuple[int, str]] = []
|
|
177
|
-
for each_statement in function_node.body:
|
|
178
|
-
for each_node in _walk_skipping_nested_function_defs(each_statement):
|
|
179
|
-
finding = _branch_finding_for_node(each_node, all_candidate_names)
|
|
180
|
-
if finding is not None:
|
|
181
|
-
all_findings.append(finding)
|
|
182
|
-
return all_findings
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def check_dead_split_truthiness_branch(content: str, file_path: str) -> list[str]:
|
|
186
|
-
"""Flag a conditional whose branch is unreachable after a separator split.
|
|
187
|
-
|
|
188
|
-
``str.split(sep)`` and ``bytes.split(sep)`` with a separator always return a
|
|
189
|
-
list holding at least one element, so a value bound from such a call is
|
|
190
|
-
always truthy. A conditional that tests that value's truthiness carries a
|
|
191
|
-
dead branch: ``parts[0] if parts else fallback`` never reaches ``fallback``,
|
|
192
|
-
and ``if not parts:`` never runs its body. Both the conditional-expression
|
|
193
|
-
form and the ``if`` / ``if not`` statement form are reported, scoped to a
|
|
194
|
-
value bound exactly once in the function from a separator split. Config
|
|
195
|
-
files and test files are exempt.
|
|
196
|
-
|
|
197
|
-
Args:
|
|
198
|
-
content: The source text to inspect.
|
|
199
|
-
file_path: The path the source will be written to, used for exemptions.
|
|
200
|
-
|
|
201
|
-
Returns:
|
|
202
|
-
One issue per dead conditional branch found, capped at the module limit.
|
|
203
|
-
"""
|
|
204
|
-
if is_test_file(file_path) or is_config_file(file_path):
|
|
205
|
-
return []
|
|
206
|
-
try:
|
|
207
|
-
tree = ast.parse(content)
|
|
208
|
-
except SyntaxError:
|
|
209
|
-
return []
|
|
210
|
-
issues: list[str] = []
|
|
211
|
-
for each_function_node in ast.walk(tree):
|
|
212
|
-
if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
-
continue
|
|
214
|
-
all_candidate_names = _separator_split_target_names(each_function_node)
|
|
215
|
-
if not all_candidate_names:
|
|
216
|
-
continue
|
|
217
|
-
for each_line, each_name in _dead_branch_findings(
|
|
218
|
-
each_function_node, all_candidate_names
|
|
219
|
-
):
|
|
220
|
-
issues.append(
|
|
221
|
-
f"Line {each_line}: {each_name!r} {DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX}"
|
|
222
|
-
)
|
|
223
|
-
if len(issues) >= MAX_DEAD_SPLIT_BRANCH_ISSUES:
|
|
224
|
-
return issues
|
|
225
|
-
return issues
|
|
@@ -1,295 +0,0 @@
|
|
|
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: # noqa: B007
|
|
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
|
-
)
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
"""Lexical scope-binding collectors used by the unused-import check."""
|
|
2
|
-
|
|
3
|
-
import ast
|
|
4
|
-
|
|
5
|
-
from hooks_constants.stuttering_import_binding_constants import (
|
|
6
|
-
WILDCARD_IMPORT_SENTINEL,
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _attribute_root_name_if_loaded(attribute_node: ast.Attribute) -> ast.Name | None:
|
|
11
|
-
current: ast.expr = attribute_node
|
|
12
|
-
while isinstance(current, ast.Attribute):
|
|
13
|
-
current = current.value
|
|
14
|
-
if isinstance(current, ast.Name) and isinstance(current.ctx, ast.Load):
|
|
15
|
-
return current
|
|
16
|
-
return None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class _ScopeBindingCollector(ast.NodeVisitor):
|
|
20
|
-
def __init__(self) -> None:
|
|
21
|
-
self.binding_names: set[str] = set()
|
|
22
|
-
self.global_names: set[str] = set()
|
|
23
|
-
|
|
24
|
-
def collect_arguments(self, arguments: ast.arguments) -> None:
|
|
25
|
-
for each_argument in (
|
|
26
|
-
arguments.posonlyargs
|
|
27
|
-
+ arguments.args
|
|
28
|
-
+ arguments.kwonlyargs
|
|
29
|
-
):
|
|
30
|
-
self.binding_names.add(each_argument.arg)
|
|
31
|
-
if arguments.vararg is not None:
|
|
32
|
-
self.binding_names.add(arguments.vararg.arg)
|
|
33
|
-
if arguments.kwarg is not None:
|
|
34
|
-
self.binding_names.add(arguments.kwarg.arg)
|
|
35
|
-
|
|
36
|
-
def visit_Global(self, node: ast.Global) -> None:
|
|
37
|
-
self.global_names.update(node.names)
|
|
38
|
-
|
|
39
|
-
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
40
|
-
self.binding_names.update(node.names)
|
|
41
|
-
|
|
42
|
-
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
43
|
-
self.binding_names.add(node.name)
|
|
44
|
-
|
|
45
|
-
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
46
|
-
self.binding_names.add(node.name)
|
|
47
|
-
|
|
48
|
-
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
49
|
-
self.binding_names.add(node.name)
|
|
50
|
-
|
|
51
|
-
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
52
|
-
return None
|
|
53
|
-
|
|
54
|
-
def visit_Name(self, node: ast.Name) -> None:
|
|
55
|
-
if isinstance(node.ctx, ast.Store):
|
|
56
|
-
self.binding_names.add(node.id)
|
|
57
|
-
|
|
58
|
-
def visit_Import(self, node: ast.Import) -> None:
|
|
59
|
-
for each_alias in node.names:
|
|
60
|
-
self.binding_names.add(each_alias.asname or each_alias.name.split(".")[0])
|
|
61
|
-
|
|
62
|
-
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
63
|
-
for each_alias in node.names:
|
|
64
|
-
if each_alias.name != WILDCARD_IMPORT_SENTINEL:
|
|
65
|
-
self.binding_names.add(each_alias.asname or each_alias.name)
|
|
66
|
-
|
|
67
|
-
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
68
|
-
return None
|
|
69
|
-
|
|
70
|
-
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
71
|
-
return None
|
|
72
|
-
|
|
73
|
-
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
80
|
-
if node.name is not None:
|
|
81
|
-
self.binding_names.add(node.name)
|
|
82
|
-
self.generic_visit(node)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _scope_binding_names(scope_node: ast.AST) -> tuple[set[str], set[str]]:
|
|
86
|
-
collector = _ScopeBindingCollector()
|
|
87
|
-
if isinstance(scope_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
88
|
-
collector.collect_arguments(scope_node.args)
|
|
89
|
-
for each_statement in scope_node.body:
|
|
90
|
-
collector.visit(each_statement)
|
|
91
|
-
elif isinstance(scope_node, ast.Lambda):
|
|
92
|
-
collector.collect_arguments(scope_node.args)
|
|
93
|
-
collector.visit(scope_node.body)
|
|
94
|
-
elif isinstance(scope_node, ast.ClassDef):
|
|
95
|
-
for each_statement in scope_node.body:
|
|
96
|
-
collector.visit(each_statement)
|
|
97
|
-
return collector.binding_names, collector.global_names
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _load_name_is_shadowed(
|
|
101
|
-
load_node: ast.AST,
|
|
102
|
-
name: str,
|
|
103
|
-
parent_by_node_id: dict[int, ast.AST],
|
|
104
|
-
) -> bool:
|
|
105
|
-
current = parent_by_node_id.get(id(load_node))
|
|
106
|
-
has_passed_function_scope = False
|
|
107
|
-
while current is not None:
|
|
108
|
-
if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
109
|
-
has_passed_function_scope = True
|
|
110
|
-
binding_names, global_names = _scope_binding_names(current)
|
|
111
|
-
if name in global_names:
|
|
112
|
-
return False
|
|
113
|
-
if name in binding_names:
|
|
114
|
-
return True
|
|
115
|
-
elif isinstance(current, ast.ClassDef) and not has_passed_function_scope:
|
|
116
|
-
# Class body bindings are order-dependent (name resolution is
|
|
117
|
-
# dynamic, unlike function locals). A load before an assignment
|
|
118
|
-
# still resolves to the module-level name, so conservatively
|
|
119
|
-
# skip class-body shadow detection to avoid false positives.
|
|
120
|
-
pass
|
|
121
|
-
current = parent_by_node_id.get(id(current))
|
|
122
|
-
return False
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _names_from_annotation_text(annotation_text: str) -> set[str]:
|
|
126
|
-
try:
|
|
127
|
-
annotation_tree = ast.parse(annotation_text, mode="eval")
|
|
128
|
-
except SyntaxError:
|
|
129
|
-
return set()
|
|
130
|
-
referenced_names: set[str] = set()
|
|
131
|
-
for each_node in ast.walk(annotation_tree):
|
|
132
|
-
if isinstance(each_node, ast.Name):
|
|
133
|
-
referenced_names.add(each_node.id)
|
|
134
|
-
elif isinstance(each_node, ast.Attribute):
|
|
135
|
-
root_name = _attribute_root_name_if_loaded(each_node)
|
|
136
|
-
if root_name is not None:
|
|
137
|
-
referenced_names.add(root_name.id)
|
|
138
|
-
return referenced_names
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _collect_string_annotation_names(tree: ast.Module) -> set[str]:
|
|
142
|
-
referenced_names: set[str] = set()
|
|
143
|
-
for each_node in ast.walk(tree):
|
|
144
|
-
annotation = None
|
|
145
|
-
if isinstance(each_node, ast.arg):
|
|
146
|
-
annotation = each_node.annotation
|
|
147
|
-
elif isinstance(each_node, (ast.AnnAssign, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
148
|
-
annotation = each_node.annotation if isinstance(each_node, ast.AnnAssign) else each_node.returns
|
|
149
|
-
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
150
|
-
referenced_names.update(_names_from_annotation_text(annotation.value))
|
|
151
|
-
return referenced_names
|