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
@@ -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. Files
195
- whose module body includes ``if TYPE_CHECKING:`` (or
196
- ``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
197
- ``# noqa`` or an explicit ``F401`` code in the noqa list only.
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 is_workflow_registry_file(file_path) or is_migration_file(file_path):
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)