claude-dev-env 1.58.0 → 1.60.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-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- 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_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -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_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -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_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -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_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -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/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -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
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Dead module-level constant check for dedicated constants modules.
|
|
2
|
+
|
|
3
|
+
A constants module (`*_constants.py`, or any module under a ``config/``
|
|
4
|
+
directory) exists to export named values to importer modules elsewhere in the
|
|
5
|
+
project, so a constant defined there is never proven dead by a single-file scan
|
|
6
|
+
alone. This check resolves the enclosing package tree — the scan root — and
|
|
7
|
+
flags an UPPER_SNAKE constant defined in the written module whose name appears
|
|
8
|
+
in no ``.py`` module anywhere under that root: not as an imported name, not as a
|
|
9
|
+
read, not as a re-export. That is the ``MEDIUM_TEXT``-style dead constant the
|
|
10
|
+
CODE_RULES §9.8 dead-code rule targets, caught at Write/Edit time before the
|
|
11
|
+
unused constant lands.
|
|
12
|
+
|
|
13
|
+
The scan is deliberately conservative to keep false positives near zero:
|
|
14
|
+
|
|
15
|
+
- Only dedicated constants modules participate; ordinary production modules,
|
|
16
|
+
whose file-global constants are governed by the use-count rule, are skipped.
|
|
17
|
+
- A module declaring ``__all__`` is skipped: the author has named its export
|
|
18
|
+
surface explicitly, so a name listed there is live by declaration and a name
|
|
19
|
+
absent there is the author's stated intent, neither of which this check second
|
|
20
|
+
guesses.
|
|
21
|
+
- A constant is live when its name appears anywhere under the scan root —
|
|
22
|
+
imported, read, listed in ``__all__``, or referenced in a string annotation —
|
|
23
|
+
in any ``.py`` module, including the constants module itself.
|
|
24
|
+
- Test modules under the scan root still count as references, so a constant used
|
|
25
|
+
only by a test stays live.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
34
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
35
|
+
if _blocking_directory not in sys.path:
|
|
36
|
+
sys.path.insert(0, _blocking_directory)
|
|
37
|
+
if _hooks_directory not in sys.path:
|
|
38
|
+
sys.path.insert(0, _hooks_directory)
|
|
39
|
+
|
|
40
|
+
from code_rules_shared import ( # noqa: E402
|
|
41
|
+
is_migration_file,
|
|
42
|
+
is_test_file,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
from hooks_constants.dead_module_constant_constants import ( # noqa: E402
|
|
46
|
+
CONFIG_DIRECTORY_SEGMENT,
|
|
47
|
+
CONSTANTS_MODULE_SUFFIX,
|
|
48
|
+
DEAD_MODULE_CONSTANT_GUIDANCE,
|
|
49
|
+
DUNDER_ALL_NAME,
|
|
50
|
+
DUNDER_INIT_FILENAME,
|
|
51
|
+
MAX_DEAD_MODULE_CONSTANT_ISSUES,
|
|
52
|
+
MAX_SCAN_ROOT_FILE_COUNT,
|
|
53
|
+
MINIMUM_UPPER_SNAKE_LENGTH,
|
|
54
|
+
PYTHON_SOURCE_SUFFIX,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_dedicated_constants_module(file_path: str) -> bool:
|
|
59
|
+
"""Return whether a path is a dedicated constants module.
|
|
60
|
+
|
|
61
|
+
A dedicated constants module is one whose filename ends in
|
|
62
|
+
``_constants.py`` or whose path includes a ``config`` directory segment.
|
|
63
|
+
These modules export named values to importers, so their constants need a
|
|
64
|
+
cross-module scan to judge liveness.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
file_path: The destination path of the write.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True for a constants-suffixed module or a module under ``config/``.
|
|
71
|
+
"""
|
|
72
|
+
normalized_path = file_path.replace("\\", "/").lower()
|
|
73
|
+
if normalized_path.endswith(CONSTANTS_MODULE_SUFFIX):
|
|
74
|
+
return True
|
|
75
|
+
path_segments = normalized_path.split("/")
|
|
76
|
+
return CONFIG_DIRECTORY_SEGMENT in path_segments[:-1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_upper_snake_name(name: str) -> bool:
|
|
80
|
+
"""Return whether a name is an UPPER_SNAKE_CASE constant identifier."""
|
|
81
|
+
if len(name) < MINIMUM_UPPER_SNAKE_LENGTH:
|
|
82
|
+
return False
|
|
83
|
+
if not name.replace("_", "").isalnum():
|
|
84
|
+
return False
|
|
85
|
+
return name == name.upper() and any(each_char.isalpha() for each_char in name)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _module_constant_definitions(tree: ast.Module) -> list[tuple[str, int]]:
|
|
89
|
+
"""Return (name, line) for each module-scope UPPER_SNAKE constant assignment.
|
|
90
|
+
|
|
91
|
+
Both plain assignments (``NAME = value``) and annotated assignments
|
|
92
|
+
(``NAME: type = value``) at module scope are collected. A name bound more
|
|
93
|
+
than once keeps the line of its first binding.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
tree: The parsed constants module.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
One (name, line) pair per distinct module-scope constant, in source
|
|
100
|
+
order.
|
|
101
|
+
"""
|
|
102
|
+
line_by_name: dict[str, int] = {}
|
|
103
|
+
for each_statement in tree.body:
|
|
104
|
+
targets: list[ast.expr] = []
|
|
105
|
+
if isinstance(each_statement, ast.Assign):
|
|
106
|
+
targets = list(each_statement.targets)
|
|
107
|
+
elif isinstance(each_statement, ast.AnnAssign) and each_statement.value is not None:
|
|
108
|
+
targets = [each_statement.target]
|
|
109
|
+
for each_target in targets:
|
|
110
|
+
if not isinstance(each_target, ast.Name):
|
|
111
|
+
continue
|
|
112
|
+
if not _is_upper_snake_name(each_target.id):
|
|
113
|
+
continue
|
|
114
|
+
if each_target.id not in line_by_name:
|
|
115
|
+
line_by_name[each_target.id] = each_statement.lineno
|
|
116
|
+
return list(line_by_name.items())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _statement_binds_dunder_all(statement: ast.stmt) -> bool:
|
|
120
|
+
"""Return whether a single statement assigns or annotates ``__all__``."""
|
|
121
|
+
if isinstance(statement, ast.Assign):
|
|
122
|
+
return any(
|
|
123
|
+
isinstance(each_target, ast.Name) and each_target.id == DUNDER_ALL_NAME
|
|
124
|
+
for each_target in statement.targets
|
|
125
|
+
)
|
|
126
|
+
return (
|
|
127
|
+
isinstance(statement, ast.AnnAssign)
|
|
128
|
+
and isinstance(statement.target, ast.Name)
|
|
129
|
+
and statement.target.id == DUNDER_ALL_NAME
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _module_declares_dunder_all(tree: ast.Module) -> bool:
|
|
134
|
+
"""Return whether the module body assigns or annotates ``__all__``."""
|
|
135
|
+
return any(_statement_binds_dunder_all(each_node) for each_node in tree.body)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _referenced_names_in_source(source: str, load_only: bool = False) -> set[str]:
|
|
139
|
+
"""Return every name a module references — imported, read, or re-exported.
|
|
140
|
+
|
|
141
|
+
Collects imported binding names, ``from`` import member names, name
|
|
142
|
+
references, attribute roots, and string literals (so a name listed in an
|
|
143
|
+
``__all__`` literal or named in a string annotation counts as a reference).
|
|
144
|
+
A module that fails to parse contributes no names. With ``load_only`` set,
|
|
145
|
+
only ``Load``-context names count, so a constant's own assignment target in
|
|
146
|
+
the module being judged does not count as a reference to itself.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
source: The full text of a ``.py`` module under the scan root.
|
|
150
|
+
load_only: When True, count only ``Load``-context name references,
|
|
151
|
+
excluding ``Store``/``Del`` targets. Used for the written constants
|
|
152
|
+
module so a definition is not mistaken for its own consumer.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The set of names the module references.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
tree = ast.parse(source)
|
|
159
|
+
except SyntaxError:
|
|
160
|
+
return set()
|
|
161
|
+
referenced_names: set[str] = set()
|
|
162
|
+
for each_node in ast.walk(tree):
|
|
163
|
+
if isinstance(each_node, ast.Name):
|
|
164
|
+
if load_only and not isinstance(each_node.ctx, ast.Load):
|
|
165
|
+
continue
|
|
166
|
+
referenced_names.add(each_node.id)
|
|
167
|
+
elif isinstance(each_node, ast.Import | ast.ImportFrom):
|
|
168
|
+
for each_alias in each_node.names:
|
|
169
|
+
referenced_names.add(each_alias.asname or each_alias.name)
|
|
170
|
+
referenced_names.add(each_alias.name)
|
|
171
|
+
elif isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
|
|
172
|
+
referenced_names.add(each_node.value)
|
|
173
|
+
return referenced_names
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _scan_root_for_constants_module(file_path: str) -> Path:
|
|
177
|
+
"""Return the directory tree to scan for references to the module's constants.
|
|
178
|
+
|
|
179
|
+
For a constants module inside a package subdirectory
|
|
180
|
+
(``pkg/foo_constants.py``), the scan root is the package's parent, so an
|
|
181
|
+
importer one directory up (``pkg/../consumer.py``) is in scope. For a
|
|
182
|
+
constants module at the top of a directory, the scan root is that directory.
|
|
183
|
+
A ``config/`` module's scan root is the parent of the ``config`` directory.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
file_path: The destination path of the write.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The absolute directory to scan recursively for references.
|
|
190
|
+
"""
|
|
191
|
+
written_path = Path(file_path).resolve()
|
|
192
|
+
enclosing_directory = written_path.parent
|
|
193
|
+
if enclosing_directory.name.lower() == CONFIG_DIRECTORY_SEGMENT:
|
|
194
|
+
return enclosing_directory.parent
|
|
195
|
+
if (enclosing_directory / DUNDER_INIT_FILENAME).is_file():
|
|
196
|
+
return enclosing_directory.parent
|
|
197
|
+
return enclosing_directory
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _all_referenced_names_under_root(
|
|
201
|
+
scan_root: Path,
|
|
202
|
+
written_path: Path,
|
|
203
|
+
written_content: str,
|
|
204
|
+
) -> tuple[set[str], bool]:
|
|
205
|
+
"""Return referenced names under the scan root and whether the file cap was hit.
|
|
206
|
+
|
|
207
|
+
The written module's on-disk text is replaced by ``written_content`` so the
|
|
208
|
+
post-edit view is judged, never the stale disk copy. Sibling modules are
|
|
209
|
+
read from disk. Reading stops after the configured file cap so a write under
|
|
210
|
+
an unexpectedly large tree cannot stall the hook; the boolean signals the
|
|
211
|
+
caller to treat that case as "cannot prove dead".
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
scan_root: The directory tree to scan.
|
|
215
|
+
written_path: The resolved path of the module being written.
|
|
216
|
+
written_content: The post-edit text of the written module.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
A (referenced_names, cap_was_hit) pair. The name set is the union across
|
|
220
|
+
every scanned module; cap_was_hit is True when the scan stopped at the
|
|
221
|
+
configured file cap before scanning the whole tree.
|
|
222
|
+
"""
|
|
223
|
+
all_referenced_names = _referenced_names_in_source(written_content, load_only=True)
|
|
224
|
+
written_path_key = os.path.normcase(str(written_path))
|
|
225
|
+
scanned_file_count = 1
|
|
226
|
+
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
227
|
+
if not each_path.is_file():
|
|
228
|
+
continue
|
|
229
|
+
if os.path.normcase(str(each_path.resolve())) == written_path_key:
|
|
230
|
+
continue
|
|
231
|
+
scanned_file_count += 1
|
|
232
|
+
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
233
|
+
return all_referenced_names, True
|
|
234
|
+
try:
|
|
235
|
+
sibling_source = each_path.read_text(encoding="utf-8")
|
|
236
|
+
except (OSError, UnicodeDecodeError):
|
|
237
|
+
continue
|
|
238
|
+
all_referenced_names |= _referenced_names_in_source(sibling_source)
|
|
239
|
+
return all_referenced_names, False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _module_is_exempt_from_constant_check(file_path: str) -> bool:
|
|
243
|
+
"""Return whether a path is exempt from the dead module-constant check.
|
|
244
|
+
|
|
245
|
+
Test modules and migration modules are exempt, and any module that is not a
|
|
246
|
+
dedicated constants module is out of scope because its file-global constants
|
|
247
|
+
are governed by the use-count rule instead.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
file_path: The destination path of the write.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True when the dead module-constant check must not run on this path.
|
|
254
|
+
"""
|
|
255
|
+
if is_test_file(file_path):
|
|
256
|
+
return True
|
|
257
|
+
if is_migration_file(file_path):
|
|
258
|
+
return True
|
|
259
|
+
return not _is_dedicated_constants_module(file_path)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def check_dead_module_constants(
|
|
263
|
+
content: str,
|
|
264
|
+
file_path: str,
|
|
265
|
+
full_file_content: str | None = None,
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
"""Flag an UPPER_SNAKE constant in a constants module read by no module.
|
|
268
|
+
|
|
269
|
+
Runs only on a dedicated constants module (``*_constants.py`` or a module
|
|
270
|
+
under ``config/``); every other production module's file-global constants
|
|
271
|
+
are governed by the use-count rule instead. A constant is dead when its name
|
|
272
|
+
appears in no ``.py`` module anywhere under the enclosing package tree — not
|
|
273
|
+
imported, not read, not listed in an ``__all__`` literal, not named in a
|
|
274
|
+
string annotation. A module declaring its own ``__all__`` is skipped so the
|
|
275
|
+
author's explicit export surface is never second-guessed. Whole-file
|
|
276
|
+
analysis runs against ``full_file_content`` when supplied so an Edit fragment
|
|
277
|
+
is judged against the reconstructed post-edit file.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
content: The new content under validation (Edit fragment or whole file).
|
|
281
|
+
file_path: The destination path, used for the constants-module gate and
|
|
282
|
+
the test/registry exemptions.
|
|
283
|
+
full_file_content: The reconstructed post-edit whole-file content for an
|
|
284
|
+
Edit, or None for a Write where ``content`` is already the whole file.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
One violation message per dead module-level constant, capped at the
|
|
288
|
+
configured maximum.
|
|
289
|
+
"""
|
|
290
|
+
if _module_is_exempt_from_constant_check(file_path):
|
|
291
|
+
return []
|
|
292
|
+
effective_content = content if full_file_content is None else full_file_content
|
|
293
|
+
try:
|
|
294
|
+
tree = ast.parse(effective_content)
|
|
295
|
+
except SyntaxError:
|
|
296
|
+
return []
|
|
297
|
+
if _module_declares_dunder_all(tree):
|
|
298
|
+
return []
|
|
299
|
+
constant_definitions = _module_constant_definitions(tree)
|
|
300
|
+
if not constant_definitions:
|
|
301
|
+
return []
|
|
302
|
+
scan_root = _scan_root_for_constants_module(file_path)
|
|
303
|
+
written_path = Path(file_path).resolve()
|
|
304
|
+
all_referenced_names, cap_was_hit = _all_referenced_names_under_root(
|
|
305
|
+
scan_root,
|
|
306
|
+
written_path,
|
|
307
|
+
effective_content,
|
|
308
|
+
)
|
|
309
|
+
if cap_was_hit:
|
|
310
|
+
return []
|
|
311
|
+
issues: list[str] = []
|
|
312
|
+
for each_name, each_line in constant_definitions:
|
|
313
|
+
if each_name in all_referenced_names:
|
|
314
|
+
continue
|
|
315
|
+
issues.append(
|
|
316
|
+
f"Line {each_line}: module-level constant {each_name!r}"
|
|
317
|
+
f" - {DEAD_MODULE_CONSTANT_GUIDANCE}"
|
|
318
|
+
)
|
|
319
|
+
if len(issues) >= MAX_DEAD_MODULE_CONSTANT_ISSUES:
|
|
320
|
+
break
|
|
321
|
+
return issues
|