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.
- package/_shared/pr-loop/scripts/CLAUDE.md +3 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -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/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +58 -4
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +7 -66
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +10 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -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 +3 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +28 -7
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -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 +235 -247
- 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
|
@@ -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"
|