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.
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_unused_imports.py +7 -63
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +96 -15
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -0
- package/skills/autoconverge/SKILL.md +26 -5
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +110 -228
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- 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
|
|
9
|
-
does not declare `__all__
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
383
|
-
"TYPE_CHECKING
|
|
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
|
|
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
|
|
488
|
-
|
|
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
|
|
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
|
|
505
|
-
|
|
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
|
@@ -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.
|
|
117
|
-
|
|
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
|
|
44
|
-
const body = functionBody('
|
|
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, /
|
|
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, /
|
|
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/);
|