claude-dev-env 1.79.0 → 1.80.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 (33) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  5. package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
  6. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  7. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  8. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  9. package/hooks/blocking/code_rules_enforcer.py +4 -0
  10. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  11. package/hooks/blocking/code_rules_unused_imports.py +7 -63
  12. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  13. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +96 -15
  14. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  15. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  16. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  17. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  18. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  19. package/package.json +1 -1
  20. package/rules/docstring-prose-matches-implementation.md +1 -0
  21. package/skills/autoconverge/SKILL.md +26 -5
  22. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  23. package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
  24. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
  25. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  26. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  27. package/skills/autoconverge/workflow/converge.mjs +110 -228
  28. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  29. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  30. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  31. package/skills/pr-converge/SKILL.md +28 -2
  32. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  33. package/skills/pr-converge/reference/state-schema.md +11 -0
@@ -5,9 +5,12 @@ ALL_REPOSITORY_ROOT_MARKER_FILENAMES, VENV_DIRECTORY_NAME and other
5
5
  imports that survived into a PR without ever being referenced.
6
6
 
7
7
  The detector is intentionally narrow: it only flags `from X import Y`
8
- or `import X` where Y/X is never referenced in the file body, the file
9
- does not declare `__all__`, and the file does not use TYPE_CHECKING
10
- conditional imports. The narrow scope keeps false positives low.
8
+ or `import X` where Y/X is never referenced in the file body and the
9
+ file does not declare `__all__`. A `TYPE_CHECKING` guard block no longer
10
+ exempts the whole file its guarded imports live nested inside the
11
+ `if TYPE_CHECKING:` node so they are never flag candidates, while a
12
+ genuinely dead top-level runtime import in the same file is still
13
+ flagged. The narrow scope keeps false positives low.
11
14
  """
12
15
 
13
16
  from __future__ import annotations
@@ -102,7 +105,7 @@ def test_should_skip_file_with_dunder_all_annotated_assignment() -> None:
102
105
  )
103
106
 
104
107
 
105
- def test_should_skip_file_using_type_checking_block() -> None:
108
+ def test_should_flag_dead_top_level_import_in_type_checking_file() -> None:
106
109
  source = (
107
110
  "from typing import TYPE_CHECKING\n"
108
111
  "from hooks_constants.constants import UNUSED_NAME\n"
@@ -114,8 +117,76 @@ def test_should_skip_file_using_type_checking_block() -> None:
114
117
  " return None\n"
115
118
  )
116
119
  issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
120
+ assert any("UNUSED_NAME" in each_issue for each_issue in issues), (
121
+ "A dead top-level runtime import must be flagged even when the file "
122
+ f"also declares a TYPE_CHECKING block, got: {issues}"
123
+ )
124
+ assert not any("OtherName" in each_issue for each_issue in issues), (
125
+ "A TYPE_CHECKING-guarded import is nested inside the if block and is "
126
+ f"never a flag candidate, got: {issues}"
127
+ )
128
+
129
+
130
+ def test_should_not_flag_standard_type_checking_idiom() -> None:
131
+ source = (
132
+ "from typing import TYPE_CHECKING\n"
133
+ "from hooks_constants.constants import RUNTIME_NAME\n"
134
+ "\n"
135
+ "if TYPE_CHECKING:\n"
136
+ " from somewhere import TypeOnly\n"
137
+ "\n"
138
+ "def run(node: TypeOnly) -> None:\n"
139
+ " RUNTIME_NAME()\n"
140
+ )
141
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
117
142
  assert issues == [], (
118
- f"TYPE_CHECKING-using files have annotation-only imports skip, got: {issues}"
143
+ "The standard TYPE_CHECKING idiom TYPE_CHECKING referenced by the "
144
+ "guard, the runtime import used in the body, and the guarded import "
145
+ f"used in an annotation — must produce no findings, got: {issues}"
146
+ )
147
+
148
+
149
+ def test_should_flag_dead_import_beside_type_checking_string_annotation() -> None:
150
+ source = (
151
+ "from typing import TYPE_CHECKING\n"
152
+ "from shared_utils.config import portal_specific_timeouts\n"
153
+ "\n"
154
+ "if TYPE_CHECKING:\n"
155
+ " from shared_utils.base import BaseAutomation\n"
156
+ "\n"
157
+ 'async def run(automation: "BaseAutomation") -> None:\n'
158
+ " return None\n"
159
+ )
160
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
161
+ assert any("portal_specific_timeouts" in each_issue for each_issue in issues), (
162
+ "A dead top-level import in a module that guards its annotation-only "
163
+ f"import under TYPE_CHECKING must still be flagged, got: {issues}"
164
+ )
165
+ assert not any("BaseAutomation" in each_issue for each_issue in issues), (
166
+ "The TYPE_CHECKING-guarded import used in a string annotation must not "
167
+ f"be flagged, got: {issues}"
168
+ )
169
+
170
+
171
+ def test_should_flag_dead_import_in_workflow_handler_file() -> None:
172
+ workflow_handler_path = (
173
+ "shared_utils/web_automation/samsung_portal/workflow/binary/delete_handlers.py"
174
+ )
175
+ source = (
176
+ "from typing import TYPE_CHECKING\n"
177
+ "from shared_utils.config import portal_specific_timeouts\n"
178
+ "\n"
179
+ "if TYPE_CHECKING:\n"
180
+ " from shared_utils.base import BaseAutomation\n"
181
+ "\n"
182
+ 'async def run(automation: "BaseAutomation") -> None:\n'
183
+ " return None\n"
184
+ )
185
+ issues = check_unused_module_level_imports(source, workflow_handler_path)
186
+ assert any("portal_specific_timeouts" in each_issue for each_issue in issues), (
187
+ "A handler file under a workflow/ directory is not a state/module "
188
+ "registry; a dead import in it must be flagged, not exempted by the "
189
+ f"workflow path, got: {issues}"
119
190
  )
120
191
 
121
192
 
@@ -364,7 +435,7 @@ def test_should_skip_when_full_file_declares_dunder_all() -> None:
364
435
  )
365
436
 
366
437
 
367
- def test_should_skip_when_full_file_uses_type_checking_gate() -> None:
438
+ def test_should_flag_dead_fragment_import_when_full_file_uses_type_checking() -> None:
368
439
  fragment = "from hooks_constants.constants import NEW_NAME\n"
369
440
  full_file = (
370
441
  "from typing import TYPE_CHECKING\n"
@@ -379,9 +450,9 @@ def test_should_skip_when_full_file_uses_type_checking_gate() -> None:
379
450
  issues = check_unused_module_level_imports(
380
451
  fragment, PRODUCTION_FILE_PATH, full_file_content=full_file,
381
452
  )
382
- assert issues == [], (
383
- "TYPE_CHECKING gate in the post-edit file must skip the scan, "
384
- f"got: {issues}"
453
+ assert any("NEW_NAME" in each_issue for each_issue in issues), (
454
+ "A TYPE_CHECKING block in the post-edit file must not exempt a dead "
455
+ f"import added by the edit fragment, got: {issues}"
385
456
  )
386
457
 
387
458
 
@@ -472,7 +543,7 @@ def test_should_flag_import_when_only_shadowed_local_name_is_loaded() -> None:
472
543
  )
473
544
 
474
545
 
475
- def test_should_skip_when_type_checking_uses_imported_alias() -> None:
546
+ def test_should_not_flag_guarded_import_under_imported_type_checking_alias() -> None:
476
547
  source = (
477
548
  "from typing import TYPE_CHECKING as IS_TYPE_CHECKING\n"
478
549
  "from hooks_constants.constants import UNUSED_NAME\n"
@@ -484,12 +555,17 @@ def test_should_skip_when_type_checking_uses_imported_alias() -> None:
484
555
  " return None\n"
485
556
  )
486
557
  issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
487
- assert issues == [], (
488
- f"TYPE_CHECKING imported aliases must skip annotation-only files, got: {issues}"
558
+ assert not any("OtherName" in each_issue for each_issue in issues), (
559
+ "An import guarded under an aliased TYPE_CHECKING is nested in the if "
560
+ f"block and is never a flag candidate, got: {issues}"
561
+ )
562
+ assert any("UNUSED_NAME" in each_issue for each_issue in issues), (
563
+ "A dead top-level import beside an aliased TYPE_CHECKING guard is still "
564
+ f"flagged, got: {issues}"
489
565
  )
490
566
 
491
567
 
492
- def test_should_skip_when_type_checking_uses_module_alias() -> None:
568
+ def test_should_not_flag_guarded_import_under_module_type_checking_alias() -> None:
493
569
  source = (
494
570
  "import typing as t\n"
495
571
  "from hooks_constants.constants import UNUSED_NAME\n"
@@ -501,8 +577,13 @@ def test_should_skip_when_type_checking_uses_module_alias() -> None:
501
577
  " return None\n"
502
578
  )
503
579
  issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
504
- assert issues == [], (
505
- f"TYPE_CHECKING module aliases must skip annotation-only files, got: {issues}"
580
+ assert not any("OtherName" in each_issue for each_issue in issues), (
581
+ "An import guarded under a module-qualified TYPE_CHECKING is nested in "
582
+ f"the if block and is never a flag candidate, got: {issues}"
583
+ )
584
+ assert any("UNUSED_NAME" in each_issue for each_issue in issues), (
585
+ "A dead top-level import beside a module-qualified TYPE_CHECKING guard "
586
+ f"is still flagged, got: {issues}"
506
587
  )
507
588
 
508
589
 
@@ -0,0 +1,167 @@
1
+ """Tests for check_js_returns_object_schemaless_branch.
2
+
3
+ The check catches the JS/.mjs Category O6 docstring-prose-vs-implementation
4
+ drift PR #807 surfaced: a ``function`` whose JSDoc ``@returns {Promise<object>}``
5
+ promises a structured object, while one branch returns the agent-spawn helper
6
+ with an options object that omits ``schema``. A schema-less agent call resolves
7
+ to a transcript string, not the object the JSDoc claims, so the contract drifts
8
+ for that branch while a sibling branch that does pass ``schema`` returns a real
9
+ object.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import pathlib
16
+ import sys
17
+
18
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
19
+ if str(_HOOK_DIRECTORY) not in sys.path:
20
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
21
+
22
+ _module_spec = importlib.util.spec_from_file_location(
23
+ "code_rules_imports_logging",
24
+ _HOOK_DIRECTORY / "code_rules_imports_logging.py",
25
+ )
26
+ assert _module_spec is not None
27
+ assert _module_spec.loader is not None
28
+ _imports_logging_module = importlib.util.module_from_spec(_module_spec)
29
+ _module_spec.loader.exec_module(_imports_logging_module)
30
+
31
+ check_js_returns_object_schemaless_branch = (
32
+ _imports_logging_module.check_js_returns_object_schemaless_branch
33
+ )
34
+
35
+ _MJS_PATH = "skills/autoconverge/workflow/converge.mjs"
36
+
37
+ _SHIPPED_CONVERGE_MJS = (
38
+ _HOOK_DIRECTORY.parents[1] / "skills" / "autoconverge" / "workflow" / "converge.mjs"
39
+ )
40
+
41
+
42
+ def _mixed_schema_source() -> str:
43
+ return (
44
+ "/**\n"
45
+ " * Spawn a git-utility agent for a task.\n"
46
+ " * @returns {Promise<object>} the structured output\n"
47
+ " */\n"
48
+ "function runGitTask(task, head) {\n"
49
+ " if (task === 'resolve-head') {\n"
50
+ " return convergeAgent(\n"
51
+ " `Print the HEAD SHA of ${prCoordinates}.`,\n"
52
+ " { label: 'git-utility', phase: 'Converge', schema: HEAD_SCHEMA, agentType: 'Explore' },\n"
53
+ " )\n"
54
+ " }\n"
55
+ " if (task === 'prefetch-main') {\n"
56
+ " return convergeAgent(\n"
57
+ " `Refresh the base ref for ${prCoordinates}.`,\n"
58
+ " { label: 'git-utility', phase: 'Converge', agentType: 'Explore' },\n"
59
+ " )\n"
60
+ " }\n"
61
+ " return convergeAgent(\n"
62
+ " `Report conflicts for ${prCoordinates}.`,\n"
63
+ " { label: 'git-utility', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },\n"
64
+ " )\n"
65
+ "}\n"
66
+ )
67
+
68
+
69
+ def _all_schema_source() -> str:
70
+ return _mixed_schema_source().replace(
71
+ " { label: 'git-utility', phase: 'Converge', agentType: 'Explore' },\n",
72
+ " { label: 'git-utility', phase: 'Converge', schema: FETCH_SCHEMA, agentType: 'Explore' },\n",
73
+ )
74
+
75
+
76
+ def _plain_object_return_source() -> str:
77
+ return (
78
+ "/**\n"
79
+ " * Joined fixer recovery loop.\n"
80
+ " * @returns {Promise<object>} FIX_SCHEMA result\n"
81
+ " */\n"
82
+ "async function fixerWithRecovery(head, findings, sourceLabel) {\n"
83
+ " if (!verdictPassed) {\n"
84
+ " return {\n"
85
+ " newSha: head,\n"
86
+ " pushed: false,\n"
87
+ " summary: `verify step did not pass ${sourceLabel}`,\n"
88
+ " }\n"
89
+ " }\n"
90
+ " return commitWithRecovery({\n"
91
+ " runCommit: () => runFixerTask('commit', { head }),\n"
92
+ " runVerify: () => runVerifierTask('fix-verify', { head }),\n"
93
+ " })\n"
94
+ "}\n"
95
+ )
96
+
97
+
98
+ def _transcript_typed_source() -> str:
99
+ return _mixed_schema_source().replace(
100
+ " * @returns {Promise<object>} the structured output\n",
101
+ " * @returns {Promise<*>} the agent() result\n",
102
+ )
103
+
104
+
105
+ def _destructured_parameter_mixed_schema_source() -> str:
106
+ return (
107
+ "/**\n"
108
+ " * Commit and verify with recovery.\n"
109
+ " * @returns {Promise<object>} the structured verify output\n"
110
+ " */\n"
111
+ "async function commitWithRecovery({ runCommit, runVerify, head }) {\n"
112
+ " if (runVerify) {\n"
113
+ " return convergeAgent(\n"
114
+ " `Verify the commit for ${head}.`,\n"
115
+ " { label: 'commit-recovery', phase: 'Converge', schema: VERIFY_SCHEMA, agentType: 'Explore' },\n"
116
+ " )\n"
117
+ " }\n"
118
+ " return convergeAgent(\n"
119
+ " `Commit ${head} without verification.`,\n"
120
+ " { label: 'commit-recovery', phase: 'Converge', agentType: 'Explore' },\n"
121
+ " )\n"
122
+ "}\n"
123
+ )
124
+
125
+
126
+ def test_flags_schema_less_branch_under_returns_object_claim() -> None:
127
+ issues = check_js_returns_object_schemaless_branch(_mixed_schema_source(), _MJS_PATH)
128
+ assert len(issues) == 1
129
+ assert "runGitTask" in issues[0]
130
+ assert "convergeAgent" in issues[0]
131
+
132
+
133
+ def test_flags_schema_less_branch_with_destructured_parameter() -> None:
134
+ issues = check_js_returns_object_schemaless_branch(
135
+ _destructured_parameter_mixed_schema_source(), _MJS_PATH
136
+ )
137
+ assert len(issues) == 1
138
+ assert "commitWithRecovery" in issues[0]
139
+ assert "convergeAgent" in issues[0]
140
+
141
+
142
+ def test_accepts_function_when_every_branch_passes_a_schema() -> None:
143
+ issues = check_js_returns_object_schemaless_branch(_all_schema_source(), _MJS_PATH)
144
+ assert issues == []
145
+
146
+
147
+ def test_accepts_plain_object_returns_with_no_schema_driven_helper() -> None:
148
+ issues = check_js_returns_object_schemaless_branch(_plain_object_return_source(), _MJS_PATH)
149
+ assert issues == []
150
+
151
+
152
+ def test_accepts_transcript_typed_returns_clause() -> None:
153
+ issues = check_js_returns_object_schemaless_branch(_transcript_typed_source(), _MJS_PATH)
154
+ assert issues == []
155
+
156
+
157
+ def test_skips_python_files() -> None:
158
+ issues = check_js_returns_object_schemaless_branch(
159
+ _mixed_schema_source(), "workflow/converge.py"
160
+ )
161
+ assert issues == []
162
+
163
+
164
+ def test_shipped_converge_mjs_passes_its_own_check() -> None:
165
+ shipped_source = _SHIPPED_CONVERGE_MJS.read_text(encoding="utf-8")
166
+ issues = check_js_returns_object_schemaless_branch(shipped_source, _MJS_PATH)
167
+ assert issues == []
@@ -45,6 +45,7 @@ ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX: tuple[str, ...] = (
45
45
  "json",
46
46
  )
47
47
  MAX_JS_RESUME_TASK_ENUMERATION_ISSUES: int = 5
48
+ MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES: int = 5
48
49
  MINIMUM_RESUME_TASK_ENUMERATION_ITEMS: int = 2
49
50
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
50
51
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
@@ -222,6 +222,20 @@ ENUMERATION_TASK_ITEM_PATTERN: re.Pattern[str] = re.compile(
222
222
  HYPHENATED_TASK_ITEM_PATTERN: re.Pattern[str] = re.compile(
223
223
  r"^[a-z0-9]+(?:-[a-z0-9]+)+$"
224
224
  )
225
+ FUNCTION_WITH_JSDOC_PATTERN: re.Pattern[str] = re.compile(
226
+ r"/\*\*(?P<jsdoc>(?:(?!\*/).)*?)\*/\s*"
227
+ r"(?:async\s+)?function\s+(?P<name>\w+)\s*\(",
228
+ re.DOTALL,
229
+ )
230
+ JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN: re.Pattern[str] = re.compile(
231
+ r"@returns?\s*\{[^}]*Promise\s*<\s*[Oo]bject\s*>[^}]*\}"
232
+ )
233
+ RETURN_CALL_OPENING_PARENTHESIS_PATTERN: re.Pattern[str] = re.compile(
234
+ r"\breturn\s+(?:await\s+)?(?P<callee>\w+)\s*\("
235
+ )
236
+ SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN: re.Pattern[str] = re.compile(
237
+ r"(?<![A-Za-z0-9_])schema\s*:"
238
+ )
225
239
  ALL_BUILTIN_DICT_METHOD_NAMES: frozenset[str] = frozenset({
226
240
  "get", "items", "keys", "values", "update", "pop",
227
241
  "setdefault", "copy", "clear",
@@ -0,0 +1,93 @@
1
+ """Behavior tests for the JS returns-object schema-less detection patterns.
2
+
3
+ These regexes drive ``check_js_returns_object_schemaless_branch``: they locate a
4
+ JSDoc-documented ``function`` declaration, decide whether its ``@returns`` clause
5
+ promises a ``Promise<object>``, find each ``return`` that calls a helper, and spot
6
+ a ``schema`` key inside an options object. Each test pins one pattern against the
7
+ shapes it must accept and the near-misses it must reject.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
16
+ if str(_HOOKS_ROOT) not in sys.path:
17
+ sys.path.insert(0, str(_HOOKS_ROOT))
18
+
19
+ from hooks_constants.code_rules_enforcer_constants import (
20
+ FUNCTION_WITH_JSDOC_PATTERN,
21
+ JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
22
+ RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
23
+ SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
24
+ )
25
+
26
+
27
+ def test_function_with_jsdoc_pattern_captures_name_and_jsdoc() -> None:
28
+ source = (
29
+ "/**\n * @returns {Promise<object>} the structured output\n */\n"
30
+ "function runGitTask(task, head) {\n"
31
+ )
32
+ match = FUNCTION_WITH_JSDOC_PATTERN.search(source)
33
+ assert match is not None
34
+ assert match.group("name") == "runGitTask"
35
+ assert "@returns" in match.group("jsdoc")
36
+
37
+
38
+ def test_function_with_jsdoc_pattern_captures_async_declaration() -> None:
39
+ source = "/** @returns {Promise<object>} */\nasync function fixerWithRecovery(head) {\n"
40
+ match = FUNCTION_WITH_JSDOC_PATTERN.search(source)
41
+ assert match is not None
42
+ assert match.group("name") == "fixerWithRecovery"
43
+
44
+
45
+ def test_returns_object_promise_pattern_matches_structured_object_claim() -> None:
46
+ lowercase_match = JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search(
47
+ "@returns {Promise<object>} the structured output"
48
+ )
49
+ assert lowercase_match is not None
50
+ assert lowercase_match.group(0) == "@returns {Promise<object>}"
51
+ capitalized_match = JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search(
52
+ "@return {Promise<Object>}"
53
+ )
54
+ assert capitalized_match is not None
55
+ assert capitalized_match.group(0) == "@return {Promise<Object>}"
56
+
57
+
58
+ def test_returns_object_promise_pattern_rejects_string_and_array_claims() -> None:
59
+ assert JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search("@returns {Promise<*>}") is None
60
+ assert (
61
+ JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search("@returns {Promise<string>}") is None
62
+ )
63
+ assert (
64
+ JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search("@returns {Promise<object[]>}")
65
+ is None
66
+ )
67
+
68
+
69
+ def test_return_call_pattern_captures_plain_and_awaited_callees() -> None:
70
+ plain_match = RETURN_CALL_OPENING_PARENTHESIS_PATTERN.search(
71
+ " return convergeAgent(prompt, {})"
72
+ )
73
+ assert plain_match is not None
74
+ assert plain_match.group("callee") == "convergeAgent"
75
+ awaited_match = RETURN_CALL_OPENING_PARENTHESIS_PATTERN.search(
76
+ " return await commitWithRecovery({})"
77
+ )
78
+ assert awaited_match is not None
79
+ assert awaited_match.group("callee") == "commitWithRecovery"
80
+
81
+
82
+ def test_return_call_pattern_ignores_identifiers_beginning_with_return() -> None:
83
+ assert RETURN_CALL_OPENING_PARENTHESIS_PATTERN.search("const x = returnValue(payload)") is None
84
+
85
+
86
+ def test_schema_property_key_pattern_matches_only_the_options_key() -> None:
87
+ assert (
88
+ SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN.search("{ label: 'x', schema: HEAD_SCHEMA }")
89
+ is not None
90
+ )
91
+ assert SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN.search("{ label: 'x', schema : X }") is not None
92
+ assert SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN.search("{ agentType, phase }, HEAD_SCHEMA") is None
93
+ assert SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN.search("{ myschema: X }") is None
@@ -14,7 +14,6 @@ UNUSED_IMPORT_GUIDANCE: str = (
14
14
  "remove unused import; if kept for side effects, mark with `# noqa: F401`"
15
15
  )
16
16
  TYPE_CHECKING_IDENTIFIER: str = "TYPE_CHECKING"
17
- ALL_TYPING_MODULE_NAMES: frozenset[str] = frozenset({"typing", "typing_extensions"})
18
17
 
19
18
 
20
19
  def _comment_text_from_line(line_text: str) -> str | None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.79.0",
3
+ "version": "1.80.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@ Read the body and the docstring side by side:
17
17
  - **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
18
18
  - **Step order.** A docstring that says `A then B then C` matches the call order in the body. A step enumeration that names the body's linear steps also names every corrective step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`). The `check_docstring_step_enumeration_dispatch_coverage` gate blocks the branch-guarded-dispatch form of this drift — a step-enumeration docstring that omits a two-or-more-token dispatch step the body guards inside a branch — at Write/Edit time.
19
19
  - **JS/`.mjs` resume-task enumerations.** A `spawn<Role>Agent` JSDoc that enumerates its sibling `resume<Role>Agent`'s resume tasks in a parenthetical `resume (repair-verify, hardening-verify)` list names every `task === '<name>'` branch the resume body dispatches on. The `check_js_resume_task_enumeration_coverage` gate blocks the JavaScript form of this drift — a spawn JSDoc whose resume enumeration omits a dispatched task — at Write/Edit time. This is the `.mjs` slice of the same Category O6 standard the Python gates carry; the Python AST docstring gates never inspect JavaScript source.
20
+ - **JS/`.mjs` `@returns` object with a schema-less branch.** A `function` whose JSDoc `@returns {Promise<object>}` promises a structured object names a return type every branch honors. When the body returns one agent-spawn helper both with a `schema` options object and without one, the schema-less branch resolves to a transcript string, not the object the JSDoc claims. The `check_js_returns_object_schemaless_branch` gate blocks this drift — a `Promise<object>` JSDoc whose body returns the same helper with and without a `schema` key — at Write/Edit time. This is the `.mjs` slice of the same Category O6 standard the Python gates carry; the Python AST docstring gates never inspect JavaScript source.
20
21
  - **Returns-clause cardinality.** A `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) matches the count of keys in that family in the returned dict literal. When the dict holds one key in the family (`sheen_mid`), the noun is singular (`the sheen stop`); a plural noun there claims two or more entries the dict does not hold. The `check_docstring_returns_plural_cardinality` gate blocks the single-key-with-plural-noun form of this drift at Write/Edit time.
21
22
  - **Length-constant superlative vs exact gate.** A module docstring that describes an integer `*_LENGTH` constant with a superlative or range word (`the longest color string the swatch accepts`, `no longer than`) matches how the code consumes the constant. When the only consumer compares `len(...)` against the constant with `==`/`!=` — an exact-length gate where every other length is rejected, not accepted at a shorter length — the superlative prose claims a range of accepted lengths the code never allows. State the exact required length (`the exact #AARRGGBB length`), not a longest/range form. The `check_docstring_length_constant_superlative_vs_exact_gate` gate blocks this drift at Write/Edit time, scanning the constant module's package tree so it sees a consumer that lives in a sibling module; a constant genuinely used as a ceiling (`len(x) <= LIMIT`) is left alone.
22
23
  - **Args single-line scope vs span body.** An `Args:` entry that scopes a finding to one named line (`a finding blocks only when its block-anchor line is among the changed lines`) matches the line breadth the body scopes by. When the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper that blocks when any line of the span is among the changed lines, the single-line Args wording understates the scope: an edit touching a non-anchor line of the span still blocks. State the Args entry on the same span breadth the body uses (`a finding blocks when any line of its block span is among the changed lines`). The `check_docstring_args_single_line_scope_vs_span` gate blocks the single-line-Args-over-span-body form of this drift at Write/Edit time.
@@ -97,6 +97,18 @@ PR's owner.
97
97
  prompt can add a standing Bash permission allow-rule for that script in their
98
98
  settings.
99
99
 
100
+ 5. **Copilot quota pre-check.** Before the `Workflow` call, run once:
101
+ `python "$HOME/.claude/_shared/pr-loop/scripts/copilot_quota.py"`
102
+ It reads the account's remaining Copilot premium-request quota via
103
+ `gh api copilot_internal/user` and prints one line — log that line. Exit 0
104
+ means Copilot has quota to run, so pass `copilotDisabled: false`. Any non-zero
105
+ exit means skip Copilot for this run — the account is out of quota, the quota
106
+ API or account access is down, or no account is set — so pass
107
+ `copilotDisabled: true`; the workflow then skips the Copilot gate with no agent
108
+ spawned. The account comes from the `COPILOT_QUOTA_ACCOUNT` environment
109
+ variable or a git-ignored `.env` file, and the no-account line names the exact
110
+ `.env` path and key to set.
111
+
100
112
  ## Run the workflow
101
113
 
102
114
  Call the `Workflow` tool against the colocated script:
@@ -104,7 +116,7 @@ Call the `Workflow` tool against the colocated script:
104
116
  ```
105
117
  Workflow({
106
118
  scriptPath: "<this skill dir>/workflow/converge.mjs",
107
- args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false }
119
+ args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false, copilotDisabled: false }
108
120
  })
109
121
  ```
110
122
 
@@ -113,8 +125,10 @@ own directory (on this install,
113
125
  `<home>/.claude/skills/autoconverge/workflow/converge.mjs`). Set
114
126
  `bugbotDisabled: true` only when the user has opted Cursor Bugbot out for the
115
127
  run; otherwise the workflow detects an opt-out or an unreachable Bugbot on its
116
- own. The workflow runs in the background and notifies this session on
117
- completion. Watch live progress with `/workflows`.
128
+ own. Set `copilotDisabled: true` when the step 5 quota pre-check exits non-zero,
129
+ and `false` when it exits 0; on `true` the workflow skips the Copilot gate with
130
+ no agent spawned. The workflow runs in the background and notifies this session
131
+ on completion. Watch live progress with `/workflows`.
118
132
 
119
133
  The workflow returns
120
134
  `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote }`.
@@ -341,6 +355,13 @@ each PR its own checkout with `git worktree add`. For each PR the user named:
341
355
  4. **Grant project permissions once per repository** — the single-PR pre-flight
342
356
  step 4 grant covers every worktree of the same repo, so run it one time for
343
357
  the repo the PRs live in.
358
+ 5. **Copilot quota pre-check once for the whole run** — run the single-PR
359
+ pre-flight step 5 check one time:
360
+ `python "$HOME/.claude/_shared/pr-loop/scripts/copilot_quota.py"`. Every PR in
361
+ the run shares one account's Copilot premium-request quota, so one check covers
362
+ them all. Exit 0 sets `copilotDisabled: false` on every PR entry below; any
363
+ non-zero exit sets `copilotDisabled: true` on every entry, so each child skips
364
+ the Copilot gate with no agent spawned.
344
365
 
345
366
  ### Launch the multi-PR workflow
346
367
 
@@ -353,8 +374,8 @@ Workflow({
353
374
  args: {
354
375
  convergeScriptPath: "<this skill dir>/workflow/converge.mjs",
355
376
  prs: [
356
- { owner: "<O>", repo: "<R>", prNumber: <N1>, repoPath: "<abs worktree 1>", bugbotDisabled: false },
357
- { owner: "<O>", repo: "<R>", prNumber: <N2>, repoPath: "<abs worktree 2>", bugbotDisabled: false }
377
+ { owner: "<O>", repo: "<R>", prNumber: <N1>, repoPath: "<abs worktree 1>", bugbotDisabled: false, copilotDisabled: false },
378
+ { owner: "<O>", repo: "<R>", prNumber: <N2>, repoPath: "<abs worktree 2>", bugbotDisabled: false, copilotDisabled: false }
358
379
  ]
359
380
  }
360
381
  })
@@ -40,8 +40,8 @@ test('cleanAuditBlocker falls back to a no-result reason when the post agent die
40
40
  assert.match(message, /the post agent returned no result/);
41
41
  });
42
42
 
43
- test('the post-clean-audit task in resumeGeneralUtilityAgent returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
44
- const body = functionBody('resumeGeneralUtilityAgent');
43
+ test('the post-clean-audit task in runGeneralUtilityTask returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
44
+ const body = functionBody('runGeneralUtilityTask');
45
45
  assert.match(body, /task === 'post-clean-audit'/);
46
46
  assert.match(body, /schema: CLEAN_AUDIT_SCHEMA/);
47
47
  assert.doesNotMatch(body, /agent transcript \(unused\)/);
@@ -59,7 +59,7 @@ test('the standards-only call site breaks with a clean-audit blocker when the po
59
59
  convergeSource.indexOf('if (isStandardsOnlyRound(findings)) {'),
60
60
  convergeSource.indexOf('if (findings.length > 0) {'),
61
61
  );
62
- assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
62
+ assert.match(branch, /runGeneralUtilityTask\(.*'post-clean-audit'/);
63
63
  assert.match(branch, /if \(!auditResult\?\.posted\)/);
64
64
  assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
65
65
  assert.match(branch, /\bbreak\b/);
@@ -70,7 +70,7 @@ test('the all-clean call site breaks with a clean-audit blocker when the post do
70
70
  convergeSource.indexOf('all lenses clean on'),
71
71
  convergeSource.indexOf("if (phase === 'COPILOT') {"),
72
72
  );
73
- assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
73
+ assert.match(branch, /runGeneralUtilityTask\(.*'post-clean-audit'/);
74
74
  assert.match(branch, /if \(!auditResult\?\.posted\)/);
75
75
  assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
76
76
  assert.match(branch, /\bbreak\b/);