claude-dev-env 1.59.0 → 1.61.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 +4 -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-f-silent-failures.md +1 -1
- 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/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -15
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -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 +118 -0
- 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_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -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_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 +213 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +490 -0
- package/hooks/blocking/test_verified_commit_gate.py +495 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +686 -0
- package/hooks/blocking/verified_commit_gate.py +535 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +221 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/orphan-css-class.md +23 -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 +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
- package/skills/autoconverge/workflow/converge.mjs +520 -57
- 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 +518 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- 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,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
|
|
@@ -28,6 +28,15 @@ leave the exact violation class unguarded. The enforcer entry points route a
|
|
|
28
28
|
hook ``.py`` target to this single check even though the full code-rules verdict
|
|
29
29
|
stays off hook infrastructure, so a Write or pre-check against a file under the
|
|
30
30
|
``blocking/`` directory still blocks a copied sibling helper.
|
|
31
|
+
|
|
32
|
+
``advise_cross_skill_duplicate_helper`` is the non-blocking companion for a
|
|
33
|
+
different layout: a helper copied between two skills' ``scripts`` directories.
|
|
34
|
+
Two skill folders install on their own, so a shared module would break
|
|
35
|
+
independent install and a same-directory block would be a false positive on a
|
|
36
|
+
sanctioned skill-isolation copy. The advisory prints a ``[CODE_RULES advisory]``
|
|
37
|
+
line to stderr naming the source skill and function so a reviewer confirms the
|
|
38
|
+
copy is intentional, and never enters the deny path. It fires only across skill
|
|
39
|
+
folders; within one skill the blocking check above already covers the copy.
|
|
31
40
|
"""
|
|
32
41
|
|
|
33
42
|
import ast
|
|
@@ -47,11 +56,16 @@ from code_rules_shared import ( # noqa: E402
|
|
|
47
56
|
)
|
|
48
57
|
|
|
49
58
|
from hooks_constants.duplicate_function_body_constants import ( # noqa: E402
|
|
59
|
+
CROSS_SKILL_ADVISORY_PREFIX,
|
|
60
|
+
CROSS_SKILL_DUPLICATE_GUIDANCE,
|
|
50
61
|
DUNDER_INIT_FILENAME,
|
|
51
62
|
DUPLICATE_BODY_GUIDANCE,
|
|
63
|
+
MAX_CROSS_SKILL_ADVISORY_ISSUES,
|
|
52
64
|
MAX_DUPLICATE_BODY_ISSUES,
|
|
53
65
|
MINIMUM_DUPLICATE_BODY_STATEMENTS,
|
|
54
66
|
PYTHON_SOURCE_SUFFIX,
|
|
67
|
+
SKILL_SCRIPTS_DIRECTORY_NAME,
|
|
68
|
+
SKILLS_DIRECTORY_NAME,
|
|
55
69
|
)
|
|
56
70
|
|
|
57
71
|
|
|
@@ -285,3 +299,141 @@ def check_duplicate_function_body_across_files(
|
|
|
285
299
|
all_changed_lines,
|
|
286
300
|
defer_scope_to_caller,
|
|
287
301
|
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _skill_scripts_root(file_path: str) -> Path | None:
|
|
305
|
+
"""Return the ``skills/<name>/scripts`` root the written file sits under.
|
|
306
|
+
|
|
307
|
+
A skill's helper scripts live at ``<...>/skills/<skill-name>/scripts/<file>``.
|
|
308
|
+
This walks the written file's parents for a ``scripts`` directory whose own
|
|
309
|
+
parent's parent is named ``skills``, and returns that ``scripts`` directory.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
file_path: The destination path of the write.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
The ``skills/<name>/scripts`` directory containing the file, or None when
|
|
316
|
+
the file is not under a skill's ``scripts`` directory.
|
|
317
|
+
"""
|
|
318
|
+
written_path = Path(file_path).resolve()
|
|
319
|
+
for each_ancestor in written_path.parents:
|
|
320
|
+
if each_ancestor.name != SKILL_SCRIPTS_DIRECTORY_NAME:
|
|
321
|
+
continue
|
|
322
|
+
skill_directory = each_ancestor.parent
|
|
323
|
+
if skill_directory.parent.name == SKILLS_DIRECTORY_NAME:
|
|
324
|
+
return each_ancestor
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _other_skill_scripts_directories(scripts_root: Path) -> list[Path]:
|
|
329
|
+
"""List the ``scripts`` directories of every sibling skill folder.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
scripts_root: The ``skills/<name>/scripts`` directory of the written file.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
The ``scripts`` directory of each sibling skill that has one, excluding
|
|
336
|
+
the written file's own skill.
|
|
337
|
+
"""
|
|
338
|
+
own_skill_directory = scripts_root.parent
|
|
339
|
+
skills_directory = own_skill_directory.parent
|
|
340
|
+
all_other_scripts_directories: list[Path] = []
|
|
341
|
+
try:
|
|
342
|
+
all_skill_entries = sorted(skills_directory.iterdir())
|
|
343
|
+
except OSError:
|
|
344
|
+
return []
|
|
345
|
+
for each_skill_directory in all_skill_entries:
|
|
346
|
+
if not each_skill_directory.is_dir():
|
|
347
|
+
continue
|
|
348
|
+
if each_skill_directory == own_skill_directory:
|
|
349
|
+
continue
|
|
350
|
+
candidate_scripts = each_skill_directory / SKILL_SCRIPTS_DIRECTORY_NAME
|
|
351
|
+
if candidate_scripts.is_dir():
|
|
352
|
+
all_other_scripts_directories.append(candidate_scripts)
|
|
353
|
+
return all_other_scripts_directories
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _cross_skill_source_signatures(
|
|
357
|
+
all_other_scripts_directories: list[Path],
|
|
358
|
+
) -> dict[str, list[str]]:
|
|
359
|
+
"""Map each function body signature to the ``skill/module::function`` copies.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
all_other_scripts_directories: The ``scripts`` directory of each sibling skill.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
A signature-to-source-names mapping naming the skill, module, and function
|
|
366
|
+
that carry each comparable top-level body.
|
|
367
|
+
"""
|
|
368
|
+
source_names_by_signature: dict[str, list[str]] = {}
|
|
369
|
+
for each_scripts_directory in all_other_scripts_directories:
|
|
370
|
+
skill_name = each_scripts_directory.parent.name
|
|
371
|
+
try:
|
|
372
|
+
all_entries = sorted(each_scripts_directory.iterdir())
|
|
373
|
+
except OSError:
|
|
374
|
+
continue
|
|
375
|
+
for each_entry in all_entries:
|
|
376
|
+
if not _is_comparable_sibling(each_entry, ""):
|
|
377
|
+
continue
|
|
378
|
+
try:
|
|
379
|
+
sibling_source = each_entry.read_text(encoding="utf-8")
|
|
380
|
+
sibling_tree = ast.parse(sibling_source)
|
|
381
|
+
except (OSError, UnicodeDecodeError, SyntaxError):
|
|
382
|
+
continue
|
|
383
|
+
for each_name, each_signature in _top_level_function_signatures(sibling_tree).items():
|
|
384
|
+
location = f"{skill_name}/{each_entry.name}::{each_name}"
|
|
385
|
+
source_names_by_signature.setdefault(each_signature, []).append(location)
|
|
386
|
+
return source_names_by_signature
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def advise_cross_skill_duplicate_helper(content: str, file_path: str) -> None:
|
|
390
|
+
"""Emit non-blocking stderr advisories for helpers copied across skill folders.
|
|
391
|
+
|
|
392
|
+
A top-level function in the file being written whose normalized body matches a
|
|
393
|
+
top-level function in another skill's ``scripts`` directory is surfaced as a
|
|
394
|
+
``[CODE_RULES advisory]`` line on stderr — never a block. Two skill folders
|
|
395
|
+
install on their own, so a shared module would break independent install; the
|
|
396
|
+
copy is a defensible skill-isolation tradeoff the writer confirms rather than
|
|
397
|
+
a violation the gate denies. Within one skill the blocking duplicate-body gate
|
|
398
|
+
already covers the copy, so this advisory fires only across skill folders.
|
|
399
|
+
|
|
400
|
+
Test files and ``__init__.py`` are skipped on both the writing side and the
|
|
401
|
+
sibling side, mirroring the blocking gate.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
content: The full post-edit file content being written.
|
|
405
|
+
file_path: The destination path of the write.
|
|
406
|
+
"""
|
|
407
|
+
written_name = Path(file_path).name
|
|
408
|
+
if written_name == DUNDER_INIT_FILENAME:
|
|
409
|
+
return
|
|
410
|
+
if is_test_file(file_path):
|
|
411
|
+
return
|
|
412
|
+
scripts_root = _skill_scripts_root(file_path)
|
|
413
|
+
if scripts_root is None:
|
|
414
|
+
return
|
|
415
|
+
try:
|
|
416
|
+
written_tree = ast.parse(content)
|
|
417
|
+
except SyntaxError:
|
|
418
|
+
return
|
|
419
|
+
written_signatures = _top_level_function_signatures(written_tree)
|
|
420
|
+
if not written_signatures:
|
|
421
|
+
return
|
|
422
|
+
all_other_scripts_directories = _other_skill_scripts_directories(scripts_root)
|
|
423
|
+
if not all_other_scripts_directories:
|
|
424
|
+
return
|
|
425
|
+
source_names_by_signature = _cross_skill_source_signatures(all_other_scripts_directories)
|
|
426
|
+
advisory_count = 0
|
|
427
|
+
for each_name, each_signature in written_signatures.items():
|
|
428
|
+
matching_locations = source_names_by_signature.get(each_signature)
|
|
429
|
+
if not matching_locations:
|
|
430
|
+
continue
|
|
431
|
+
print(
|
|
432
|
+
f"{CROSS_SKILL_ADVISORY_PREFIX} {file_path}: function {each_name!r} "
|
|
433
|
+
f"duplicates {matching_locations[0]} in another skill — "
|
|
434
|
+
f"{CROSS_SKILL_DUPLICATE_GUIDANCE}",
|
|
435
|
+
file=sys.stderr,
|
|
436
|
+
)
|
|
437
|
+
advisory_count += 1
|
|
438
|
+
if advisory_count >= MAX_CROSS_SKILL_ADVISORY_ISSUES:
|
|
439
|
+
break
|
|
@@ -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,
|
|
@@ -54,11 +55,15 @@ from code_rules_constants_config import ( # noqa: E402
|
|
|
54
55
|
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
55
56
|
check_dead_dataclass_fields,
|
|
56
57
|
)
|
|
58
|
+
from code_rules_dead_module_constant import ( # noqa: E402
|
|
59
|
+
check_dead_module_constants,
|
|
60
|
+
)
|
|
57
61
|
from code_rules_docstrings import ( # noqa: E402
|
|
58
62
|
check_docstring_args_match_signature,
|
|
59
63
|
check_docstring_format,
|
|
60
64
|
)
|
|
61
65
|
from code_rules_duplicate_body import ( # noqa: E402
|
|
66
|
+
advise_cross_skill_duplicate_helper,
|
|
62
67
|
check_duplicate_function_body_across_files,
|
|
63
68
|
)
|
|
64
69
|
from code_rules_imports_logging import ( # noqa: E402
|
|
@@ -85,6 +90,9 @@ from code_rules_optional_params import ( # noqa: E402
|
|
|
85
90
|
check_duplicated_format_patterns,
|
|
86
91
|
check_unused_optional_parameters,
|
|
87
92
|
)
|
|
93
|
+
from code_rules_orphan_css_class import ( # noqa: E402
|
|
94
|
+
check_orphan_css_classes,
|
|
95
|
+
)
|
|
88
96
|
from code_rules_paths_syspath import ( # noqa: E402
|
|
89
97
|
check_hardcoded_user_paths,
|
|
90
98
|
check_sys_path_insert_deduplication_guard,
|
|
@@ -120,6 +128,7 @@ from code_rules_typeddict_stub import ( # noqa: E402
|
|
|
120
128
|
check_stub_implementations,
|
|
121
129
|
check_thin_wrapper_files,
|
|
122
130
|
check_typed_dict_encode_decode,
|
|
131
|
+
check_zero_payload_function_alias,
|
|
123
132
|
)
|
|
124
133
|
from code_rules_unused_imports import ( # noqa: E402
|
|
125
134
|
check_unused_module_level_imports,
|
|
@@ -228,6 +237,7 @@ def validate_content(
|
|
|
228
237
|
all_issues.extend(check_test_branching_in_production(effective_content, file_path))
|
|
229
238
|
all_issues.extend(check_bare_except(effective_content, file_path))
|
|
230
239
|
all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
|
|
240
|
+
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
231
241
|
all_issues.extend(check_boundary_types(effective_content, file_path))
|
|
232
242
|
all_issues.extend(check_docstring_format(effective_content, file_path))
|
|
233
243
|
all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
|
|
@@ -269,9 +279,15 @@ def validate_content(
|
|
|
269
279
|
all_issues.extend(
|
|
270
280
|
check_dead_dataclass_fields(content, file_path, full_file_content)
|
|
271
281
|
)
|
|
282
|
+
all_issues.extend(
|
|
283
|
+
check_dead_module_constants(content, file_path, full_file_content)
|
|
284
|
+
)
|
|
272
285
|
all_issues.extend(check_library_print(content, file_path))
|
|
273
286
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
274
287
|
all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
|
|
288
|
+
all_issues.extend(
|
|
289
|
+
check_unused_known_pytest_fixture_parameters(content, file_path)
|
|
290
|
+
)
|
|
275
291
|
all_issues.extend(check_return_annotations(content, file_path))
|
|
276
292
|
all_issues.extend(
|
|
277
293
|
check_function_length(
|
|
@@ -285,8 +301,10 @@ def validate_content(
|
|
|
285
301
|
all_issues.extend(check_inline_literal_collections(content, file_path))
|
|
286
302
|
all_issues.extend(check_inline_tuple_string_magic(content, file_path))
|
|
287
303
|
all_issues.extend(check_string_literal_magic(content, file_path))
|
|
304
|
+
all_issues.extend(check_orphan_css_classes(effective_content, file_path))
|
|
288
305
|
check_incomplete_mocks(content, file_path)
|
|
289
306
|
check_duplicated_format_patterns(content, file_path)
|
|
307
|
+
advise_cross_skill_duplicate_helper(effective_content, file_path)
|
|
290
308
|
|
|
291
309
|
elif extension in ALL_JAVASCRIPT_EXTENSIONS:
|
|
292
310
|
if not is_test_file(file_path):
|
|
@@ -383,18 +401,20 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
|
383
401
|
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
384
402
|
|
|
385
403
|
|
|
386
|
-
def
|
|
404
|
+
def _hook_infrastructure_blocking_issues(
|
|
387
405
|
content: str,
|
|
388
406
|
file_path: str,
|
|
389
407
|
full_file_content: str | None = None,
|
|
390
408
|
prior_full_file_content: str = "",
|
|
391
409
|
) -> list[str]:
|
|
392
|
-
"""Run
|
|
410
|
+
"""Run the checks that still guard a hook Python target.
|
|
393
411
|
|
|
394
412
|
The whole code-rules verdict stays off hook-infrastructure files, so this
|
|
395
|
-
runs the
|
|
396
|
-
an edit touched exactly as ``validate_content``
|
|
397
|
-
code
|
|
413
|
+
runs the two checks that must still guard them: the cross-file duplicate-body
|
|
414
|
+
check, span-scoped to the lines an edit touched exactly as ``validate_content``
|
|
415
|
+
scopes it for production code; and the zero-payload alias check, whose
|
|
416
|
+
docstring names hook modules as its motivating case, run over the whole
|
|
417
|
+
post-edit file.
|
|
398
418
|
|
|
399
419
|
Args:
|
|
400
420
|
content: The fragment or whole-file body under validation.
|
|
@@ -405,7 +425,8 @@ def _hook_infrastructure_duplicate_body_issues(
|
|
|
405
425
|
recover the changed lines on an Edit.
|
|
406
426
|
|
|
407
427
|
Returns:
|
|
408
|
-
The in-scope duplicate-body violations
|
|
428
|
+
The in-scope duplicate-body violations and the zero-payload alias
|
|
429
|
+
violations for the target.
|
|
409
430
|
"""
|
|
410
431
|
effective_content = content if full_file_content is None else full_file_content
|
|
411
432
|
all_changed_lines = (
|
|
@@ -413,11 +434,13 @@ def _hook_infrastructure_duplicate_body_issues(
|
|
|
413
434
|
if full_file_content is not None
|
|
414
435
|
else None
|
|
415
436
|
)
|
|
416
|
-
|
|
437
|
+
all_issues = check_duplicate_function_body_across_files(
|
|
417
438
|
effective_content,
|
|
418
439
|
file_path,
|
|
419
440
|
all_changed_lines,
|
|
420
441
|
)
|
|
442
|
+
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
443
|
+
return all_issues
|
|
421
444
|
|
|
422
445
|
|
|
423
446
|
def _without_line_prefix(violation_text: str) -> str:
|
|
@@ -540,7 +563,7 @@ def _run_precheck(
|
|
|
540
563
|
old_content = _read_existing_file_content(target_path) or ""
|
|
541
564
|
all_issues = validate_content(candidate_content, target_path, old_content)
|
|
542
565
|
else:
|
|
543
|
-
all_issues =
|
|
566
|
+
all_issues = _hook_infrastructure_blocking_issues(
|
|
544
567
|
candidate_content, target_path
|
|
545
568
|
)
|
|
546
569
|
for each_issue in all_issues:
|
|
@@ -755,18 +778,18 @@ def _report_blocking_violations(
|
|
|
755
778
|
)
|
|
756
779
|
|
|
757
780
|
|
|
758
|
-
def
|
|
781
|
+
def _report_hook_blocking_issues(
|
|
759
782
|
content: str,
|
|
760
783
|
file_path: str,
|
|
761
784
|
full_file_content_after_edit: str | None,
|
|
762
785
|
prior_full_file_content: str,
|
|
763
786
|
deny_stream: TextIO,
|
|
764
787
|
) -> None:
|
|
765
|
-
"""Write a deny payload when a hook target
|
|
788
|
+
"""Write a deny payload when a hook target trips a check that still guards it.
|
|
766
789
|
|
|
767
|
-
The full code-rules verdict stays off hook-infrastructure files; this runs
|
|
768
|
-
|
|
769
|
-
payload when
|
|
790
|
+
The full code-rules verdict stays off hook-infrastructure files; this runs the
|
|
791
|
+
two checks that must still guard them — the cross-file duplicate-body check and
|
|
792
|
+
the zero-payload alias check — and emits the deny payload when either fires.
|
|
770
793
|
|
|
771
794
|
Args:
|
|
772
795
|
content: The fragment or whole-file body under validation.
|
|
@@ -776,7 +799,7 @@ def _report_hook_duplicate_body(
|
|
|
776
799
|
prior_full_file_content: The on-disk content before the edit.
|
|
777
800
|
deny_stream: The stream the JSON deny payload is written to.
|
|
778
801
|
"""
|
|
779
|
-
all_blocking_issues =
|
|
802
|
+
all_blocking_issues = _hook_infrastructure_blocking_issues(
|
|
780
803
|
content,
|
|
781
804
|
file_path,
|
|
782
805
|
full_file_content_after_edit,
|
|
@@ -835,7 +858,7 @@ def main(all_arguments: list[str]) -> None:
|
|
|
835
858
|
sys.exit(0)
|
|
836
859
|
|
|
837
860
|
if not runs_full_verdict:
|
|
838
|
-
|
|
861
|
+
_report_hook_blocking_issues(
|
|
839
862
|
content,
|
|
840
863
|
file_path,
|
|
841
864
|
full_file_content_after_edit,
|