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.
Files changed (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. 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