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
@@ -95,3 +95,228 @@ def test_should_skip_return_check_in_test_files() -> None:
95
95
  assert issues == [], f"Test files must be exempt, got: {issues}"
96
96
 
97
97
 
98
+ def test_should_flag_unannotated_known_fixture_in_test_file() -> None:
99
+ source = "def test_board(tmp_path):\n assert tmp_path.exists()\n"
100
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
101
+ source, TEST_FILE_PATH
102
+ )
103
+ assert any(
104
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
105
+ ), f"Expected unannotated tmp_path fixture flagged, got: {issues}"
106
+
107
+
108
+ def test_should_not_flag_annotated_known_fixture_in_test_file() -> None:
109
+ source = (
110
+ "from pathlib import Path\n"
111
+ "def test_board(tmp_path: Path) -> None:\n"
112
+ " assert tmp_path.exists()\n"
113
+ )
114
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
115
+ source, TEST_FILE_PATH
116
+ )
117
+ assert issues == [], f"Annotated tmp_path must not be flagged, got: {issues}"
118
+
119
+
120
+ def test_should_not_flag_ordinary_test_parameter() -> None:
121
+ source = "def test_thing(some_value):\n assert some_value\n"
122
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
123
+ source, TEST_FILE_PATH
124
+ )
125
+ assert issues == [], (
126
+ f"Ordinary test params stay exempt; only known fixtures are checked, "
127
+ f"got: {issues}"
128
+ )
129
+
130
+
131
+ def test_should_not_flag_known_fixture_name_outside_test_files() -> None:
132
+ source = "def build(monkeypatch):\n return monkeypatch\n"
133
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
134
+ source, PRODUCTION_FILE_PATH
135
+ )
136
+ assert issues == [], (
137
+ f"Non-test files are covered by the broad parameter check, not this one, "
138
+ f"got: {issues}"
139
+ )
140
+
141
+
142
+ def test_should_flag_unannotated_monkeypatch_fixture() -> None:
143
+ source = "def test_env(monkeypatch):\n monkeypatch.setenv('A', 'B')\n"
144
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
145
+ source, TEST_FILE_PATH
146
+ )
147
+ assert any(
148
+ "monkeypatch" in each_issue and "MonkeyPatch" in each_issue
149
+ for each_issue in issues
150
+ ), f"Expected unannotated monkeypatch fixture flagged, got: {issues}"
151
+
152
+
153
+ def test_should_not_flag_known_fixture_in_non_test_helper() -> None:
154
+ source = "def render_view(request):\n return request.path\n"
155
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
156
+ source, TEST_FILE_PATH
157
+ )
158
+ assert issues == [], (
159
+ f"Ordinary helper (non-test, non-fixture) must not be flagged, got: {issues}"
160
+ )
161
+
162
+
163
+ def test_should_flag_unannotated_fixture_in_decorated_fixture() -> None:
164
+ source = (
165
+ "import pytest\n"
166
+ "@pytest.fixture\n"
167
+ "def board(tmp_path):\n"
168
+ " return tmp_path\n"
169
+ )
170
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
171
+ source, TEST_FILE_PATH
172
+ )
173
+ assert any(
174
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
175
+ ), f"Expected unannotated tmp_path in @pytest.fixture-decorated function flagged, got: {issues}"
176
+
177
+
178
+ def test_should_flag_known_fixture_with_wrong_annotation() -> None:
179
+ source = "def test_board(tmp_path: str):\n assert tmp_path\n"
180
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
181
+ source, TEST_FILE_PATH
182
+ )
183
+ assert any(
184
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
185
+ ), f"Expected wrongly annotated tmp_path: str flagged, got: {issues}"
186
+
187
+
188
+ def test_should_flag_known_fixture_with_unrelated_annotation() -> None:
189
+ source = "def test_board(tmp_path: int):\n assert tmp_path\n"
190
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
191
+ source, TEST_FILE_PATH
192
+ )
193
+ assert any(
194
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
195
+ ), f"Expected wrongly annotated tmp_path: int flagged, got: {issues}"
196
+
197
+
198
+ def test_should_not_flag_correctly_annotated_qualified_fixture() -> None:
199
+ source = (
200
+ "import pytest\n"
201
+ "def test_env(monkeypatch: pytest.MonkeyPatch) -> None:\n"
202
+ " monkeypatch.setenv('A', 'B')\n"
203
+ )
204
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
205
+ source, TEST_FILE_PATH
206
+ )
207
+ assert issues == [], (
208
+ f"Correctly annotated monkeypatch must not be flagged, got: {issues}"
209
+ )
210
+
211
+
212
+ def test_should_not_flag_dotted_pathlib_path_fixture_annotation() -> None:
213
+ source = (
214
+ "import pathlib\n"
215
+ "def test_board(tmp_path: pathlib.Path) -> None:\n"
216
+ " assert tmp_path.exists()\n"
217
+ )
218
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
219
+ source, TEST_FILE_PATH
220
+ )
221
+ assert issues == [], (
222
+ f"tmp_path: pathlib.Path is an equally-correct spelling, got: {issues}"
223
+ )
224
+
225
+
226
+ def test_should_not_flag_bare_tail_of_qualified_fixture_annotation() -> None:
227
+ source = (
228
+ "from pytest import MonkeyPatch\n"
229
+ "def test_env(monkeypatch: MonkeyPatch) -> None:\n"
230
+ " monkeypatch.setenv('A', 'B')\n"
231
+ )
232
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
233
+ source, TEST_FILE_PATH
234
+ )
235
+ assert issues == [], (
236
+ f"monkeypatch: MonkeyPatch matches the qualified expected tail, got: {issues}"
237
+ )
238
+
239
+
240
+ def test_should_not_flag_forward_reference_fixture_annotation() -> None:
241
+ source = 'def test_board(tmp_path: "Path") -> None:\n assert tmp_path\n'
242
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
243
+ source, TEST_FILE_PATH
244
+ )
245
+ assert issues == [], (
246
+ f'A forward-ref "Path" annotation must not be flagged, got: {issues}'
247
+ )
248
+
249
+
250
+ def test_should_not_flag_star_arg_fixture_name() -> None:
251
+ source = "def test_board(*tmp_path):\n assert tmp_path\n"
252
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
253
+ source, TEST_FILE_PATH
254
+ )
255
+ assert issues == [], (
256
+ f"A *vararg sharing a fixture name is not an injection site, got: {issues}"
257
+ )
258
+
259
+
260
+ def test_should_not_flag_double_star_arg_fixture_name() -> None:
261
+ source = "def test_env(**monkeypatch):\n assert monkeypatch\n"
262
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
263
+ source, TEST_FILE_PATH
264
+ )
265
+ assert issues == [], (
266
+ f"A **kwarg sharing a fixture name is not an injection site, got: {issues}"
267
+ )
268
+
269
+
270
+ def test_should_not_flag_positional_only_fixture_name() -> None:
271
+ source = "def test_board(tmp_path, /):\n pass\n"
272
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
273
+ source, TEST_FILE_PATH
274
+ )
275
+ assert issues == [], (
276
+ f"A positional-only param cannot receive a keyword-injected fixture, got: {issues}"
277
+ )
278
+
279
+
280
+ def test_should_flag_keyword_only_fixture_name() -> None:
281
+ source = "def test_board(*, tmp_path):\n pass\n"
282
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
283
+ source, TEST_FILE_PATH
284
+ )
285
+ assert any(
286
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
287
+ ), f"A keyword-only fixture is still an injection site, got: {issues}"
288
+
289
+
290
+ def test_should_not_flag_defaulted_fixture_name() -> None:
291
+ source = "def test_board(tmp_path=None):\n pass\n"
292
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
293
+ source, TEST_FILE_PATH
294
+ )
295
+ assert issues == [], (
296
+ f"A defaulted param is not fixture-injected and must not be flagged, got: {issues}"
297
+ )
298
+
299
+
300
+ def test_should_not_flag_defaulted_keyword_only_fixture_name() -> None:
301
+ source = "def test_board(*, tmp_path=None):\n pass\n"
302
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
303
+ source, TEST_FILE_PATH
304
+ )
305
+ assert issues == [], (
306
+ f"A defaulted keyword-only param is not fixture-injected, got: {issues}"
307
+ )
308
+
309
+
310
+ def test_should_flag_undefaulted_fixture_before_defaulted_one() -> None:
311
+ source = "def test_board(tmp_path, capsys=None):\n pass\n"
312
+ issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
313
+ source, TEST_FILE_PATH
314
+ )
315
+ assert any(
316
+ "tmp_path" in each_issue and "Path" in each_issue for each_issue in issues
317
+ ), f"An undefaulted leading fixture stays an injection site, got: {issues}"
318
+ assert not any("capsys" in each_issue for each_issue in issues), (
319
+ f"The trailing defaulted param must not be flagged, got: {issues}"
320
+ )
321
+
322
+
@@ -70,6 +70,7 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
70
70
  "check_file_global_constants_use_count",
71
71
  "check_imports_at_top",
72
72
  "check_inline_literal_collections",
73
+ "check_known_pytest_fixture_annotations",
73
74
  "check_library_print",
74
75
  "check_loop_variable_naming",
75
76
  "check_parameter_annotations",
@@ -0,0 +1,146 @@
1
+ """Tests for the cross-skill duplicate-helper advisory.
2
+
3
+ PR #233 on JonEcho/python-automation copied a Chrome-launch helper from the
4
+ ``stp-recolor`` skill's ``scripts`` directory into the ``iconize-and-recolor-stp``
5
+ skill's ``scripts`` directory. The two skills install on their own, so a shared
6
+ module would couple them and break independent install; the copy is a defensible
7
+ skill-isolation tradeoff. ``advise_cross_skill_duplicate_helper`` surfaces that
8
+ copy as a non-blocking ``[CODE_RULES advisory]`` on stderr so a reviewer confirms
9
+ the copy was intentional, without denying the write.
10
+
11
+ The tests build a real ``skills/<name>/scripts`` layout on disk and run the
12
+ advisory against it, so they exercise the on-disk cross-skill scan rather than a
13
+ stubbed view of the filesystem.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.util
19
+ import pathlib
20
+ import shutil
21
+ import sys
22
+ import tempfile
23
+ from collections.abc import Iterator
24
+
25
+ import pytest
26
+
27
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
28
+ if str(_HOOK_DIRECTORY) not in sys.path:
29
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
30
+
31
+ _hook_spec = importlib.util.spec_from_file_location(
32
+ "code_rules_duplicate_body",
33
+ _HOOK_DIRECTORY / "code_rules_duplicate_body.py",
34
+ )
35
+ assert _hook_spec is not None
36
+ assert _hook_spec.loader is not None
37
+ _hook_module = importlib.util.module_from_spec(_hook_spec)
38
+ _hook_spec.loader.exec_module(_hook_module)
39
+ advise_cross_skill_duplicate_helper = _hook_module.advise_cross_skill_duplicate_helper
40
+
41
+
42
+ CHROME_HELPER_SOURCE = (
43
+ "import winreg\n"
44
+ "from pathlib import Path\n"
45
+ "\n"
46
+ "chrome_app_paths_key = r'SOFTWARE\\Microsoft\\App Paths\\chrome.exe'\n"
47
+ "chrome_fallback_paths = ('C:/chrome.exe',)\n"
48
+ "\n"
49
+ "def _chrome_executable() -> Path | None:\n"
50
+ " for each_root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):\n"
51
+ " try:\n"
52
+ " registered = winreg.QueryValue(each_root, chrome_app_paths_key)\n"
53
+ " except OSError:\n"
54
+ " continue\n"
55
+ " registered_path = Path(registered)\n"
56
+ " if registered_path.exists():\n"
57
+ " return registered_path\n"
58
+ " for each_fallback in chrome_fallback_paths:\n"
59
+ " fallback_path = Path(each_fallback)\n"
60
+ " if fallback_path.exists():\n"
61
+ " return fallback_path\n"
62
+ " return None\n"
63
+ )
64
+
65
+
66
+ @pytest.fixture
67
+ def skills_root() -> Iterator[pathlib.Path]:
68
+ base_directory = pathlib.Path(tempfile.mkdtemp())
69
+ skills_directory = base_directory / "skills"
70
+ skills_directory.mkdir()
71
+ try:
72
+ yield skills_directory
73
+ finally:
74
+ shutil.rmtree(base_directory, ignore_errors=False)
75
+
76
+
77
+ def _make_skill_scripts(skills_directory: pathlib.Path, skill_name: str) -> pathlib.Path:
78
+ scripts_directory = skills_directory / skill_name / "scripts"
79
+ scripts_directory.mkdir(parents=True)
80
+ return scripts_directory
81
+
82
+
83
+ def test_advises_when_helper_copied_from_another_skill(
84
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
85
+ ) -> None:
86
+ source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
87
+ (source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
88
+ target_scripts = _make_skill_scripts(skills_root, "iconize-and-recolor-stp")
89
+ target_file = target_scripts / "combine_report.py"
90
+
91
+ advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
92
+
93
+ captured = capsys.readouterr()
94
+ assert "[CODE_RULES advisory]" in captured.err, (
95
+ f"Expected a cross-skill advisory on stderr, got: {captured.err!r}"
96
+ )
97
+ assert "_chrome_executable" in captured.err, (
98
+ f"Expected the duplicated function named, got: {captured.err!r}"
99
+ )
100
+ assert "stp-recolor" in captured.err, f"Expected the source skill named, got: {captured.err!r}"
101
+
102
+
103
+ def test_advisory_does_not_block(
104
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
105
+ ) -> None:
106
+ source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
107
+ (source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
108
+ target_scripts = _make_skill_scripts(skills_root, "iconize-and-recolor-stp")
109
+ target_file = target_scripts / "combine_report.py"
110
+
111
+ returned = advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
112
+
113
+ assert returned is None, "The advisory returns nothing so it never enters the deny path"
114
+
115
+
116
+ def test_no_advisory_within_one_skill(
117
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
118
+ ) -> None:
119
+ scripts_directory = _make_skill_scripts(skills_root, "stp-recolor")
120
+ (scripts_directory / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
121
+ sibling_file = scripts_directory / "combine_report.py"
122
+
123
+ advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(sibling_file))
124
+
125
+ captured = capsys.readouterr()
126
+ assert captured.err == "", (
127
+ "Within one skill the blocking gate covers the copy; the cross-skill "
128
+ f"advisory must stay silent, got: {captured.err!r}"
129
+ )
130
+
131
+
132
+ def test_no_advisory_outside_a_skill_scripts_directory(
133
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
134
+ ) -> None:
135
+ source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
136
+ (source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
137
+ non_skill_directory = skills_root.parent / "elsewhere"
138
+ non_skill_directory.mkdir()
139
+ target_file = non_skill_directory / "combine_report.py"
140
+
141
+ advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
142
+
143
+ captured = capsys.readouterr()
144
+ assert captured.err == "", (
145
+ f"A file outside a skill scripts directory draws no advisory, got: {captured.err!r}"
146
+ )