claude-dev-env 8.78.3 → 8.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +3 -0
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
10
|
from datetime import datetime, timezone
|
|
11
|
+
from collections.abc import Callable
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from types import SimpleNamespace
|
|
13
14
|
|
|
@@ -15,6 +16,7 @@ import pytest
|
|
|
15
16
|
|
|
16
17
|
import account_broker_support as support
|
|
17
18
|
from account_broker_support import Account, Meters, Product, ProductAdapter
|
|
19
|
+
from codex_account_meters import CodexAccountMeters, UsageWindow
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
def test_should_load_claude_roster_from_both_lists(
|
|
@@ -45,6 +47,61 @@ def _claude_home_with_extras(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ex
|
|
|
45
47
|
return main_home
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
def test_should_name_a_chain_account_profile_from_its_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
51
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
52
|
+
monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
|
|
53
|
+
main_home = tmp_path / ".claude"
|
|
54
|
+
main_home.mkdir()
|
|
55
|
+
profiles_root = tmp_path / ".claude-profiles"
|
|
56
|
+
launcher = "C:\\bin\\claude-editor.cmd"
|
|
57
|
+
(main_home / "claude-chain.json").write_text(
|
|
58
|
+
json.dumps(
|
|
59
|
+
{
|
|
60
|
+
"chain": [
|
|
61
|
+
{"command": launcher, "credentials_path": str(profiles_root / "editor" / ".credentials.json")},
|
|
62
|
+
{"command": "C:\\bin\\other.cmd", "credentials_path": str(tmp_path / "elsewhere" / ".credentials.json")},
|
|
63
|
+
{"command": "C:\\bin\\reserved.cmd", "credentials_path": str(profiles_root / "main" / ".credentials.json")},
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
),
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
(main_home / "extra-profiles.json").write_text('["extra"]', encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
accounts = support.load_claude_accounts()
|
|
72
|
+
|
|
73
|
+
assert [(each_account.name, each_account.profile) for each_account in accounts] == [
|
|
74
|
+
("main", None),
|
|
75
|
+
(launcher, "editor"),
|
|
76
|
+
("C:\\bin\\other.cmd", None),
|
|
77
|
+
("C:\\bin\\reserved.cmd", None),
|
|
78
|
+
("extra", "extra"),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
("target_parts", "expected_profile"),
|
|
84
|
+
[(("claude-profiles-link-target", "target"), None), ((".claude-profiles", "target"), "target")],
|
|
85
|
+
)
|
|
86
|
+
def test_should_name_an_extra_account_profile_from_its_resolved_home(
|
|
87
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, create_directory_link: Callable[..., None], target_parts: tuple[str, str], expected_profile: str | None
|
|
88
|
+
) -> None:
|
|
89
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
90
|
+
monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
|
|
91
|
+
main_home = tmp_path / ".claude"
|
|
92
|
+
main_home.mkdir()
|
|
93
|
+
profiles_root = tmp_path / ".claude-profiles"
|
|
94
|
+
profiles_root.mkdir()
|
|
95
|
+
target = tmp_path.joinpath(*target_parts)
|
|
96
|
+
target.mkdir(parents=True)
|
|
97
|
+
create_directory_link(from_link=profiles_root / "alias", to_target=target)
|
|
98
|
+
(main_home / "extra-profiles.json").write_text('["alias"]', encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
accounts = support.load_claude_accounts()
|
|
101
|
+
|
|
102
|
+
assert [(each_account.name, each_account.profile) for each_account in accounts[1:]] == [("target", expected_profile)]
|
|
103
|
+
|
|
104
|
+
|
|
48
105
|
def test_should_leave_claude_priorities_unset_without_an_order_file(
|
|
49
106
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
50
107
|
) -> None:
|
|
@@ -128,16 +185,24 @@ def test_should_read_claude_meters_through_injected_probe(
|
|
|
128
185
|
def test_should_read_codex_meters_through_injected_reader(
|
|
129
186
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
130
187
|
) -> None:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
usage =
|
|
188
|
+
short_reset = datetime(2026, 10, 3, 5, tzinfo=timezone.utc)
|
|
189
|
+
unknown_length_reset = datetime(2026, 10, 9, tzinfo=timezone.utc)
|
|
190
|
+
usage = CodexAccountMeters(
|
|
191
|
+
(
|
|
192
|
+
UsageWindow(300, 20.0, short_reset),
|
|
193
|
+
UsageWindow(10080, 30.0, None),
|
|
194
|
+
UsageWindow(None, 50.0, unknown_length_reset),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
134
197
|
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
|
|
135
198
|
monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
|
|
136
199
|
|
|
137
200
|
meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
|
|
138
201
|
|
|
139
202
|
assert meters.session_percent_left == 80.0
|
|
140
|
-
assert meters.
|
|
203
|
+
assert meters.session_resets_at == short_reset
|
|
204
|
+
assert meters.weekly_percent_left == 50.0
|
|
205
|
+
assert meters.weekly_resets_at == unknown_length_reset
|
|
141
206
|
|
|
142
207
|
|
|
143
208
|
def _codex_meter_reads(
|
|
@@ -205,8 +270,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
|
|
|
205
270
|
def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
206
271
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
207
272
|
) -> None:
|
|
208
|
-
|
|
209
|
-
usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
|
|
273
|
+
usage = CodexAccountMeters((UsageWindow(10080, 30.0, None),))
|
|
210
274
|
unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
|
|
211
275
|
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
|
|
212
276
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
@@ -219,6 +283,24 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
|
219
283
|
assert all_delays == [1.0, 2.0]
|
|
220
284
|
|
|
221
285
|
|
|
286
|
+
def test_should_stop_at_once_when_no_codex_executable_is_found(
|
|
287
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
288
|
+
) -> None:
|
|
289
|
+
unpatched_resolve_codex_path = support.codex_account_meters.resolve_codex_path
|
|
290
|
+
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [])
|
|
291
|
+
monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", unpatched_resolve_codex_path)
|
|
292
|
+
monkeypatch.setenv("PATH", "")
|
|
293
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
294
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
295
|
+
|
|
296
|
+
reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
297
|
+
|
|
298
|
+
assert reading.meters is None
|
|
299
|
+
assert reading.unread_reason == "no codex executable on PATH or at its install path"
|
|
300
|
+
assert all_homes_read == []
|
|
301
|
+
assert all_delays == []
|
|
302
|
+
|
|
303
|
+
|
|
222
304
|
def test_should_keep_the_unread_reason_after_every_claude_read_fails(
|
|
223
305
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
224
306
|
) -> None:
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import importlib
|
|
6
|
-
import os
|
|
7
5
|
import subprocess
|
|
8
6
|
import sys
|
|
9
7
|
import urllib.error
|
|
10
8
|
from datetime import datetime, timezone
|
|
9
|
+
from collections.abc import Callable
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
|
|
13
12
|
import pytest
|
|
@@ -105,18 +104,11 @@ def test_probe_rejects_missing_access_token_for_another_account(
|
|
|
105
104
|
usage.probe_account_meters(Path("home") / ".claude-profiles" / "ev" / ".credentials.json")
|
|
106
105
|
|
|
107
106
|
|
|
108
|
-
def
|
|
109
|
-
if sys.platform.startswith("win32"):
|
|
110
|
-
importlib.import_module("_winapi").CreateJunction(str(to_target), str(from_link))
|
|
111
|
-
return
|
|
112
|
-
os.symlink(to_target, from_link, target_is_directory=True)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path) -> None:
|
|
107
|
+
def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path, create_directory_link: Callable[..., None]) -> None:
|
|
116
108
|
home = tmp_path.resolve()
|
|
117
109
|
(home / ".agents" / "scripts").mkdir(parents=True)
|
|
118
110
|
(home / ".claude").mkdir()
|
|
119
|
-
|
|
111
|
+
create_directory_link(
|
|
120
112
|
from_link=home / ".claude" / "scripts", to_target=home / ".agents" / "scripts"
|
|
121
113
|
)
|
|
122
114
|
|
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
import pytest
|
|
12
12
|
|
|
13
13
|
from codex_account_meters import (
|
|
14
|
+
CodexExecutableNotFoundError,
|
|
14
15
|
CodexMeterUnreadError,
|
|
15
16
|
exchange_with_app_server,
|
|
16
17
|
parse_rate_limits_reply,
|
|
@@ -30,7 +31,7 @@ def reply_line(rate_limits: object) -> str:
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class TestParseRateLimitsReply:
|
|
33
|
-
def
|
|
34
|
+
def should_read_every_window(self) -> None:
|
|
34
35
|
meters = parse_rate_limits_reply(
|
|
35
36
|
reply_line(
|
|
36
37
|
{
|
|
@@ -47,7 +48,7 @@ class TestParseRateLimitsReply:
|
|
|
47
48
|
}
|
|
48
49
|
)
|
|
49
50
|
)
|
|
50
|
-
assert meters.
|
|
51
|
+
assert [each.used_percent for each in meters.all_windows] == [30.0, 85.0]
|
|
51
52
|
assert [each.duration_minutes for each in meters.all_windows] == [300, 10080]
|
|
52
53
|
assert meters.all_windows[0].resets_at == datetime.fromtimestamp(
|
|
53
54
|
RESET_SECONDS, tz=timezone.utc
|
|
@@ -62,11 +63,11 @@ class TestParseRateLimitsReply:
|
|
|
62
63
|
}
|
|
63
64
|
)
|
|
64
65
|
)
|
|
65
|
-
assert meters.
|
|
66
|
+
assert [each.used_percent for each in meters.all_windows] == [40.0]
|
|
66
67
|
|
|
67
68
|
def should_clamp_used_percent_past_full(self) -> None:
|
|
68
69
|
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 130}}))
|
|
69
|
-
assert meters.
|
|
70
|
+
assert [each.used_percent for each in meters.all_windows] == [100.0]
|
|
70
71
|
|
|
71
72
|
@pytest.mark.parametrize(
|
|
72
73
|
"line",
|
|
@@ -103,20 +104,31 @@ class TestShortWindowPercentLeft:
|
|
|
103
104
|
)
|
|
104
105
|
assert meters.short_window_percent_left is None
|
|
105
106
|
|
|
107
|
+
def should_count_a_window_without_a_length_as_weekly(self) -> None:
|
|
108
|
+
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 90}}))
|
|
109
|
+
assert meters.short_window_percent_left is None
|
|
110
|
+
assert meters.weekly_window is not None
|
|
111
|
+
assert meters.weekly_window.used_percent == 90.0
|
|
112
|
+
|
|
106
113
|
|
|
107
|
-
class
|
|
108
|
-
def
|
|
114
|
+
class TestTightestWindows:
|
|
115
|
+
def should_pick_the_most_used_window_in_each_class(self) -> None:
|
|
109
116
|
meters = parse_rate_limits_reply(
|
|
110
117
|
reply_line(
|
|
111
118
|
{
|
|
112
|
-
"primary": {"usedPercent":
|
|
113
|
-
"secondary": {"usedPercent":
|
|
119
|
+
"primary": {"usedPercent": 70, "windowDurationMins": 300, "resetsAt": RESET_SECONDS},
|
|
120
|
+
"secondary": {"usedPercent": 20, "windowDurationMins": 10080, "resetsAt": RESET_SECONDS + 3600},
|
|
114
121
|
}
|
|
115
122
|
)
|
|
116
123
|
)
|
|
117
|
-
assert meters.
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
assert meters.short_window is not None
|
|
125
|
+
assert meters.short_window.resets_at == datetime.fromtimestamp(RESET_SECONDS, tz=timezone.utc)
|
|
126
|
+
assert meters.weekly_window is not None
|
|
127
|
+
assert meters.weekly_window.resets_at == datetime.fromtimestamp(RESET_SECONDS + 3600, tz=timezone.utc)
|
|
128
|
+
|
|
129
|
+
def should_name_no_weekly_window_for_a_short_only_account(self) -> None:
|
|
130
|
+
meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 70, "windowDurationMins": 300}}))
|
|
131
|
+
assert meters.weekly_window is None
|
|
120
132
|
|
|
121
133
|
|
|
122
134
|
class TestReadCodexMeters:
|
|
@@ -141,7 +153,7 @@ class TestReadCodexMeters:
|
|
|
141
153
|
]
|
|
142
154
|
|
|
143
155
|
meters = read_codex_meters(Path("codex"), Path("profiles") / "codex-1", fake_exchange)
|
|
144
|
-
assert meters.
|
|
156
|
+
assert [each.used_percent for each in meters.all_windows] == [20.0]
|
|
145
157
|
assert all_calls == [
|
|
146
158
|
(
|
|
147
159
|
Path("codex"),
|
|
@@ -215,7 +227,7 @@ class TestExchangeWithAppServer:
|
|
|
215
227
|
|
|
216
228
|
meters = read_codex_meters(fake_codex, tmp_path / "codex-2", exchange_with_app_server)
|
|
217
229
|
|
|
218
|
-
assert meters.
|
|
230
|
+
assert [each.used_percent for each in meters.all_windows] == [12.0]
|
|
219
231
|
|
|
220
232
|
|
|
221
233
|
class TestRequestMessages:
|
|
@@ -234,5 +246,5 @@ class TestResolveCodexPath:
|
|
|
234
246
|
) -> None:
|
|
235
247
|
monkeypatch.setenv("PATH", "")
|
|
236
248
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
237
|
-
with pytest.raises(
|
|
249
|
+
with pytest.raises(CodexExecutableNotFoundError):
|
|
238
250
|
resolve_codex_path(None)
|
|
@@ -11,6 +11,7 @@ import pytest
|
|
|
11
11
|
import merge_review_receipt
|
|
12
12
|
|
|
13
13
|
HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
14
|
+
RULINGS_LINK = "https://github.com/acme/tools/pull/12#issuecomment-1"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@pytest.fixture(autouse=True)
|
|
@@ -36,3 +37,88 @@ def test_record_review_replaces_the_previous_pass(receipt_folder: Path) -> None:
|
|
|
36
37
|
path = merge_review_receipt.record_review("Acme/Tools", 12, HEAD, 0)
|
|
37
38
|
assert path == receipt_folder / "acme__tools__12.json"
|
|
38
39
|
assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD, "findings": 0}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_print_clean_when_every_finding_is_ruled_out_with_a_link() -> None:
|
|
43
|
+
verdict_stream = StringIO()
|
|
44
|
+
exit_code = merge_review_receipt.main(
|
|
45
|
+
["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", RULINGS_LINK],
|
|
46
|
+
verdict_stream,
|
|
47
|
+
)
|
|
48
|
+
assert exit_code == 0
|
|
49
|
+
assert verdict_stream.getvalue() == (
|
|
50
|
+
f"CLEAN acme/tools#12 {HEAD} (2 findings ruled out: {RULINGS_LINK})\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_should_record_the_rulings_link_without_surrounding_whitespace(
|
|
55
|
+
receipt_folder: Path,
|
|
56
|
+
) -> None:
|
|
57
|
+
verdict_stream = StringIO()
|
|
58
|
+
exit_code = merge_review_receipt.main(
|
|
59
|
+
["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", f" {RULINGS_LINK}\n"],
|
|
60
|
+
verdict_stream,
|
|
61
|
+
)
|
|
62
|
+
assert exit_code == 0
|
|
63
|
+
assert verdict_stream.getvalue() == (
|
|
64
|
+
f"CLEAN acme/tools#12 {HEAD} (2 findings ruled out: {RULINGS_LINK})\n"
|
|
65
|
+
)
|
|
66
|
+
(receipt_file,) = receipt_folder.rglob("*.json")
|
|
67
|
+
assert json.loads(receipt_file.read_text(encoding="utf-8"))["rulings"] == RULINGS_LINK
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_keep_the_raw_finding_count_beside_the_ruled_out_count(
|
|
71
|
+
receipt_folder: Path,
|
|
72
|
+
) -> None:
|
|
73
|
+
path = merge_review_receipt.record_review("acme/tools", 12, HEAD, 2, 2, RULINGS_LINK)
|
|
74
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {
|
|
75
|
+
"head": HEAD,
|
|
76
|
+
"findings": 2,
|
|
77
|
+
"ruled_out": 2,
|
|
78
|
+
"rulings": RULINGS_LINK,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_report_reviewed_when_some_findings_stay_open() -> None:
|
|
83
|
+
verdict_stream = StringIO()
|
|
84
|
+
exit_code = merge_review_receipt.main(
|
|
85
|
+
["acme/tools", "12", HEAD, "3", "--ruled-out", "2", "--rulings", RULINGS_LINK],
|
|
86
|
+
verdict_stream,
|
|
87
|
+
)
|
|
88
|
+
assert exit_code == 1
|
|
89
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 3 findings\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize(
|
|
93
|
+
"extra_arguments",
|
|
94
|
+
[["--ruled-out", "2"], ["--ruled-out", "4", "--rulings", RULINGS_LINK], ["--ruled-out", "-1"]],
|
|
95
|
+
)
|
|
96
|
+
def test_should_refuse_a_ruled_out_count_it_cannot_back(extra_arguments: list[str]) -> None:
|
|
97
|
+
with pytest.raises(SystemExit):
|
|
98
|
+
merge_review_receipt.main(["acme/tools", "12", HEAD, "3", *extra_arguments], StringIO())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
"all_arguments",
|
|
103
|
+
[
|
|
104
|
+
["acme/tools", "12", HEAD, "-1"],
|
|
105
|
+
["acme/tools", "12", HEAD, "2", "--rulings", RULINGS_LINK],
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
def test_should_refuse_counts_and_links_it_cannot_record(
|
|
109
|
+
all_arguments: list[str], receipt_folder: Path
|
|
110
|
+
) -> None:
|
|
111
|
+
with pytest.raises(SystemExit):
|
|
112
|
+
merge_review_receipt.main(all_arguments, StringIO())
|
|
113
|
+
assert list(receipt_folder.iterdir()) == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_should_report_reviewed_when_the_rulings_link_points_at_another_pull_request() -> None:
|
|
117
|
+
verdict_stream = StringIO()
|
|
118
|
+
other_link = "https://github.com/acme/tools/pull/13#issuecomment-1"
|
|
119
|
+
exit_code = merge_review_receipt.main(
|
|
120
|
+
["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", other_link],
|
|
121
|
+
verdict_stream,
|
|
122
|
+
)
|
|
123
|
+
assert exit_code == 1
|
|
124
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 2 findings\n"
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import sys
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
10
9
|
import pytest
|
|
11
10
|
|
|
11
|
+
import claude_account_profile
|
|
12
12
|
import shared_tree_paths
|
|
13
13
|
from dev_env_scripts_constants.grok_worker_constants import (
|
|
14
14
|
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
@@ -23,27 +23,17 @@ from dev_env_scripts_constants.shared_tree_constants import (
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
try:
|
|
33
|
-
os.symlink(to_target, from_link, target_is_directory=True)
|
|
34
|
-
except OSError:
|
|
35
|
-
pytest.skip("directory symlink creation is not available")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _build_junction_scripts_layout(tmp_path: Path) -> Path:
|
|
39
|
-
real_scripts_directory = tmp_path / "real" / SCRIPTS_DIRECTORY_NAME
|
|
40
|
-
real_scripts_directory.mkdir(parents=True)
|
|
41
|
-
(real_scripts_directory / "runner.py").write_text("", encoding="utf-8")
|
|
26
|
+
def _build_junction_scripts_layout(
|
|
27
|
+
tmp_path: Path, create_directory_link: Callable[..., None]
|
|
28
|
+
) -> Path:
|
|
29
|
+
linked_scripts_directory = tmp_path / "target" / SCRIPTS_DIRECTORY_NAME
|
|
30
|
+
linked_scripts_directory.mkdir(parents=True)
|
|
31
|
+
(linked_scripts_directory / "runner.py").write_text("", encoding="utf-8")
|
|
42
32
|
managed_root = tmp_path / "root"
|
|
43
33
|
managed_root.mkdir()
|
|
44
|
-
|
|
34
|
+
create_directory_link(
|
|
45
35
|
from_link=managed_root / SCRIPTS_DIRECTORY_NAME,
|
|
46
|
-
to_target=
|
|
36
|
+
to_target=linked_scripts_directory,
|
|
47
37
|
)
|
|
48
38
|
return managed_root / SCRIPTS_DIRECTORY_NAME / "runner.py"
|
|
49
39
|
|
|
@@ -63,9 +53,9 @@ def _write_process_tree_kill_stub(from_root: Path) -> Path:
|
|
|
63
53
|
|
|
64
54
|
|
|
65
55
|
def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
|
|
66
|
-
tmp_path: Path,
|
|
56
|
+
tmp_path: Path, create_directory_link: Callable[..., None],
|
|
67
57
|
) -> None:
|
|
68
|
-
module_file = _build_junction_scripts_layout(tmp_path)
|
|
58
|
+
module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
|
|
69
59
|
expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
|
|
70
60
|
resolved_scripts_directory = (
|
|
71
61
|
shared_tree_paths.resolve_shared_process_tree_scripts_directory(
|
|
@@ -76,10 +66,10 @@ def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
|
|
|
76
66
|
|
|
77
67
|
|
|
78
68
|
def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
|
|
79
|
-
tmp_path: Path,
|
|
69
|
+
tmp_path: Path, create_directory_link: Callable[..., None],
|
|
80
70
|
) -> None:
|
|
81
|
-
module_file = _build_junction_scripts_layout(tmp_path)
|
|
82
|
-
expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "
|
|
71
|
+
module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
|
|
72
|
+
expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "target")
|
|
83
73
|
resolved_scripts_directory = (
|
|
84
74
|
shared_tree_paths.resolve_shared_process_tree_scripts_directory(
|
|
85
75
|
module_file, all_environment={}
|
|
@@ -89,9 +79,9 @@ def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
|
|
|
89
79
|
|
|
90
80
|
|
|
91
81
|
def test_configured_root_is_used_when_neither_parent_holds_the_module(
|
|
92
|
-
tmp_path: Path,
|
|
82
|
+
tmp_path: Path, create_directory_link: Callable[..., None],
|
|
93
83
|
) -> None:
|
|
94
|
-
module_file = _build_junction_scripts_layout(tmp_path)
|
|
84
|
+
module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
|
|
95
85
|
configured_root = tmp_path / "configured"
|
|
96
86
|
expected_scripts_directory = _write_process_tree_kill_stub(configured_root)
|
|
97
87
|
resolved_scripts_directory = (
|
|
@@ -106,9 +96,9 @@ def test_configured_root_is_used_when_neither_parent_holds_the_module(
|
|
|
106
96
|
|
|
107
97
|
|
|
108
98
|
def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
|
|
109
|
-
tmp_path: Path,
|
|
99
|
+
tmp_path: Path, create_directory_link: Callable[..., None],
|
|
110
100
|
) -> None:
|
|
111
|
-
module_file = _build_junction_scripts_layout(tmp_path)
|
|
101
|
+
module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
|
|
112
102
|
expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
|
|
113
103
|
configured_root = tmp_path / "configured"
|
|
114
104
|
_write_process_tree_kill_stub(configured_root)
|
|
@@ -123,8 +113,8 @@ def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
|
|
|
123
113
|
assert resolved_scripts_directory == expected_scripts_directory
|
|
124
114
|
|
|
125
115
|
|
|
126
|
-
def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
|
|
127
|
-
module_file = _build_junction_scripts_layout(tmp_path)
|
|
116
|
+
def test_missing_module_returns_unresolved_candidate(tmp_path: Path, create_directory_link: Callable[..., None]) -> None:
|
|
117
|
+
module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
|
|
128
118
|
expected_scripts_directory = (
|
|
129
119
|
tmp_path
|
|
130
120
|
/ "root"
|
|
@@ -144,9 +134,9 @@ def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
|
|
|
144
134
|
|
|
145
135
|
|
|
146
136
|
def test_junction_scripts_dir_finds_advisor_scripts_on_unresolved_parent(
|
|
147
|
-
tmp_path: Path,
|
|
137
|
+
tmp_path: Path, create_directory_link: Callable[..., None],
|
|
148
138
|
) -> None:
|
|
149
|
-
module_file = _build_junction_scripts_layout(tmp_path)
|
|
139
|
+
module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
|
|
150
140
|
expected_scripts_directory = (
|
|
151
141
|
tmp_path
|
|
152
142
|
/ "root"
|
|
@@ -198,7 +188,7 @@ def test_generalized_resolver_serves_a_deeper_anchor(tmp_path: Path) -> None:
|
|
|
198
188
|
|
|
199
189
|
@pytest.mark.parametrize("managed_name", (".claude", "profile-a"))
|
|
200
190
|
def test_resolved_agents_scripts_find_the_verified_managed_root(
|
|
201
|
-
tmp_path: Path, managed_name: str,
|
|
191
|
+
tmp_path: Path, create_directory_link: Callable[..., None], managed_name: str,
|
|
202
192
|
) -> None:
|
|
203
193
|
agents_name = ".agents" if managed_name == ".claude" else f"{managed_name}.agents"
|
|
204
194
|
agents_root = tmp_path / agents_name
|
|
@@ -208,7 +198,7 @@ def test_resolved_agents_scripts_find_the_verified_managed_root(
|
|
|
208
198
|
module_file.write_text("", encoding="utf-8")
|
|
209
199
|
managed_root = tmp_path / managed_name
|
|
210
200
|
managed_root.mkdir()
|
|
211
|
-
|
|
201
|
+
create_directory_link(from_link=managed_root / "scripts", to_target=scripts_directory)
|
|
212
202
|
expected = _write_process_tree_kill_stub(managed_root)
|
|
213
203
|
resolved_directory = shared_tree_paths.resolve_shared_process_tree_scripts_directory(module_file, {})
|
|
214
204
|
assert resolved_directory == expected
|
|
@@ -227,3 +217,35 @@ def test_neighboring_managed_root_cannot_supply_a_foreign_shared_tree(tmp_path:
|
|
|
227
217
|
resolved_directory = shared_tree_paths.resolve_shared_process_tree_scripts_directory(module_file, {})
|
|
228
218
|
expected = agents_root / "_shared" / "process-tree" / "scripts"
|
|
229
219
|
assert resolved_directory == expected
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_should_fail_with_the_link_command_output_when_a_link_cannot_be_made(
|
|
223
|
+
tmp_path: Path,
|
|
224
|
+
create_directory_link: Callable[..., None],
|
|
225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
+
) -> None:
|
|
227
|
+
def refuse_link(source_path: Path, link_path: Path) -> None:
|
|
228
|
+
raise subprocess.CalledProcessError(1, ["mklink"], b"", b"Access is denied.")
|
|
229
|
+
|
|
230
|
+
monkeypatch.setattr(claude_account_profile, "_link_directory", refuse_link)
|
|
231
|
+
|
|
232
|
+
with pytest.raises(pytest.fail.Exception) as failure:
|
|
233
|
+
create_directory_link(from_link=tmp_path / "link", to_target=tmp_path)
|
|
234
|
+
|
|
235
|
+
assert "Access is denied." in str(failure.value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_should_fail_with_the_os_error_when_a_symlink_cannot_be_made(
|
|
239
|
+
tmp_path: Path,
|
|
240
|
+
create_directory_link: Callable[..., None],
|
|
241
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
242
|
+
) -> None:
|
|
243
|
+
def refuse_link(source_path: Path, link_path: Path) -> None:
|
|
244
|
+
raise PermissionError("Operation not permitted")
|
|
245
|
+
|
|
246
|
+
monkeypatch.setattr(claude_account_profile, "_link_directory", refuse_link)
|
|
247
|
+
|
|
248
|
+
with pytest.raises(pytest.fail.Exception) as failure:
|
|
249
|
+
create_directory_link(from_link=tmp_path / "link", to_target=tmp_path)
|
|
250
|
+
|
|
251
|
+
assert "Operation not permitted" in str(failure.value)
|
|
@@ -58,6 +58,14 @@ def test_pull_request_lifecycle_gate_registration_is_clean(tmp_path: Path) -> No
|
|
|
58
58
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def test_new_merge_offer_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
62
|
+
current_document = _hook_document(
|
|
63
|
+
["hooks/blocking/merge_over_review_offer_gate.py"],
|
|
64
|
+
'{"hooks": {}}',
|
|
65
|
+
)
|
|
66
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
67
|
+
|
|
68
|
+
|
|
61
69
|
def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
62
70
|
current_document = _hook_document(
|
|
63
71
|
["hooks/blocking/step_note_gate.py"],
|
package/settings.json
CHANGED
|
@@ -22,5 +22,8 @@
|
|
|
22
22
|
"Updating or installing the claude-dev-env package is allowed: running `npx claude-dev-env@latest --update` or `npx --yes claude-dev-env@latest --update --target <claude config dir>` is the user's own daily environment sync from the user's own public package, and its writes to the Claude config directory are the intended result. This covers only that package and command.",
|
|
23
23
|
"Developing the claude-dev-env repository is allowed: editing, testing, committing and pushing its settings, hooks, scripts and tests is the user's requested work on the user's own public package. It changes the package source in the repository; it does not change the running session's own configuration."
|
|
24
24
|
]
|
|
25
|
+
},
|
|
26
|
+
"env": {
|
|
27
|
+
"CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS": "100000"
|
|
25
28
|
}
|
|
26
29
|
}
|