claude-dev-env 1.58.0 → 1.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Cross-file duplicate top-level function body detection.
|
|
2
|
+
|
|
3
|
+
The check flags a top-level function in the file being written whose body is
|
|
4
|
+
structurally identical to a top-level function already defined in a sibling
|
|
5
|
+
``.py`` module in the same directory. This catches the Reuse-before-create / DRY
|
|
6
|
+
violation where a helper is copy-pasted across several modules instead of being
|
|
7
|
+
imported from one shared home.
|
|
8
|
+
|
|
9
|
+
The scan is deliberately conservative to keep false positives near zero:
|
|
10
|
+
|
|
11
|
+
- Only module-scope ``def`` / ``async def`` bodies are compared (the copied-helper
|
|
12
|
+
case), never methods nested in a class.
|
|
13
|
+
- Bodies are compared by their normalized AST structure with the leading
|
|
14
|
+
docstring dropped, so reformatting and comment differences do not hide a copy.
|
|
15
|
+
The comparison keeps identifier names, so a match requires the body statements,
|
|
16
|
+
including local variable names, to be structurally identical; it does not
|
|
17
|
+
consider the parameter list, decorators, or whether the function is ``async``.
|
|
18
|
+
- A body must contain at least ``MINIMUM_DUPLICATE_BODY_STATEMENTS`` statements;
|
|
19
|
+
trivial one- or two-line helpers (``return None``, a single delegation) are too
|
|
20
|
+
common to flag.
|
|
21
|
+
- Test files and ``__init__.py`` re-export surfaces never participate, on either
|
|
22
|
+
the writing side or the sibling side.
|
|
23
|
+
|
|
24
|
+
Unlike most code-rules checks, this one runs on hook-infrastructure files: the
|
|
25
|
+
copied-helper violation it targets appears most often in the ``blocking/`` hook
|
|
26
|
+
directory itself, so gating it behind the hook-infrastructure exemption would
|
|
27
|
+
leave the exact violation class unguarded. The enforcer entry points route a
|
|
28
|
+
hook ``.py`` target to this single check even though the full code-rules verdict
|
|
29
|
+
stays off hook infrastructure, so a Write or pre-check against a file under the
|
|
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.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
47
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
48
|
+
if _blocking_directory not in sys.path:
|
|
49
|
+
sys.path.insert(0, _blocking_directory)
|
|
50
|
+
if _hooks_directory not in sys.path:
|
|
51
|
+
sys.path.insert(0, _hooks_directory)
|
|
52
|
+
|
|
53
|
+
from code_rules_shared import ( # noqa: E402
|
|
54
|
+
_scope_violations_to_changed_lines,
|
|
55
|
+
is_test_file,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
from hooks_constants.duplicate_function_body_constants import ( # noqa: E402
|
|
59
|
+
CROSS_SKILL_ADVISORY_PREFIX,
|
|
60
|
+
CROSS_SKILL_DUPLICATE_GUIDANCE,
|
|
61
|
+
DUNDER_INIT_FILENAME,
|
|
62
|
+
DUPLICATE_BODY_GUIDANCE,
|
|
63
|
+
MAX_CROSS_SKILL_ADVISORY_ISSUES,
|
|
64
|
+
MAX_DUPLICATE_BODY_ISSUES,
|
|
65
|
+
MINIMUM_DUPLICATE_BODY_STATEMENTS,
|
|
66
|
+
PYTHON_SOURCE_SUFFIX,
|
|
67
|
+
SKILL_SCRIPTS_DIRECTORY_NAME,
|
|
68
|
+
SKILLS_DIRECTORY_NAME,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalized_body_signature(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
73
|
+
"""Return a position-independent structural fingerprint of the function body.
|
|
74
|
+
|
|
75
|
+
The docstring statement, when present, is dropped so two copies that differ
|
|
76
|
+
only in their docstring still collide. Returns None when the remaining body
|
|
77
|
+
is shorter than the minimum statement count, which signals the caller to skip
|
|
78
|
+
this function as too trivial to be a meaningful duplicate.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
function_node: The module-scope function definition to fingerprint.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A normalized AST dump of the body statements, or None when the body is
|
|
85
|
+
too small to compare.
|
|
86
|
+
"""
|
|
87
|
+
body_statements = list(function_node.body)
|
|
88
|
+
if body_statements and isinstance(body_statements[0], ast.Expr):
|
|
89
|
+
first_value = body_statements[0].value
|
|
90
|
+
if isinstance(first_value, ast.Constant) and isinstance(first_value.value, str):
|
|
91
|
+
body_statements = body_statements[1:]
|
|
92
|
+
if len(body_statements) < MINIMUM_DUPLICATE_BODY_STATEMENTS:
|
|
93
|
+
return None
|
|
94
|
+
return "\n".join(
|
|
95
|
+
ast.dump(each_statement, annotate_fields=False) for each_statement in body_statements
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _top_level_function_signatures(tree: ast.Module) -> dict[str, str]:
|
|
100
|
+
"""Map each module-scope function name to its normalized body signature.
|
|
101
|
+
|
|
102
|
+
Functions whose body is too trivial to compare are omitted.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tree: The parsed module.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A name-to-signature mapping for the comparable top-level functions.
|
|
109
|
+
"""
|
|
110
|
+
signature_by_name: dict[str, str] = {}
|
|
111
|
+
for each_node in tree.body:
|
|
112
|
+
if isinstance(each_node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
113
|
+
body_signature = _normalized_body_signature(each_node)
|
|
114
|
+
if body_signature is not None:
|
|
115
|
+
signature_by_name[each_node.name] = body_signature
|
|
116
|
+
return signature_by_name
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _function_definition_span(
|
|
120
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
121
|
+
) -> range:
|
|
122
|
+
"""Return the inclusive 1-indexed source-line span of a function definition.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
function_node: The module-scope function definition.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A range covering the signature line through the last body line, so a
|
|
129
|
+
changed-line set intersects the span when an edit touches any line of the
|
|
130
|
+
function — mirroring the span scoping the sibling whole-file checks use.
|
|
131
|
+
"""
|
|
132
|
+
last_line = function_node.end_lineno or function_node.lineno
|
|
133
|
+
return range(function_node.lineno, last_line + 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _top_level_function_signature_spans(
|
|
137
|
+
tree: ast.Module,
|
|
138
|
+
) -> dict[str, tuple[str, range]]:
|
|
139
|
+
"""Map each comparable module-scope function to its signature and source span.
|
|
140
|
+
|
|
141
|
+
Functions whose body is too trivial to compare are omitted.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
tree: The parsed module being written.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A name-to-``(signature, span)`` mapping for the comparable top-level
|
|
148
|
+
functions, where the span covers the function's source lines.
|
|
149
|
+
"""
|
|
150
|
+
signature_span_by_name: dict[str, tuple[str, range]] = {}
|
|
151
|
+
for each_node in tree.body:
|
|
152
|
+
if isinstance(each_node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
153
|
+
body_signature = _normalized_body_signature(each_node)
|
|
154
|
+
if body_signature is not None:
|
|
155
|
+
signature_span_by_name[each_node.name] = (
|
|
156
|
+
body_signature,
|
|
157
|
+
_function_definition_span(each_node),
|
|
158
|
+
)
|
|
159
|
+
return signature_span_by_name
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_comparable_sibling(sibling_path: Path, written_file_name: str) -> bool:
|
|
163
|
+
"""Return whether a directory entry is a sibling module worth comparing against.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
sibling_path: A candidate path from the written file's directory.
|
|
167
|
+
written_file_name: The base name of the file being written.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True for a Python source file other than the written file itself,
|
|
171
|
+
excluding ``__init__.py`` and test modules.
|
|
172
|
+
"""
|
|
173
|
+
if not sibling_path.is_file():
|
|
174
|
+
return False
|
|
175
|
+
if sibling_path.suffix != PYTHON_SOURCE_SUFFIX:
|
|
176
|
+
return False
|
|
177
|
+
if sibling_path.name == written_file_name:
|
|
178
|
+
return False
|
|
179
|
+
if sibling_path.name == DUNDER_INIT_FILENAME:
|
|
180
|
+
return False
|
|
181
|
+
return not is_test_file(sibling_path.name)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _sibling_signatures(
|
|
185
|
+
file_path: str,
|
|
186
|
+
sibling_directory: Path | None = None,
|
|
187
|
+
) -> dict[str, list[str]]:
|
|
188
|
+
"""Collect normalized body signatures from every comparable sibling module.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
file_path: The path of the file being written.
|
|
192
|
+
sibling_directory: An absolute directory to scan for sibling modules.
|
|
193
|
+
When None, the directory is derived from ``file_path``'s parent,
|
|
194
|
+
which resolves against the process CWD for a relative ``file_path``.
|
|
195
|
+
The commit/push gate passes the resolved file's parent so sibling
|
|
196
|
+
resolution stays anchored to the repository regardless of the gate
|
|
197
|
+
process's working directory.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
A signature-to-source-names mapping, where the value lists the
|
|
201
|
+
``module.py::function`` locations carrying that body.
|
|
202
|
+
"""
|
|
203
|
+
written_path = Path(file_path)
|
|
204
|
+
directory = written_path.parent if sibling_directory is None else sibling_directory
|
|
205
|
+
source_names_by_signature: dict[str, list[str]] = {}
|
|
206
|
+
try:
|
|
207
|
+
all_entries = sorted(directory.iterdir())
|
|
208
|
+
except OSError:
|
|
209
|
+
return {}
|
|
210
|
+
for each_entry in all_entries:
|
|
211
|
+
if not _is_comparable_sibling(each_entry, written_path.name):
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
sibling_source = each_entry.read_text(encoding="utf-8")
|
|
215
|
+
sibling_tree = ast.parse(sibling_source)
|
|
216
|
+
except (OSError, UnicodeDecodeError, SyntaxError):
|
|
217
|
+
continue
|
|
218
|
+
for each_name, each_signature in _top_level_function_signatures(sibling_tree).items():
|
|
219
|
+
location = f"{each_entry.name}::{each_name}"
|
|
220
|
+
source_names_by_signature.setdefault(each_signature, []).append(location)
|
|
221
|
+
return source_names_by_signature
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def check_duplicate_function_body_across_files(
|
|
225
|
+
content: str,
|
|
226
|
+
file_path: str,
|
|
227
|
+
all_changed_lines: set[int] | None = None,
|
|
228
|
+
defer_scope_to_caller: bool = False,
|
|
229
|
+
sibling_directory: Path | None = None,
|
|
230
|
+
) -> list[str]:
|
|
231
|
+
"""Flag top-level functions copied byte-for-structure from a sibling module.
|
|
232
|
+
|
|
233
|
+
Compares each module-scope function in the post-edit content against the
|
|
234
|
+
top-level functions of every comparable ``.py`` sibling in the same
|
|
235
|
+
directory, and reports any whose normalized body matches. Test files and
|
|
236
|
+
``__init__.py`` are skipped on both sides.
|
|
237
|
+
|
|
238
|
+
Violations are scoped to the lines an edit touched the same way the sibling
|
|
239
|
+
whole-file checks scope theirs: an Edit blocks only on a duplicated function
|
|
240
|
+
whose source span intersects the changed lines, so an unrelated edit to a
|
|
241
|
+
file that already carries a byte-identical entrypoint shim in a sibling
|
|
242
|
+
module does not block, while a Write that newly copies a sibling helper still
|
|
243
|
+
flags because every line is in scope.
|
|
244
|
+
|
|
245
|
+
Unlike the sibling whole-file checks, this check carries no
|
|
246
|
+
``is_hook_infrastructure`` exemption: the copied-helper violation it targets
|
|
247
|
+
appears most often in the ``blocking/`` hook directory itself.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
content: The full post-edit file content being written.
|
|
251
|
+
file_path: The destination path of the write.
|
|
252
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
253
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
254
|
+
blocks only when the duplicated function's source span intersects the
|
|
255
|
+
changed lines.
|
|
256
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
257
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
258
|
+
line.
|
|
259
|
+
sibling_directory: An absolute directory to scan for sibling modules.
|
|
260
|
+
When None, the directory is derived from ``file_path``'s parent. The
|
|
261
|
+
PreToolUse path leaves this None because its ``file_path`` is already
|
|
262
|
+
absolute; the commit/push gate passes the resolved file's parent so
|
|
263
|
+
the sibling scan stays anchored to the repository regardless of the
|
|
264
|
+
gate process's working directory.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Human-readable violation strings, one per duplicated function, scoped to
|
|
268
|
+
the changed lines unless *defer_scope_to_caller* is True or
|
|
269
|
+
*all_changed_lines* is None.
|
|
270
|
+
"""
|
|
271
|
+
written_name = Path(file_path).name
|
|
272
|
+
if written_name == DUNDER_INIT_FILENAME:
|
|
273
|
+
return []
|
|
274
|
+
if is_test_file(file_path):
|
|
275
|
+
return []
|
|
276
|
+
try:
|
|
277
|
+
written_tree = ast.parse(content)
|
|
278
|
+
except SyntaxError:
|
|
279
|
+
return []
|
|
280
|
+
written_signature_spans = _top_level_function_signature_spans(written_tree)
|
|
281
|
+
if not written_signature_spans:
|
|
282
|
+
return []
|
|
283
|
+
source_names_by_signature = _sibling_signatures(file_path, sibling_directory)
|
|
284
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
285
|
+
for each_name, (each_signature, each_span) in written_signature_spans.items():
|
|
286
|
+
matching_locations = source_names_by_signature.get(each_signature)
|
|
287
|
+
if not matching_locations:
|
|
288
|
+
continue
|
|
289
|
+
first_location = matching_locations[0]
|
|
290
|
+
message = (
|
|
291
|
+
f"Function {each_name!r} duplicates {first_location} — {DUPLICATE_BODY_GUIDANCE} "
|
|
292
|
+
f"(duplicate body span at line {each_span.start}, spanning {len(each_span)} lines)"
|
|
293
|
+
)
|
|
294
|
+
all_violations_in_walk_order.append((each_span, message))
|
|
295
|
+
if len(all_violations_in_walk_order) >= MAX_DUPLICATE_BODY_ISSUES:
|
|
296
|
+
break
|
|
297
|
+
return _scope_violations_to_changed_lines(
|
|
298
|
+
all_violations_in_walk_order,
|
|
299
|
+
all_changed_lines,
|
|
300
|
+
defer_scope_to_caller,
|
|
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
|