claude-dev-env 1.57.2 → 1.59.0
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/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +317 -54
- package/bin/install.test.mjs +478 -3
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +68 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -38
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/update/SKILL.md +37 -5
|
@@ -13,6 +13,7 @@ if _hooks_directory not in sys.path:
|
|
|
13
13
|
|
|
14
14
|
from code_rules_shared import ( # noqa: E402
|
|
15
15
|
_collect_annotated_arguments,
|
|
16
|
+
_collect_fixture_injection_arguments,
|
|
16
17
|
_definition_docstring_line_span,
|
|
17
18
|
_function_definition_line_span,
|
|
18
19
|
_scope_violations_to_changed_lines,
|
|
@@ -24,8 +25,10 @@ from code_rules_shared import ( # noqa: E402
|
|
|
24
25
|
|
|
25
26
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
26
27
|
ALL_SELF_AND_CLS_PARAMETER_NAMES,
|
|
28
|
+
ANNOTATION_BY_PYTEST_FIXTURE,
|
|
27
29
|
FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
|
|
28
30
|
FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
31
|
+
KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX,
|
|
29
32
|
)
|
|
30
33
|
|
|
31
34
|
|
|
@@ -52,6 +55,156 @@ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
|
|
|
52
55
|
return issues
|
|
53
56
|
|
|
54
57
|
|
|
58
|
+
def _is_pytest_fixture_injection_site(
|
|
59
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
60
|
+
) -> bool:
|
|
61
|
+
"""Return True when a function node is a valid pytest fixture injection site.
|
|
62
|
+
|
|
63
|
+
A function qualifies as a fixture injection site when either its name begins
|
|
64
|
+
with the ``test`` prefix (matching pytest's default ``python_functions = test*``
|
|
65
|
+
collection rule) or it carries a ``@pytest.fixture`` / ``@fixture`` decorator,
|
|
66
|
+
with or without call arguments. Ordinary helper functions that happen to share
|
|
67
|
+
a parameter name with a known pytest fixture are excluded by this predicate so
|
|
68
|
+
that ``check_known_pytest_fixture_annotations`` only enforces annotation
|
|
69
|
+
requirements on the functions where pytest actually performs fixture injection.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
node: The function definition AST node to inspect.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True when the node is a pytest test function or a fixture-decorated
|
|
76
|
+
function; False otherwise.
|
|
77
|
+
"""
|
|
78
|
+
if node.name.startswith("test"):
|
|
79
|
+
return True
|
|
80
|
+
for each_decorator in node.decorator_list:
|
|
81
|
+
unwrapped = each_decorator.func if isinstance(each_decorator, ast.Call) else each_decorator
|
|
82
|
+
if isinstance(unwrapped, ast.Name) and unwrapped.id == "fixture":
|
|
83
|
+
return True
|
|
84
|
+
if isinstance(unwrapped, ast.Attribute) and unwrapped.attr == "fixture":
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_fixture_annotation_text(annotation_text: str) -> str:
|
|
90
|
+
"""Strip forward-reference string quoting from an unparsed annotation.
|
|
91
|
+
|
|
92
|
+
``ast.unparse`` renders a forward-reference annotation such as
|
|
93
|
+
``tmp_path: "Path"`` as the quoted literal ``'Path'``. Removing the
|
|
94
|
+
surrounding quotes recovers the bare type name so the quoted spelling
|
|
95
|
+
compares equal to its unquoted form.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
annotation_text: The annotation as rendered by ``ast.unparse``.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The annotation text with any single surrounding quote pair removed.
|
|
102
|
+
"""
|
|
103
|
+
if len(annotation_text) >= 2 and annotation_text[0] in {'"', "'"}:
|
|
104
|
+
if annotation_text[-1] == annotation_text[0]:
|
|
105
|
+
return annotation_text[1:-1]
|
|
106
|
+
return annotation_text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fixture_annotation_matches_expected(
|
|
110
|
+
actual_annotation: str, expected_annotation: str
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Return True when an annotation matches its fixture's documented type.
|
|
113
|
+
|
|
114
|
+
The match accepts every equally-correct spelling of the documented type:
|
|
115
|
+
the exact text, a forward-reference string form, and either the bare
|
|
116
|
+
attribute tail or the fully-qualified dotted form. Both ``tmp_path: Path``
|
|
117
|
+
and ``tmp_path: pathlib.Path`` satisfy an expected ``Path``, and both
|
|
118
|
+
``monkeypatch: pytest.MonkeyPatch`` and ``monkeypatch: MonkeyPatch``
|
|
119
|
+
satisfy an expected ``pytest.MonkeyPatch``.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
actual_annotation: The annotation as rendered by ``ast.unparse``.
|
|
123
|
+
expected_annotation: The fixture's single documented type spelling.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True when the actual annotation is an accepted spelling of the
|
|
127
|
+
expected type; False otherwise.
|
|
128
|
+
"""
|
|
129
|
+
normalized_actual = _normalize_fixture_annotation_text(actual_annotation)
|
|
130
|
+
if normalized_actual == expected_annotation:
|
|
131
|
+
return True
|
|
132
|
+
return normalized_actual.rsplit(".", 1)[-1] == expected_annotation.rsplit(
|
|
133
|
+
".", 1
|
|
134
|
+
)[-1]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_known_pytest_fixture_annotations(content: str, file_path: str) -> list[str]:
|
|
138
|
+
"""Flag well-known pytest fixture parameters lacking their type annotation.
|
|
139
|
+
|
|
140
|
+
The broad parameter-annotation rule exempts test files, so an ordinary
|
|
141
|
+
test parameter never needs a type hint. This narrower check restores
|
|
142
|
+
enforcement for exactly the pytest builtin fixtures whose injected type is
|
|
143
|
+
fixed and documented — ``tmp_path: Path``, ``monkeypatch:
|
|
144
|
+
pytest.MonkeyPatch``, and the rest of
|
|
145
|
+
``ANNOTATION_BY_PYTEST_FIXTURE``. For these names the
|
|
146
|
+
correct annotation is unambiguous, so requiring it costs the author one
|
|
147
|
+
token and removes a recurring class of reviewer noise on test fixtures.
|
|
148
|
+
A non-test file produces no findings here: the broad check already covers
|
|
149
|
+
every parameter outside test files.
|
|
150
|
+
|
|
151
|
+
A known fixture parameter is flagged both when it carries no annotation and
|
|
152
|
+
when its annotation source differs from the fixture's single documented
|
|
153
|
+
type, so ``tmp_path: str`` is flagged exactly like ``tmp_path``. Only the
|
|
154
|
+
named injection slots pytest actually fills — undefaulted
|
|
155
|
+
positional-or-keyword and keyword-only parameters — are inspected. A
|
|
156
|
+
positional-only parameter is skipped because pytest passes fixtures by
|
|
157
|
+
keyword and can never bind one positionally; a defaulted parameter is
|
|
158
|
+
skipped because pytest leaves its default in place rather than injecting a
|
|
159
|
+
fixture; and a ``*args`` or ``**kwargs`` parameter that happens to share a
|
|
160
|
+
fixture name is never a fixture injection.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
content: The Python source to analyze.
|
|
164
|
+
file_path: The path of the file being checked.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
One blocking issue per known fixture injection parameter whose
|
|
168
|
+
annotation is missing or differs from its single documented type,
|
|
169
|
+
naming the parameter and its expected type.
|
|
170
|
+
"""
|
|
171
|
+
if not is_test_file(file_path):
|
|
172
|
+
return []
|
|
173
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
174
|
+
return []
|
|
175
|
+
try:
|
|
176
|
+
tree = ast.parse(content)
|
|
177
|
+
except SyntaxError:
|
|
178
|
+
return []
|
|
179
|
+
issues: list[str] = []
|
|
180
|
+
for each_node in ast.walk(tree):
|
|
181
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
182
|
+
continue
|
|
183
|
+
if not _is_pytest_fixture_injection_site(each_node):
|
|
184
|
+
continue
|
|
185
|
+
for each_arg in _collect_fixture_injection_arguments(each_node):
|
|
186
|
+
expected_annotation = ANNOTATION_BY_PYTEST_FIXTURE.get(
|
|
187
|
+
each_arg.arg
|
|
188
|
+
)
|
|
189
|
+
if expected_annotation is None:
|
|
190
|
+
continue
|
|
191
|
+
actual_annotation = (
|
|
192
|
+
ast.unparse(each_arg.annotation)
|
|
193
|
+
if each_arg.annotation is not None
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
196
|
+
if actual_annotation is not None and _fixture_annotation_matches_expected(
|
|
197
|
+
actual_annotation, expected_annotation
|
|
198
|
+
):
|
|
199
|
+
continue
|
|
200
|
+
issues.append(
|
|
201
|
+
f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on "
|
|
202
|
+
f"{each_node.name!r} - {KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX} "
|
|
203
|
+
f"(annotate as {expected_annotation!r})"
|
|
204
|
+
)
|
|
205
|
+
return issues
|
|
206
|
+
|
|
207
|
+
|
|
55
208
|
def check_return_annotations(content: str, file_path: str) -> list[str]:
|
|
56
209
|
if is_test_file(file_path):
|
|
57
210
|
return []
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Dead dataclass-field check: a @dataclass field assigned but never read in the same file."""
|
|
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_migration_file,
|
|
16
|
+
is_test_file,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from hooks_constants.dead_dataclass_field_constants import ( # noqa: E402
|
|
20
|
+
ALL_DATACLASS_DECORATOR_NAMES,
|
|
21
|
+
ALL_WHOLE_INSTANCE_STRINGIFY_NAMES,
|
|
22
|
+
ATTRGETTER_FUNCTION_NAME,
|
|
23
|
+
CLASSVAR_ANNOTATION_NAME,
|
|
24
|
+
DEAD_DATACLASS_FIELD_GUIDANCE,
|
|
25
|
+
GETATTR_FUNCTION_NAME,
|
|
26
|
+
GETATTR_NAME_ARGUMENT_MINIMUM,
|
|
27
|
+
MAX_DEAD_DATACLASS_FIELD_ISSUES,
|
|
28
|
+
ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
|
|
29
|
+
WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _decorator_calls_dataclass(decorator_node: ast.expr) -> bool:
|
|
34
|
+
"""Return whether a decorator expression applies @dataclass (bare or called)."""
|
|
35
|
+
target_node = (
|
|
36
|
+
decorator_node.func if isinstance(decorator_node, ast.Call) else decorator_node
|
|
37
|
+
)
|
|
38
|
+
if isinstance(target_node, ast.Name):
|
|
39
|
+
return target_node.id in ALL_DATACLASS_DECORATOR_NAMES
|
|
40
|
+
if isinstance(target_node, ast.Attribute):
|
|
41
|
+
return target_node.attr in ALL_DATACLASS_DECORATOR_NAMES
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_dataclass(class_node: ast.ClassDef) -> bool:
|
|
46
|
+
return any(
|
|
47
|
+
_decorator_calls_dataclass(each_decorator)
|
|
48
|
+
for each_decorator in class_node.decorator_list
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _annotation_is_classvar(annotation_node: ast.expr | None) -> bool:
|
|
53
|
+
if annotation_node is None:
|
|
54
|
+
return False
|
|
55
|
+
if isinstance(annotation_node, ast.Name):
|
|
56
|
+
return annotation_node.id == CLASSVAR_ANNOTATION_NAME
|
|
57
|
+
if isinstance(annotation_node, ast.Attribute):
|
|
58
|
+
return annotation_node.attr == CLASSVAR_ANNOTATION_NAME
|
|
59
|
+
if isinstance(annotation_node, ast.Subscript):
|
|
60
|
+
return _annotation_is_classvar(annotation_node.value)
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dataclass_field_definitions(class_node: ast.ClassDef) -> list[tuple[str, int]]:
|
|
65
|
+
"""Return (field_name, line) for each instance field declared in a dataclass body."""
|
|
66
|
+
fields: list[tuple[str, int]] = []
|
|
67
|
+
for each_statement in class_node.body:
|
|
68
|
+
if not isinstance(each_statement, ast.AnnAssign):
|
|
69
|
+
continue
|
|
70
|
+
if not isinstance(each_statement.target, ast.Name):
|
|
71
|
+
continue
|
|
72
|
+
if _annotation_is_classvar(each_statement.annotation):
|
|
73
|
+
continue
|
|
74
|
+
fields.append((each_statement.target.id, each_statement.lineno))
|
|
75
|
+
return fields
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _string_constant_literal(node: ast.expr) -> str | None:
|
|
79
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
80
|
+
return node.value
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dynamic_access_names(tree: ast.Module) -> tuple[set[str], bool]:
|
|
85
|
+
"""Return literal attribute-name reads and whether the check must be suppressed.
|
|
86
|
+
|
|
87
|
+
Walks every ``getattr(obj, "name")`` and ``operator.attrgetter("a", "b")``
|
|
88
|
+
call, contributing each literal string name as a read attribute name —
|
|
89
|
+
``getattr`` names its attribute in the second positional argument while
|
|
90
|
+
``attrgetter`` names one attribute per positional argument. A non-literal
|
|
91
|
+
name argument, or any reflective whole-instance consumer (``asdict``,
|
|
92
|
+
``astuple``, ``fields``, ``replace``, ``vars``) that reads every field at
|
|
93
|
+
once, means a field cannot be proven unread, so the boolean signals the
|
|
94
|
+
caller to suppress the check for the whole file.
|
|
95
|
+
"""
|
|
96
|
+
literal_names: set[str] = set()
|
|
97
|
+
should_suppress_check = False
|
|
98
|
+
for each_node in ast.walk(tree):
|
|
99
|
+
if not isinstance(each_node, ast.Call):
|
|
100
|
+
continue
|
|
101
|
+
function_node = each_node.func
|
|
102
|
+
function_name = None
|
|
103
|
+
if isinstance(function_node, ast.Name):
|
|
104
|
+
function_name = function_node.id
|
|
105
|
+
elif isinstance(function_node, ast.Attribute):
|
|
106
|
+
function_name = function_node.attr
|
|
107
|
+
if function_name in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES:
|
|
108
|
+
should_suppress_check = True
|
|
109
|
+
continue
|
|
110
|
+
if function_name not in {GETATTR_FUNCTION_NAME, ATTRGETTER_FUNCTION_NAME}:
|
|
111
|
+
continue
|
|
112
|
+
string_arguments = [
|
|
113
|
+
argument
|
|
114
|
+
for argument in each_node.args
|
|
115
|
+
if not isinstance(argument, ast.Starred)
|
|
116
|
+
]
|
|
117
|
+
if function_name == GETATTR_FUNCTION_NAME:
|
|
118
|
+
name_arguments = (
|
|
119
|
+
[string_arguments[1]]
|
|
120
|
+
if len(string_arguments) >= GETATTR_NAME_ARGUMENT_MINIMUM
|
|
121
|
+
else []
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
name_arguments = string_arguments
|
|
125
|
+
if not name_arguments:
|
|
126
|
+
should_suppress_check = True
|
|
127
|
+
continue
|
|
128
|
+
for each_name_argument in name_arguments:
|
|
129
|
+
literal_name = _string_constant_literal(each_name_argument)
|
|
130
|
+
if literal_name is None:
|
|
131
|
+
should_suppress_check = True
|
|
132
|
+
else:
|
|
133
|
+
literal_names.add(literal_name)
|
|
134
|
+
return literal_names, should_suppress_check
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _augmented_assignment_attribute_names(tree: ast.Module) -> set[str]:
|
|
138
|
+
"""Return attribute names that an augmented assignment reads before writing.
|
|
139
|
+
|
|
140
|
+
``obj.field += value`` parses to an ``ast.Attribute`` target in Store
|
|
141
|
+
context, yet ``+=`` reads the current attribute value before storing the
|
|
142
|
+
result, so the target attribute counts as a read.
|
|
143
|
+
"""
|
|
144
|
+
augmented_read_names: set[str] = set()
|
|
145
|
+
for each_node in ast.walk(tree):
|
|
146
|
+
if not isinstance(each_node, ast.AugAssign):
|
|
147
|
+
continue
|
|
148
|
+
if isinstance(each_node.target, ast.Attribute):
|
|
149
|
+
augmented_read_names.add(each_node.target.attr)
|
|
150
|
+
return augmented_read_names
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _attribute_read_names(tree: ast.Module) -> tuple[set[str], bool]:
|
|
154
|
+
"""Return literal attribute-name reads and whether the check must be suppressed.
|
|
155
|
+
|
|
156
|
+
Walks every attribute read (Load context) and every augmented-assignment
|
|
157
|
+
target in the module, contributing each attribute name as a read name —
|
|
158
|
+
``obj.field += value`` reads ``field`` before writing it. A read of
|
|
159
|
+
``__dict__`` consumes every field of an instance at once, so it cannot prove
|
|
160
|
+
any single field unread and the boolean signals the caller to suppress the
|
|
161
|
+
check for the whole file.
|
|
162
|
+
"""
|
|
163
|
+
read_names: set[str] = _augmented_assignment_attribute_names(tree)
|
|
164
|
+
should_suppress_check = False
|
|
165
|
+
for each_node in ast.walk(tree):
|
|
166
|
+
if not isinstance(each_node, ast.Attribute) or not isinstance(
|
|
167
|
+
each_node.ctx, ast.Load
|
|
168
|
+
):
|
|
169
|
+
continue
|
|
170
|
+
if each_node.attr == WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME:
|
|
171
|
+
should_suppress_check = True
|
|
172
|
+
continue
|
|
173
|
+
read_names.add(each_node.attr)
|
|
174
|
+
return read_names, should_suppress_check
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _match_pattern_attribute_names(tree: ast.Module) -> set[str]:
|
|
178
|
+
"""Return field names that a class pattern reads in a ``match`` statement.
|
|
179
|
+
|
|
180
|
+
A class pattern ``case Row(url=found):`` reads the ``url`` field through
|
|
181
|
+
``ast.MatchClass.kwd_attrs``, so each keyword-pattern attribute name counts
|
|
182
|
+
as a read name even though it never appears as an attribute access.
|
|
183
|
+
"""
|
|
184
|
+
matched_names: set[str] = set()
|
|
185
|
+
for each_node in ast.walk(tree):
|
|
186
|
+
if isinstance(each_node, ast.MatchClass):
|
|
187
|
+
matched_names.update(each_node.kwd_attrs)
|
|
188
|
+
return matched_names
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _exported_names(tree: ast.Module) -> set[str]:
|
|
192
|
+
"""Return names listed in a module-level ``__all__`` literal."""
|
|
193
|
+
exported: set[str] = set()
|
|
194
|
+
for each_node in tree.body:
|
|
195
|
+
if not isinstance(each_node, ast.Assign):
|
|
196
|
+
continue
|
|
197
|
+
targets_all = any(
|
|
198
|
+
isinstance(each_target, ast.Name) and each_target.id == "__all__"
|
|
199
|
+
for each_target in each_node.targets
|
|
200
|
+
)
|
|
201
|
+
if not targets_all:
|
|
202
|
+
continue
|
|
203
|
+
if isinstance(each_node.value, (ast.List, ast.Tuple, ast.Set)):
|
|
204
|
+
for each_element in each_node.value.elts:
|
|
205
|
+
literal_name = _string_constant_literal(each_element)
|
|
206
|
+
if literal_name is not None:
|
|
207
|
+
exported.add(literal_name)
|
|
208
|
+
return exported
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _constructed_class_names(tree: ast.Module) -> set[str]:
|
|
212
|
+
"""Return names of classes instantiated by a direct call anywhere in the module."""
|
|
213
|
+
constructed: set[str] = set()
|
|
214
|
+
for each_node in ast.walk(tree):
|
|
215
|
+
if isinstance(each_node, ast.Call) and isinstance(each_node.func, ast.Name):
|
|
216
|
+
constructed.add(each_node.func.id)
|
|
217
|
+
return constructed
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _is_whole_instance_stringify_call(node: ast.AST) -> bool:
|
|
221
|
+
"""Return whether a call stringifies a whole instance via ``str``/``repr``/``format``."""
|
|
222
|
+
if not isinstance(node, ast.Call):
|
|
223
|
+
return False
|
|
224
|
+
function_node = node.func
|
|
225
|
+
if isinstance(function_node, ast.Name):
|
|
226
|
+
return function_node.id in ALL_WHOLE_INSTANCE_STRINGIFY_NAMES
|
|
227
|
+
if isinstance(function_node, ast.Attribute):
|
|
228
|
+
return function_node.attr in ALL_WHOLE_INSTANCE_STRINGIFY_NAMES
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _uses_dataclass_dunder_field_reads(tree: ast.Module) -> bool:
|
|
233
|
+
"""Return whether the file relies on auto-generated dataclass dunders to read fields.
|
|
234
|
+
|
|
235
|
+
``@dataclass`` synthesizes ``__eq__`` (and ``__lt__``/``__hash__`` under
|
|
236
|
+
``order``/``frozen``) plus the always-present ``__repr__``, each of which
|
|
237
|
+
reads every field without naming it as an attribute access. Comparing two
|
|
238
|
+
instances, placing instances in a set or dict, formatted-string conversion,
|
|
239
|
+
or stringifying a whole instance therefore reads fields the static scan
|
|
240
|
+
cannot otherwise observe, so the check is suppressed for the whole file.
|
|
241
|
+
"""
|
|
242
|
+
for each_node in ast.walk(tree):
|
|
243
|
+
if isinstance(each_node, ast.Compare):
|
|
244
|
+
return True
|
|
245
|
+
if isinstance(each_node, (ast.Set, ast.SetComp, ast.Dict, ast.DictComp)):
|
|
246
|
+
return True
|
|
247
|
+
if isinstance(each_node, ast.FormattedValue):
|
|
248
|
+
return True
|
|
249
|
+
if _is_whole_instance_stringify_call(each_node):
|
|
250
|
+
return True
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def check_dead_dataclass_fields(
|
|
255
|
+
content: str, file_path: str, full_file_content: str | None = None
|
|
256
|
+
) -> list[str]:
|
|
257
|
+
"""Flag a @dataclass field that the same file constructs but never reads.
|
|
258
|
+
|
|
259
|
+
A field is dead when its dataclass is instantiated somewhere in the file
|
|
260
|
+
(so the class is live), the field name never appears as an attribute read,
|
|
261
|
+
an augmented-assignment target, a class-pattern keyword, or a literal
|
|
262
|
+
``getattr``/``attrgetter`` access anywhere in the file, and the file contains
|
|
263
|
+
no non-literal dynamic access, reflective whole-instance consumer
|
|
264
|
+
(``asdict``, ``astuple``, ``fields``, ``replace``, ``vars``), ``__dict__``
|
|
265
|
+
read, or auto-generated dataclass dunder field read (comparison, set/dict
|
|
266
|
+
membership, or whole-instance stringification) that could read it
|
|
267
|
+
indirectly. Whole-file analysis runs against ``full_file_content`` when
|
|
268
|
+
supplied so an Edit fragment is judged against the reconstructed post-edit
|
|
269
|
+
file.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
content: The new content under validation (Edit fragment or whole file).
|
|
273
|
+
file_path: The destination path, used for the test/registry exemptions.
|
|
274
|
+
full_file_content: The reconstructed post-edit whole-file content for an
|
|
275
|
+
Edit, or None for a Write where ``content`` is already the whole file.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
One violation message per dead dataclass field, capped at the configured
|
|
279
|
+
maximum.
|
|
280
|
+
"""
|
|
281
|
+
if is_test_file(file_path):
|
|
282
|
+
return []
|
|
283
|
+
if is_migration_file(file_path):
|
|
284
|
+
return []
|
|
285
|
+
effective_content = content if full_file_content is None else full_file_content
|
|
286
|
+
try:
|
|
287
|
+
tree = ast.parse(effective_content)
|
|
288
|
+
except SyntaxError:
|
|
289
|
+
return []
|
|
290
|
+
if _uses_dataclass_dunder_field_reads(tree):
|
|
291
|
+
return []
|
|
292
|
+
dynamic_literal_names, dynamic_access_suppresses_check = _dynamic_access_names(tree)
|
|
293
|
+
attribute_read_names, instance_dict_suppresses_check = _attribute_read_names(tree)
|
|
294
|
+
if dynamic_access_suppresses_check or instance_dict_suppresses_check:
|
|
295
|
+
return []
|
|
296
|
+
read_names = (
|
|
297
|
+
attribute_read_names
|
|
298
|
+
| dynamic_literal_names
|
|
299
|
+
| _match_pattern_attribute_names(tree)
|
|
300
|
+
| _exported_names(tree)
|
|
301
|
+
)
|
|
302
|
+
constructed_class_names = _constructed_class_names(tree)
|
|
303
|
+
issues: list[str] = []
|
|
304
|
+
for each_node in ast.walk(tree):
|
|
305
|
+
if not isinstance(each_node, ast.ClassDef) or not _is_dataclass(each_node):
|
|
306
|
+
continue
|
|
307
|
+
if each_node.name not in constructed_class_names:
|
|
308
|
+
continue
|
|
309
|
+
for each_field_definition in _dataclass_field_definitions(each_node):
|
|
310
|
+
field_name, field_line = each_field_definition
|
|
311
|
+
if field_name in read_names:
|
|
312
|
+
continue
|
|
313
|
+
issues.append(
|
|
314
|
+
f"Line {field_line}: dataclass field {field_name!r} on {each_node.name}"
|
|
315
|
+
f" - {DEAD_DATACLASS_FIELD_GUIDANCE}"
|
|
316
|
+
)
|
|
317
|
+
if len(issues) >= MAX_DEAD_DATACLASS_FIELD_ISSUES:
|
|
318
|
+
return issues
|
|
319
|
+
return issues
|