claude-dev-env 1.60.0 → 1.62.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 +12 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/bin/install.mjs +1 -1
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_config_field.py +321 -0
- package/hooks/blocking/code_rules_enforcer.py +14 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +15 -2
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verification_verdict_store.py +212 -0
- package/hooks/blocking/test_verified_commit_gate.py +159 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
- package/hooks/blocking/verification_verdict_store.py +240 -0
- package/hooks/blocking/verified_commit_gate.py +31 -9
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/orphan-css-class.md +23 -0
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
- package/skills/autoconverge/workflow/converge.mjs +392 -51
- package/skills/autoconverge/workflow/test_render_report.py +55 -0
- package/skills/doc-gist/SKILL.md +3 -2
- package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
- package/skills/doc-gist/references/examples/README.md +2 -2
- package/skills/task-build/SKILL.md +31 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Dead config-dataclass field check: cross-module scan for ``*Config`` @dataclass fields.
|
|
2
|
+
|
|
3
|
+
A config ``@dataclass`` (any class whose name ends in ``"Config"``) is defined
|
|
4
|
+
in one module but constructed and consumed in others, so the per-file dead-field
|
|
5
|
+
check in ``code_rules_dead_dataclass_field`` cannot judge its fields — it skips
|
|
6
|
+
any class that is not constructed in the same file. This check resolves the
|
|
7
|
+
enclosing package tree — the scan root — and flags a ``*Config`` dataclass field
|
|
8
|
+
whose name appears as an attribute read (``obj.field``) in no production module
|
|
9
|
+
anywhere under that root.
|
|
10
|
+
|
|
11
|
+
The scan is deliberately conservative to keep false positives near zero:
|
|
12
|
+
|
|
13
|
+
- Only ``@dataclass`` classes whose name ends in ``"Config"`` participate; other
|
|
14
|
+
dataclasses are covered by the per-file check.
|
|
15
|
+
- Test and migration files are exempt as write destinations, so a field added to
|
|
16
|
+
a config dataclass inside a test is never flagged.
|
|
17
|
+
- Production modules under the scan root are scanned for attribute reads; test
|
|
18
|
+
and migration modules are deliberately excluded so a field read only by test
|
|
19
|
+
code is still flagged as dead-in-production.
|
|
20
|
+
- Field reads are collected as ``ast.Attribute.attr`` values (``obj.field``),
|
|
21
|
+
augmented-assignment targets (``cfg.field += 1`` reads ``field`` before
|
|
22
|
+
writing it), string literals (covers ``getattr(obj, "field")``),
|
|
23
|
+
keyword-argument names (covers ``ThemeUpdateConfig(debug_port=1)`` and
|
|
24
|
+
``replace(cfg, debug_port=1)``), and match-pattern keyword attribute names
|
|
25
|
+
(``case Config(field=found)``). Plain ``ast.Name`` references are excluded — a
|
|
26
|
+
local variable named ``debug_port`` is not a read of ``config.debug_port``.
|
|
27
|
+
- A production module that reflectively reads a whole instance — a bare or
|
|
28
|
+
``dataclasses``-qualified call to ``asdict``, ``astuple``, ``fields``,
|
|
29
|
+
``replace``, or ``vars``, or a read of ``obj.__dict__`` — consumes every field
|
|
30
|
+
at once without naming any single field, so the check is suppressed for the
|
|
31
|
+
whole tree (returns ``[]``).
|
|
32
|
+
- A scan root whose total file count exceeds the configured cap cannot prove any
|
|
33
|
+
field dead, so the check returns ``[]`` on a cap hit.
|
|
34
|
+
- A field read only by a module outside the resolved scan root is treated as dead
|
|
35
|
+
— the same conservative scoping the dead-module-constant check accepts.
|
|
36
|
+
|
|
37
|
+
Unlike the per-file dead-dataclass-field check, this cross-module check does NOT
|
|
38
|
+
suppress on a dataclass-dunder whole-instance read — instance comparison
|
|
39
|
+
(``cfg == other``), set or dict membership, formatted-string conversion
|
|
40
|
+
(``f"{cfg}"``), or whole-instance stringification
|
|
41
|
+
(``str(cfg)``/``repr(cfg)``/``format(cfg)``). Those syntactic forms are not bound
|
|
42
|
+
to a config instance, and tree-wide one incidental match anywhere would disable
|
|
43
|
+
the check on any realistic package. The consequence is a documented, rare
|
|
44
|
+
limitation: a ``*Config`` field read ONLY via whole-instance dunder comparison or
|
|
45
|
+
stringification, and never read directly anywhere in production, may be flagged.
|
|
46
|
+
The augmented-assignment read mechanism (``cfg.field += 1`` reads ``field``
|
|
47
|
+
before writing it) is precise and remains a counted read.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import ast
|
|
51
|
+
import os
|
|
52
|
+
import sys
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
56
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
57
|
+
if _blocking_directory not in sys.path:
|
|
58
|
+
sys.path.insert(0, _blocking_directory)
|
|
59
|
+
if _hooks_directory not in sys.path:
|
|
60
|
+
sys.path.insert(0, _hooks_directory)
|
|
61
|
+
|
|
62
|
+
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
63
|
+
_augmented_assignment_attribute_names,
|
|
64
|
+
_dataclass_field_definitions,
|
|
65
|
+
_is_dataclass,
|
|
66
|
+
)
|
|
67
|
+
from code_rules_dead_module_constant import ( # noqa: E402
|
|
68
|
+
_scan_root_for_constants_module,
|
|
69
|
+
)
|
|
70
|
+
from code_rules_shared import ( # noqa: E402
|
|
71
|
+
is_migration_file,
|
|
72
|
+
is_test_file,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from hooks_constants.dead_config_field_constants import ( # noqa: E402
|
|
76
|
+
ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
|
|
77
|
+
CONFIG_CLASS_NAME_SUFFIX,
|
|
78
|
+
DATACLASSES_MODULE_NAME,
|
|
79
|
+
DEAD_CONFIG_FIELD_GUIDANCE,
|
|
80
|
+
MAX_DEAD_CONFIG_FIELD_ISSUES,
|
|
81
|
+
MAX_SCAN_ROOT_FILE_COUNT,
|
|
82
|
+
PYTHON_SOURCE_SUFFIX,
|
|
83
|
+
WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_config_dataclass(class_node: ast.ClassDef) -> bool:
|
|
88
|
+
"""Return whether a class is a @dataclass whose name ends in ``"Config"``.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
class_node: The class definition node to test.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True when the class carries a ``@dataclass`` decorator and its name ends
|
|
95
|
+
in ``"Config"``.
|
|
96
|
+
"""
|
|
97
|
+
return _is_dataclass(class_node) and class_node.name.endswith(CONFIG_CLASS_NAME_SUFFIX)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _reads_whole_instance_reflectively(tree: ast.Module) -> bool:
|
|
101
|
+
"""Return whether a module consumes a whole instance via a reflective read.
|
|
102
|
+
|
|
103
|
+
Detects a bare call to any reflective whole-instance consumer (``asdict``,
|
|
104
|
+
``astuple``, ``fields``, ``replace``, ``vars`` imported from ``dataclasses``),
|
|
105
|
+
a ``dataclasses``-qualified call to the same consumers
|
|
106
|
+
(``dataclasses.asdict(cfg)``, ``dataclasses.replace(cfg, ...)``), and a read
|
|
107
|
+
of the ``__dict__`` attribute. The method-call form must be ``dataclasses``-
|
|
108
|
+
qualified — an unrelated ``"text".replace(...)`` or ``frame.fields(...)`` on
|
|
109
|
+
another object does not match. Each matched form reads every field of an
|
|
110
|
+
instance at once without naming any single field, so a module that uses one
|
|
111
|
+
cannot prove a config field unread.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
tree: The parsed module to inspect.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True when the module makes a bare or ``dataclasses``-qualified call to a
|
|
118
|
+
reflective whole-instance consumer, or reads ``obj.__dict__``.
|
|
119
|
+
"""
|
|
120
|
+
for each_node in ast.walk(tree):
|
|
121
|
+
if isinstance(each_node, ast.Attribute):
|
|
122
|
+
if each_node.attr == WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME:
|
|
123
|
+
return True
|
|
124
|
+
continue
|
|
125
|
+
if not isinstance(each_node, ast.Call):
|
|
126
|
+
continue
|
|
127
|
+
function_node = each_node.func
|
|
128
|
+
if isinstance(function_node, ast.Name) and function_node.id in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES:
|
|
129
|
+
return True
|
|
130
|
+
if (
|
|
131
|
+
isinstance(function_node, ast.Attribute)
|
|
132
|
+
and isinstance(function_node.value, ast.Name)
|
|
133
|
+
and function_node.value.id == DATACLASSES_MODULE_NAME
|
|
134
|
+
and function_node.attr in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES
|
|
135
|
+
):
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _attribute_read_names_in_source(source: str) -> tuple[set[str], bool]:
|
|
141
|
+
"""Return attribute names read in a module's source and a suppression flag.
|
|
142
|
+
|
|
143
|
+
Collects attribute names via five mechanisms: ``ast.Attribute.attr`` values
|
|
144
|
+
in Load context, augmented-assignment targets (so ``cfg.debug_port += 1``
|
|
145
|
+
contributes ``"debug_port"`` because ``+=`` reads the attribute before
|
|
146
|
+
writing it), string literals (so ``getattr(obj, "field")`` contributes
|
|
147
|
+
``"field"``), keyword-argument names (so ``ThemeUpdateConfig(debug_port=1)``
|
|
148
|
+
and ``replace(cfg, debug_port=1)`` each contribute ``"debug_port"``), and
|
|
149
|
+
``ast.MatchClass.kwd_attrs`` names (so ``case Config(field=x)`` contributes
|
|
150
|
+
``"field"``). The boolean reports whether the module suppresses the
|
|
151
|
+
dead-field check, which it does only when it reflectively reads a whole
|
|
152
|
+
instance — a bare or ``dataclasses``-qualified ``asdict``/``astuple``/
|
|
153
|
+
``fields``/``replace``/``vars`` call, or an ``obj.__dict__`` read — because
|
|
154
|
+
that pattern reads every field at once without naming any single field, so
|
|
155
|
+
the caller treats it as "cannot prove any field dead". A ``SyntaxError``
|
|
156
|
+
contributes no names and no suppression.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
source: The full text of a ``.py`` module.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A (read_names, suppresses_dead_field_check) pair. The name set is every
|
|
163
|
+
attribute name the module reads via any of the five mechanisms above;
|
|
164
|
+
suppresses_dead_field_check is True only when a reflective whole-instance
|
|
165
|
+
read is present.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
tree = ast.parse(source)
|
|
169
|
+
except SyntaxError:
|
|
170
|
+
return set(), False
|
|
171
|
+
all_read_names: set[str] = _augmented_assignment_attribute_names(tree)
|
|
172
|
+
for each_node in ast.walk(tree):
|
|
173
|
+
if isinstance(each_node, ast.Attribute) and isinstance(each_node.ctx, ast.Load):
|
|
174
|
+
all_read_names.add(each_node.attr)
|
|
175
|
+
elif isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
|
|
176
|
+
all_read_names.add(each_node.value)
|
|
177
|
+
elif isinstance(each_node, ast.MatchClass):
|
|
178
|
+
all_read_names.update(each_node.kwd_attrs)
|
|
179
|
+
elif isinstance(each_node, ast.keyword) and each_node.arg is not None:
|
|
180
|
+
all_read_names.add(each_node.arg)
|
|
181
|
+
suppresses_dead_field_check = _reads_whole_instance_reflectively(tree)
|
|
182
|
+
return all_read_names, suppresses_dead_field_check
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _all_production_read_names_under_root(
|
|
186
|
+
scan_root: Path,
|
|
187
|
+
written_path: Path,
|
|
188
|
+
written_content: str,
|
|
189
|
+
) -> tuple[set[str], bool, bool]:
|
|
190
|
+
"""Return read names, a cap-hit flag, and a suppression flag for the tree.
|
|
191
|
+
|
|
192
|
+
Scans every production ``.py`` module under ``scan_root`` (excluding test and
|
|
193
|
+
migration files) for attribute reads. The written module's post-edit content
|
|
194
|
+
replaces its on-disk text so the current edit is included. Scanning stops at
|
|
195
|
+
the configured file cap. A module that reflectively reads a whole instance —
|
|
196
|
+
a bare or ``dataclasses``-qualified ``asdict``/``astuple``/``fields``/
|
|
197
|
+
``replace``/``vars`` call, or an ``obj.__dict__`` read — sets the suppression
|
|
198
|
+
flag, signalling the caller that no field can be proven dead.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
scan_root: The directory tree to scan.
|
|
202
|
+
written_path: The resolved path of the module being written.
|
|
203
|
+
written_content: The post-edit text of the written module.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
A (read_names, cap_was_hit, suppresses_dead_field_check) triple. The name
|
|
207
|
+
set is the union of attribute reads across every scanned production
|
|
208
|
+
module; cap_was_hit is True when the scan stopped at the configured file
|
|
209
|
+
cap before finishing the tree; suppresses_dead_field_check is True when
|
|
210
|
+
any scanned module reflectively reads a whole instance.
|
|
211
|
+
"""
|
|
212
|
+
all_read_names, suppresses_dead_field_check = _attribute_read_names_in_source(written_content)
|
|
213
|
+
written_path_key = os.path.normcase(str(written_path))
|
|
214
|
+
scanned_file_count = 1
|
|
215
|
+
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
216
|
+
if not each_path.is_file():
|
|
217
|
+
continue
|
|
218
|
+
if os.path.normcase(str(each_path.resolve())) == written_path_key:
|
|
219
|
+
continue
|
|
220
|
+
if is_test_file(str(each_path)):
|
|
221
|
+
continue
|
|
222
|
+
if is_migration_file(str(each_path)):
|
|
223
|
+
continue
|
|
224
|
+
scanned_file_count += 1
|
|
225
|
+
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
226
|
+
return all_read_names, True, suppresses_dead_field_check
|
|
227
|
+
try:
|
|
228
|
+
sibling_source = each_path.read_text(encoding="utf-8")
|
|
229
|
+
except (OSError, UnicodeDecodeError):
|
|
230
|
+
continue
|
|
231
|
+
sibling_read_names, sibling_suppresses_dead_field_check = _attribute_read_names_in_source(
|
|
232
|
+
sibling_source
|
|
233
|
+
)
|
|
234
|
+
all_read_names |= sibling_read_names
|
|
235
|
+
suppresses_dead_field_check = (
|
|
236
|
+
suppresses_dead_field_check or sibling_suppresses_dead_field_check
|
|
237
|
+
)
|
|
238
|
+
return all_read_names, False, suppresses_dead_field_check
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_dead_config_dataclass_fields(
|
|
242
|
+
content: str, file_path: str, full_file_content: str | None = None
|
|
243
|
+
) -> list[str]:
|
|
244
|
+
"""Flag a ``*Config`` @dataclass field read by no production module in the package tree.
|
|
245
|
+
|
|
246
|
+
Runs a cross-module scan restricted to ``@dataclass`` classes whose name ends
|
|
247
|
+
in ``"Config"``. For each such config dataclass in the written file, every
|
|
248
|
+
instance field whose name does not appear as an attribute read (``obj.field``),
|
|
249
|
+
augmented-assignment target (``cfg.field += 1``), string literal,
|
|
250
|
+
keyword-argument name (constructor or ``replace`` keyword), or match-pattern
|
|
251
|
+
keyword attribute in any production module under the enclosing scan root is
|
|
252
|
+
flagged as dead. When any production module under the scan root reflectively
|
|
253
|
+
reads a whole instance — a bare or ``dataclasses``-qualified call to
|
|
254
|
+
``asdict``, ``astuple``, ``fields``, ``replace``, or ``vars``, or a read of
|
|
255
|
+
``obj.__dict__`` — the check is suppressed for the whole tree and returns
|
|
256
|
+
``[]``, since that pattern reads every field at once without naming any single
|
|
257
|
+
field. Test and
|
|
258
|
+
migration files are exempt as write destinations; production modules under the
|
|
259
|
+
scan root are scanned while test and migration modules in the tree are excluded
|
|
260
|
+
so fields read only by test code are still flagged as dead-in-production.
|
|
261
|
+
Whole-file analysis runs against ``full_file_content`` when supplied so an Edit
|
|
262
|
+
fragment is judged against the reconstructed post-edit file. A scan root
|
|
263
|
+
exceeding the file cap returns ``[]`` (cannot prove dead). The scan root is
|
|
264
|
+
resolved the same way as the dead-module-constant check: a ``config/`` module's
|
|
265
|
+
root is its parent directory, a module in a package directory's root is the
|
|
266
|
+
package's parent, and a top-level module's root is its enclosing directory.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
content: The new content under validation (Edit fragment or whole file).
|
|
270
|
+
file_path: The destination path, used for the test/migration exemptions
|
|
271
|
+
and scan-root resolution.
|
|
272
|
+
full_file_content: The reconstructed post-edit whole-file content for an
|
|
273
|
+
Edit, or None for a Write where ``content`` is already the whole file.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
One violation message per dead config dataclass field, capped at the
|
|
277
|
+
configured maximum. Returns an empty list when the file is exempt, no
|
|
278
|
+
qualifying config dataclass is found, the scan root exceeds the file cap,
|
|
279
|
+
or a SyntaxError prevents parsing.
|
|
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
|
+
all_config_classes = [
|
|
291
|
+
each_node
|
|
292
|
+
for each_node in ast.walk(tree)
|
|
293
|
+
if isinstance(each_node, ast.ClassDef) and _is_config_dataclass(each_node)
|
|
294
|
+
]
|
|
295
|
+
if not all_config_classes:
|
|
296
|
+
return []
|
|
297
|
+
scan_root = _scan_root_for_constants_module(file_path)
|
|
298
|
+
written_path = Path(file_path).resolve()
|
|
299
|
+
all_read_names, cap_was_hit, suppresses_dead_field_check = (
|
|
300
|
+
_all_production_read_names_under_root(
|
|
301
|
+
scan_root,
|
|
302
|
+
written_path,
|
|
303
|
+
effective_content,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
if cap_was_hit:
|
|
307
|
+
return []
|
|
308
|
+
if suppresses_dead_field_check:
|
|
309
|
+
return []
|
|
310
|
+
all_issues: list[str] = []
|
|
311
|
+
for each_class in all_config_classes:
|
|
312
|
+
for each_field_name, each_field_line in _dataclass_field_definitions(each_class):
|
|
313
|
+
if each_field_name in all_read_names:
|
|
314
|
+
continue
|
|
315
|
+
all_issues.append(
|
|
316
|
+
f"Line {each_field_line}: config dataclass field {each_field_name!r}"
|
|
317
|
+
f" on {each_class.name} - {DEAD_CONFIG_FIELD_GUIDANCE}"
|
|
318
|
+
)
|
|
319
|
+
if len(all_issues) >= MAX_DEAD_CONFIG_FIELD_ISSUES:
|
|
320
|
+
return all_issues
|
|
321
|
+
return all_issues
|
|
@@ -33,6 +33,7 @@ from code_rules_annotations_length import ( # noqa: E402
|
|
|
33
33
|
check_known_pytest_fixture_annotations,
|
|
34
34
|
check_parameter_annotations,
|
|
35
35
|
check_return_annotations,
|
|
36
|
+
check_unused_known_pytest_fixture_parameters,
|
|
36
37
|
)
|
|
37
38
|
from code_rules_banned_identifiers import ( # noqa: E402
|
|
38
39
|
check_banned_identifiers,
|
|
@@ -51,6 +52,9 @@ from code_rules_constants_config import ( # noqa: E402
|
|
|
51
52
|
check_constants_outside_config_advisory,
|
|
52
53
|
check_file_global_constants_use_count,
|
|
53
54
|
)
|
|
55
|
+
from code_rules_dead_config_field import ( # noqa: E402
|
|
56
|
+
check_dead_config_dataclass_fields,
|
|
57
|
+
)
|
|
54
58
|
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
55
59
|
check_dead_dataclass_fields,
|
|
56
60
|
)
|
|
@@ -89,6 +93,9 @@ from code_rules_optional_params import ( # noqa: E402
|
|
|
89
93
|
check_duplicated_format_patterns,
|
|
90
94
|
check_unused_optional_parameters,
|
|
91
95
|
)
|
|
96
|
+
from code_rules_orphan_css_class import ( # noqa: E402
|
|
97
|
+
check_orphan_css_classes,
|
|
98
|
+
)
|
|
92
99
|
from code_rules_paths_syspath import ( # noqa: E402
|
|
93
100
|
check_hardcoded_user_paths,
|
|
94
101
|
check_sys_path_insert_deduplication_guard,
|
|
@@ -275,12 +282,18 @@ def validate_content(
|
|
|
275
282
|
all_issues.extend(
|
|
276
283
|
check_dead_dataclass_fields(content, file_path, full_file_content)
|
|
277
284
|
)
|
|
285
|
+
all_issues.extend(
|
|
286
|
+
check_dead_config_dataclass_fields(content, file_path, full_file_content)
|
|
287
|
+
)
|
|
278
288
|
all_issues.extend(
|
|
279
289
|
check_dead_module_constants(content, file_path, full_file_content)
|
|
280
290
|
)
|
|
281
291
|
all_issues.extend(check_library_print(content, file_path))
|
|
282
292
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
283
293
|
all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
|
|
294
|
+
all_issues.extend(
|
|
295
|
+
check_unused_known_pytest_fixture_parameters(content, file_path)
|
|
296
|
+
)
|
|
284
297
|
all_issues.extend(check_return_annotations(content, file_path))
|
|
285
298
|
all_issues.extend(
|
|
286
299
|
check_function_length(
|
|
@@ -294,6 +307,7 @@ def validate_content(
|
|
|
294
307
|
all_issues.extend(check_inline_literal_collections(content, file_path))
|
|
295
308
|
all_issues.extend(check_inline_tuple_string_magic(content, file_path))
|
|
296
309
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
310
|
+
all_issues.extend(check_orphan_css_classes(effective_content, file_path))
|
|
297
311
|
check_incomplete_mocks(content, file_path)
|
|
298
312
|
check_duplicated_format_patterns(content, file_path)
|
|
299
313
|
advise_cross_skill_duplicate_helper(effective_content, file_path)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Orphan-CSS-class check: class attributes in markup with no matching selector."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
if _hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_directory)
|
|
13
|
+
|
|
14
|
+
from code_rules_shared import ( # noqa: E402
|
|
15
|
+
is_test_file,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from hooks_constants.orphan_css_class_constants import ( # noqa: E402
|
|
19
|
+
CLASS_ATTRIBUTE_PATTERN,
|
|
20
|
+
CSS_CLASS_SELECTOR_PATTERN,
|
|
21
|
+
MAX_ORPHAN_CSS_CLASS_ISSUES,
|
|
22
|
+
MAX_SIBLING_MODULES_SCANNED,
|
|
23
|
+
ORPHAN_CSS_CLASS_MESSAGE_SUFFIX,
|
|
24
|
+
PYTHON_MODULE_GLOB,
|
|
25
|
+
STYLE_BLOCK_PATTERN,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _string_literals_with_lines(tree: ast.Module) -> list[tuple[str, int]]:
|
|
30
|
+
"""Return every string-constant value in the tree paired with its line number.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
tree: The parsed module to walk.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
A list of ``(string_value, line_number)`` pairs, one per string constant.
|
|
37
|
+
"""
|
|
38
|
+
literals: list[tuple[str, int]] = []
|
|
39
|
+
for each_node in ast.walk(tree):
|
|
40
|
+
if isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
|
|
41
|
+
literals.append((each_node.value, each_node.lineno))
|
|
42
|
+
return literals
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _class_names_in_attribute(attribute_text: str) -> list[str]:
|
|
46
|
+
"""Return the individual class names in a single ``class="..."`` attribute.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
attribute_text: The whitespace-separated class list from one attribute.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Each non-empty class token, in order.
|
|
53
|
+
"""
|
|
54
|
+
return [each_token for each_token in attribute_text.split() if each_token]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _class_references_with_lines(
|
|
58
|
+
all_string_literals: list[tuple[str, int]],
|
|
59
|
+
) -> list[tuple[str, int]]:
|
|
60
|
+
"""Return every class name referenced in a ``class="..."`` attribute.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
all_string_literals: The ``(literal_text, line_number)`` constants to scan.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A list of ``(class_name, line_number)`` pairs, one per referenced class.
|
|
67
|
+
"""
|
|
68
|
+
references: list[tuple[str, int]] = []
|
|
69
|
+
for each_text, each_line in all_string_literals:
|
|
70
|
+
for each_match in CLASS_ATTRIBUTE_PATTERN.finditer(each_text):
|
|
71
|
+
for each_class_name in _class_names_in_attribute(each_match.group(1)):
|
|
72
|
+
references.append((each_class_name, each_line))
|
|
73
|
+
return references
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _defined_class_selectors(all_string_literals: list[tuple[str, int]]) -> set[str]:
|
|
77
|
+
"""Return every CSS class name defined by a selector inside a ``<style>`` block.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
all_string_literals: The ``(literal_text, line_number)`` constants to scan.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The set of class names that carry a matching ``.<class>`` selector.
|
|
84
|
+
"""
|
|
85
|
+
defined: set[str] = set()
|
|
86
|
+
for each_text, _ in all_string_literals:
|
|
87
|
+
for each_style_match in STYLE_BLOCK_PATTERN.finditer(each_text):
|
|
88
|
+
for each_selector in CSS_CLASS_SELECTOR_PATTERN.finditer(
|
|
89
|
+
each_style_match.group(1)
|
|
90
|
+
):
|
|
91
|
+
defined.add(each_selector.group(1))
|
|
92
|
+
return defined
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _sibling_module_paths(file_path: str) -> list[Path]:
|
|
96
|
+
"""Return the importable sibling Python modules near *file_path*.
|
|
97
|
+
|
|
98
|
+
Scans the file's own directory and its immediate child directories, since a
|
|
99
|
+
markup module commonly imports its ``<style>`` constant from a companion
|
|
100
|
+
package directory beside it. The scan is bounded so a large tree never
|
|
101
|
+
stalls a write.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
file_path: The absolute path of the file under validation.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The sibling ``.py`` paths to read for cross-module selector resolution,
|
|
108
|
+
excluding the file itself, capped at the scan budget.
|
|
109
|
+
"""
|
|
110
|
+
target = Path(file_path)
|
|
111
|
+
base_directory = target.parent
|
|
112
|
+
if not base_directory.is_dir():
|
|
113
|
+
return []
|
|
114
|
+
siblings: list[Path] = []
|
|
115
|
+
for each_path in sorted(base_directory.rglob(PYTHON_MODULE_GLOB)):
|
|
116
|
+
if each_path.resolve() == target.resolve():
|
|
117
|
+
continue
|
|
118
|
+
siblings.append(each_path)
|
|
119
|
+
if len(siblings) >= MAX_SIBLING_MODULES_SCANNED:
|
|
120
|
+
break
|
|
121
|
+
return siblings
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _selectors_from_sibling_modules(file_path: str) -> set[str]:
|
|
125
|
+
"""Return CSS class selectors defined in ``<style>`` blocks of sibling modules.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
file_path: The absolute path of the file under validation.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The union of class names whose selectors appear in any readable sibling
|
|
132
|
+
module's string literals.
|
|
133
|
+
"""
|
|
134
|
+
selectors: set[str] = set()
|
|
135
|
+
for each_sibling in _sibling_module_paths(file_path):
|
|
136
|
+
try:
|
|
137
|
+
sibling_source = each_sibling.read_text(encoding="utf-8")
|
|
138
|
+
except (OSError, UnicodeDecodeError):
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
sibling_tree = ast.parse(sibling_source)
|
|
142
|
+
except SyntaxError:
|
|
143
|
+
continue
|
|
144
|
+
selectors |= _defined_class_selectors(_string_literals_with_lines(sibling_tree))
|
|
145
|
+
return selectors
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check_orphan_css_classes(content: str, file_path: str) -> list[str]:
|
|
149
|
+
"""Flag ``class="..."`` markup whose class has no matching CSS selector.
|
|
150
|
+
|
|
151
|
+
A module that emits HTML names each class it references with a matching
|
|
152
|
+
``.<class>`` selector, either in a ``<style>`` block in the same file or in
|
|
153
|
+
a companion module beside it. A referenced class with no selector anywhere
|
|
154
|
+
is a dead attribute (or a missing rule), so this flags it. The check only
|
|
155
|
+
fires for a file that itself emits markup, and only after a ``<style>``
|
|
156
|
+
block exists in the file or a sibling — a file with markup but no style
|
|
157
|
+
source nearby is left alone, since its stylesheet lives outside the scan.
|
|
158
|
+
Test files are exempt, since a fixture may carry intentional orphan markup.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
content: The new or whole-file content being written.
|
|
162
|
+
file_path: The destination path of the write or edit.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
One issue per orphan class reference, capped at the issue budget.
|
|
166
|
+
"""
|
|
167
|
+
if is_test_file(file_path):
|
|
168
|
+
return []
|
|
169
|
+
try:
|
|
170
|
+
tree = ast.parse(content)
|
|
171
|
+
except SyntaxError:
|
|
172
|
+
return []
|
|
173
|
+
all_string_literals = _string_literals_with_lines(tree)
|
|
174
|
+
class_references = _class_references_with_lines(all_string_literals)
|
|
175
|
+
if not class_references:
|
|
176
|
+
return []
|
|
177
|
+
defined_selectors = _defined_class_selectors(all_string_literals)
|
|
178
|
+
defined_selectors |= _selectors_from_sibling_modules(file_path)
|
|
179
|
+
if not defined_selectors:
|
|
180
|
+
return []
|
|
181
|
+
issues: list[str] = []
|
|
182
|
+
reported_classes: set[str] = set()
|
|
183
|
+
for each_class_name, each_line in class_references:
|
|
184
|
+
if each_class_name in defined_selectors:
|
|
185
|
+
continue
|
|
186
|
+
if each_class_name in reported_classes:
|
|
187
|
+
continue
|
|
188
|
+
reported_classes.add(each_class_name)
|
|
189
|
+
issues.append(
|
|
190
|
+
f"Line {each_line}: CSS class {each_class_name!r} used in markup"
|
|
191
|
+
f" has no matching '.{each_class_name}' selector - "
|
|
192
|
+
f"{ORPHAN_CSS_CLASS_MESSAGE_SUFFIX}"
|
|
193
|
+
)
|
|
194
|
+
if len(issues) >= MAX_ORPHAN_CSS_CLASS_ISSUES:
|
|
195
|
+
break
|
|
196
|
+
return issues
|
|
@@ -47,6 +47,20 @@ WRITE_CALL_REGION_PATTERN = (
|
|
|
47
47
|
)
|
|
48
48
|
VERDICT_KEY_ALL_PASS = "all_pass"
|
|
49
49
|
VERDICT_KEY_MANIFEST_SHA256 = "manifest_sha256"
|
|
50
|
+
VERDICT_KEY_FINDINGS = "findings"
|
|
51
|
+
SUBAGENTS_DIRECTORY_NAME = "subagents"
|
|
52
|
+
AGENT_TRANSCRIPT_GLOB = "agent-*.jsonl"
|
|
53
|
+
AGENT_META_SIDECAR_SUFFIX = ".meta.json"
|
|
54
|
+
AGENT_META_TYPE_KEY = "agentType"
|
|
55
|
+
TRANSCRIPT_ENTRY_TYPE_KEY = "type"
|
|
56
|
+
TRANSCRIPT_ASSISTANT_ENTRY_TYPE = "assistant"
|
|
57
|
+
TRANSCRIPT_MESSAGE_KEY = "message"
|
|
58
|
+
TRANSCRIPT_CONTENT_KEY = "content"
|
|
59
|
+
TRANSCRIPT_CONTENT_TYPE_KEY = "type"
|
|
60
|
+
TRANSCRIPT_TEXT_CONTENT_TYPE = "text"
|
|
61
|
+
TRANSCRIPT_TEXT_KEY = "text"
|
|
62
|
+
VERDICT_FENCE_PATTERN = r"```verdict\s*\n(.*?)```"
|
|
63
|
+
MANIFEST_HASH_CLI_FLAG = "--manifest-hash"
|
|
50
64
|
DOCS_ONLY_EXTENSIONS = frozenset(
|
|
51
65
|
{".md", ".txt", ".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
|
|
52
66
|
)
|
|
@@ -82,9 +96,8 @@ COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN = r"[;&|\n][\s]*\S"
|
|
|
82
96
|
OPTION_WITH_VALUE_STEP = 2
|
|
83
97
|
ALL_GATED_TOOL_NAMES = ("Bash", "PowerShell")
|
|
84
98
|
HASH_PREVIEW_LENGTH = 16
|
|
99
|
+
VERIFICATION_BYPASS_MARKER = "# verify-skip"
|
|
85
100
|
MINTING_AGENT_TYPE = "code-verifier"
|
|
86
|
-
SPAWN_LOOKUP_ATTEMPT_COUNT = 3
|
|
87
|
-
SPAWN_LOOKUP_RETRY_DELAY_SECONDS = 0.1
|
|
88
101
|
VERDICT_DIRECTORY_GUARD_MESSAGE = (
|
|
89
102
|
"BLOCKED: [VERDICT_DIRECTORY_GUARD] Shell access to the verification "
|
|
90
103
|
"verdict directory (~/.claude/verification/) is denied. Only the "
|