claude-dev-env 1.75.0 → 1.77.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 (47) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/CLAUDE.md +2 -2
  9. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  10. package/hooks/blocking/code_rules_docstrings.py +378 -0
  11. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  12. package/hooks/blocking/code_rules_enforcer.py +48 -5
  13. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  14. package/hooks/blocking/code_rules_shared.py +8 -5
  15. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  16. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  21. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  22. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  23. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  24. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  25. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  27. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  28. package/hooks/blocking/verification_verdict_store.py +19 -5
  29. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  30. package/hooks/hooks_constants/blocking_check_limits.py +56 -0
  31. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
  33. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/claude-md-orphan-file.md +7 -8
  37. package/rules/docstring-prose-matches-implementation.md +5 -1
  38. package/rules/package-inventory-stale-entry.md +16 -0
  39. package/rules/plain-illustrative-docstrings.md +56 -0
  40. package/skills/anthropic-plan/CLAUDE.md +1 -1
  41. package/skills/anthropic-plan/SKILL.md +15 -2
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
  43. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
  44. package/skills/autoconverge/workflow/converge.mjs +86 -110
  45. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  46. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  47. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -0,0 +1,157 @@
1
+ """Tests for the ruff-I001 import-block-sort gate in code_rules_imports_logging.
2
+
3
+ The gate delegates to ruff itself, so these tests run real ruff over real source
4
+ shapes (the same way the repo quality gate does) rather than mocking the
5
+ subprocess. A sorted block passes; the PR #749 shape — two local ``from`` imports
6
+ out of alphabetical order in one contiguous block — fails with an I001 issue.
7
+ """
8
+
9
+ import importlib.util
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+
13
+ IMPORTS_MODULE_FILENAME = "code_rules_imports_logging.py"
14
+ IMPORTS_MODULE_NAME = "code_rules_imports_logging_under_test"
15
+ PRODUCTION_PYTHON_TARGET = str(Path(__file__).parent / "production_module.py")
16
+
17
+ SORTED_IMPORT_BLOCK_SOURCE = (
18
+ "import json\n"
19
+ "import os\n"
20
+ "\n"
21
+ "from package.alpha import first\n"
22
+ "from package.beta import second\n"
23
+ "from package.gamma import third\n"
24
+ )
25
+
26
+ UNSORTED_IMPORT_BLOCK_SOURCE = (
27
+ "import json\n"
28
+ "import os\n"
29
+ "\n"
30
+ "from package.gamma import third\n"
31
+ "from package.alpha import first\n"
32
+ "from package.beta import second\n"
33
+ )
34
+
35
+
36
+ def load_imports_module() -> ModuleType:
37
+ module_path = Path(__file__).parent / IMPORTS_MODULE_FILENAME
38
+ module_spec = importlib.util.spec_from_file_location(IMPORTS_MODULE_NAME, module_path)
39
+ assert module_spec is not None
40
+ assert module_spec.loader is not None
41
+ imports_module = importlib.util.module_from_spec(module_spec)
42
+ module_spec.loader.exec_module(imports_module)
43
+ return imports_module
44
+
45
+
46
+ imports_logging = load_imports_module()
47
+
48
+
49
+ def test_should_flag_unsorted_import_block() -> None:
50
+ issues = imports_logging.check_import_block_sorted(
51
+ UNSORTED_IMPORT_BLOCK_SOURCE, PRODUCTION_PYTHON_TARGET
52
+ )
53
+ assert len(issues) == 1
54
+ assert "un-sorted (ruff I001)" in issues[0]
55
+
56
+
57
+ def test_should_allow_sorted_import_block() -> None:
58
+ issues = imports_logging.check_import_block_sorted(
59
+ SORTED_IMPORT_BLOCK_SOURCE, PRODUCTION_PYTHON_TARGET
60
+ )
61
+ assert issues == []
62
+
63
+
64
+ def test_should_report_the_block_anchor_line_number() -> None:
65
+ issues = imports_logging.check_import_block_sorted(
66
+ UNSORTED_IMPORT_BLOCK_SOURCE, PRODUCTION_PYTHON_TARGET
67
+ )
68
+ assert issues[0].startswith("Line 1:")
69
+
70
+
71
+ def test_should_exempt_test_files() -> None:
72
+ issues = imports_logging.check_import_block_sorted(
73
+ UNSORTED_IMPORT_BLOCK_SOURCE, "test_something.py"
74
+ )
75
+ assert issues == []
76
+
77
+
78
+ def test_should_exempt_non_python_files() -> None:
79
+ issues = imports_logging.check_import_block_sorted(UNSORTED_IMPORT_BLOCK_SOURCE, "notes.md")
80
+ assert issues == []
81
+
82
+
83
+ def test_should_fail_open_on_unparseable_source() -> None:
84
+ broken_source = "def (:::\n import os\n"
85
+ issues = imports_logging.check_import_block_sorted(broken_source, PRODUCTION_PYTHON_TARGET)
86
+ assert issues == []
87
+
88
+
89
+ def test_should_fail_open_when_no_ruff_config_is_discoverable(tmp_path: Path) -> None:
90
+ target_without_config = tmp_path / "standalone_module.py"
91
+ issues = imports_logging.check_import_block_sorted(
92
+ UNSORTED_IMPORT_BLOCK_SOURCE, str(target_without_config)
93
+ )
94
+ assert issues == []
95
+
96
+
97
+ def test_should_skip_unsorted_block_when_edit_is_distant_from_it() -> None:
98
+ changed_lines_away_from_block = {9}
99
+ issues = imports_logging.check_import_block_sorted(
100
+ UNSORTED_IMPORT_BLOCK_SOURCE,
101
+ PRODUCTION_PYTHON_TARGET,
102
+ changed_lines_away_from_block,
103
+ )
104
+ assert issues == []
105
+
106
+
107
+ def test_should_flag_unsorted_block_when_edit_touches_anchor_line() -> None:
108
+ changed_lines_on_block_anchor = {1}
109
+ issues = imports_logging.check_import_block_sorted(
110
+ UNSORTED_IMPORT_BLOCK_SOURCE,
111
+ PRODUCTION_PYTHON_TARGET,
112
+ changed_lines_on_block_anchor,
113
+ )
114
+ assert len(issues) == 1
115
+ assert "un-sorted (ruff I001)" in issues[0]
116
+
117
+
118
+ def test_should_flag_unsorted_block_when_edit_touches_non_anchor_block_line() -> None:
119
+ changed_lines_inside_block_below_anchor = {4}
120
+ issues = imports_logging.check_import_block_sorted(
121
+ UNSORTED_IMPORT_BLOCK_SOURCE,
122
+ PRODUCTION_PYTHON_TARGET,
123
+ changed_lines_inside_block_below_anchor,
124
+ )
125
+ assert len(issues) == 1
126
+ assert "un-sorted (ruff I001)" in issues[0]
127
+
128
+
129
+ def test_should_flag_unsorted_block_when_edit_touches_last_block_line() -> None:
130
+ changed_lines_on_block_final_line = {6}
131
+ issues = imports_logging.check_import_block_sorted(
132
+ UNSORTED_IMPORT_BLOCK_SOURCE,
133
+ PRODUCTION_PYTHON_TARGET,
134
+ changed_lines_on_block_final_line,
135
+ )
136
+ assert len(issues) == 1
137
+ assert "un-sorted (ruff I001)" in issues[0]
138
+
139
+
140
+ def test_should_flag_unsorted_block_when_changed_lines_is_none() -> None:
141
+ issues = imports_logging.check_import_block_sorted(
142
+ UNSORTED_IMPORT_BLOCK_SOURCE,
143
+ PRODUCTION_PYTHON_TARGET,
144
+ None,
145
+ )
146
+ assert len(issues) == 1
147
+
148
+
149
+ def test_should_defer_unsorted_block_to_caller_scope() -> None:
150
+ changed_lines_away_from_block = {9}
151
+ issues = imports_logging.check_import_block_sorted(
152
+ UNSORTED_IMPORT_BLOCK_SOURCE,
153
+ PRODUCTION_PYTHON_TARGET,
154
+ changed_lines_away_from_block,
155
+ defer_scope_to_caller=True,
156
+ )
157
+ assert len(issues) == 1
@@ -0,0 +1,466 @@
1
+ """Tests for same-file inline duplicate function-body detection.
2
+
3
+ PR #470 (JonEcho/python-automation) added ``_wait_for_content_image_to_render``,
4
+ a top-level async helper whose body is byte-for-structure identical to a
5
+ content-image-wait block already inlined inside ``_wait_for_page_after_account_switch``
6
+ in the same module. The cross-file duplicate-body check never compares two
7
+ functions in the same file, and it only matches a whole function against a whole
8
+ function — so the inlined-block copy slipped past it. This check closes that gap:
9
+ a top-level function whose body appears verbatim as a contiguous statement block
10
+ inside another function in the same module is flagged so the author calls the
11
+ helper from that function instead of repeating the block.
12
+
13
+ The tests build real source strings and run the check directly, exercising the
14
+ in-process AST scan the production enforcer runs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.util
20
+ import pathlib
21
+ import sys
22
+
23
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
24
+ if str(_HOOK_DIRECTORY) not in sys.path:
25
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
26
+
27
+ _hook_spec = importlib.util.spec_from_file_location(
28
+ "code_rules_enforcer",
29
+ _HOOK_DIRECTORY / "code_rules_enforcer.py",
30
+ )
31
+ assert _hook_spec is not None
32
+ assert _hook_spec.loader is not None
33
+ _hook_module = importlib.util.module_from_spec(_hook_spec)
34
+ _hook_spec.loader.exec_module(_hook_module)
35
+ check_same_file_inline_duplicate_body = _hook_module.check_same_file_inline_duplicate_body
36
+
37
+
38
+ PR_470_SHAPE_SOURCE = (
39
+ "import asyncio\n"
40
+ "\n"
41
+ "\n"
42
+ "async def _wait_for_content_image_to_render(automation: object) -> None:\n"
43
+ ' """Wait for the content image to render before detection."""\n'
44
+ " assert automation.detector is not None\n"
45
+ " try:\n"
46
+ " await automation.detector.wait_for_element(\n"
47
+ " account_detector_config.content_image_selector,\n"
48
+ " timeout=page_navigation_delays.account_switch_page_load,\n"
49
+ " )\n"
50
+ " except (TimeoutError, RuntimeError) as content_error:\n"
51
+ " logger.warning('content images did not appear: %s', content_error)\n"
52
+ "\n"
53
+ "\n"
54
+ "async def _wait_for_page_after_account_switch(automation: object) -> None:\n"
55
+ " await asyncio.to_thread(automation.cdp._navigate_to_url, url)\n"
56
+ " await asyncio.to_thread(automation.cdp._wait_for_page_load)\n"
57
+ " assert automation.detector is not None\n"
58
+ " try:\n"
59
+ " await automation.detector.wait_for_element(\n"
60
+ " account_detector_config.content_image_selector,\n"
61
+ " timeout=page_navigation_delays.account_switch_page_load,\n"
62
+ " )\n"
63
+ " except (TimeoutError, RuntimeError) as content_error:\n"
64
+ " logger.warning('content images did not appear: %s', content_error)\n"
65
+ )
66
+
67
+
68
+ def test_should_flag_helper_whose_body_is_inlined_in_another_function() -> None:
69
+ issues = check_same_file_inline_duplicate_body(PR_470_SHAPE_SOURCE, "account_switcher.py")
70
+ assert any("_wait_for_content_image_to_render" in each_issue for each_issue in issues), (
71
+ f"The helper duplicating an inline block must be flagged, got: {issues}"
72
+ )
73
+ assert any("_wait_for_page_after_account_switch" in each_issue for each_issue in issues), (
74
+ f"The enclosing function carrying the inline copy must be named, got: {issues}"
75
+ )
76
+
77
+
78
+ def test_should_not_flag_when_no_function_inlines_the_helper_body() -> None:
79
+ source = (
80
+ "async def _wait_for_content_image_to_render(automation: object) -> None:\n"
81
+ " assert automation.detector is not None\n"
82
+ " await automation.detector.wait_for_element(selector)\n"
83
+ " logger.info('rendered')\n"
84
+ "\n"
85
+ "\n"
86
+ "async def _do_other_work(automation: object) -> None:\n"
87
+ " await automation.cdp.reload()\n"
88
+ " logger.info('reloaded')\n"
89
+ " return None\n"
90
+ )
91
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
92
+ assert issues == [], f"No function inlines the helper body, so nothing must flag, got: {issues}"
93
+
94
+
95
+ def test_should_not_flag_helper_whose_body_is_too_trivial() -> None:
96
+ source = (
97
+ "def small_helper() -> int:\n"
98
+ " return 7\n"
99
+ "\n"
100
+ "\n"
101
+ "def caller() -> int:\n"
102
+ " other = 1\n"
103
+ " again = 2\n"
104
+ " return 7\n"
105
+ )
106
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
107
+ assert issues == [], (
108
+ f"A body under the minimum statement count is too common to flag, got: {issues}"
109
+ )
110
+
111
+
112
+ def test_should_not_flag_flat_helper_with_no_compound_statement() -> None:
113
+ source = (
114
+ "def assign_pair(target: object) -> None:\n"
115
+ " target.left = 1\n"
116
+ " target.right = 2\n"
117
+ "\n"
118
+ "\n"
119
+ "def run(target: object) -> None:\n"
120
+ " target.begin()\n"
121
+ " target.left = 1\n"
122
+ " target.right = 2\n"
123
+ " target.finish()\n"
124
+ )
125
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
126
+ assert issues == [], (
127
+ "A two-statement helper with no compound statement is too common to be a "
128
+ f"meaningful duplicate and must not flag, got: {issues}"
129
+ )
130
+
131
+
132
+ def test_should_flag_two_statement_helper_with_a_compound_statement() -> None:
133
+ source = (
134
+ "async def _wait_for_render(automation: object) -> None:\n"
135
+ " assert automation.detector is not None\n"
136
+ " try:\n"
137
+ " await automation.detector.wait_for_element(selector)\n"
138
+ " except (TimeoutError, RuntimeError) as render_error:\n"
139
+ " logger.warning('did not render: %s', render_error)\n"
140
+ "\n"
141
+ "\n"
142
+ "async def _navigate_then_wait(automation: object) -> None:\n"
143
+ " await automation.cdp.navigate(url)\n"
144
+ " assert automation.detector is not None\n"
145
+ " try:\n"
146
+ " await automation.detector.wait_for_element(selector)\n"
147
+ " except (TimeoutError, RuntimeError) as render_error:\n"
148
+ " logger.warning('did not render: %s', render_error)\n"
149
+ )
150
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
151
+ assert any("_wait_for_render" in each_issue for each_issue in issues), (
152
+ "A two-statement helper carrying a try/except whose body is inlined "
153
+ f"elsewhere is a real duplicate and must flag, got: {issues}"
154
+ )
155
+
156
+
157
+ def test_should_flag_helper_inlined_inside_an_enclosing_finally_block() -> None:
158
+ source = (
159
+ "def _drain_queue(queue: object) -> None:\n"
160
+ " for each_message in queue.pending:\n"
161
+ " queue.deliver(each_message)\n"
162
+ " queue.acknowledge(each_message)\n"
163
+ "\n"
164
+ "\n"
165
+ "def shutdown(queue: object) -> None:\n"
166
+ " try:\n"
167
+ " queue.stop_accepting()\n"
168
+ " finally:\n"
169
+ " for each_message in queue.pending:\n"
170
+ " queue.deliver(each_message)\n"
171
+ " queue.acknowledge(each_message)\n"
172
+ )
173
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
174
+ assert any("_drain_queue" in each_issue for each_issue in issues), (
175
+ "A helper whose loop body is inlined verbatim in an enclosing function's "
176
+ f"finally block is a real duplicate and must flag, got: {issues}"
177
+ )
178
+
179
+
180
+ def test_should_flag_helper_inlined_inside_an_enclosing_except_handler() -> None:
181
+ source = (
182
+ "def _drain_queue(queue: object) -> None:\n"
183
+ " for each_message in queue.pending:\n"
184
+ " queue.deliver(each_message)\n"
185
+ " queue.acknowledge(each_message)\n"
186
+ "\n"
187
+ "\n"
188
+ "def run(queue: object) -> None:\n"
189
+ " try:\n"
190
+ " queue.start()\n"
191
+ " except RuntimeError:\n"
192
+ " for each_message in queue.pending:\n"
193
+ " queue.deliver(each_message)\n"
194
+ " queue.acknowledge(each_message)\n"
195
+ )
196
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
197
+ assert any("_drain_queue" in each_issue for each_issue in issues), (
198
+ "A helper whose loop body is inlined verbatim in an enclosing function's "
199
+ f"except handler is a real duplicate and must flag, got: {issues}"
200
+ )
201
+
202
+
203
+ def test_should_flag_helper_inlined_inside_a_single_top_level_if_guard() -> None:
204
+ source = (
205
+ "def _send_pending(client: object) -> None:\n"
206
+ " queued = client.collect()\n"
207
+ " try:\n"
208
+ " client.transmit(queued)\n"
209
+ " except (TimeoutError, ConnectionError) as send_error:\n"
210
+ " logger.warning('send failed: %s', send_error)\n"
211
+ "\n"
212
+ "\n"
213
+ "def flush(client: object) -> None:\n"
214
+ " if client.enabled:\n"
215
+ " queued = client.collect()\n"
216
+ " try:\n"
217
+ " client.transmit(queued)\n"
218
+ " except (TimeoutError, ConnectionError) as send_error:\n"
219
+ " logger.warning('send failed: %s', send_error)\n"
220
+ )
221
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
222
+ assert any("_send_pending" in each_issue for each_issue in issues), (
223
+ "A helper whose two-statement window is inlined inside a single top-level "
224
+ f"if guard is a real duplicate and must flag, got: {issues}"
225
+ )
226
+
227
+
228
+ def test_should_not_flag_structural_twin_peers_wrapped_in_a_compound() -> None:
229
+ source = (
230
+ "def _deliver_left(queue: object) -> None:\n"
231
+ " for each_message in queue.left:\n"
232
+ " queue.deliver(each_message)\n"
233
+ " queue.acknowledge(each_message)\n"
234
+ "\n"
235
+ "\n"
236
+ "def _deliver_right(queue: object) -> None:\n"
237
+ " for each_message in queue.left:\n"
238
+ " queue.deliver(each_message)\n"
239
+ " queue.acknowledge(each_message)\n"
240
+ )
241
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
242
+ assert issues == [], (
243
+ "Two peer helpers that share the same statement shape are structural twins "
244
+ f"left to the cross-file check, so neither must flag, got: {issues}"
245
+ )
246
+
247
+
248
+ def test_should_match_through_a_docstring_only_difference() -> None:
249
+ source = (
250
+ "def render_block(target: object) -> None:\n"
251
+ ' """Render the block."""\n'
252
+ " target.prepare()\n"
253
+ " for each_step in target.steps:\n"
254
+ " each_step.run()\n"
255
+ " target.finalize()\n"
256
+ "\n"
257
+ "\n"
258
+ "def orchestrate(target: object) -> None:\n"
259
+ " target.begin()\n"
260
+ " target.prepare()\n"
261
+ " for each_step in target.steps:\n"
262
+ " each_step.run()\n"
263
+ " target.finalize()\n"
264
+ )
265
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
266
+ assert any("render_block" in each_issue for each_issue in issues), (
267
+ f"A docstring-only difference must not hide the inline duplicate, got: {issues}"
268
+ )
269
+
270
+
271
+ def test_should_skip_test_file_being_written() -> None:
272
+ issues = check_same_file_inline_duplicate_body(PR_470_SHAPE_SOURCE, "test_account_switcher.py")
273
+ assert issues == [], f"Test files are exempt on the writing side, got: {issues}"
274
+
275
+
276
+ def test_should_return_empty_on_syntax_error() -> None:
277
+ issues = check_same_file_inline_duplicate_body("def broken(\n", "module.py")
278
+ assert issues == [], f"Unparseable content must return empty, got: {issues}"
279
+
280
+
281
+ def test_should_not_flag_when_changed_lines_miss_both_functions() -> None:
282
+ leading = (
283
+ "def unrelated(left: int, right: int) -> int:\n"
284
+ " summed = left + right\n"
285
+ " tripled = summed * 3\n"
286
+ " return tripled\n"
287
+ "\n"
288
+ "\n"
289
+ )
290
+ post_edit_content = leading + PR_470_SHAPE_SOURCE
291
+ changed_lines_outside = {1, 2, 3, 4}
292
+ issues = check_same_file_inline_duplicate_body(
293
+ post_edit_content,
294
+ "account_switcher.py",
295
+ all_changed_lines=changed_lines_outside,
296
+ )
297
+ assert issues == [], (
298
+ f"An edit that touches neither the helper nor its inline copy must not block, got: {issues}"
299
+ )
300
+
301
+
302
+ def test_should_flag_when_changed_lines_touch_the_helper() -> None:
303
+ helper_definition_line = (
304
+ PR_470_SHAPE_SOURCE.splitlines().index(
305
+ "async def _wait_for_content_image_to_render(automation: object) -> None:"
306
+ )
307
+ + 1
308
+ )
309
+ issues = check_same_file_inline_duplicate_body(
310
+ PR_470_SHAPE_SOURCE,
311
+ "account_switcher.py",
312
+ all_changed_lines={helper_definition_line + 2},
313
+ )
314
+ assert any("_wait_for_content_image_to_render" in each_issue for each_issue in issues), (
315
+ f"An edit touching the new helper must flag, got: {issues}"
316
+ )
317
+
318
+
319
+ def test_should_return_every_violation_when_scope_deferred_to_caller() -> None:
320
+ leading = (
321
+ "def unrelated(left: int, right: int) -> int:\n"
322
+ " summed = left + right\n"
323
+ " tripled = summed * 3\n"
324
+ " return tripled\n"
325
+ "\n"
326
+ "\n"
327
+ )
328
+ post_edit_content = leading + PR_470_SHAPE_SOURCE
329
+ issues = check_same_file_inline_duplicate_body(
330
+ post_edit_content,
331
+ "account_switcher.py",
332
+ all_changed_lines={1, 2, 3, 4},
333
+ defer_scope_to_caller=True,
334
+ )
335
+ assert any("_wait_for_content_image_to_render" in each_issue for each_issue in issues), (
336
+ "The commit gate scopes by added line, so the check must return every "
337
+ f"violation when scope is deferred, got: {issues}"
338
+ )
339
+
340
+
341
+ NON_ADJACENT_DUPLICATE_SOURCE = (
342
+ "import asyncio\n"
343
+ "\n"
344
+ "\n"
345
+ "async def _wait_for_content_image_to_render(automation: object) -> None:\n"
346
+ ' """Wait for the content image to render before detection."""\n'
347
+ " assert automation.detector is not None\n"
348
+ " try:\n"
349
+ " await automation.detector.wait_for_element(\n"
350
+ " account_detector_config.content_image_selector,\n"
351
+ " timeout=page_navigation_delays.account_switch_page_load,\n"
352
+ " )\n"
353
+ " except (TimeoutError, RuntimeError) as content_error:\n"
354
+ " logger.warning('content images did not appear: %s', content_error)\n"
355
+ "\n"
356
+ "\n"
357
+ "def _unrelated_intervening(left: int, right: int) -> int:\n"
358
+ " summed = left + right\n"
359
+ " tripled = summed * 3\n"
360
+ " return tripled\n"
361
+ "\n"
362
+ "\n"
363
+ "async def _wait_for_page_after_account_switch(automation: object) -> None:\n"
364
+ " await asyncio.to_thread(automation.cdp._navigate_to_url, url)\n"
365
+ " await asyncio.to_thread(automation.cdp._wait_for_page_load)\n"
366
+ " assert automation.detector is not None\n"
367
+ " try:\n"
368
+ " await automation.detector.wait_for_element(\n"
369
+ " account_detector_config.content_image_selector,\n"
370
+ " timeout=page_navigation_delays.account_switch_page_load,\n"
371
+ " )\n"
372
+ " except (TimeoutError, RuntimeError) as content_error:\n"
373
+ " logger.warning('content images did not appear: %s', content_error)\n"
374
+ )
375
+
376
+
377
+ def _line_number_of(source: str, needle: str) -> int:
378
+ return source.splitlines().index(needle) + 1
379
+
380
+
381
+ def test_should_not_flag_when_edit_touches_only_a_line_between_the_two_functions() -> None:
382
+ intervening_body_line = (
383
+ _line_number_of(NON_ADJACENT_DUPLICATE_SOURCE, " summed = left + right")
384
+ )
385
+ issues = check_same_file_inline_duplicate_body(
386
+ NON_ADJACENT_DUPLICATE_SOURCE,
387
+ "account_switcher.py",
388
+ all_changed_lines={intervening_body_line},
389
+ )
390
+ assert issues == [], (
391
+ "An edit confined to an unrelated function that sits strictly between the "
392
+ "helper and its inline copy must not block, even though that line falls in "
393
+ f"the gap between the two duplicate functions, got: {issues}"
394
+ )
395
+
396
+
397
+ def test_should_flag_when_edit_touches_the_helper_in_the_non_adjacent_layout() -> None:
398
+ helper_body_line = (
399
+ _line_number_of(
400
+ NON_ADJACENT_DUPLICATE_SOURCE,
401
+ " assert automation.detector is not None",
402
+ )
403
+ )
404
+ issues = check_same_file_inline_duplicate_body(
405
+ NON_ADJACENT_DUPLICATE_SOURCE,
406
+ "account_switcher.py",
407
+ all_changed_lines={helper_body_line},
408
+ )
409
+ assert any("_wait_for_content_image_to_render" in each_issue for each_issue in issues), (
410
+ "An edit touching the helper must still flag in the non-adjacent layout, "
411
+ f"got: {issues}"
412
+ )
413
+
414
+
415
+ def test_should_flag_when_edit_touches_the_enclosing_in_the_non_adjacent_layout() -> None:
416
+ enclosing_first_body_line = (
417
+ _line_number_of(
418
+ NON_ADJACENT_DUPLICATE_SOURCE,
419
+ " await asyncio.to_thread(automation.cdp._navigate_to_url, url)",
420
+ )
421
+ )
422
+ issues = check_same_file_inline_duplicate_body(
423
+ NON_ADJACENT_DUPLICATE_SOURCE,
424
+ "account_switcher.py",
425
+ all_changed_lines={enclosing_first_body_line},
426
+ )
427
+ assert any("_wait_for_page_after_account_switch" in each_issue for each_issue in issues), (
428
+ "An edit touching the enclosing function must still flag in the "
429
+ f"non-adjacent layout, got: {issues}"
430
+ )
431
+
432
+
433
+ def test_should_carry_both_helper_and_enclosing_spans_for_the_commit_gate_scoper() -> None:
434
+ helper_definition_line = _line_number_of(
435
+ PR_470_SHAPE_SOURCE,
436
+ "async def _wait_for_content_image_to_render(automation: object) -> None:",
437
+ )
438
+ enclosing_definition_line = _line_number_of(
439
+ PR_470_SHAPE_SOURCE,
440
+ "async def _wait_for_page_after_account_switch(automation: object) -> None:",
441
+ )
442
+ helper_span_length = 10
443
+ enclosing_span_length = 11
444
+ issues = check_same_file_inline_duplicate_body(
445
+ PR_470_SHAPE_SOURCE,
446
+ "account_switcher.py",
447
+ all_changed_lines=None,
448
+ defer_scope_to_caller=True,
449
+ )
450
+ matching_issues = [
451
+ each_issue
452
+ for each_issue in issues
453
+ if "_wait_for_content_image_to_render" in each_issue
454
+ ]
455
+ assert matching_issues, f"expected an inline-duplicate issue, got: {issues}"
456
+ expected_fragment = (
457
+ f"(inline duplicate body spans: helper at line {helper_definition_line} "
458
+ f"spanning {helper_span_length} lines, enclosing at line "
459
+ f"{enclosing_definition_line} spanning {enclosing_span_length} lines)"
460
+ )
461
+ assert expected_fragment in matching_issues[0], (
462
+ "The commit gate reconstructs scope from the message text, so the inline "
463
+ "duplicate message must carry BOTH the helper and the enclosing spans — "
464
+ "the union the PreToolUse path scopes on — not the helper span alone, "
465
+ f"got: {matching_issues[0]}"
466
+ )
@@ -81,12 +81,11 @@ def test_should_flag_named_constant_compared_to_literal() -> None:
81
81
  def test_should_advise_when_scenario_test_omits_flag_its_siblings_patch(
82
82
  capsys: pytest.CaptureFixture[str],
83
83
  ) -> None:
84
- issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
84
+ code_rules_enforcer.check_flag_gated_scenario_test_naming(
85
85
  _THREE_SIBLINGS_PATCH_THE_FLAG_ONE_SCENARIO_TEST_DOES_NOT,
86
86
  SCENARIO_TEST_FILE_PATH,
87
87
  )
88
88
  advisory_text = capsys.readouterr().err
89
- assert issues == [], "Advisory check must never add a blocking issue"
90
89
  assert "test_should_submit_when_gate_passes" in advisory_text, (
91
90
  f"Expected an advisory naming the un-patched scenario test, got: {advisory_text!r}"
92
91
  )
@@ -107,11 +106,10 @@ def test_should_stay_silent_when_scenario_test_patches_the_flag(
107
106
  " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
108
107
  " assert run() == 'failed'\n"
109
108
  )
110
- issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
109
+ code_rules_enforcer.check_flag_gated_scenario_test_naming(
111
110
  source, SCENARIO_TEST_FILE_PATH
112
111
  )
113
112
  advisory_text = capsys.readouterr().err
114
- assert issues == []
115
113
  assert advisory_text == "", (
116
114
  f"Expected silence when the scenario test patches the flag, got: {advisory_text!r}"
117
115
  )
@@ -128,19 +126,23 @@ def test_should_stay_silent_when_only_one_sibling_patches_the_flag(
128
126
  " monkeypatch.setattr('pkg.pipeline.IS_STAGED_VERIFICATION_ENABLED', True)\n"
129
127
  " assert run() == 'failed'\n"
130
128
  )
131
- issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
129
+ code_rules_enforcer.check_flag_gated_scenario_test_naming(
132
130
  source, SCENARIO_TEST_FILE_PATH
133
131
  )
134
132
  advisory_text = capsys.readouterr().err
135
- assert issues == []
136
133
  assert advisory_text == "", (
137
134
  f"One sibling patch is not an established flag; expected silence, got: {advisory_text!r}"
138
135
  )
139
136
 
140
137
 
141
- def test_should_not_advise_for_production_file() -> None:
142
- issues = code_rules_enforcer.check_flag_gated_scenario_test_naming(
138
+ def test_should_not_advise_for_production_file(
139
+ capsys: pytest.CaptureFixture[str],
140
+ ) -> None:
141
+ code_rules_enforcer.check_flag_gated_scenario_test_naming(
143
142
  _THREE_SIBLINGS_PATCH_THE_FLAG_ONE_SCENARIO_TEST_DOES_NOT,
144
143
  "packages/app/services/submission_pipeline.py",
145
144
  )
146
- assert issues == []
145
+ advisory_text = capsys.readouterr().err
146
+ assert advisory_text == "", (
147
+ f"Production files are exempt; expected no advisory, got: {advisory_text!r}"
148
+ )