claude-dev-env 8.6.1 → 8.7.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/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- package/scripts/tests/test_email_exemptions.py +0 -158
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Tests for the agent merge-readiness check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
import agent_merge_check
|
|
15
|
+
from dev_env_scripts_constants.agent_merge_check_constants import (
|
|
16
|
+
BEHIND_HOLD_REASON,
|
|
17
|
+
BLOCKED_HOLD_REASON,
|
|
18
|
+
DIRTY_HOLD_REASON,
|
|
19
|
+
DRAFT_HOLD_REASON,
|
|
20
|
+
HOLD_VERDICT_LABEL,
|
|
21
|
+
MERGE_VERDICT_LABEL,
|
|
22
|
+
SETTLE_ATTEMPT_COUNT,
|
|
23
|
+
UNSTABLE_HOLD_REASON,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CLEAN_PULL_REQUEST = {
|
|
27
|
+
"number": 1442,
|
|
28
|
+
"draft": False,
|
|
29
|
+
"mergeable_state": "clean",
|
|
30
|
+
"head": {"sha": "ab845eb6945650055ebe852312a9057b9f067d6a"},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pull_request(**all_overrides: object) -> dict[str, object]:
|
|
35
|
+
return {**CLEAN_PULL_REQUEST, **all_overrides}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_clean_pull_request_with_no_open_thread_may_merge() -> None:
|
|
39
|
+
assert agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 0) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
("all_overrides", "expected_reason"),
|
|
44
|
+
[
|
|
45
|
+
({"draft": True}, DRAFT_HOLD_REASON),
|
|
46
|
+
({"mergeable_state": "behind"}, BEHIND_HOLD_REASON),
|
|
47
|
+
({"mergeable_state": "dirty"}, DIRTY_HOLD_REASON),
|
|
48
|
+
({"mergeable_state": "blocked"}, BLOCKED_HOLD_REASON),
|
|
49
|
+
({"mergeable_state": "unstable"}, UNSTABLE_HOLD_REASON),
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
def test_each_unready_state_holds_with_its_own_reason(
|
|
53
|
+
all_overrides: dict[str, object],
|
|
54
|
+
expected_reason: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
assert (
|
|
57
|
+
agent_merge_check.hold_reason(_pull_request(**all_overrides), 0)
|
|
58
|
+
== expected_reason
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_draft_holds_even_when_its_merge_state_is_clean() -> None:
|
|
63
|
+
assert (
|
|
64
|
+
agent_merge_check.hold_reason(_pull_request(draft=True), 0) == DRAFT_HOLD_REASON
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_an_unreported_merge_state_holds_and_names_what_github_said() -> None:
|
|
69
|
+
reason = agent_merge_check.hold_reason(_pull_request(mergeable_state="unknown"), 0)
|
|
70
|
+
assert reason is not None
|
|
71
|
+
assert "unknown" in reason
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_an_open_review_thread_holds_a_green_pull_request() -> None:
|
|
75
|
+
reason = agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 2)
|
|
76
|
+
assert reason is not None
|
|
77
|
+
assert "2" in reason
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_an_open_thread_count_reaches_the_verdict_line() -> None:
|
|
81
|
+
reason = agent_merge_check.hold_reason(CLEAN_PULL_REQUEST, 1)
|
|
82
|
+
line = agent_merge_check.verdict_line(
|
|
83
|
+
"jl-cmd/claude-dev-env", CLEAN_PULL_REQUEST, reason
|
|
84
|
+
)
|
|
85
|
+
assert line.startswith(HOLD_VERDICT_LABEL)
|
|
86
|
+
assert "jl-cmd/claude-dev-env#1442" in line
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_the_ready_verdict_line_names_the_repository_and_head() -> None:
|
|
90
|
+
line = agent_merge_check.verdict_line(
|
|
91
|
+
"jl-cmd/claude-dev-env", CLEAN_PULL_REQUEST, None
|
|
92
|
+
)
|
|
93
|
+
assert line.startswith(MERGE_VERDICT_LABEL)
|
|
94
|
+
assert "jl-cmd/claude-dev-env#1442" in line
|
|
95
|
+
assert "ab845eb" in line
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_a_resolved_thread_and_an_outdated_thread_hold_nothing_back() -> None:
|
|
99
|
+
all_thread_records = [
|
|
100
|
+
{"isResolved": True, "isOutdated": False},
|
|
101
|
+
{"isResolved": False, "isOutdated": True},
|
|
102
|
+
]
|
|
103
|
+
assert agent_merge_check.count_unresolved_threads(all_thread_records) == 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_an_unresolved_current_thread_counts() -> None:
|
|
107
|
+
all_thread_records = [
|
|
108
|
+
{"isResolved": False, "isOutdated": False},
|
|
109
|
+
{"isResolved": True, "isOutdated": False},
|
|
110
|
+
{"isResolved": False, "isOutdated": False},
|
|
111
|
+
]
|
|
112
|
+
assert agent_merge_check.count_unresolved_threads(all_thread_records) == 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_a_thread_record_of_another_shape_counts_as_nothing() -> None:
|
|
116
|
+
assert agent_merge_check.count_unresolved_threads(["", None]) == 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_read_pull_request_returns_the_fields_the_api_answered_with(
|
|
120
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
121
|
+
) -> None:
|
|
122
|
+
monkeypatch.setattr(
|
|
123
|
+
agent_merge_check,
|
|
124
|
+
"_request_json",
|
|
125
|
+
lambda url, token, all_payload_fields: CLEAN_PULL_REQUEST,
|
|
126
|
+
)
|
|
127
|
+
assert (
|
|
128
|
+
agent_merge_check.read_pull_request("jl-cmd/claude-dev-env", 1442, "token")
|
|
129
|
+
== CLEAN_PULL_REQUEST
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_read_pull_request_rejects_an_answer_of_another_shape(
|
|
134
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
135
|
+
) -> None:
|
|
136
|
+
monkeypatch.setattr(
|
|
137
|
+
agent_merge_check,
|
|
138
|
+
"_request_json",
|
|
139
|
+
lambda url, token, all_payload_fields: ["not a pull request"],
|
|
140
|
+
)
|
|
141
|
+
with pytest.raises(agent_merge_check.MergeCheckError):
|
|
142
|
+
agent_merge_check.read_pull_request("jl-cmd/claude-dev-env", 1442, "token")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_read_unresolved_thread_count_reads_the_rest_route(
|
|
146
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
147
|
+
) -> None:
|
|
148
|
+
monkeypatch.setattr(
|
|
149
|
+
agent_merge_check,
|
|
150
|
+
"_request_json",
|
|
151
|
+
lambda url, token, all_payload_fields: [
|
|
152
|
+
{"isResolved": False, "isOutdated": False},
|
|
153
|
+
{"isResolved": True, "isOutdated": False},
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
assert (
|
|
157
|
+
agent_merge_check.read_unresolved_thread_count(
|
|
158
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
159
|
+
)
|
|
160
|
+
== 1
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_read_unresolved_thread_count_falls_back_to_the_query(
|
|
165
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
166
|
+
) -> None:
|
|
167
|
+
def _answer(
|
|
168
|
+
url: str,
|
|
169
|
+
token: str,
|
|
170
|
+
all_payload_fields: object,
|
|
171
|
+
) -> object:
|
|
172
|
+
if all_payload_fields is None:
|
|
173
|
+
raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
|
|
174
|
+
return {
|
|
175
|
+
"data": {
|
|
176
|
+
"repository": {
|
|
177
|
+
"pullRequest": {
|
|
178
|
+
"reviewThreads": {
|
|
179
|
+
"nodes": [{"isResolved": False, "isOutdated": False}]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
|
|
187
|
+
assert (
|
|
188
|
+
agent_merge_check.read_unresolved_thread_count(
|
|
189
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
190
|
+
)
|
|
191
|
+
== 1
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_read_unresolved_thread_count_rejects_a_query_answer_of_another_shape(
|
|
196
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
197
|
+
) -> None:
|
|
198
|
+
def _answer(
|
|
199
|
+
url: str,
|
|
200
|
+
token: str,
|
|
201
|
+
all_payload_fields: object,
|
|
202
|
+
) -> object:
|
|
203
|
+
if all_payload_fields is None:
|
|
204
|
+
raise agent_merge_check.MergeCheckError("HTTP Error 403: Forbidden")
|
|
205
|
+
return {"errors": [{"message": "Bad credentials"}]}
|
|
206
|
+
|
|
207
|
+
monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
|
|
208
|
+
with pytest.raises(agent_merge_check.MergeCheckError):
|
|
209
|
+
agent_merge_check.read_unresolved_thread_count(
|
|
210
|
+
"jl-cmd/claude-dev-env", 1442, "token"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_read_settled_pull_request_reads_again_while_the_state_is_unknown(
|
|
215
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
216
|
+
) -> None:
|
|
217
|
+
all_answers = [
|
|
218
|
+
_pull_request(mergeable_state="unknown"),
|
|
219
|
+
_pull_request(mergeable_state="unknown"),
|
|
220
|
+
CLEAN_PULL_REQUEST,
|
|
221
|
+
]
|
|
222
|
+
all_waits: list[float] = []
|
|
223
|
+
monkeypatch.setattr(
|
|
224
|
+
agent_merge_check,
|
|
225
|
+
"read_pull_request",
|
|
226
|
+
lambda slug, number, token: all_answers.pop(0),
|
|
227
|
+
)
|
|
228
|
+
settled = agent_merge_check.read_settled_pull_request(
|
|
229
|
+
"jl-cmd/claude-dev-env", 1442, "token", all_waits.append
|
|
230
|
+
)
|
|
231
|
+
assert settled == CLEAN_PULL_REQUEST
|
|
232
|
+
assert len(all_waits) == 2
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_read_settled_pull_request_stops_after_its_last_attempt(
|
|
236
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
237
|
+
) -> None:
|
|
238
|
+
all_waits: list[float] = []
|
|
239
|
+
read_count = 0
|
|
240
|
+
|
|
241
|
+
def _unknown(slug: str, number: int, token: str) -> dict[str, object]:
|
|
242
|
+
nonlocal read_count
|
|
243
|
+
read_count += 1
|
|
244
|
+
return _pull_request(mergeable_state="unknown")
|
|
245
|
+
|
|
246
|
+
monkeypatch.setattr(agent_merge_check, "read_pull_request", _unknown)
|
|
247
|
+
settled = agent_merge_check.read_settled_pull_request(
|
|
248
|
+
"jl-cmd/claude-dev-env", 1442, "token", all_waits.append
|
|
249
|
+
)
|
|
250
|
+
assert settled["mergeable_state"] == "unknown"
|
|
251
|
+
assert read_count == SETTLE_ATTEMPT_COUNT
|
|
252
|
+
assert len(all_waits) == SETTLE_ATTEMPT_COUNT - 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_read_settled_pull_request_waits_for_no_settled_state(
|
|
256
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
257
|
+
) -> None:
|
|
258
|
+
all_waits: list[float] = []
|
|
259
|
+
monkeypatch.setattr(
|
|
260
|
+
agent_merge_check,
|
|
261
|
+
"read_pull_request",
|
|
262
|
+
lambda slug, number, token: CLEAN_PULL_REQUEST,
|
|
263
|
+
)
|
|
264
|
+
agent_merge_check.read_settled_pull_request(
|
|
265
|
+
"jl-cmd/claude-dev-env", 1442, "token", all_waits.append
|
|
266
|
+
)
|
|
267
|
+
assert all_waits == []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_a_missing_token_reports_the_error_exit_code(
|
|
271
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
272
|
+
) -> None:
|
|
273
|
+
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
274
|
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
275
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_a_ready_pull_request_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
279
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
280
|
+
monkeypatch.setattr(
|
|
281
|
+
agent_merge_check,
|
|
282
|
+
"read_pull_request",
|
|
283
|
+
lambda slug, number, token: CLEAN_PULL_REQUEST,
|
|
284
|
+
)
|
|
285
|
+
monkeypatch.setattr(agent_merge_check.time, "sleep", lambda seconds: None)
|
|
286
|
+
monkeypatch.setattr(
|
|
287
|
+
agent_merge_check,
|
|
288
|
+
"read_unresolved_thread_count",
|
|
289
|
+
lambda slug, number, token: 0,
|
|
290
|
+
)
|
|
291
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_a_held_pull_request_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
295
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
296
|
+
monkeypatch.setattr(
|
|
297
|
+
agent_merge_check,
|
|
298
|
+
"read_pull_request",
|
|
299
|
+
lambda slug, number, token: _pull_request(mergeable_state="behind"),
|
|
300
|
+
)
|
|
301
|
+
monkeypatch.setattr(
|
|
302
|
+
agent_merge_check,
|
|
303
|
+
"read_unresolved_thread_count",
|
|
304
|
+
lambda slug, number, token: 0,
|
|
305
|
+
)
|
|
306
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 1
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_an_unreadable_pull_request_exits_two(
|
|
310
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
311
|
+
) -> None:
|
|
312
|
+
def _fail(slug: str, number: int, token: str) -> dict[str, object]:
|
|
313
|
+
raise agent_merge_check.MergeCheckError("no route to host")
|
|
314
|
+
|
|
315
|
+
monkeypatch.setenv("GH_TOKEN", "token")
|
|
316
|
+
monkeypatch.setattr(agent_merge_check, "read_pull_request", _fail)
|
|
317
|
+
assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
|
|
@@ -460,3 +460,49 @@ def should_report_a_rewritten_release_body_through_the_command_line(
|
|
|
460
460
|
)
|
|
461
461
|
assert completed_process.returncode == 1
|
|
462
462
|
assert "keep the generated text whole" in completed_process.stderr
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_contrast_framing_in_a_body_reports() -> None:
|
|
466
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
467
|
+
action="pr-comment",
|
|
468
|
+
title=None,
|
|
469
|
+
body_text="The queue ejected the entry, not the runner.\n",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
assert [each_finding.code for each_finding in all_findings] == ["contrast-framing"]
|
|
473
|
+
assert "trailing-comma-not" in all_findings[0].message
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_contrast_framing_in_a_title_reports() -> None:
|
|
477
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
478
|
+
action="pr-create",
|
|
479
|
+
title="fix(lint): read the base rather than the worktree",
|
|
480
|
+
body_text=VALID_PR_BODY,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
assert [each_finding.code for each_finding in all_findings] == ["contrast-framing"]
|
|
484
|
+
assert "substitution-rather-than" in all_findings[0].message
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_a_body_stating_what_is_stays_clean() -> None:
|
|
488
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
489
|
+
action="pr-comment",
|
|
490
|
+
title=None,
|
|
491
|
+
body_text="The queue ejected the entry. The runner stayed up.\n",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
assert all_findings == ()
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_a_release_body_carrying_a_contrast_stays_accepted() -> None:
|
|
498
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
499
|
+
action="pr-edit",
|
|
500
|
+
title=None,
|
|
501
|
+
body_text=RELEASE_BODY.replace(
|
|
502
|
+
"This PR was generated with",
|
|
503
|
+
"Bumped rather than pinned. This PR was generated with",
|
|
504
|
+
),
|
|
505
|
+
head_branch=RELEASE_BRANCH,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
assert all_findings == ()
|
|
@@ -9,6 +9,8 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from dev_env_scripts_constants.followup_constants import (
|
|
11
11
|
BACKLOG_EXCEEDED_EXIT_CODE,
|
|
12
|
+
BACKLOG_EXCEEDED_TEMPLATE,
|
|
13
|
+
BACKLOG_WITHIN_TEMPLATE,
|
|
12
14
|
FOLLOWUP_BACKLOG_THRESHOLD,
|
|
13
15
|
)
|
|
14
16
|
from followup_cli import main
|
|
@@ -251,6 +253,44 @@ def test_count_escalates_once_the_backlog_passes_the_threshold(
|
|
|
251
253
|
assert str(FOLLOWUP_BACKLOG_THRESHOLD) in output_text
|
|
252
254
|
|
|
253
255
|
|
|
256
|
+
def test_count_writes_the_within_threshold_template_verbatim(
|
|
257
|
+
tmp_path: Path,
|
|
258
|
+
) -> None:
|
|
259
|
+
exit_code, output_text = run_command(["count", "--repository-root", str(tmp_path)])
|
|
260
|
+
|
|
261
|
+
assert exit_code == 0
|
|
262
|
+
assert output_text == (
|
|
263
|
+
BACKLOG_WITHIN_TEMPLATE.format(
|
|
264
|
+
finding_count=0, threshold=FOLLOWUP_BACKLOG_THRESHOLD
|
|
265
|
+
)
|
|
266
|
+
+ "\n"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_count_writes_the_exceeded_template_verbatim(tmp_path: Path) -> None:
|
|
271
|
+
for each_index in range(FOLLOWUP_BACKLOG_THRESHOLD + 1):
|
|
272
|
+
record_followup_finding(
|
|
273
|
+
tmp_path,
|
|
274
|
+
FollowupFinding(
|
|
275
|
+
"code-rules",
|
|
276
|
+
f"run{each_index}.py",
|
|
277
|
+
"one smell",
|
|
278
|
+
f"code-rules/x{each_index}",
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
exit_code, output_text = run_command(["count", "--repository-root", str(tmp_path)])
|
|
283
|
+
|
|
284
|
+
assert exit_code == BACKLOG_EXCEEDED_EXIT_CODE
|
|
285
|
+
assert output_text == (
|
|
286
|
+
BACKLOG_EXCEEDED_TEMPLATE.format(
|
|
287
|
+
finding_count=FOLLOWUP_BACKLOG_THRESHOLD + 1,
|
|
288
|
+
threshold=FOLLOWUP_BACKLOG_THRESHOLD,
|
|
289
|
+
)
|
|
290
|
+
+ "\n"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
254
294
|
def test_list_names_the_check_behind_each_finding(tmp_path: Path) -> None:
|
|
255
295
|
record_followup_finding(
|
|
256
296
|
tmp_path,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Specifications for holding a merge policy document to its branch ruleset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from merge_gate_checks import (
|
|
9
|
+
main,
|
|
10
|
+
missing_contexts,
|
|
11
|
+
parse_declared_contexts,
|
|
12
|
+
ruleset_contexts,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
|
16
|
+
POLICY_DOCUMENT_PATH = REPOSITORY_ROOT / "docs" / "merge-policy.md"
|
|
17
|
+
RULESET_SNAPSHOT_PATH = REPOSITORY_ROOT / "docs" / "merge-policy-ruleset.json"
|
|
18
|
+
INSTRUCTION_PAIRS_CONTEXT = "instruction-pairs / instruction-pairs"
|
|
19
|
+
UTF8_ENCODING = "utf-8"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ruleset_with_contexts(*all_contexts: str) -> list[dict[str, object]]:
|
|
23
|
+
return [
|
|
24
|
+
{"type": "deletion"},
|
|
25
|
+
{
|
|
26
|
+
"type": "required_status_checks",
|
|
27
|
+
"parameters": {
|
|
28
|
+
"required_status_checks": [
|
|
29
|
+
{"context": each_context, "integration_id": 15368}
|
|
30
|
+
for each_context in all_contexts
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def should_read_the_contexts_the_fenced_block_declares() -> None:
|
|
38
|
+
document_text = (
|
|
39
|
+
"# Merge policy\n\n"
|
|
40
|
+
"```required-status-checks\n"
|
|
41
|
+
f"{INSTRUCTION_PAIRS_CONTEXT}\n"
|
|
42
|
+
"```\n\n"
|
|
43
|
+
"```\n"
|
|
44
|
+
"python merge_gate_checks.py --refresh\n"
|
|
45
|
+
"```\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert parse_declared_contexts(document_text) == [INSTRUCTION_PAIRS_CONTEXT]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def should_ignore_a_document_with_no_declaration_block() -> None:
|
|
52
|
+
assert parse_declared_contexts("# Merge policy\n\nNo block here.\n") == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def should_read_every_context_the_ruleset_requires() -> None:
|
|
56
|
+
payload = _ruleset_with_contexts(INSTRUCTION_PAIRS_CONTEXT, "Ruff")
|
|
57
|
+
|
|
58
|
+
assert ruleset_contexts(payload) == {INSTRUCTION_PAIRS_CONTEXT, "Ruff"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def should_report_a_check_the_ruleset_stopped_requiring() -> None:
|
|
62
|
+
document_text = f"```required-status-checks\n{INSTRUCTION_PAIRS_CONTEXT}\n```\n"
|
|
63
|
+
|
|
64
|
+
assert missing_contexts(document_text, _ruleset_with_contexts("Ruff")) == [
|
|
65
|
+
INSTRUCTION_PAIRS_CONTEXT
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def should_find_every_check_the_checked_in_policy_names() -> None:
|
|
70
|
+
document_text = POLICY_DOCUMENT_PATH.read_text(encoding=UTF8_ENCODING)
|
|
71
|
+
ruleset_payload = json.loads(
|
|
72
|
+
RULESET_SNAPSHOT_PATH.read_text(encoding=UTF8_ENCODING)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert parse_declared_contexts(document_text)
|
|
76
|
+
assert missing_contexts(document_text, ruleset_payload) == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def should_exit_non_zero_when_a_named_check_left_the_ruleset(tmp_path: Path) -> None:
|
|
80
|
+
document_path = tmp_path / "merge-policy.md"
|
|
81
|
+
document_path.write_text(
|
|
82
|
+
f"```required-status-checks\n{INSTRUCTION_PAIRS_CONTEXT}\n```\n",
|
|
83
|
+
encoding=UTF8_ENCODING,
|
|
84
|
+
)
|
|
85
|
+
ruleset_path = tmp_path / "merge-policy-ruleset.json"
|
|
86
|
+
ruleset_path.write_text(
|
|
87
|
+
json.dumps(_ruleset_with_contexts("Ruff")), encoding=UTF8_ENCODING
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
exit_code = main(["--document", str(document_path), "--ruleset", str(ruleset_path)])
|
|
91
|
+
|
|
92
|
+
assert exit_code == 1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Specifications for the permission modes the review binary is asked for.
|
|
2
|
+
|
|
3
|
+
The review binary accepts one permission mode for an ordinary caller and a
|
|
4
|
+
different one for root. Naming the wrong mode means the binary refuses the
|
|
5
|
+
call, no review runs, and no stamp is ever minted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from dev_env_scripts_constants import code_review_constants
|
|
15
|
+
|
|
16
|
+
BYPASS_MODE_TOKEN: str = "bypassPermissions"
|
|
17
|
+
ACCEPT_EDITS_MODE_TOKEN: str = "acceptEdits"
|
|
18
|
+
ROOT_USER_ID: int = 0
|
|
19
|
+
UNPRIVILEGED_USER_ID: int = 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _permission_mode_seen_by(
|
|
23
|
+
monkeypatch: pytest.MonkeyPatch, effective_user_id: int
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Read the review permission mode a caller with this user id resolves."""
|
|
26
|
+
monkeypatch.setattr(os, "geteuid", lambda: effective_user_id)
|
|
27
|
+
reloaded_constants = importlib.reload(code_review_constants)
|
|
28
|
+
return str(reloaded_constants.REVIEW_PERMISSION_MODE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_root_detection_agrees_with_this_process(
|
|
32
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
33
|
+
) -> None:
|
|
34
|
+
monkeypatch.setattr(os, "geteuid", lambda: ROOT_USER_ID)
|
|
35
|
+
reloaded_constants = importlib.reload(code_review_constants)
|
|
36
|
+
|
|
37
|
+
assert reloaded_constants.IS_ROOT_CALLER is True
|
|
38
|
+
assert reloaded_constants.ROOT_EFFECTIVE_USER_ID == ROOT_USER_ID
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_bypass_mode_names_the_token_the_binary_reads() -> None:
|
|
42
|
+
assert code_review_constants.PERMISSION_MODE_BYPASS == BYPASS_MODE_TOKEN
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_accept_edits_mode_names_the_token_the_binary_reads() -> None:
|
|
46
|
+
assert code_review_constants.PERMISSION_MODE_ACCEPT_EDITS == ACCEPT_EDITS_MODE_TOKEN
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_a_root_caller_resolves_the_mode_the_binary_accepts(
|
|
50
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
51
|
+
) -> None:
|
|
52
|
+
assert _permission_mode_seen_by(monkeypatch, ROOT_USER_ID) == (
|
|
53
|
+
ACCEPT_EDITS_MODE_TOKEN
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_an_ordinary_caller_resolves_the_bypass_mode(
|
|
58
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
59
|
+
) -> None:
|
|
60
|
+
assert _permission_mode_seen_by(monkeypatch, UNPRIVILEGED_USER_ID) == (
|
|
61
|
+
BYPASS_MODE_TOKEN
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_a_platform_without_a_user_id_still_loads_the_constants(
|
|
66
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Windows exposes no ``os.geteuid``, and the module still imports.
|
|
69
|
+
|
|
70
|
+
Reading a missing ``os.geteuid`` raises, and every caller of the review
|
|
71
|
+
invoker dies at import. A platform that reports no user id is never the
|
|
72
|
+
root the review binary refuses, so it reads as an ordinary caller.
|
|
73
|
+
"""
|
|
74
|
+
monkeypatch.delattr(os, "geteuid", raising=False)
|
|
75
|
+
reloaded_constants = importlib.reload(code_review_constants)
|
|
76
|
+
|
|
77
|
+
assert reloaded_constants.IS_ROOT_CALLER is False
|
|
78
|
+
assert reloaded_constants.REVIEW_PERMISSION_MODE == BYPASS_MODE_TOKEN
|
|
@@ -173,9 +173,9 @@ def test_public_legacy_call_forms_validate_collection_entries(tmp_path: Path) ->
|
|
|
173
173
|
publish_plan(config, all_planned_files=[object()])
|
|
174
174
|
|
|
175
175
|
|
|
176
|
-
def
|
|
177
|
-
with pytest.raises(MaterializerError, match="requires a Codex excerpt"):
|
|
178
|
-
materializer.
|
|
176
|
+
def test_render_codex_instruction_excerpt_requires_the_excerpt_heading() -> None:
|
|
177
|
+
with pytest.raises(MaterializerError, match="rules/correction-lens.md requires a Codex excerpt"):
|
|
178
|
+
materializer.render_codex_instruction_excerpt("# No excerpt\n", "rules/correction-lens.md")
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
def test_validation_rejects_reparse_point_from_portable_attribute_seam(
|
|
@@ -823,13 +823,57 @@ def test_failure_blast_radius_projection_uses_the_canonical_excerpt() -> None:
|
|
|
823
823
|
canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
|
|
824
824
|
canonical_rule = canonical_rule_path.read_text(encoding="utf-8")
|
|
825
825
|
|
|
826
|
-
projected_instruction = materializer.
|
|
826
|
+
projected_instruction = materializer.render_codex_instruction_excerpt(
|
|
827
|
+
canonical_rule, "rules/failure-blast-radius.md"
|
|
828
|
+
)
|
|
827
829
|
|
|
828
830
|
assert projected_instruction.startswith("Failure handling for this run")
|
|
829
831
|
assert "Three attempts, then park." in projected_instruction
|
|
830
832
|
assert "Report as: N of M complete" in projected_instruction
|
|
831
833
|
|
|
832
834
|
|
|
835
|
+
def test_codex_instruction_projection_carries_every_listed_rule(tmp_path: Path) -> None:
|
|
836
|
+
rules_root = Path(__file__).parents[2] / "rules"
|
|
837
|
+
source = tmp_path / "source"
|
|
838
|
+
target = tmp_path / "target"
|
|
839
|
+
(source / "rules").mkdir(parents=True)
|
|
840
|
+
for each_relative_path in materializer.codex_instruction_rule_relative_paths:
|
|
841
|
+
canonical_path = rules_root / Path(each_relative_path).name
|
|
842
|
+
(source / each_relative_path).write_text(
|
|
843
|
+
canonical_path.read_text(encoding="utf-8"), encoding="utf-8"
|
|
844
|
+
)
|
|
845
|
+
config = MaterializerConfig(source, target, should_apply=True)
|
|
846
|
+
|
|
847
|
+
planned, report = build_plan(config, all_agents=[])
|
|
848
|
+
publish_plan(config, planned, report)
|
|
849
|
+
|
|
850
|
+
projected_instruction = (target / "AGENTS.md").read_text(encoding="utf-8")
|
|
851
|
+
assert "Failure handling for this run" in projected_instruction
|
|
852
|
+
assert "Correction handling for this run" in projected_instruction
|
|
853
|
+
manifest_record = _manifest_files(_required_manifest_path(config))["AGENTS.md"]
|
|
854
|
+
assert manifest_record["source"] == "rules/failure-blast-radius.md, rules/correction-lens.md"
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def test_codex_instruction_projection_skips_an_absent_listed_rule(
|
|
858
|
+
tmp_path: Path,
|
|
859
|
+
) -> None:
|
|
860
|
+
rules_root = Path(__file__).parents[2] / "rules"
|
|
861
|
+
source = tmp_path / "source"
|
|
862
|
+
(source / "rules").mkdir(parents=True)
|
|
863
|
+
(source / "rules" / "correction-lens.md").write_text(
|
|
864
|
+
(rules_root / "correction-lens.md").read_text(encoding="utf-8"),
|
|
865
|
+
encoding="utf-8",
|
|
866
|
+
)
|
|
867
|
+
config = MaterializerConfig(source, tmp_path / "target", should_apply=False)
|
|
868
|
+
|
|
869
|
+
projection = materializer._build_codex_instruction_projection(config)
|
|
870
|
+
|
|
871
|
+
assert projection is not None
|
|
872
|
+
assert projection.source_identity == "rules/correction-lens.md"
|
|
873
|
+
assert "Correction handling for this run" in projection.content
|
|
874
|
+
assert "Failure handling for this run" not in projection.content
|
|
875
|
+
|
|
876
|
+
|
|
833
877
|
def test_build_plan_publishes_owned_agents_projection_and_tracks_drift(
|
|
834
878
|
tmp_path: Path,
|
|
835
879
|
) -> None:
|