claude-dev-env 1.59.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -22,7 +22,7 @@
22
22
  | B3 | Regex syntax vs engine flavor | Lookbehind / lookahead support; named groups (`(?P<…>)` vs `(?<…>)`); backreferences; Unicode character classes. |
23
23
  | B4 | Shell / CLI / cmdlet syntax vs runtime version | PowerShell 5.1 vs 7+; bash 3 vs 5; cmdlet parameters added in later versions; CLI flag deprecations. |
24
24
  | B5 | JSON path / XPath / structural query vs library | jq vs Python jsonpath-ng vs JavaScript jsonpath syntax; XPath 1.0 vs 2.0/3.0 functions. |
25
- | B6 | Search query DSL vs engine | Lucene / Elasticsearch / Zoekt / OpenSearch syntax; differences in escaping, fuzzy matching, multi-field queries. |
25
+ | B6 | Search query DSL vs engine | Lucene / Elasticsearch / OpenSearch syntax; differences in escaping, fuzzy matching, multi-field queries. |
26
26
  | B7 | ORM vs raw SQL semantic differences | SQLAlchemy `.filter()` vs `.filter_by()`; Django Q expressions vs raw SQL; lazy vs eager evaluation. |
27
27
 
28
28
  Use 5–10 sub-buckets for any single audit. For an audit that doesn't touch SQL or web frontends, drop B1 / B2 entirely and split B4 across the relevant runtimes.
@@ -26,6 +26,7 @@
26
26
  | E6 | Removed-but-not-deleted symbol references | Symbols renamed/removed elsewhere with stale import or call sites left behind. |
27
27
  | E7 | Test fixtures / helpers defined but never used | Pytest fixtures, test data builders, mock factories with no callers. |
28
28
  | E8 | Stub / placeholder code without TODO | `pass`, `...`, `raise NotImplementedError` left without explanation or tracking. |
29
+ | E9 | Constants-module exports with no importer | A module-level `UPPER_SNAKE` constant added to a `*_constants.py` / `config/` module that no module in the repo imports and that the constants file itself never references. The file-global use-count gate exempts a constants module because every name it exports legitimately carries zero in-file references, so a genuinely dead export slips past the write-time gate. Distinguish dead from live by grepping the whole repo for each constant name: a sibling such as `MEDIUM_TERMINAL` imported by a consumer module is live; a `MEDIUM_TEXT` that no `from ... import` line and no in-file reference names is dead (CODE_RULES 9.8). Remove the dead export. |
29
30
 
30
31
  ---
31
32
 
@@ -25,7 +25,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
25
25
  | O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
26
26
  | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. |
27
27
  | O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
28
- | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. See `../../rules/docstring-prose-matches-implementation.md`. |
28
+ | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
 
31
31
  ---
@@ -52,7 +52,7 @@ ID prefix: `find`.
52
52
  **B6. Search query DSL vs engine**
53
53
  - Every Lucene/Elasticsearch query string — verify field syntax (`field:value`), required/excluded operators (`+`, `-`), fuzzy (`term~2`), proximity (`"a b"~5`), and wildcard rules (`*`, `?`) match the engine version's parser.
54
54
  - Every Elasticsearch query DSL object (`match`, `bool`, `should`, `must`, `filter`, `term`, `terms`) — verify removed/renamed clauses across major versions (e.g. `query_string` defaults, `term` vs `match` for `text` fields, mapping-type removal in ES 7+).
55
- - Every Zoekt / Sourcegraph / OpenSearch / Solr query — verify dialect-specific operators and that the deployment has the relevant features enabled (e.g. ES `query_string` may be disabled for security).
55
+ - Every Sourcegraph / OpenSearch / Solr query — verify dialect-specific operators and that the deployment has the relevant features enabled (e.g. ES `query_string` may be disabled for security).
56
56
  - Every escaping rule for special characters in the DSL (`+ - && || ! ( ) { } [ ] ^ " ~ * ? : \ /`) — verify the producer escapes them before handing to the engine; flag any user-supplied input concatenated raw.
57
57
  - Every analyzer assumption (whitespace, standard, keyword, ngram) — verify the index mapping matches what the query string assumes.
58
58
 
@@ -375,7 +375,7 @@ Write-Host "$TaskName registered — runs every ${IntervalMinutes}min against '$
375
375
  - Probe B5.c: confirm no JSON-pointer (`/foo/bar`) string literals, no JsonPath-style `$.foo[?(@.bar)]` patterns, no XPath `/html/body//div[@class='x']` patterns in any string in the four files. Walk every f-string and string literal.
376
376
 
377
377
  **B6. Search query DSL vs engine**
378
- - The four PR #394 files contain no search-engine queries, no Lucene/Elasticsearch/Zoekt/OpenSearch DSL.
378
+ - The four PR #394 files contain no search-engine queries, no Lucene/Elasticsearch/OpenSearch DSL.
379
379
  - Shape B proof-of-absence expected. Adversarial probes must each verify a distinct search-DSL dimension:
380
380
  - Probe B6.a: confirm no HTTP calls to `/_search`, `/_msearch`, `/_count`, `/_analyze` endpoints — `sweep_empty_dirs.py` does not import `requests`, `urllib`, `httpx`, `aiohttp`. Pure stdlib + local config.
381
381
  - Probe B6.b: confirm no Lucene-syntax fragments — no `field:value`, no `+required -excluded`, no fuzzy `term~2`, no proximity `"a b"~5`. The only colon-bearing literals in the diff are PowerShell hash separators (`$($action.Execute) $($action.Arguments)` at `Install-SweepEmptyDirs.ps1:31`) and the time literal `"00:00"` at line 71 — neither is a search-DSL fragment.
@@ -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