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,568 +0,0 @@
|
|
|
1
|
-
"""Dead config-dataclass field check: cross-module scan for config-like @dataclass fields.
|
|
2
|
-
|
|
3
|
-
A config-like ``@dataclass`` — any class whose name ends in ``"Config"`` or
|
|
4
|
-
``"Selectors"`` — is defined in one module but constructed and consumed in
|
|
5
|
-
others, so the per-file dead-field check in
|
|
6
|
-
``code_rules_dead_dataclass_field`` cannot judge its fields — it skips any class
|
|
7
|
-
that is not constructed in the same file. This check resolves the enclosing
|
|
8
|
-
package tree — the scan root — and flags a config-like dataclass field whose
|
|
9
|
-
name appears as an attribute read (``obj.field``) in no production module
|
|
10
|
-
anywhere under that root. A selectors dataclass is the same shape as a config
|
|
11
|
-
dataclass: it is bound to a module-level singleton (``binary_selectors =
|
|
12
|
-
BinarySelectors()``) and its fields are read across files, so an unwired
|
|
13
|
-
selector field is caught the same way as a dead config field.
|
|
14
|
-
|
|
15
|
-
The scan is deliberately conservative to keep false positives near zero:
|
|
16
|
-
|
|
17
|
-
- Only ``@dataclass`` classes whose name ends in ``"Config"`` or ``"Selectors"``
|
|
18
|
-
participate; other dataclasses are covered by the per-file check.
|
|
19
|
-
- Test and migration files are exempt as write destinations, so a field added to
|
|
20
|
-
a config dataclass inside a test is never flagged.
|
|
21
|
-
- Production modules under the scan root are scanned for attribute reads; test
|
|
22
|
-
and migration modules are deliberately excluded so a field read only by test
|
|
23
|
-
code is still flagged as dead-in-production.
|
|
24
|
-
- Field reads are collected as ``ast.Attribute.attr`` values (``obj.field``),
|
|
25
|
-
augmented-assignment targets (``cfg.field += 1`` reads ``field`` before
|
|
26
|
-
writing it), string literals (covers ``getattr(obj, "field")``),
|
|
27
|
-
keyword-argument names on non-constructor calls (covers
|
|
28
|
-
``replace(cfg, debug_port=1)``), and match-pattern keyword attribute names
|
|
29
|
-
(``case Config(field=found)``). Two field-write forms are excluded because they
|
|
30
|
-
name a field without consuming it: a keyword that writes a field in a constructor
|
|
31
|
-
of a known config-like dataclass defined under the scan root
|
|
32
|
-
(``ThemeUpdateConfig(debug_port=1)``, excluded per keyword node so a same-named
|
|
33
|
-
keyword on a ``replace`` call stays a read, and a factory function whose name
|
|
34
|
-
merely ends in ``"Config"`` is not excluded), and a self-referential attribute
|
|
35
|
-
read inside a config-like field's own default-value expression in the class body
|
|
36
|
-
whose attribute name equals the field being defined
|
|
37
|
-
(``debug_port: int = source.debug_port``) — a field written only these ways and
|
|
38
|
-
read by no module is the dead-config case this check exists to catch. A default
|
|
39
|
-
that sources a differently-named field on another object
|
|
40
|
-
(``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
|
|
41
|
-
``base_timeout`` stays a live consumer. Plain
|
|
42
|
-
``ast.Name`` references are excluded — a local variable named ``debug_port`` is
|
|
43
|
-
not a read of ``config.debug_port``.
|
|
44
|
-
- A production module that reflectively reads a whole instance — a bare or
|
|
45
|
-
``dataclasses``-qualified call to ``asdict``, ``astuple``, ``fields``,
|
|
46
|
-
``replace``, or ``vars``, or a read of ``obj.__dict__`` — consumes every field
|
|
47
|
-
at once without naming any single field, so the check is suppressed for the
|
|
48
|
-
whole tree (returns ``[]``).
|
|
49
|
-
- A scan root whose total file count exceeds the configured cap cannot prove any
|
|
50
|
-
field dead, so the check returns ``[]`` on a cap hit.
|
|
51
|
-
- A field read only by a module outside the resolved scan root is treated as dead
|
|
52
|
-
— the same conservative scoping the dead-module-constant check accepts.
|
|
53
|
-
|
|
54
|
-
Unlike the per-file dead-dataclass-field check, this cross-module check does NOT
|
|
55
|
-
suppress on a dataclass-dunder whole-instance read — instance comparison
|
|
56
|
-
(``cfg == other``), set or dict membership, formatted-string conversion
|
|
57
|
-
(``f"{cfg}"``), or whole-instance stringification
|
|
58
|
-
(``str(cfg)``/``repr(cfg)``/``format(cfg)``). Those syntactic forms are not bound
|
|
59
|
-
to a config instance, and tree-wide one incidental match anywhere would disable
|
|
60
|
-
the check on any realistic package. The consequence is a documented, rare
|
|
61
|
-
limitation: a config-like field read ONLY via whole-instance dunder comparison or
|
|
62
|
-
stringification, and never read directly anywhere in production, may be flagged.
|
|
63
|
-
The augmented-assignment read mechanism (``cfg.field += 1`` reads ``field``
|
|
64
|
-
before writing it) is precise and remains a counted read.
|
|
65
|
-
"""
|
|
66
|
-
|
|
67
|
-
import ast
|
|
68
|
-
import os
|
|
69
|
-
import sys
|
|
70
|
-
from collections.abc import Iterator
|
|
71
|
-
from pathlib import Path
|
|
72
|
-
|
|
73
|
-
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
74
|
-
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
75
|
-
if _blocking_directory not in sys.path:
|
|
76
|
-
sys.path.insert(0, _blocking_directory)
|
|
77
|
-
if _hooks_directory not in sys.path:
|
|
78
|
-
sys.path.insert(0, _hooks_directory)
|
|
79
|
-
|
|
80
|
-
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
81
|
-
_augmented_assignment_attribute_names,
|
|
82
|
-
_dataclass_field_definitions,
|
|
83
|
-
_is_dataclass,
|
|
84
|
-
)
|
|
85
|
-
from code_rules_dead_module_constant import ( # noqa: E402
|
|
86
|
-
_scan_root_for_constants_module,
|
|
87
|
-
)
|
|
88
|
-
from code_rules_shared import ( # noqa: E402
|
|
89
|
-
is_migration_file,
|
|
90
|
-
is_test_file,
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
from hooks_constants.dead_config_field_constants import ( # noqa: E402
|
|
94
|
-
ALL_CONFIG_CLASS_NAME_SUFFIXES,
|
|
95
|
-
ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
|
|
96
|
-
DATACLASSES_MODULE_NAME,
|
|
97
|
-
DEAD_CONFIG_FIELD_GUIDANCE,
|
|
98
|
-
MAX_DEAD_CONFIG_FIELD_ISSUES,
|
|
99
|
-
MAX_SCAN_ROOT_FILE_COUNT,
|
|
100
|
-
PYTHON_SOURCE_SUFFIX,
|
|
101
|
-
WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _is_config_dataclass(class_node: ast.ClassDef) -> bool:
|
|
106
|
-
"""Return whether a class is a @dataclass whose name ends in a config-like suffix.
|
|
107
|
-
|
|
108
|
-
A config-like surface is a ``@dataclass`` whose name ends in ``"Config"`` or
|
|
109
|
-
``"Selectors"``. Both shapes are defined in one module, bound to a
|
|
110
|
-
module-level singleton, and read across files, so the per-file dead-field
|
|
111
|
-
check cannot judge their fields and the cross-module scan covers them here.
|
|
112
|
-
|
|
113
|
-
Args:
|
|
114
|
-
class_node: The class definition node to test.
|
|
115
|
-
|
|
116
|
-
Returns:
|
|
117
|
-
True when the class carries a ``@dataclass`` decorator and its name ends
|
|
118
|
-
in one of ``ALL_CONFIG_CLASS_NAME_SUFFIXES``.
|
|
119
|
-
"""
|
|
120
|
-
return _is_dataclass(class_node) and class_node.name.endswith(
|
|
121
|
-
ALL_CONFIG_CLASS_NAME_SUFFIXES
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _reads_whole_instance_reflectively(tree: ast.Module) -> bool:
|
|
126
|
-
"""Return whether a module consumes a whole instance via a reflective read.
|
|
127
|
-
|
|
128
|
-
Detects a bare call to any reflective whole-instance consumer (``asdict``,
|
|
129
|
-
``astuple``, ``fields``, ``replace``, ``vars`` imported from ``dataclasses``),
|
|
130
|
-
a ``dataclasses``-qualified call to the same consumers
|
|
131
|
-
(``dataclasses.asdict(cfg)``, ``dataclasses.replace(cfg, ...)``), and a read
|
|
132
|
-
of the ``__dict__`` attribute. The method-call form must be ``dataclasses``-
|
|
133
|
-
qualified — an unrelated ``"text".replace(...)`` or ``frame.fields(...)`` on
|
|
134
|
-
another object does not match. Each matched form reads every field of an
|
|
135
|
-
instance at once without naming any single field, so a module that uses one
|
|
136
|
-
cannot prove a config field unread.
|
|
137
|
-
|
|
138
|
-
Args:
|
|
139
|
-
tree: The parsed module to inspect.
|
|
140
|
-
|
|
141
|
-
Returns:
|
|
142
|
-
True when the module makes a bare or ``dataclasses``-qualified call to a
|
|
143
|
-
reflective whole-instance consumer, or reads ``obj.__dict__``.
|
|
144
|
-
"""
|
|
145
|
-
for each_node in ast.walk(tree):
|
|
146
|
-
if isinstance(each_node, ast.Attribute):
|
|
147
|
-
if each_node.attr == WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME:
|
|
148
|
-
return True
|
|
149
|
-
continue
|
|
150
|
-
if not isinstance(each_node, ast.Call):
|
|
151
|
-
continue
|
|
152
|
-
function_node = each_node.func
|
|
153
|
-
if isinstance(function_node, ast.Name) and function_node.id in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES:
|
|
154
|
-
return True
|
|
155
|
-
if (
|
|
156
|
-
isinstance(function_node, ast.Attribute)
|
|
157
|
-
and isinstance(function_node.value, ast.Name)
|
|
158
|
-
and function_node.value.id == DATACLASSES_MODULE_NAME
|
|
159
|
-
and function_node.attr in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES
|
|
160
|
-
):
|
|
161
|
-
return True
|
|
162
|
-
return False
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def _config_dataclass_names(tree: ast.Module) -> set[str]:
|
|
166
|
-
"""Return names of config-like ``@dataclass`` classes defined in a module.
|
|
167
|
-
|
|
168
|
-
A constructor-keyword exclusion fires only for a callee that names a genuine
|
|
169
|
-
config-like dataclass, so the caller first gathers the config-like dataclass
|
|
170
|
-
names a module defines, then unions those names across the scan root.
|
|
171
|
-
|
|
172
|
-
Args:
|
|
173
|
-
tree: The parsed module to inspect.
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
Every class name in the module that is a ``@dataclass`` whose name ends in
|
|
177
|
-
one of ``ALL_CONFIG_CLASS_NAME_SUFFIXES``.
|
|
178
|
-
"""
|
|
179
|
-
config_dataclass_names: set[str] = set()
|
|
180
|
-
for each_node in ast.walk(tree):
|
|
181
|
-
if isinstance(each_node, ast.ClassDef) and _is_config_dataclass(each_node):
|
|
182
|
-
config_dataclass_names.add(each_node.name)
|
|
183
|
-
return config_dataclass_names
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def _call_constructs_config_class(
|
|
187
|
-
call_node: ast.Call, all_known_config_class_names: set[str]
|
|
188
|
-
) -> bool:
|
|
189
|
-
"""Return whether a call constructs a known config-like dataclass.
|
|
190
|
-
|
|
191
|
-
A call whose callee names a config-like dataclass defined under the scan root —
|
|
192
|
-
``AppInfoConfig(...)`` or a qualified ``module.AppInfoConfig(...)`` —
|
|
193
|
-
constructs the instance, and its keyword arguments write the named fields
|
|
194
|
-
rather than read them. A factory function whose name merely ends in
|
|
195
|
-
``"Config"`` (``getThemeConfig(...)``) is not a known config-like dataclass, so
|
|
196
|
-
its keyword arguments stay genuine reads.
|
|
197
|
-
|
|
198
|
-
Args:
|
|
199
|
-
call_node: The call expression to test.
|
|
200
|
-
all_known_config_class_names: Names of config-like dataclasses defined under
|
|
201
|
-
the scan root.
|
|
202
|
-
|
|
203
|
-
Returns:
|
|
204
|
-
True when the callee names a known config-like dataclass.
|
|
205
|
-
"""
|
|
206
|
-
callee_node = call_node.func
|
|
207
|
-
if isinstance(callee_node, ast.Name):
|
|
208
|
-
return callee_node.id in all_known_config_class_names
|
|
209
|
-
if isinstance(callee_node, ast.Attribute):
|
|
210
|
-
return callee_node.attr in all_known_config_class_names
|
|
211
|
-
return False
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _config_constructor_keyword_node_ids(
|
|
215
|
-
tree: ast.Module, all_known_config_class_names: set[str]
|
|
216
|
-
) -> set[int]:
|
|
217
|
-
"""Return ids of keyword nodes that write fields in a known config-like constructor.
|
|
218
|
-
|
|
219
|
-
A keyword in an ``AppInfoConfig(field=value)`` call sets ``field`` rather than
|
|
220
|
-
reading it, so its node id is collected for the caller to exclude. The
|
|
221
|
-
exclusion is keyed per keyword node, not by name, so a same-named keyword in a
|
|
222
|
-
``replace(cfg, field=value)`` call — which reuses a live instance and stays a
|
|
223
|
-
read — keeps its own distinct node and is not stripped.
|
|
224
|
-
|
|
225
|
-
Args:
|
|
226
|
-
tree: The parsed module to inspect.
|
|
227
|
-
all_known_config_class_names: Names of config-like dataclasses defined under
|
|
228
|
-
the scan root.
|
|
229
|
-
|
|
230
|
-
Returns:
|
|
231
|
-
The ``id()`` of every keyword node passed to a known config-like
|
|
232
|
-
constructor call.
|
|
233
|
-
"""
|
|
234
|
-
constructor_keyword_node_ids: set[int] = set()
|
|
235
|
-
for each_node in ast.walk(tree):
|
|
236
|
-
if not isinstance(each_node, ast.Call):
|
|
237
|
-
continue
|
|
238
|
-
if not _call_constructs_config_class(each_node, all_known_config_class_names):
|
|
239
|
-
continue
|
|
240
|
-
for each_keyword in each_node.keywords:
|
|
241
|
-
if each_keyword.arg is not None:
|
|
242
|
-
constructor_keyword_node_ids.add(id(each_keyword))
|
|
243
|
-
return constructor_keyword_node_ids
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _self_referential_default_attribute_node_ids(
|
|
247
|
-
field_name: str, default_value: ast.expr
|
|
248
|
-
) -> set[int]:
|
|
249
|
-
"""Return ids of attribute reads in a default whose name equals the field.
|
|
250
|
-
|
|
251
|
-
Walks a config-like field's default-value expression and collects the ``id()``
|
|
252
|
-
of each ``ast.Attribute`` read whose ``.attr`` equals ``field_name``. Such a
|
|
253
|
-
read — ``sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms``
|
|
254
|
-
— names the field being defined inside the class body, so it is not a consumer
|
|
255
|
-
of the field. An attribute read of a differently-named field
|
|
256
|
-
(``timeout_ms: int = other_config.base_timeout``) is a genuine consumer and is
|
|
257
|
-
left out of the returned set.
|
|
258
|
-
|
|
259
|
-
Args:
|
|
260
|
-
field_name: The name of the field being defined.
|
|
261
|
-
default_value: The default-value expression of that field.
|
|
262
|
-
|
|
263
|
-
Returns:
|
|
264
|
-
The ``id()`` of every self-referential attribute read inside the
|
|
265
|
-
default-value expression.
|
|
266
|
-
"""
|
|
267
|
-
self_referential_node_ids: set[int] = set()
|
|
268
|
-
for each_inner_node in ast.walk(default_value):
|
|
269
|
-
if isinstance(each_inner_node, ast.Attribute) and each_inner_node.attr == field_name:
|
|
270
|
-
self_referential_node_ids.add(id(each_inner_node))
|
|
271
|
-
return self_referential_node_ids
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _config_field_default_value_nodes(tree: ast.Module) -> set[int]:
|
|
275
|
-
"""Return ids of self-referential attribute reads in config-like field defaults.
|
|
276
|
-
|
|
277
|
-
A field default such as ``sound_upload_timeout_ms: int =``
|
|
278
|
-
``submission_timing.sound_upload_timeout_ms`` is an attribute read whose name
|
|
279
|
-
matches the field being defined. That self-referential read inside the
|
|
280
|
-
config-like class body is not a consumer of the field, so its node id is
|
|
281
|
-
collected here for the caller to exclude from the attribute-read set. Only the
|
|
282
|
-
attribute read whose ``.attr`` equals the field name is collected; a default
|
|
283
|
-
that sources a differently-named field on another object
|
|
284
|
-
(``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
|
|
285
|
-
``base_timeout`` stays a live consumer.
|
|
286
|
-
|
|
287
|
-
Args:
|
|
288
|
-
tree: The parsed module to inspect.
|
|
289
|
-
|
|
290
|
-
Returns:
|
|
291
|
-
The ``id()`` of every self-referential attribute read within the
|
|
292
|
-
default-value expression of a field declared in a config-like dataclass
|
|
293
|
-
body.
|
|
294
|
-
"""
|
|
295
|
-
default_value_node_ids: set[int] = set()
|
|
296
|
-
for each_node in ast.walk(tree):
|
|
297
|
-
if not isinstance(each_node, ast.ClassDef) or not _is_config_dataclass(each_node):
|
|
298
|
-
continue
|
|
299
|
-
for each_statement in each_node.body:
|
|
300
|
-
if not isinstance(each_statement, ast.AnnAssign):
|
|
301
|
-
continue
|
|
302
|
-
if each_statement.value is None:
|
|
303
|
-
continue
|
|
304
|
-
if not isinstance(each_statement.target, ast.Name):
|
|
305
|
-
continue
|
|
306
|
-
default_value_node_ids |= _self_referential_default_attribute_node_ids(
|
|
307
|
-
each_statement.target.id, each_statement.value
|
|
308
|
-
)
|
|
309
|
-
return default_value_node_ids
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def _attribute_read_names_in_tree(
|
|
313
|
-
tree: ast.Module, all_known_config_class_names: set[str]
|
|
314
|
-
) -> tuple[set[str], bool]:
|
|
315
|
-
"""Return attribute names read in a parsed module and a suppression flag.
|
|
316
|
-
|
|
317
|
-
Collects attribute names via five mechanisms: ``ast.Attribute.attr`` values
|
|
318
|
-
in Load context, augmented-assignment targets (so ``cfg.debug_port += 1``
|
|
319
|
-
contributes ``"debug_port"`` because ``+=`` reads the attribute before
|
|
320
|
-
writing it), string literals (so ``getattr(obj, "field")`` contributes
|
|
321
|
-
``"field"``), keyword-argument names (so ``replace(cfg, debug_port=1)``
|
|
322
|
-
contributes ``"debug_port"``), and ``ast.MatchClass.kwd_attrs`` names (so
|
|
323
|
-
``case Config(field=x)`` contributes ``"field"``). Two field-write forms are
|
|
324
|
-
excluded because they name a field without consuming it: a keyword that writes
|
|
325
|
-
a field in a known config-like constructor (``AppInfoConfig(field=value)``,
|
|
326
|
-
excluded per keyword node so a same-named ``replace`` keyword stays a read),
|
|
327
|
-
and a self-referential attribute read inside a config-like dataclass field's
|
|
328
|
-
own default-value expression (``field: int = source.field`` in the class body,
|
|
329
|
-
excluded only when the read name equals the field name) — counting either
|
|
330
|
-
would hide a field that is written but read by no module. A keyword passed to a
|
|
331
|
-
factory function whose name merely ends in ``"Config"`` is not a known
|
|
332
|
-
config-like constructor, so it stays a read. The boolean reports whether the module
|
|
333
|
-
suppresses the dead-field check, which it does only when it reflectively reads a
|
|
334
|
-
whole instance — a bare or ``dataclasses``-qualified
|
|
335
|
-
``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
|
|
336
|
-
``obj.__dict__`` read — because that pattern reads every field at once without
|
|
337
|
-
naming any single field, so the caller treats it as "cannot prove any field
|
|
338
|
-
dead".
|
|
339
|
-
|
|
340
|
-
Args:
|
|
341
|
-
tree: The parsed module to inspect.
|
|
342
|
-
all_known_config_class_names: Names of config-like dataclasses defined under
|
|
343
|
-
the scan root, used to scope the constructor-keyword exclusion to
|
|
344
|
-
genuine config-like constructors.
|
|
345
|
-
|
|
346
|
-
Returns:
|
|
347
|
-
A (read_names, suppresses_dead_field_check) pair. The name set is every
|
|
348
|
-
attribute name the module reads via the mechanisms above, excluding known
|
|
349
|
-
config-like constructor keyword nodes and self-referential config-like-field
|
|
350
|
-
default-value attribute reads; suppresses_dead_field_check is True only when
|
|
351
|
-
a reflective whole-instance read is present.
|
|
352
|
-
"""
|
|
353
|
-
all_read_names: set[str] = _augmented_assignment_attribute_names(tree)
|
|
354
|
-
config_constructor_keyword_node_ids = _config_constructor_keyword_node_ids(
|
|
355
|
-
tree, all_known_config_class_names
|
|
356
|
-
)
|
|
357
|
-
config_field_default_node_ids = _config_field_default_value_nodes(tree)
|
|
358
|
-
for each_node in ast.walk(tree):
|
|
359
|
-
if (
|
|
360
|
-
isinstance(each_node, ast.Attribute)
|
|
361
|
-
and isinstance(each_node.ctx, ast.Load)
|
|
362
|
-
and id(each_node) not in config_field_default_node_ids
|
|
363
|
-
):
|
|
364
|
-
all_read_names.add(each_node.attr)
|
|
365
|
-
elif isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
|
|
366
|
-
all_read_names.add(each_node.value)
|
|
367
|
-
elif isinstance(each_node, ast.MatchClass):
|
|
368
|
-
all_read_names.update(each_node.kwd_attrs)
|
|
369
|
-
elif (
|
|
370
|
-
isinstance(each_node, ast.keyword)
|
|
371
|
-
and each_node.arg is not None
|
|
372
|
-
and id(each_node) not in config_constructor_keyword_node_ids
|
|
373
|
-
):
|
|
374
|
-
all_read_names.add(each_node.arg)
|
|
375
|
-
suppresses_dead_field_check = _reads_whole_instance_reflectively(tree)
|
|
376
|
-
return all_read_names, suppresses_dead_field_check
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
def _iter_production_module_sources(
|
|
380
|
-
scan_root: Path,
|
|
381
|
-
written_path: Path,
|
|
382
|
-
written_content: str,
|
|
383
|
-
) -> Iterator[str | None]:
|
|
384
|
-
"""Yield the source of each production module under the scan root.
|
|
385
|
-
|
|
386
|
-
Yields ``written_content`` for the written module so the current edit is
|
|
387
|
-
included, then each sibling production module's source (excluding test and
|
|
388
|
-
migration files). A sibling whose text cannot be read is skipped. A single
|
|
389
|
-
``None`` is yielded when the production module count exceeds the configured
|
|
390
|
-
file cap, signalling the caller that no field can be proven dead.
|
|
391
|
-
|
|
392
|
-
Args:
|
|
393
|
-
scan_root: The directory tree to scan.
|
|
394
|
-
written_path: The resolved path of the module being written.
|
|
395
|
-
written_content: The post-edit text of the written module.
|
|
396
|
-
|
|
397
|
-
Yields:
|
|
398
|
-
Each production module's source text, or a single ``None`` on a cap hit.
|
|
399
|
-
"""
|
|
400
|
-
yield written_content
|
|
401
|
-
written_path_key = os.path.normcase(str(written_path))
|
|
402
|
-
scanned_file_count = 1
|
|
403
|
-
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
404
|
-
if not each_path.is_file():
|
|
405
|
-
continue
|
|
406
|
-
if os.path.normcase(str(each_path.resolve())) == written_path_key:
|
|
407
|
-
continue
|
|
408
|
-
if is_test_file(str(each_path)):
|
|
409
|
-
continue
|
|
410
|
-
if is_migration_file(str(each_path)):
|
|
411
|
-
continue
|
|
412
|
-
scanned_file_count += 1
|
|
413
|
-
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
414
|
-
yield None
|
|
415
|
-
return
|
|
416
|
-
try:
|
|
417
|
-
yield each_path.read_text(encoding="utf-8")
|
|
418
|
-
except (OSError, UnicodeDecodeError):
|
|
419
|
-
continue
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
def _all_production_read_names_under_root(
|
|
423
|
-
scan_root: Path,
|
|
424
|
-
written_path: Path,
|
|
425
|
-
written_content: str,
|
|
426
|
-
) -> tuple[set[str], bool, bool]:
|
|
427
|
-
"""Return read names, a cap-hit flag, and a suppression flag for the tree.
|
|
428
|
-
|
|
429
|
-
Reads and AST-parses every production ``.py`` module under ``scan_root``
|
|
430
|
-
(excluding test and migration files) at most once: the module sources are
|
|
431
|
-
materialized once, a first pass over the cached sources gathers every
|
|
432
|
-
config-like dataclass name defined under the root so the constructor-keyword
|
|
433
|
-
exclusion fires only for a genuine config-like constructor, and a second pass over
|
|
434
|
-
the same cached sources collects attribute reads. The written module's
|
|
435
|
-
post-edit content replaces its on-disk text so the current edit is included.
|
|
436
|
-
Scanning stops at the configured file cap. A module that reflectively reads a
|
|
437
|
-
whole instance — a bare or ``dataclasses``-qualified
|
|
438
|
-
``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
|
|
439
|
-
``obj.__dict__`` read — sets the suppression flag, signalling the caller that
|
|
440
|
-
no field can be proven dead.
|
|
441
|
-
|
|
442
|
-
Args:
|
|
443
|
-
scan_root: The directory tree to scan.
|
|
444
|
-
written_path: The resolved path of the module being written.
|
|
445
|
-
written_content: The post-edit text of the written module.
|
|
446
|
-
|
|
447
|
-
Returns:
|
|
448
|
-
A (read_names, cap_was_hit, suppresses_dead_field_check) triple. The name
|
|
449
|
-
set is the union of attribute reads across every scanned production
|
|
450
|
-
module; cap_was_hit is True when the scan stopped at the configured file
|
|
451
|
-
cap before finishing the tree; suppresses_dead_field_check is True when
|
|
452
|
-
any scanned module reflectively reads a whole instance.
|
|
453
|
-
"""
|
|
454
|
-
all_module_sources = list(
|
|
455
|
-
_iter_production_module_sources(scan_root, written_path, written_content)
|
|
456
|
-
)
|
|
457
|
-
if None in all_module_sources:
|
|
458
|
-
return set(), True, False
|
|
459
|
-
all_production_trees: list[ast.Module] = []
|
|
460
|
-
for each_source in all_module_sources:
|
|
461
|
-
if each_source is None:
|
|
462
|
-
continue
|
|
463
|
-
try:
|
|
464
|
-
all_production_trees.append(ast.parse(each_source))
|
|
465
|
-
except SyntaxError:
|
|
466
|
-
continue
|
|
467
|
-
all_known_config_class_names: set[str] = set()
|
|
468
|
-
for each_tree in all_production_trees:
|
|
469
|
-
all_known_config_class_names |= _config_dataclass_names(each_tree)
|
|
470
|
-
all_read_names: set[str] = set()
|
|
471
|
-
suppresses_dead_field_check = False
|
|
472
|
-
for each_tree in all_production_trees:
|
|
473
|
-
module_read_names, module_suppresses_dead_field_check = _attribute_read_names_in_tree(
|
|
474
|
-
each_tree, all_known_config_class_names
|
|
475
|
-
)
|
|
476
|
-
all_read_names |= module_read_names
|
|
477
|
-
suppresses_dead_field_check = (
|
|
478
|
-
suppresses_dead_field_check or module_suppresses_dead_field_check
|
|
479
|
-
)
|
|
480
|
-
return all_read_names, False, suppresses_dead_field_check
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def check_dead_config_dataclass_fields(
|
|
484
|
-
content: str, file_path: str, full_file_content: str | None = None
|
|
485
|
-
) -> list[str]:
|
|
486
|
-
"""Flag a config-like @dataclass field read by no production module in the package tree.
|
|
487
|
-
|
|
488
|
-
Runs a cross-module scan restricted to ``@dataclass`` classes whose name ends
|
|
489
|
-
in ``"Config"`` or ``"Selectors"`` — both are config-like surfaces bound to a
|
|
490
|
-
module-level singleton and read across files. For each such dataclass in the
|
|
491
|
-
written file, every
|
|
492
|
-
instance field whose name does not appear as an attribute read (``obj.field``),
|
|
493
|
-
augmented-assignment target (``cfg.field += 1``), string literal,
|
|
494
|
-
non-constructor keyword-argument name (``replace`` keyword), or match-pattern
|
|
495
|
-
keyword attribute in any production module under the enclosing scan root is
|
|
496
|
-
flagged as dead. A keyword that writes a field in a config-like constructor
|
|
497
|
-
(``ThemeUpdateConfig(debug_port=1)``) is a write, not a read, so it does not
|
|
498
|
-
clear a field — a field set by a constructor keyword and read by no module is
|
|
499
|
-
flagged. When any production module under the scan root reflectively
|
|
500
|
-
reads a whole instance — a bare or ``dataclasses``-qualified call to
|
|
501
|
-
``asdict``, ``astuple``, ``fields``, ``replace``, or ``vars``, or a read of
|
|
502
|
-
``obj.__dict__`` — the check is suppressed for the whole tree and returns
|
|
503
|
-
``[]``, since that pattern reads every field at once without naming any single
|
|
504
|
-
field. Test and
|
|
505
|
-
migration files are exempt as write destinations; production modules under the
|
|
506
|
-
scan root are scanned while test and migration modules in the tree are excluded
|
|
507
|
-
so fields read only by test code are still flagged as dead-in-production.
|
|
508
|
-
Whole-file analysis runs against ``full_file_content`` when supplied so an Edit
|
|
509
|
-
fragment is judged against the reconstructed post-edit file. A scan root
|
|
510
|
-
exceeding the file cap returns ``[]`` (cannot prove dead). The scan root is
|
|
511
|
-
resolved the same way as the dead-module-constant check: a ``config/`` module's
|
|
512
|
-
root is its parent directory, a module in a package directory's root is the
|
|
513
|
-
package's parent, and a top-level module's root is its enclosing directory.
|
|
514
|
-
|
|
515
|
-
Args:
|
|
516
|
-
content: The new content under validation (Edit fragment or whole file).
|
|
517
|
-
file_path: The destination path, used for the test/migration exemptions
|
|
518
|
-
and scan-root resolution.
|
|
519
|
-
full_file_content: The reconstructed post-edit whole-file content for an
|
|
520
|
-
Edit, or None for a Write where ``content`` is already the whole file.
|
|
521
|
-
|
|
522
|
-
Returns:
|
|
523
|
-
One violation message per dead config-like dataclass field, capped at the
|
|
524
|
-
configured maximum. Returns an empty list when the file is exempt, no
|
|
525
|
-
qualifying config-like dataclass is found, the scan root exceeds the file
|
|
526
|
-
cap, or a SyntaxError prevents parsing.
|
|
527
|
-
"""
|
|
528
|
-
if is_test_file(file_path):
|
|
529
|
-
return []
|
|
530
|
-
if is_migration_file(file_path):
|
|
531
|
-
return []
|
|
532
|
-
effective_content = content if full_file_content is None else full_file_content
|
|
533
|
-
try:
|
|
534
|
-
tree = ast.parse(effective_content)
|
|
535
|
-
except SyntaxError:
|
|
536
|
-
return []
|
|
537
|
-
all_config_classes = [
|
|
538
|
-
each_node
|
|
539
|
-
for each_node in ast.walk(tree)
|
|
540
|
-
if isinstance(each_node, ast.ClassDef) and _is_config_dataclass(each_node)
|
|
541
|
-
]
|
|
542
|
-
if not all_config_classes:
|
|
543
|
-
return []
|
|
544
|
-
scan_root = _scan_root_for_constants_module(file_path)
|
|
545
|
-
written_path = Path(file_path).resolve()
|
|
546
|
-
all_read_names, cap_was_hit, suppresses_dead_field_check = (
|
|
547
|
-
_all_production_read_names_under_root(
|
|
548
|
-
scan_root,
|
|
549
|
-
written_path,
|
|
550
|
-
effective_content,
|
|
551
|
-
)
|
|
552
|
-
)
|
|
553
|
-
if cap_was_hit:
|
|
554
|
-
return []
|
|
555
|
-
if suppresses_dead_field_check:
|
|
556
|
-
return []
|
|
557
|
-
all_issues: list[str] = []
|
|
558
|
-
for each_class in all_config_classes:
|
|
559
|
-
for each_field_name, each_field_line in _dataclass_field_definitions(each_class):
|
|
560
|
-
if each_field_name in all_read_names:
|
|
561
|
-
continue
|
|
562
|
-
all_issues.append(
|
|
563
|
-
f"Line {each_field_line}: dataclass field {each_field_name!r}"
|
|
564
|
-
f" on {each_class.name} - {DEAD_CONFIG_FIELD_GUIDANCE}"
|
|
565
|
-
)
|
|
566
|
-
if len(all_issues) >= MAX_DEAD_CONFIG_FIELD_ISSUES:
|
|
567
|
-
return all_issues
|
|
568
|
-
return all_issues
|