claude-dev-env 1.79.0 → 1.81.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 (76) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +3 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
  10. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  12. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  13. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  14. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  15. package/audit-rubrics/CLAUDE.md +3 -2
  16. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  17. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  18. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  19. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  20. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  21. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  22. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  23. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  24. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  25. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  26. package/docs/CODE_RULES.md +2 -2
  27. package/hooks/blocking/CLAUDE.md +2 -0
  28. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  29. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  30. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  31. package/hooks/blocking/code_rules_docstrings.py +93 -0
  32. package/hooks/blocking/code_rules_enforcer.py +58 -4
  33. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  34. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  35. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  36. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  37. package/hooks/blocking/code_rules_unused_imports.py +7 -66
  38. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  40. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  41. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  42. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  43. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  44. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  45. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  46. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
  47. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  48. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  49. package/hooks/hooks_constants/CLAUDE.md +2 -0
  50. package/hooks/hooks_constants/blocking_check_limits.py +10 -0
  51. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  52. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  53. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  54. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  55. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  56. package/package.json +1 -1
  57. package/rules/docstring-prose-matches-implementation.md +3 -1
  58. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  59. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  60. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  61. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  62. package/skills/autoconverge/SKILL.md +28 -7
  63. package/skills/autoconverge/reference/convergence.md +3 -3
  64. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  65. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  66. package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
  67. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
  68. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  69. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  70. package/skills/autoconverge/workflow/converge.mjs +235 -247
  71. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  72. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  74. package/skills/pr-converge/SKILL.md +28 -2
  75. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  76. package/skills/pr-converge/reference/state-schema.md +11 -0
@@ -1953,3 +1953,342 @@ def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
1953
1953
  "a staged deletion has no staged blob; the gate must skip it cleanly "
1954
1954
  "rather than fail closed as if the file were unreadable"
1955
1955
  )
1956
+
1957
+
1958
+ def test_run_staged_test_files_passes_for_a_passing_staged_test(
1959
+ temporary_git_repository: Path,
1960
+ ) -> None:
1961
+ write_file(
1962
+ temporary_git_repository / "test_ok.py",
1963
+ "def test_ok() -> None:\n assert True\n",
1964
+ )
1965
+ stage_file(temporary_git_repository, "test_ok.py")
1966
+
1967
+ exit_code = gate_module.run_staged_test_files(temporary_git_repository)
1968
+
1969
+ assert exit_code == 0
1970
+
1971
+
1972
+ def test_run_staged_test_files_fails_for_a_failing_staged_test(
1973
+ temporary_git_repository: Path,
1974
+ ) -> None:
1975
+ write_file(
1976
+ temporary_git_repository / "test_bad.py",
1977
+ "def test_bad() -> None:\n assert False\n",
1978
+ )
1979
+ stage_file(temporary_git_repository, "test_bad.py")
1980
+
1981
+ exit_code = gate_module.run_staged_test_files(temporary_git_repository)
1982
+
1983
+ assert exit_code != 0
1984
+
1985
+
1986
+ def test_run_staged_test_files_zero_when_no_test_file_staged(
1987
+ temporary_git_repository: Path,
1988
+ ) -> None:
1989
+ write_file(temporary_git_repository / "helper.py", "first_value = 1\n")
1990
+ stage_file(temporary_git_repository, "helper.py")
1991
+
1992
+ assert gate_module.run_staged_test_files(temporary_git_repository) == 0
1993
+
1994
+
1995
+ def test_run_staged_test_files_zero_when_staged_conftest_collects_no_tests(
1996
+ temporary_git_repository: Path,
1997
+ ) -> None:
1998
+ write_file(
1999
+ temporary_git_repository / "conftest.py",
2000
+ "import pytest\n\n\n@pytest.fixture()\ndef sample_fixture() -> int:\n"
2001
+ " return 1\n",
2002
+ )
2003
+ stage_file(temporary_git_repository, "conftest.py")
2004
+
2005
+ assert gate_module.run_staged_test_files(temporary_git_repository) == 0
2006
+
2007
+
2008
+ def test_hunk_header_pattern_captures_new_start_and_count() -> None:
2009
+ header_match = gate_module.hunk_header_pattern().match("@@ -12,3 +45,6 @@ def scope")
2010
+ assert header_match is not None
2011
+ assert header_match.group(1) == "45"
2012
+ assert header_match.group(2) == "6"
2013
+
2014
+
2015
+ def test_hunk_header_pattern_leaves_count_group_none_for_single_line_hunk() -> None:
2016
+ header_match = gate_module.hunk_header_pattern().match("@@ -0,0 +7 @@")
2017
+ assert header_match is not None
2018
+ assert header_match.group(1) == "7"
2019
+ assert header_match.group(2) is None
2020
+
2021
+
2022
+ def test_violation_line_pattern_captures_leading_line_number() -> None:
2023
+ violation_match = gate_module.violation_line_pattern().match(
2024
+ "Line 88: banned identifier"
2025
+ )
2026
+ assert violation_match is not None
2027
+ assert violation_match.group(1) == "88"
2028
+
2029
+
2030
+ def test_violation_line_pattern_does_not_match_unprefixed_text() -> None:
2031
+ assert gate_module.violation_line_pattern().match("Function foo is too long") is None
2032
+
2033
+
2034
+ def test_filter_paths_under_prefixes_keeps_only_nested_paths(tmp_path: Path) -> None:
2035
+ kept_path = tmp_path / "packages" / "core" / "module.py"
2036
+ dropped_path = tmp_path / "docs" / "guide.py"
2037
+ kept_path.parent.mkdir(parents=True)
2038
+ dropped_path.parent.mkdir(parents=True)
2039
+ kept_path.write_text("kept = 1\n", encoding="utf-8")
2040
+ dropped_path.write_text("dropped = 1\n", encoding="utf-8")
2041
+
2042
+ filtered_paths = gate_module.filter_paths_under_prefixes(
2043
+ [kept_path, dropped_path], tmp_path, ["packages/core"]
2044
+ )
2045
+
2046
+ assert filtered_paths == [kept_path]
2047
+
2048
+
2049
+ def test_filter_paths_under_prefixes_returns_input_when_no_prefixes(
2050
+ tmp_path: Path,
2051
+ ) -> None:
2052
+ only_path = tmp_path / "anywhere.py"
2053
+ only_path.write_text("value = 1\n", encoding="utf-8")
2054
+
2055
+ filtered_paths = gate_module.filter_paths_under_prefixes([only_path], tmp_path, [])
2056
+
2057
+ assert filtered_paths == [only_path]
2058
+
2059
+
2060
+ def test_is_code_path_accepts_python_and_typescript_suffixes() -> None:
2061
+ assert gate_module.is_code_path(Path("pkg/module.py"))
2062
+ assert gate_module.is_code_path(Path("pkg/component.tsx"))
2063
+
2064
+
2065
+ def test_is_code_path_rejects_non_code_suffix() -> None:
2066
+ assert not gate_module.is_code_path(Path("docs/readme.md"))
2067
+
2068
+
2069
+ def test_parse_added_line_numbers_collects_every_added_line() -> None:
2070
+ unified_diff_text = (
2071
+ "diff --git a/target.py b/target.py\n"
2072
+ "@@ -1,0 +2,3 @@\n"
2073
+ "+second = 2\n"
2074
+ "+third = 3\n"
2075
+ "+fourth = 4\n"
2076
+ )
2077
+
2078
+ added_line_numbers = gate_module.parse_added_line_numbers(unified_diff_text)
2079
+
2080
+ assert added_line_numbers == {2, 3, 4}
2081
+
2082
+
2083
+ def test_parse_added_line_numbers_ignores_zero_count_hunks() -> None:
2084
+ unified_diff_text = "@@ -5,2 +5,0 @@\n"
2085
+
2086
+ assert gate_module.parse_added_line_numbers(unified_diff_text) == set()
2087
+
2088
+
2089
+ def test_is_file_new_at_base_true_for_file_added_after_base(
2090
+ temporary_git_repository: Path,
2091
+ ) -> None:
2092
+ write_file(temporary_git_repository / "original.py", "first_count = 1\n")
2093
+ commit_all_files(temporary_git_repository, "baseline")
2094
+ base_sha = run_git_in_repository(
2095
+ temporary_git_repository, "rev-parse", "HEAD"
2096
+ ).strip()
2097
+ write_file(temporary_git_repository / "added.py", "second_count = 2\n")
2098
+ commit_all_files(temporary_git_repository, "add second file")
2099
+
2100
+ assert gate_module.is_file_new_at_base(temporary_git_repository, base_sha, "added.py")
2101
+ assert not gate_module.is_file_new_at_base(
2102
+ temporary_git_repository, base_sha, "original.py"
2103
+ )
2104
+
2105
+
2106
+ def test_added_lines_for_file_reports_lines_added_since_base(
2107
+ temporary_git_repository: Path,
2108
+ ) -> None:
2109
+ write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
2110
+ commit_all_files(temporary_git_repository, "baseline")
2111
+ base_sha = run_git_in_repository(
2112
+ temporary_git_repository, "rev-parse", "HEAD"
2113
+ ).strip()
2114
+ write_file(
2115
+ temporary_git_repository / "target.py",
2116
+ "first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
2117
+ )
2118
+ commit_all_files(temporary_git_repository, "append lines")
2119
+
2120
+ added_line_numbers = gate_module.added_lines_for_file(
2121
+ temporary_git_repository, base_sha, "target.py"
2122
+ )
2123
+
2124
+ assert 3 in added_line_numbers
2125
+ assert 4 in added_line_numbers
2126
+ assert 1 not in added_line_numbers
2127
+
2128
+
2129
+ def test_extract_violation_line_number_returns_parsed_line() -> None:
2130
+ assert gate_module.extract_violation_line_number("Line 17: magic value") == 17
2131
+
2132
+
2133
+ def test_extract_violation_line_number_returns_none_for_unprefixed_text() -> None:
2134
+ assert gate_module.extract_violation_line_number("no line prefix here") is None
2135
+
2136
+
2137
+ def test_function_length_span_range_covers_declared_span() -> None:
2138
+ violation_text = "Function 'compute_total' (defined at line 10) is 25 lines - split it"
2139
+
2140
+ span_range = gate_module.function_length_span_range(violation_text)
2141
+
2142
+ assert span_range == range(10, 35)
2143
+
2144
+
2145
+ def test_function_length_span_range_returns_none_for_other_message() -> None:
2146
+ assert gate_module.function_length_span_range("Line 3: banned identifier") is None
2147
+
2148
+
2149
+ def test_isolation_span_range_covers_enclosing_test_span() -> None:
2150
+ violation_text = (
2151
+ "Line 5: Test 'test_probe' (defined at line 12, spanning 8 lines) probes HOME"
2152
+ )
2153
+
2154
+ span_range = gate_module.isolation_span_range(violation_text)
2155
+
2156
+ assert span_range == range(12, 20)
2157
+
2158
+
2159
+ def test_isolation_span_range_returns_none_for_other_message() -> None:
2160
+ assert gate_module.isolation_span_range("Function foo is 5 lines") is None
2161
+
2162
+
2163
+ def test_enclosing_span_range_dispatches_to_function_length_extractor() -> None:
2164
+ violation_text = "Function 'render' (defined at line 4) is 6 lines - too long"
2165
+
2166
+ span_range = gate_module.enclosing_span_range(violation_text)
2167
+
2168
+ assert span_range == range(4, 10)
2169
+
2170
+
2171
+ def test_enclosing_span_range_returns_none_when_no_span_fragment() -> None:
2172
+ assert gate_module.enclosing_span_range("Line 9: plain violation") is None
2173
+
2174
+
2175
+ def test_print_violation_section_writes_header_and_grouped_paths(
2176
+ tmp_path: Path,
2177
+ capsys: pytest.CaptureFixture[str],
2178
+ ) -> None:
2179
+ offending_path = tmp_path / "package" / "module.py"
2180
+ offending_path.parent.mkdir(parents=True)
2181
+ offending_path.write_text("value = 1\n", encoding="utf-8")
2182
+
2183
+ gate_module.print_violation_section(
2184
+ "BLOCKING violations:",
2185
+ {offending_path.resolve(): ["Line 3: magic value", "Line 4: banned name"]},
2186
+ tmp_path,
2187
+ )
2188
+
2189
+ captured = capsys.readouterr()
2190
+ assert "BLOCKING violations:" in captured.err
2191
+ assert "package/module.py" in captured.err.replace("\\", "/")
2192
+ assert "Line 3: magic value" in captured.err
2193
+ assert "Line 4: banned name" in captured.err
2194
+
2195
+
2196
+ def test_read_prior_committed_content_returns_head_version(
2197
+ temporary_git_repository: Path,
2198
+ ) -> None:
2199
+ write_file(temporary_git_repository / "tracked.py", "committed = 1\n")
2200
+ commit_all_files(temporary_git_repository, "commit tracked")
2201
+ write_file(temporary_git_repository / "tracked.py", "working_tree_edit = 2\n")
2202
+
2203
+ committed_content = gate_module.read_prior_committed_content(
2204
+ temporary_git_repository, "tracked.py"
2205
+ )
2206
+
2207
+ assert committed_content == "committed = 1\n"
2208
+
2209
+
2210
+ def test_read_prior_committed_content_returns_empty_for_untracked_path(
2211
+ temporary_git_repository: Path,
2212
+ ) -> None:
2213
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2214
+ commit_all_files(temporary_git_repository, "baseline")
2215
+
2216
+ assert (
2217
+ gate_module.read_prior_committed_content(temporary_git_repository, "missing.py")
2218
+ == ""
2219
+ )
2220
+
2221
+
2222
+ def test_read_staged_content_returns_staged_blob(
2223
+ temporary_git_repository: Path,
2224
+ ) -> None:
2225
+ write_file(temporary_git_repository / "staged.py", "staged_value = 1\n")
2226
+ stage_file(temporary_git_repository, "staged.py")
2227
+
2228
+ staged_content = gate_module.read_staged_content(
2229
+ temporary_git_repository, "staged.py"
2230
+ )
2231
+
2232
+ assert staged_content == "staged_value = 1\n"
2233
+
2234
+
2235
+ def test_read_staged_content_returns_none_for_unstaged_path(
2236
+ temporary_git_repository: Path,
2237
+ ) -> None:
2238
+ write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
2239
+ commit_all_files(temporary_git_repository, "baseline")
2240
+
2241
+ assert (
2242
+ gate_module.read_staged_content(temporary_git_repository, "never_staged.py")
2243
+ is None
2244
+ )
2245
+
2246
+
2247
+ def test_staged_blob_exists_true_for_staged_file(
2248
+ temporary_git_repository: Path,
2249
+ ) -> None:
2250
+ write_file(temporary_git_repository / "present.py", "present_value = 1\n")
2251
+ stage_file(temporary_git_repository, "present.py")
2252
+
2253
+ assert gate_module.staged_blob_exists(temporary_git_repository, "present.py")
2254
+
2255
+
2256
+ def test_staged_blob_exists_false_for_staged_deletion(
2257
+ temporary_git_repository: Path,
2258
+ ) -> None:
2259
+ write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
2260
+ commit_all_files(temporary_git_repository, "initial")
2261
+ run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
2262
+
2263
+ assert not gate_module.staged_blob_exists(temporary_git_repository, "removable.py")
2264
+
2265
+
2266
+ def test_parse_arguments_reads_staged_base_and_prefix_flags() -> None:
2267
+ parsed_arguments = gate_module.parse_arguments(
2268
+ [
2269
+ "--staged",
2270
+ "--base",
2271
+ "main",
2272
+ "--repo-root",
2273
+ "some/root",
2274
+ "--only-under",
2275
+ "packages/core",
2276
+ "explicit_file.py",
2277
+ ]
2278
+ )
2279
+
2280
+ assert parsed_arguments.staged is True
2281
+ assert parsed_arguments.base == "main"
2282
+ assert parsed_arguments.repo_root == Path("some/root")
2283
+ assert parsed_arguments.only_under == ["packages/core"]
2284
+ assert parsed_arguments.paths == [Path("explicit_file.py")]
2285
+
2286
+
2287
+ def test_parse_arguments_applies_documented_defaults() -> None:
2288
+ parsed_arguments = gate_module.parse_arguments([])
2289
+
2290
+ assert parsed_arguments.staged is False
2291
+ assert parsed_arguments.base == "origin/main"
2292
+ assert parsed_arguments.repo_root is None
2293
+ assert parsed_arguments.only_under == []
2294
+ assert parsed_arguments.paths == []
@@ -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"