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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Direct unit tests for the copilot_quota pre-check.
|
|
2
|
+
|
|
3
|
+
Every case drives the production ``main`` / ``evaluate_copilot_quota`` path with
|
|
4
|
+
only the ``gh`` subprocess boundary (``_run_gh``) stubbed, fed a captured
|
|
5
|
+
``copilot_internal/user`` JSON fixture in the jonecho shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_copilot_quota_module() -> ModuleType:
|
|
18
|
+
scripts_directory = Path(__file__).parent.parent
|
|
19
|
+
if str(scripts_directory) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(scripts_directory))
|
|
21
|
+
module_path = scripts_directory / "copilot_quota.py"
|
|
22
|
+
specification = importlib.util.spec_from_file_location("copilot_quota", module_path)
|
|
23
|
+
assert specification is not None
|
|
24
|
+
assert specification.loader is not None
|
|
25
|
+
module = importlib.util.module_from_spec(specification)
|
|
26
|
+
sys.modules[specification.name] = module
|
|
27
|
+
specification.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
copilot_quota = _load_copilot_quota_module()
|
|
32
|
+
|
|
33
|
+
FIXTURE_PATH = Path(__file__).parent / "fixtures" / "copilot_internal_user_jonecho.json"
|
|
34
|
+
AVAILABLE_USER_JSON = FIXTURE_PATH.read_text(encoding="utf-8")
|
|
35
|
+
FAKE_TOKEN_RESULT = (0, "ghp_faketoken_value\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _exhausted_user_json() -> str:
|
|
39
|
+
user = json.loads(AVAILABLE_USER_JSON)
|
|
40
|
+
premium = user["quota_snapshots"]["premium_interactions"]
|
|
41
|
+
premium["remaining"] = 0
|
|
42
|
+
premium["quota_remaining"] = 0.0
|
|
43
|
+
premium["percent_remaining"] = 0.0
|
|
44
|
+
return json.dumps(user)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _user_json_without_premium_snapshot() -> str:
|
|
48
|
+
user = json.loads(AVAILABLE_USER_JSON)
|
|
49
|
+
del user["quota_snapshots"]
|
|
50
|
+
return json.dumps(user)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _gh_stub(token_result: tuple[int, str], api_result: tuple[int, str]):
|
|
54
|
+
def _fake_run_gh(
|
|
55
|
+
command_arguments: list[str],
|
|
56
|
+
extra_environment: dict[str, str] | None = None,
|
|
57
|
+
) -> tuple[int, str]:
|
|
58
|
+
if command_arguments and command_arguments[0] == "auth":
|
|
59
|
+
return token_result
|
|
60
|
+
if command_arguments and command_arguments[0] == "api":
|
|
61
|
+
return api_result
|
|
62
|
+
raise AssertionError(f"unexpected gh command {command_arguments}")
|
|
63
|
+
|
|
64
|
+
return _fake_run_gh
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture(autouse=True)
|
|
68
|
+
def _isolate_account_sources(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
69
|
+
monkeypatch.delenv("COPILOT_QUOTA_ACCOUNT", raising=False)
|
|
70
|
+
monkeypatch.setattr(
|
|
71
|
+
copilot_quota, "COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH", tmp_path / ".env"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_main_runs_copilot_when_premium_quota_available(
|
|
76
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
77
|
+
) -> None:
|
|
78
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
|
|
79
|
+
monkeypatch.setattr(
|
|
80
|
+
copilot_quota,
|
|
81
|
+
"_run_gh",
|
|
82
|
+
_gh_stub(FAKE_TOKEN_RESULT, (0, AVAILABLE_USER_JSON)),
|
|
83
|
+
)
|
|
84
|
+
exit_code = copilot_quota.main([])
|
|
85
|
+
captured = capsys.readouterr()
|
|
86
|
+
assert exit_code == 0
|
|
87
|
+
assert "running Copilot" in captured.out
|
|
88
|
+
assert "6817" in captured.out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_main_exits_out_of_quota_when_premium_exhausted(
|
|
92
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
93
|
+
) -> None:
|
|
94
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
|
|
95
|
+
monkeypatch.setattr(
|
|
96
|
+
copilot_quota,
|
|
97
|
+
"_run_gh",
|
|
98
|
+
_gh_stub(FAKE_TOKEN_RESULT, (0, _exhausted_user_json())),
|
|
99
|
+
)
|
|
100
|
+
exit_code = copilot_quota.main([])
|
|
101
|
+
captured = capsys.readouterr()
|
|
102
|
+
assert exit_code == 1
|
|
103
|
+
assert "scenario A" in captured.err
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_main_exits_api_down_on_non_json_response(
|
|
107
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
108
|
+
) -> None:
|
|
109
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
|
|
110
|
+
monkeypatch.setattr(
|
|
111
|
+
copilot_quota,
|
|
112
|
+
"_run_gh",
|
|
113
|
+
_gh_stub(FAKE_TOKEN_RESULT, (0, "not json at all")),
|
|
114
|
+
)
|
|
115
|
+
exit_code = copilot_quota.main([])
|
|
116
|
+
captured = capsys.readouterr()
|
|
117
|
+
assert exit_code == 2
|
|
118
|
+
assert "scenario B" in captured.err
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_main_exits_api_down_on_missing_premium_snapshot(
|
|
122
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
123
|
+
) -> None:
|
|
124
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
|
|
125
|
+
monkeypatch.setattr(
|
|
126
|
+
copilot_quota,
|
|
127
|
+
"_run_gh",
|
|
128
|
+
_gh_stub(FAKE_TOKEN_RESULT, (0, _user_json_without_premium_snapshot())),
|
|
129
|
+
)
|
|
130
|
+
exit_code = copilot_quota.main([])
|
|
131
|
+
captured = capsys.readouterr()
|
|
132
|
+
assert exit_code == 2
|
|
133
|
+
assert "scenario B" in captured.err
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_main_exits_api_down_when_gh_token_unresolved(
|
|
137
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
138
|
+
) -> None:
|
|
139
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
|
|
140
|
+
monkeypatch.setattr(
|
|
141
|
+
copilot_quota,
|
|
142
|
+
"_run_gh",
|
|
143
|
+
_gh_stub((1, ""), (0, AVAILABLE_USER_JSON)),
|
|
144
|
+
)
|
|
145
|
+
exit_code = copilot_quota.main([])
|
|
146
|
+
captured = capsys.readouterr()
|
|
147
|
+
assert exit_code == 2
|
|
148
|
+
assert "scenario B" in captured.err
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_main_exits_no_config_and_names_env_path_and_key(
|
|
152
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
153
|
+
) -> None:
|
|
154
|
+
def _fail_if_called(
|
|
155
|
+
command_arguments: list[str],
|
|
156
|
+
extra_environment: dict[str, str] | None = None,
|
|
157
|
+
) -> tuple[int, str]:
|
|
158
|
+
raise AssertionError("gh must not run when no account is configured")
|
|
159
|
+
|
|
160
|
+
monkeypatch.setattr(copilot_quota, "_run_gh", _fail_if_called)
|
|
161
|
+
exit_code = copilot_quota.main([])
|
|
162
|
+
captured = capsys.readouterr()
|
|
163
|
+
assert exit_code == 3
|
|
164
|
+
assert "scenario C" in captured.err
|
|
165
|
+
assert "COPILOT_QUOTA_ACCOUNT" in captured.err
|
|
166
|
+
assert ".env" in captured.err
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize(
|
|
170
|
+
("api_result", "expected_exit_code", "expected_scenario"),
|
|
171
|
+
[
|
|
172
|
+
((0, _exhausted_user_json()), 1, "scenario A"),
|
|
173
|
+
((0, "not json at all"), 2, "scenario B"),
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
def test_every_gh_backed_skip_writes_a_log_line_naming_the_scenario(
|
|
177
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
178
|
+
capsys: pytest.CaptureFixture[str],
|
|
179
|
+
api_result: tuple[int, str],
|
|
180
|
+
expected_exit_code: int,
|
|
181
|
+
expected_scenario: str,
|
|
182
|
+
) -> None:
|
|
183
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "jonecho")
|
|
184
|
+
monkeypatch.setattr(
|
|
185
|
+
copilot_quota, "_run_gh", _gh_stub(FAKE_TOKEN_RESULT, api_result)
|
|
186
|
+
)
|
|
187
|
+
exit_code = copilot_quota.main([])
|
|
188
|
+
captured = capsys.readouterr()
|
|
189
|
+
assert exit_code == expected_exit_code
|
|
190
|
+
assert captured.out == ""
|
|
191
|
+
assert expected_scenario in captured.err
|
|
192
|
+
assert captured.err.strip() != ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_cli_account_takes_precedence_over_env_var(
|
|
196
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
197
|
+
) -> None:
|
|
198
|
+
monkeypatch.setenv("COPILOT_QUOTA_ACCOUNT", "env-user")
|
|
199
|
+
accounts_seen: list[str] = []
|
|
200
|
+
|
|
201
|
+
def _fake_run_gh(
|
|
202
|
+
command_arguments: list[str],
|
|
203
|
+
extra_environment: dict[str, str] | None = None,
|
|
204
|
+
) -> tuple[int, str]:
|
|
205
|
+
if command_arguments[0] == "auth":
|
|
206
|
+
accounts_seen.append(command_arguments[-1])
|
|
207
|
+
return FAKE_TOKEN_RESULT
|
|
208
|
+
return (0, AVAILABLE_USER_JSON)
|
|
209
|
+
|
|
210
|
+
monkeypatch.setattr(copilot_quota, "_run_gh", _fake_run_gh)
|
|
211
|
+
decision = copilot_quota.evaluate_copilot_quota(
|
|
212
|
+
cli_account="cli-user", env_file_path=tmp_path / ".env"
|
|
213
|
+
)
|
|
214
|
+
assert decision.exit_code == 0
|
|
215
|
+
assert accounts_seen == ["cli-user"]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_account_resolves_from_env_file_when_flag_and_env_absent(
|
|
219
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
220
|
+
) -> None:
|
|
221
|
+
env_file = tmp_path / ".env"
|
|
222
|
+
env_file.write_text(
|
|
223
|
+
"# local copilot quota account\nCOPILOT_QUOTA_ACCOUNT=file-user\n",
|
|
224
|
+
encoding="utf-8",
|
|
225
|
+
)
|
|
226
|
+
accounts_seen: list[str] = []
|
|
227
|
+
|
|
228
|
+
def _fake_run_gh(
|
|
229
|
+
command_arguments: list[str],
|
|
230
|
+
extra_environment: dict[str, str] | None = None,
|
|
231
|
+
) -> tuple[int, str]:
|
|
232
|
+
if command_arguments[0] == "auth":
|
|
233
|
+
accounts_seen.append(command_arguments[-1])
|
|
234
|
+
return FAKE_TOKEN_RESULT
|
|
235
|
+
return (0, AVAILABLE_USER_JSON)
|
|
236
|
+
|
|
237
|
+
monkeypatch.setattr(copilot_quota, "_run_gh", _fake_run_gh)
|
|
238
|
+
decision = copilot_quota.evaluate_copilot_quota(
|
|
239
|
+
cli_account=None, env_file_path=env_file
|
|
240
|
+
)
|
|
241
|
+
assert decision.exit_code == 0
|
|
242
|
+
assert accounts_seen == ["file-user"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Tests for copilot_quota_constants.py extracted constant set."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_constants_module() -> ModuleType:
|
|
9
|
+
module_path = (
|
|
10
|
+
Path(__file__).parent.parent
|
|
11
|
+
/ "pr_loop_shared_constants"
|
|
12
|
+
/ "copilot_quota_constants.py"
|
|
13
|
+
)
|
|
14
|
+
specification = importlib.util.spec_from_file_location(
|
|
15
|
+
"pr_loop_shared_constants.copilot_quota_constants", module_path
|
|
16
|
+
)
|
|
17
|
+
assert specification is not None
|
|
18
|
+
assert specification.loader is not None
|
|
19
|
+
module = importlib.util.module_from_spec(specification)
|
|
20
|
+
specification.loader.exec_module(module)
|
|
21
|
+
return module
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
constants_module = _load_constants_module()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_copilot_quota_account_env_var_name() -> None:
|
|
28
|
+
assert (
|
|
29
|
+
constants_module.COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME == "COPILOT_QUOTA_ACCOUNT"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_gh_token_env_var_name() -> None:
|
|
34
|
+
assert constants_module.GH_TOKEN_ENV_VAR_NAME == "GH_TOKEN"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_copilot_internal_user_api_path() -> None:
|
|
38
|
+
assert constants_module.COPILOT_INTERNAL_USER_API_PATH == "copilot_internal/user"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_premium_interactions_gating_field_names() -> None:
|
|
42
|
+
assert constants_module.QUOTA_SNAPSHOTS_FIELD_NAME == "quota_snapshots"
|
|
43
|
+
assert constants_module.PREMIUM_INTERACTIONS_FIELD_NAME == "premium_interactions"
|
|
44
|
+
assert constants_module.PREMIUM_UNLIMITED_FIELD_NAME == "unlimited"
|
|
45
|
+
assert constants_module.PREMIUM_REMAINING_FIELD_NAME == "remaining"
|
|
46
|
+
assert constants_module.PREMIUM_OVERAGE_PERMITTED_FIELD_NAME == "overage_permitted"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_exit_codes_are_the_four_distinct_scenarios() -> None:
|
|
50
|
+
all_exit_codes = (
|
|
51
|
+
constants_module.EXIT_CODE_QUOTA_AVAILABLE,
|
|
52
|
+
constants_module.EXIT_CODE_OUT_OF_QUOTA,
|
|
53
|
+
constants_module.EXIT_CODE_QUOTA_API_DOWN,
|
|
54
|
+
constants_module.EXIT_CODE_NO_ACCOUNT_CONFIGURED,
|
|
55
|
+
)
|
|
56
|
+
assert all_exit_codes == (0, 1, 2, 3)
|
|
57
|
+
assert len(set(all_exit_codes)) == len(all_exit_codes)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_default_env_file_path_sits_at_the_package_root() -> None:
|
|
61
|
+
default_env_file_path = constants_module.COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH
|
|
62
|
+
assert default_env_file_path.name == ".env"
|
|
63
|
+
assert default_env_file_path.parent.name == "claude-dev-env"
|
|
@@ -102,6 +102,7 @@ from code_rules_imports_logging import ( # noqa: E402
|
|
|
102
102
|
check_import_block_sorted,
|
|
103
103
|
check_imports_at_top,
|
|
104
104
|
check_js_resume_task_enumeration_coverage,
|
|
105
|
+
check_js_returns_object_schemaless_branch,
|
|
105
106
|
check_library_print,
|
|
106
107
|
check_logging_fstrings,
|
|
107
108
|
check_logging_printf_tokens,
|
|
@@ -468,6 +469,9 @@ def validate_content(
|
|
|
468
469
|
all_issues.extend(
|
|
469
470
|
check_js_resume_task_enumeration_coverage(content, file_path)
|
|
470
471
|
)
|
|
472
|
+
all_issues.extend(
|
|
473
|
+
check_js_returns_object_schemaless_branch(content, file_path)
|
|
474
|
+
)
|
|
471
475
|
|
|
472
476
|
if extension in ALL_CODE_EXTENSIONS:
|
|
473
477
|
advise_file_line_count(content, file_path)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Imports-at-top, import-block-sorted, logging f-string, win32gui None, E2E spec naming, JS resume-task enumeration coverage, file-length advisory, and library-print checks."""
|
|
1
|
+
"""Imports-at-top, import-block-sorted, logging f-string, win32gui None, E2E spec naming, JS resume-task enumeration coverage, JS returns-object schema-less branch, file-length advisory, and library-print checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import json
|
|
@@ -34,6 +34,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
34
34
|
MAX_E2E_TEST_NAMING_ISSUES,
|
|
35
35
|
MAX_IMPORT_BLOCK_SORT_ISSUES,
|
|
36
36
|
MAX_JS_RESUME_TASK_ENUMERATION_ISSUES,
|
|
37
|
+
MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES,
|
|
37
38
|
MAX_LOGGING_FSTRING_ISSUES,
|
|
38
39
|
MAX_LOGGING_PRINTF_TOKEN_ISSUES,
|
|
39
40
|
MAX_WINDOWS_API_NONE_ISSUES,
|
|
@@ -55,17 +56,21 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
55
56
|
ENUMERATION_LEADING_CONJUNCTION_PATTERN,
|
|
56
57
|
ENUMERATION_LIST_ITEM_SEPARATOR_PATTERN,
|
|
57
58
|
ENUMERATION_TASK_ITEM_PATTERN,
|
|
59
|
+
FUNCTION_WITH_JSDOC_PATTERN,
|
|
58
60
|
HYPHENATED_TASK_ITEM_PATTERN,
|
|
59
61
|
JAVASCRIPT_BLOCK_COMMENT_CLOSER,
|
|
60
62
|
JAVASCRIPT_BLOCK_COMMENT_OPENER,
|
|
61
63
|
JAVASCRIPT_LINE_COMMENT_OPENER,
|
|
62
64
|
JAVASCRIPT_REGEX_DELIMITER,
|
|
63
65
|
JAVASCRIPT_STRING_ESCAPE_CHARACTER,
|
|
66
|
+
JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
|
|
64
67
|
LOGGING_FSTRING_PATTERN,
|
|
65
68
|
LOGGING_PRINTF_TOKEN_PATTERN,
|
|
66
69
|
MINIMUM_FORMAT_LOGGER_ARGUMENT_COUNT,
|
|
67
70
|
NOT_INSIDE_TYPE_CHECKING_BLOCK,
|
|
68
71
|
RESUME_TASK_ENUMERATION_PATTERN,
|
|
72
|
+
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
73
|
+
SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
|
|
69
74
|
SPAWN_AGENT_WITH_JSDOC_PATTERN,
|
|
70
75
|
TASK_DISPATCH_NAME_PATTERN,
|
|
71
76
|
TRIPLE_DOUBLE_QUOTE_DELIMITER,
|
|
@@ -919,6 +924,136 @@ def check_js_resume_task_enumeration_coverage(
|
|
|
919
924
|
return issues[:MAX_JS_RESUME_TASK_ENUMERATION_ISSUES]
|
|
920
925
|
|
|
921
926
|
|
|
927
|
+
def _parameter_list_end_index(blanked_content: str, after_open_parenthesis_index: int) -> int:
|
|
928
|
+
"""Return the index just past the ``)`` that closes an opened parameter list.
|
|
929
|
+
|
|
930
|
+
``after_open_parenthesis_index`` sits just past the opening ``(``, so the scan
|
|
931
|
+
starts one level deep and walks paren-balanced. A nested ``(`` inside the list
|
|
932
|
+
— a default-value call, a destructure default — does not close the list early.
|
|
933
|
+
"""
|
|
934
|
+
depth = 1
|
|
935
|
+
for each_position in range(after_open_parenthesis_index, len(blanked_content)):
|
|
936
|
+
character = blanked_content[each_position]
|
|
937
|
+
if character == "(":
|
|
938
|
+
depth += 1
|
|
939
|
+
elif character == ")":
|
|
940
|
+
depth -= 1
|
|
941
|
+
if depth == 0:
|
|
942
|
+
return each_position + 1
|
|
943
|
+
return len(blanked_content)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _javascript_function_body(content: str, header_end_index: int) -> str | None:
|
|
947
|
+
"""Return the brace-balanced body of the function whose header ends at the index.
|
|
948
|
+
|
|
949
|
+
``header_end_index`` sits just past the opening ``(`` of the parameter list, so
|
|
950
|
+
the scan advances past the paren-balanced parameter list before finding the body
|
|
951
|
+
brace. A destructured parameter (``function f({ ... }) { ... }``) opens a brace
|
|
952
|
+
inside that list; the body brace is the first ``{`` after the list closes, not
|
|
953
|
+
the destructure brace.
|
|
954
|
+
"""
|
|
955
|
+
body_search_index = _parameter_list_end_index(
|
|
956
|
+
_blank_non_code_regions(content), header_end_index
|
|
957
|
+
)
|
|
958
|
+
bounded_content = content[: _next_top_level_function_index(content, body_search_index)]
|
|
959
|
+
return _balanced_brace_body(bounded_content, body_search_index)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _balanced_parenthesis_span(blanked_content: str, opening_index: int) -> str:
|
|
963
|
+
"""Return the paren-balanced slice of blanked source starting at an opening ``(``."""
|
|
964
|
+
depth = 0
|
|
965
|
+
for each_position in range(opening_index, len(blanked_content)):
|
|
966
|
+
character = blanked_content[each_position]
|
|
967
|
+
if character == "(":
|
|
968
|
+
depth += 1
|
|
969
|
+
elif character == ")":
|
|
970
|
+
depth -= 1
|
|
971
|
+
if depth == 0:
|
|
972
|
+
return blanked_content[opening_index : each_position + 1]
|
|
973
|
+
return blanked_content[opening_index:]
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _mixed_schema_return_callees(function_body: str) -> set[str]:
|
|
977
|
+
"""Return callees a function returns both with and without a schema options object.
|
|
978
|
+
|
|
979
|
+
The body is blanked so a brace or keyword inside a prompt string never counts.
|
|
980
|
+
Each ``return <callee>(...)`` whose argument list carries an object literal is
|
|
981
|
+
grouped by whether that argument list also carries a ``schema`` key. A callee
|
|
982
|
+
that appears on both sides marks a function that returns a schema object in one
|
|
983
|
+
branch and a schema-less transcript in another.
|
|
984
|
+
"""
|
|
985
|
+
blanked_body = _blank_non_code_regions(function_body)
|
|
986
|
+
schema_bearing_callees: set[str] = set()
|
|
987
|
+
schema_less_callees: set[str] = set()
|
|
988
|
+
for each_return_match in RETURN_CALL_OPENING_PARENTHESIS_PATTERN.finditer(blanked_body):
|
|
989
|
+
argument_span = _balanced_parenthesis_span(blanked_body, each_return_match.end() - 1)
|
|
990
|
+
if "{" not in argument_span:
|
|
991
|
+
continue
|
|
992
|
+
callee_name = each_return_match.group("callee")
|
|
993
|
+
if SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN.search(argument_span) is not None:
|
|
994
|
+
schema_bearing_callees.add(callee_name)
|
|
995
|
+
else:
|
|
996
|
+
schema_less_callees.add(callee_name)
|
|
997
|
+
return schema_bearing_callees & schema_less_callees
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def check_js_returns_object_schemaless_branch(content: str, file_path: str) -> list[str]:
|
|
1001
|
+
"""Flag a JSDoc @returns Promise<object> whose branch returns a transcript string.
|
|
1002
|
+
|
|
1003
|
+
Picture a dispatcher that spawns an agent and returns whatever the agent
|
|
1004
|
+
resolves to. Most branches pass a ``schema`` in the agent options, so the agent
|
|
1005
|
+
resolves to a structured object — and the JSDoc reads
|
|
1006
|
+
``@returns {Promise<object>}``. But one branch spawns the same agent with no
|
|
1007
|
+
schema, so that branch resolves to a plain transcript string. A caller who
|
|
1008
|
+
trusts the object contract and reads a field off the result gets undefined on
|
|
1009
|
+
that branch.
|
|
1010
|
+
|
|
1011
|
+
The check reads each ``function`` declaration that carries a JSDoc block. When
|
|
1012
|
+
the JSDoc promises a ``Promise<object>`` and the body returns one agent helper
|
|
1013
|
+
both with a ``schema`` options object and without one, the schema-less branch
|
|
1014
|
+
contradicts the object claim and the function is flagged. A function whose every
|
|
1015
|
+
return passes a schema, one that returns plain object literals, and one whose
|
|
1016
|
+
``@returns`` already admits a string are all left alone. This is the JS/.mjs
|
|
1017
|
+
slice of Category O6 docstring-prose-vs-implementation drift; the Python
|
|
1018
|
+
enforcer's AST docstring checks never inspect JavaScript source.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
content: The source text to inspect.
|
|
1022
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
One issue per function whose object return-type claim a schema-less branch
|
|
1026
|
+
contradicts, capped at the module limit.
|
|
1027
|
+
"""
|
|
1028
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
1029
|
+
return []
|
|
1030
|
+
if get_file_extension(file_path) not in ALL_JAVASCRIPT_EXTENSIONS:
|
|
1031
|
+
return []
|
|
1032
|
+
issues: list[str] = []
|
|
1033
|
+
for each_match in FUNCTION_WITH_JSDOC_PATTERN.finditer(content):
|
|
1034
|
+
if JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN.search(each_match.group("jsdoc")) is None:
|
|
1035
|
+
continue
|
|
1036
|
+
function_body = _javascript_function_body(content, each_match.end())
|
|
1037
|
+
if function_body is None:
|
|
1038
|
+
continue
|
|
1039
|
+
mixed_callees = _mixed_schema_return_callees(function_body)
|
|
1040
|
+
if not mixed_callees:
|
|
1041
|
+
continue
|
|
1042
|
+
function_name = each_match.group("name")
|
|
1043
|
+
line_number = content.count("\n", 0, each_match.start("name")) + 1
|
|
1044
|
+
joined_callees = ", ".join(sorted(mixed_callees))
|
|
1045
|
+
issues.append(
|
|
1046
|
+
f"Line {line_number}: {function_name}() JSDoc @returns a Promise<object> but a "
|
|
1047
|
+
f"branch returns {joined_callees}(...) with a schema-less options object — that "
|
|
1048
|
+
"branch resolves to a transcript string, not the structured object the @returns "
|
|
1049
|
+
"claims; widen the @returns type to admit the string transcript, or pass a schema "
|
|
1050
|
+
"(Category O6 docstring-vs-implementation drift)"
|
|
1051
|
+
)
|
|
1052
|
+
if len(issues) >= MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES:
|
|
1053
|
+
break
|
|
1054
|
+
return issues[:MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES]
|
|
1055
|
+
|
|
1056
|
+
|
|
922
1057
|
def _is_cli_entry_point(file_path: str) -> bool:
|
|
923
1058
|
path_lower = file_path.lower().replace("\\", "/")
|
|
924
1059
|
return any(marker.replace("\\", "/") in path_lower for marker in ALL_CLI_FILE_PATH_MARKERS)
|
|
@@ -20,13 +20,10 @@ from code_rules_shared import ( # noqa: E402
|
|
|
20
20
|
_build_parent_map,
|
|
21
21
|
is_migration_file,
|
|
22
22
|
is_test_file,
|
|
23
|
-
is_workflow_registry_file,
|
|
24
23
|
)
|
|
25
24
|
|
|
26
25
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
27
|
-
ALL_TYPING_MODULE_NAMES,
|
|
28
26
|
MAX_UNUSED_IMPORT_ISSUES,
|
|
29
|
-
TYPE_CHECKING_IDENTIFIER,
|
|
30
27
|
UNUSED_IMPORT_GUIDANCE,
|
|
31
28
|
line_suppresses_unused_import_via_noqa,
|
|
32
29
|
)
|
|
@@ -73,59 +70,6 @@ def _line_number_falls_in_import_ranges(
|
|
|
73
70
|
return False
|
|
74
71
|
|
|
75
72
|
|
|
76
|
-
def _type_checking_guard_aliases(tree: ast.Module) -> tuple[set[str], set[str]]:
|
|
77
|
-
all_type_checking_names = {TYPE_CHECKING_IDENTIFIER}
|
|
78
|
-
all_type_checking_module_aliases = set(ALL_TYPING_MODULE_NAMES)
|
|
79
|
-
for each_statement in tree.body:
|
|
80
|
-
if isinstance(each_statement, ast.Import):
|
|
81
|
-
for each_alias in each_statement.names:
|
|
82
|
-
if each_alias.name in ALL_TYPING_MODULE_NAMES:
|
|
83
|
-
all_type_checking_module_aliases.add(
|
|
84
|
-
each_alias.asname or each_alias.name
|
|
85
|
-
)
|
|
86
|
-
elif isinstance(each_statement, ast.ImportFrom):
|
|
87
|
-
if each_statement.module not in ALL_TYPING_MODULE_NAMES:
|
|
88
|
-
continue
|
|
89
|
-
for each_alias in each_statement.names:
|
|
90
|
-
if each_alias.name == TYPE_CHECKING_IDENTIFIER:
|
|
91
|
-
all_type_checking_names.add(each_alias.asname or each_alias.name)
|
|
92
|
-
return all_type_checking_names, all_type_checking_module_aliases
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _expression_guards_type_checking_block(
|
|
96
|
-
test_expression: ast.expr,
|
|
97
|
-
all_type_checking_names: set[str],
|
|
98
|
-
all_type_checking_module_aliases: set[str],
|
|
99
|
-
) -> bool:
|
|
100
|
-
if isinstance(test_expression, ast.Name):
|
|
101
|
-
return test_expression.id in all_type_checking_names
|
|
102
|
-
if isinstance(test_expression, ast.Attribute):
|
|
103
|
-
if test_expression.attr != TYPE_CHECKING_IDENTIFIER:
|
|
104
|
-
return False
|
|
105
|
-
receiver = test_expression.value
|
|
106
|
-
return (
|
|
107
|
-
isinstance(receiver, ast.Name)
|
|
108
|
-
and receiver.id in all_type_checking_module_aliases
|
|
109
|
-
)
|
|
110
|
-
return False
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _module_body_declares_type_checking_gate(tree: ast.Module) -> bool:
|
|
114
|
-
(
|
|
115
|
-
all_type_checking_names,
|
|
116
|
-
all_type_checking_module_aliases,
|
|
117
|
-
) = _type_checking_guard_aliases(tree)
|
|
118
|
-
return any(
|
|
119
|
-
isinstance(each_statement, ast.If)
|
|
120
|
-
and _expression_guards_type_checking_block(
|
|
121
|
-
each_statement.test,
|
|
122
|
-
all_type_checking_names,
|
|
123
|
-
all_type_checking_module_aliases,
|
|
124
|
-
)
|
|
125
|
-
for each_statement in tree.body
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
73
|
def _collect_load_names_outside_import_ranges(
|
|
130
74
|
tree: ast.Module,
|
|
131
75
|
all_import_line_ranges: list[tuple[int, int]],
|
|
@@ -191,10 +135,12 @@ def check_unused_module_level_imports(
|
|
|
191
135
|
|
|
192
136
|
References are detected from AST ``Name`` / ``Attribute`` loads outside import
|
|
193
137
|
statements so mentions in comments or string literals do not count. Files
|
|
194
|
-
declaring ``__all__`` (including annotated assignments) are skipped.
|
|
195
|
-
|
|
196
|
-
``
|
|
197
|
-
|
|
138
|
+
declaring ``__all__`` (including annotated assignments) are skipped. A
|
|
139
|
+
``if TYPE_CHECKING:`` block does not exempt the file: its guarded imports
|
|
140
|
+
are nested inside the ``If`` node and are never scanned as top-level
|
|
141
|
+
bindings, while a dead top-level runtime import in the same file is still
|
|
142
|
+
flagged. Suppression honors bare ``# noqa`` or an explicit ``F401`` code in
|
|
143
|
+
the noqa list only.
|
|
198
144
|
|
|
199
145
|
When ``full_file_content`` is provided, ``content`` is treated as an Edit
|
|
200
146
|
fragment containing the imports being added or replaced, while the
|
|
@@ -205,7 +151,7 @@ def check_unused_module_level_imports(
|
|
|
205
151
|
"""
|
|
206
152
|
if is_test_file(file_path):
|
|
207
153
|
return []
|
|
208
|
-
if
|
|
154
|
+
if is_migration_file(file_path):
|
|
209
155
|
return []
|
|
210
156
|
try:
|
|
211
157
|
fragment_tree = ast.parse(content)
|
|
@@ -218,8 +164,6 @@ def check_unused_module_level_imports(
|
|
|
218
164
|
return []
|
|
219
165
|
if _module_declares_dunder_all(reference_tree):
|
|
220
166
|
return []
|
|
221
|
-
if _module_body_declares_type_checking_gate(reference_tree):
|
|
222
|
-
return []
|
|
223
167
|
fragment_lines = content.splitlines()
|
|
224
168
|
reference_import_ranges = _import_statement_line_ranges(reference_tree)
|
|
225
169
|
referenced_names = _collect_load_names_outside_import_ranges(
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Enforcer-dispatch tests for the JS returns-object schema-less branch check.
|
|
2
|
+
|
|
3
|
+
These drive ``validate_content`` end-to-end on a ``.mjs`` payload, so they prove
|
|
4
|
+
the check is wired into the JavaScript branch of the enforcer, not just callable
|
|
5
|
+
in isolation. The drift: a ``function`` whose JSDoc ``@returns {Promise<object>}``
|
|
6
|
+
promises a structured object while one branch returns the agent helper with an
|
|
7
|
+
options object that omits ``schema`` and so resolves to a transcript string.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
19
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_enforcer_module() -> ModuleType:
|
|
23
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
25
|
+
assert spec is not None
|
|
26
|
+
assert spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
33
|
+
|
|
34
|
+
_MJS_PATH = "skills/autoconverge/workflow/converge.mjs"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mixed_schema_mjs() -> str:
|
|
38
|
+
return (
|
|
39
|
+
"/**\n"
|
|
40
|
+
" * Spawn a git-utility agent.\n"
|
|
41
|
+
" * @returns {Promise<object>} the structured output\n"
|
|
42
|
+
" */\n"
|
|
43
|
+
"function runGitTask(task, head) {\n"
|
|
44
|
+
" if (task === 'resolve-head') {\n"
|
|
45
|
+
" return convergeAgent(`head ${x}`, { label: 'g', schema: HEAD_SCHEMA, agentType: 'Explore' })\n"
|
|
46
|
+
" }\n"
|
|
47
|
+
" return convergeAgent(`fetch ${x}`, { label: 'g', agentType: 'Explore' })\n"
|
|
48
|
+
"}\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _all_schema_mjs() -> str:
|
|
53
|
+
return _mixed_schema_mjs().replace(
|
|
54
|
+
"{ label: 'g', agentType: 'Explore' })",
|
|
55
|
+
"{ label: 'g', schema: FETCH_SCHEMA, agentType: 'Explore' })",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _has_returns_object_finding(all_issues: list[str]) -> bool:
|
|
60
|
+
return any("runGitTask" in each_issue and "@returns" in each_issue for each_issue in all_issues)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_enforcer_reports_schema_less_branch_under_returns_object() -> None:
|
|
64
|
+
drift_source = _mixed_schema_mjs()
|
|
65
|
+
all_issues = code_rules_enforcer.validate_content(drift_source, _MJS_PATH, drift_source)
|
|
66
|
+
assert _has_returns_object_finding(all_issues)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_enforcer_accepts_every_branch_with_a_schema() -> None:
|
|
70
|
+
clean_source = _all_schema_mjs()
|
|
71
|
+
all_issues = code_rules_enforcer.validate_content(clean_source, _MJS_PATH, clean_source)
|
|
72
|
+
assert not _has_returns_object_finding(all_issues)
|