claude-dev-env 8.102.1 → 8.103.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/pull-request/reference/body-sections.md +2 -1
- package/bin/install.prompt-context.test.mjs +2 -1
- package/docs/rule-guides/proof-before-pull-request.md +3 -3
- package/hooks/blocking/pull_request_proof.py +64 -6
- package/hooks/blocking/test_pull_request_proof.py +87 -0
- package/hooks/blocking/test_turn_review_findings.py +25 -0
- package/hooks/blocking/test_turn_review_gate.py +462 -0
- package/hooks/blocking/test_turn_review_snapshot.py +107 -0
- package/hooks/blocking/turn_review_findings.py +102 -0
- package/hooks/blocking/turn_review_gate.py +362 -0
- package/hooks/blocking/turn_review_snapshot.py +116 -0
- package/hooks/features/conduct-gates.md +5 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +14 -1
- package/hooks/hooks_constants/turn_review_gate_constants.py +161 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import turn_review_gate
|
|
11
|
+
|
|
12
|
+
GATE_PATH = Path(turn_review_gate.__file__)
|
|
13
|
+
SESSION_ID = "turn-review-session"
|
|
14
|
+
REPOSITORY_SLUG = "owner/app"
|
|
15
|
+
FINDING_TEXT = "src/service.py:3 - The diff drops the retry, so one failed call loses the update."
|
|
16
|
+
STUB_CLAUDE_SOURCE = """#!/usr/bin/env python3
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
log_path = Path(os.environ["STUB_REVIEW_LOG"])
|
|
24
|
+
calls = json.loads(log_path.read_text()) if log_path.exists() else []
|
|
25
|
+
calls.append({"argv": sys.argv[1:], "cwd": os.getcwd(), "environment": dict(os.environ)})
|
|
26
|
+
log_path.write_text(json.dumps(calls))
|
|
27
|
+
exit_code = int(os.environ.get("STUB_REVIEW_EXIT_CODE", "0"))
|
|
28
|
+
if exit_code:
|
|
29
|
+
sys.exit(exit_code)
|
|
30
|
+
time.sleep(float(os.environ.get("STUB_REVIEW_SECONDS", "0")))
|
|
31
|
+
print(json.dumps({"type": "result", "is_error": False, "result": os.environ["STUB_REVIEW_TEXT"]}))
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def git(repository: Path, *all_arguments: str) -> str:
|
|
36
|
+
return subprocess.run(
|
|
37
|
+
["git", *all_arguments], cwd=repository, check=True, capture_output=True, text=True
|
|
38
|
+
).stdout.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def project(tmp_path: Path) -> Path:
|
|
43
|
+
project_directory = tmp_path / "workspace"
|
|
44
|
+
repository = project_directory / "app"
|
|
45
|
+
(repository / "src").mkdir(parents=True)
|
|
46
|
+
git(repository, "init", "-q", "-b", "main")
|
|
47
|
+
git(repository, "config", "user.email", "test@example.com")
|
|
48
|
+
git(repository, "config", "user.name", "Test")
|
|
49
|
+
git(repository, "remote", "add", "origin", f"https://github.com/{REPOSITORY_SLUG}.git")
|
|
50
|
+
(repository / "src" / "service.py").write_text("@with_retry\ndef update():\n call()\n")
|
|
51
|
+
git(repository, "add", "-A")
|
|
52
|
+
git(repository, "commit", "-q", "-m", "start")
|
|
53
|
+
return project_directory
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def gate_environment(tmp_path: Path, project: Path) -> dict[str, str]:
|
|
58
|
+
stub_directory = tmp_path / "bin"
|
|
59
|
+
stub_directory.mkdir()
|
|
60
|
+
stub_claude = stub_directory / "claude"
|
|
61
|
+
stub_claude.write_text(STUB_CLAUDE_SOURCE)
|
|
62
|
+
stub_claude.chmod(0o755)
|
|
63
|
+
state_directory = tmp_path / "state"
|
|
64
|
+
state_directory.mkdir()
|
|
65
|
+
environment = {
|
|
66
|
+
each_name: each_value
|
|
67
|
+
for each_name, each_value in os.environ.items()
|
|
68
|
+
if not each_name.startswith(("CLAUDE", "GIT_"))
|
|
69
|
+
}
|
|
70
|
+
environment.update(
|
|
71
|
+
{
|
|
72
|
+
"PATH": f"{stub_directory}{os.pathsep}{os.environ['PATH']}",
|
|
73
|
+
"TMPDIR": str(state_directory),
|
|
74
|
+
"CLAUDE_PROJECT_DIR": str(project),
|
|
75
|
+
"CLAUDE_TURN_REVIEW_REPOSITORIES": f"other/repo, {REPOSITORY_SLUG.upper()}",
|
|
76
|
+
"STUB_REVIEW_LOG": str(tmp_path / "review-calls.json"),
|
|
77
|
+
"STUB_REVIEW_TEXT": FINDING_TEXT,
|
|
78
|
+
"CLAUDE_CODE_SESSION_ID": "parent-session",
|
|
79
|
+
"CLAUDE_CODE_REMOTE_SESSION_ID": "parent-remote-session",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return environment
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_hook(environment: dict[str, str], hook_event_name: str) -> dict[str, object]:
|
|
86
|
+
hook_input = {"hook_event_name": hook_event_name, "session_id": SESSION_ID, "cwd": "/"}
|
|
87
|
+
completed_process = subprocess.run(
|
|
88
|
+
[sys.executable, str(GATE_PATH)],
|
|
89
|
+
input=json.dumps(hook_input),
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
env=environment,
|
|
93
|
+
check=True,
|
|
94
|
+
)
|
|
95
|
+
return json.loads(completed_process.stdout) if completed_process.stdout.strip() else {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def review_calls(environment: dict[str, str]) -> list[dict[str, object]]:
|
|
99
|
+
log_path = Path(environment["STUB_REVIEW_LOG"])
|
|
100
|
+
return json.loads(log_path.read_text()) if log_path.exists() else []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def drop_retry(project: Path) -> None:
|
|
104
|
+
(project / "app" / "src" / "service.py").write_text("def update():\n call()\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_block_the_stop_with_the_finding_when_the_turn_leaves_a_bug(
|
|
108
|
+
project: Path, gate_environment: dict[str, str]
|
|
109
|
+
) -> None:
|
|
110
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
111
|
+
drop_retry(project)
|
|
112
|
+
stop_output = run_hook(gate_environment, "Stop")
|
|
113
|
+
assert stop_output["decision"] == "block"
|
|
114
|
+
assert FINDING_TEXT in str(stop_output["reason"])
|
|
115
|
+
assert "app" in str(stop_output["reason"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_review_only_the_edits_made_after_the_prompt(
|
|
119
|
+
project: Path, gate_environment: dict[str, str]
|
|
120
|
+
) -> None:
|
|
121
|
+
repository = project / "app"
|
|
122
|
+
(repository / "notes.txt").write_text("work the user left before the prompt\n")
|
|
123
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
124
|
+
drop_retry(project)
|
|
125
|
+
run_hook(gate_environment, "Stop")
|
|
126
|
+
[review_call] = review_calls(gate_environment)
|
|
127
|
+
prompt_text = review_call["argv"][review_call["argv"].index("-p") + 1]
|
|
128
|
+
assert prompt_text.startswith("/code-review low ")
|
|
129
|
+
turn_range = prompt_text.split()[-1]
|
|
130
|
+
base_commit, turn_commit = turn_range.split("..")
|
|
131
|
+
assert git(repository, "diff", "--name-only", base_commit, turn_commit) == "src/service.py"
|
|
132
|
+
assert git(repository, "rev-parse", f"{turn_commit}^") == base_commit
|
|
133
|
+
assert Path(str(review_call["cwd"])).resolve() == repository.resolve()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_should_run_the_review_outside_the_parent_session(
|
|
137
|
+
project: Path, gate_environment: dict[str, str]
|
|
138
|
+
) -> None:
|
|
139
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
140
|
+
drop_retry(project)
|
|
141
|
+
run_hook(gate_environment, "Stop")
|
|
142
|
+
[review_call] = review_calls(gate_environment)
|
|
143
|
+
review_environment = review_call["environment"]
|
|
144
|
+
assert "CLAUDE_CODE_SESSION_ID" not in review_environment
|
|
145
|
+
assert "CLAUDE_CODE_REMOTE_SESSION_ID" not in review_environment
|
|
146
|
+
assert review_environment["CLAUDE_TURN_REVIEW_CHILD"] == "1"
|
|
147
|
+
assert "--permission-mode" in review_call["argv"]
|
|
148
|
+
assert review_call["argv"][review_call["argv"].index("--permission-mode") + 1] == "dontAsk"
|
|
149
|
+
assert "Bash(git diff:*)" in review_call["argv"]
|
|
150
|
+
assert not any("git -C" in each_argument for each_argument in review_call["argv"])
|
|
151
|
+
denied_rules = review_call["argv"][review_call["argv"].index("--disallowedTools") + 1 :]
|
|
152
|
+
assert "Bash(git * -c *)" in denied_rules
|
|
153
|
+
assert "Bash(git *--output*)" in denied_rules
|
|
154
|
+
settings_text = review_call["argv"][review_call["argv"].index("--settings") + 1]
|
|
155
|
+
assert json.loads(settings_text) == {"disableAllHooks": True}
|
|
156
|
+
argv = review_call["argv"]
|
|
157
|
+
assert argv[argv.index("--setting-sources") + 1] == ""
|
|
158
|
+
assert argv[argv.index("--tools") + 1] == "Read,Grep,Glob,Bash"
|
|
159
|
+
assert "--no-session-persistence" in argv
|
|
160
|
+
assert "Agent" not in argv
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_should_ignore_a_saved_baseline_that_is_not_a_commit_name(
|
|
164
|
+
tmp_path: Path, project: Path, gate_environment: dict[str, str]
|
|
165
|
+
) -> None:
|
|
166
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
167
|
+
[state_file] = (tmp_path / "state").glob("turn-review-*.json")
|
|
168
|
+
saved_state = json.loads(state_file.read_text())
|
|
169
|
+
planted_output = tmp_path / "planted.txt"
|
|
170
|
+
saved_state["baselines"] = {
|
|
171
|
+
each_root: f"--output={planted_output}" for each_root in saved_state["baselines"]
|
|
172
|
+
}
|
|
173
|
+
state_file.write_text(json.dumps(saved_state))
|
|
174
|
+
drop_retry(project)
|
|
175
|
+
run_hook(gate_environment, "Stop")
|
|
176
|
+
assert not planted_output.exists()
|
|
177
|
+
assert review_calls(gate_environment) == []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_should_ignore_a_saved_baseline_that_names_a_revision_expression(
|
|
181
|
+
tmp_path: Path, project: Path, gate_environment: dict[str, str]
|
|
182
|
+
) -> None:
|
|
183
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
184
|
+
[state_file] = (tmp_path / "state").glob("turn-review-*.json")
|
|
185
|
+
saved_state = json.loads(state_file.read_text())
|
|
186
|
+
saved_state["baselines"] = {each_root: "HEAD" for each_root in saved_state["baselines"]}
|
|
187
|
+
state_file.write_text(json.dumps(saved_state))
|
|
188
|
+
drop_retry(project)
|
|
189
|
+
run_hook(gate_environment, "Stop")
|
|
190
|
+
assert review_calls(gate_environment) == []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_should_read_the_json_findings_after_an_empty_list_in_the_prose(
|
|
194
|
+
project: Path, gate_environment: dict[str, str]
|
|
195
|
+
) -> None:
|
|
196
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
197
|
+
"load() now returns `[]` on error.\n"
|
|
198
|
+
'```json\n[{"file": "src/service.py", "line": 1, "summary": "The retry is gone."}]\n```'
|
|
199
|
+
)
|
|
200
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
201
|
+
drop_retry(project)
|
|
202
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_should_keep_a_json_finding_whose_summary_holds_an_empty_list(
|
|
206
|
+
project: Path, gate_environment: dict[str, str]
|
|
207
|
+
) -> None:
|
|
208
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
209
|
+
'[{"file": "src/service.py", "line": 1, "summary": "load() now returns [] on error."}]'
|
|
210
|
+
)
|
|
211
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
212
|
+
drop_retry(project)
|
|
213
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_should_keep_the_json_findings_before_an_empty_list_in_the_prose(
|
|
217
|
+
project: Path, gate_environment: dict[str, str]
|
|
218
|
+
) -> None:
|
|
219
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
220
|
+
'```json\n[{"file": "src/service.py", "line": 1, "summary": "The retry is gone."}]\n```\n'
|
|
221
|
+
"Callers that pass `[]` are unaffected."
|
|
222
|
+
)
|
|
223
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
224
|
+
drop_retry(project)
|
|
225
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_should_skip_a_project_folder_it_cannot_read(
|
|
229
|
+
project: Path, monkeypatch: pytest.MonkeyPatch
|
|
230
|
+
) -> None:
|
|
231
|
+
(project / "locked").mkdir()
|
|
232
|
+
original_exists = Path.exists
|
|
233
|
+
|
|
234
|
+
def exists_or_denied(self: Path, *all_arguments: object, **all_options: object) -> bool:
|
|
235
|
+
if self.parent.name == "locked":
|
|
236
|
+
raise PermissionError(13, "Permission denied", str(self))
|
|
237
|
+
return original_exists(self, *all_arguments, **all_options)
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr(Path, "exists", exists_or_denied)
|
|
240
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project))
|
|
241
|
+
all_roots = turn_review_gate._opted_in_repository_roots(frozenset({REPOSITORY_SLUG}))
|
|
242
|
+
assert all_roots == [(project / "app").resolve()]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_should_read_a_text_finding_beside_an_inline_empty_list(
|
|
246
|
+
project: Path, gate_environment: dict[str, str]
|
|
247
|
+
) -> None:
|
|
248
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
249
|
+
"src/service.py:1 resets the cache to `[]` before the retry."
|
|
250
|
+
)
|
|
251
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
252
|
+
drop_retry(project)
|
|
253
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_should_allow_the_stop_without_a_review_when_the_turn_changed_nothing(
|
|
257
|
+
gate_environment: dict[str, str],
|
|
258
|
+
) -> None:
|
|
259
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
260
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
261
|
+
assert review_calls(gate_environment) == []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_should_allow_the_stop_when_the_review_finds_nothing(
|
|
265
|
+
project: Path, gate_environment: dict[str, str]
|
|
266
|
+
) -> None:
|
|
267
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
268
|
+
"(none)\n\nThe diff only changes one line in `src/service.py:1`."
|
|
269
|
+
)
|
|
270
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
271
|
+
drop_retry(project)
|
|
272
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
273
|
+
assert len(review_calls(gate_environment)) == 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_should_block_on_a_finding_the_review_reports_as_json(
|
|
277
|
+
project: Path, gate_environment: dict[str, str]
|
|
278
|
+
) -> None:
|
|
279
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
280
|
+
'```json\n[{"file": "src/service.py", "line": 1, '
|
|
281
|
+
'"summary": "The retry is gone."}]\n```'
|
|
282
|
+
)
|
|
283
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
284
|
+
drop_retry(project)
|
|
285
|
+
stop_output = run_hook(gate_environment, "Stop")
|
|
286
|
+
assert stop_output["decision"] == "block"
|
|
287
|
+
assert "src/service.py:1 The retry is gone." in str(stop_output["reason"])
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_should_read_the_json_findings_after_a_bracket_in_the_prose(
|
|
291
|
+
project: Path, gate_environment: dict[str, str]
|
|
292
|
+
) -> None:
|
|
293
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
294
|
+
"See [service](src/service.py#L1).\n"
|
|
295
|
+
'```json\n[{"file": "src/service.py", "line": 1, "summary": "The retry is gone."}]\n```'
|
|
296
|
+
)
|
|
297
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
298
|
+
drop_retry(project)
|
|
299
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_should_allow_the_stop_when_the_json_findings_are_empty(
|
|
303
|
+
project: Path, gate_environment: dict[str, str]
|
|
304
|
+
) -> None:
|
|
305
|
+
gate_environment["STUB_REVIEW_TEXT"] = "Checked src/service.py:1 and kept it.\n```json\n[]\n```"
|
|
306
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
307
|
+
drop_retry(project)
|
|
308
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_should_allow_the_stop_when_the_finding_names_a_longer_path(
|
|
312
|
+
project: Path, gate_environment: dict[str, str]
|
|
313
|
+
) -> None:
|
|
314
|
+
gate_environment["STUB_REVIEW_TEXT"] = "vendor/src/service.py:3 - An unchanged copy has a bug."
|
|
315
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
316
|
+
drop_retry(project)
|
|
317
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_should_block_on_a_text_finding_before_an_empty_json_list(
|
|
321
|
+
project: Path, gate_environment: dict[str, str]
|
|
322
|
+
) -> None:
|
|
323
|
+
gate_environment["STUB_REVIEW_TEXT"] = "src/service.py:1 The retry is gone.\n```json\n[]\n```"
|
|
324
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
325
|
+
drop_retry(project)
|
|
326
|
+
assert "src/service.py:1 The retry is gone." in str(
|
|
327
|
+
run_hook(gate_environment, "Stop")["reason"]
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_should_allow_the_stop_when_a_clean_review_cites_a_changed_line_in_prose(
|
|
332
|
+
project: Path, gate_environment: dict[str, str]
|
|
333
|
+
) -> None:
|
|
334
|
+
gate_environment["STUB_REVIEW_TEXT"] = "I checked src/service.py:1 and found no problems."
|
|
335
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
336
|
+
drop_retry(project)
|
|
337
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_should_block_on_a_listed_finding_that_opens_with_the_changed_path(
|
|
341
|
+
project: Path, gate_environment: dict[str, str]
|
|
342
|
+
) -> None:
|
|
343
|
+
gate_environment["STUB_REVIEW_TEXT"] = (
|
|
344
|
+
"I found one problem.\n\n- `src/service.py:1` - The retry is gone."
|
|
345
|
+
)
|
|
346
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
347
|
+
drop_retry(project)
|
|
348
|
+
assert "`src/service.py:1` - The retry is gone." in str(
|
|
349
|
+
run_hook(gate_environment, "Stop")["reason"]
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_should_share_one_review_time_budget_across_the_checkouts(
|
|
354
|
+
project: Path, gate_environment: dict[str, str], monkeypatch: pytest.MonkeyPatch
|
|
355
|
+
) -> None:
|
|
356
|
+
second_repository = project / "lib"
|
|
357
|
+
second_repository.mkdir()
|
|
358
|
+
git(second_repository, "init", "-q", "-b", "main")
|
|
359
|
+
git(second_repository, "remote", "add", "origin", f"https://github.com/{REPOSITORY_SLUG}.git")
|
|
360
|
+
git(second_repository, "-c", "user.name=Test", "-c", "user.email=test", "commit", "-q", "--allow-empty", "-m", "start")
|
|
361
|
+
for each_name in ("PATH", "CLAUDE_PROJECT_DIR", "CLAUDE_TURN_REVIEW_REPOSITORIES", "STUB_REVIEW_LOG", "STUB_REVIEW_TEXT"):
|
|
362
|
+
monkeypatch.setenv(each_name, gate_environment[each_name])
|
|
363
|
+
monkeypatch.setenv("STUB_REVIEW_SECONDS", "5")
|
|
364
|
+
monkeypatch.setattr(tempfile, "tempdir", gate_environment["TMPDIR"])
|
|
365
|
+
monkeypatch.setattr(turn_review_gate, "REVIEW_TIMEOUT_SECONDS", 2)
|
|
366
|
+
all_roots = turn_review_gate._opted_in_repository_roots(
|
|
367
|
+
turn_review_gate._opted_in_repository_slugs()
|
|
368
|
+
)
|
|
369
|
+
turn_review_gate._record_prompt_baselines(SESSION_ID, all_roots)
|
|
370
|
+
drop_retry(project)
|
|
371
|
+
(second_repository / "notes.txt").write_text("new\n")
|
|
372
|
+
turn_review_gate._turn_block_reason(SESSION_ID, all_roots)
|
|
373
|
+
assert [each_call["cwd"] for each_call in review_calls(gate_environment)] == [
|
|
374
|
+
str(project / "app")
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_should_review_only_the_fix_on_the_stop_after_a_block(
|
|
379
|
+
project: Path, gate_environment: dict[str, str]
|
|
380
|
+
) -> None:
|
|
381
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
382
|
+
drop_retry(project)
|
|
383
|
+
run_hook(gate_environment, "Stop")
|
|
384
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
385
|
+
assert len(review_calls(gate_environment)) == 1
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_should_stop_blocking_after_three_blocks_in_a_row(
|
|
389
|
+
project: Path, gate_environment: dict[str, str]
|
|
390
|
+
) -> None:
|
|
391
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
392
|
+
all_decisions = []
|
|
393
|
+
for each_round in range(4):
|
|
394
|
+
(project / "app" / "src" / "service.py").write_text(
|
|
395
|
+
f"def update():\n call({each_round})\n"
|
|
396
|
+
)
|
|
397
|
+
all_decisions.append(run_hook(gate_environment, "Stop").get("decision"))
|
|
398
|
+
assert all_decisions == ["block", "block", "block", None]
|
|
399
|
+
assert len(review_calls(gate_environment)) == 3
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_should_reset_the_block_count_on_a_new_prompt(
|
|
403
|
+
project: Path, gate_environment: dict[str, str]
|
|
404
|
+
) -> None:
|
|
405
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
406
|
+
for each_round in range(3):
|
|
407
|
+
(project / "app" / "src" / "service.py").write_text(
|
|
408
|
+
f"def update():\n call({each_round})\n"
|
|
409
|
+
)
|
|
410
|
+
run_hook(gate_environment, "Stop")
|
|
411
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
412
|
+
drop_retry(project)
|
|
413
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_should_skip_a_turn_that_changed_more_than_thirty_files(
|
|
417
|
+
project: Path, gate_environment: dict[str, str]
|
|
418
|
+
) -> None:
|
|
419
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
420
|
+
for each_index in range(31):
|
|
421
|
+
(project / "app" / "src" / f"module_{each_index}.py").write_text("value = 1\n")
|
|
422
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
423
|
+
assert review_calls(gate_environment) == []
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_should_leave_a_repository_the_environment_does_not_list(
|
|
427
|
+
project: Path, gate_environment: dict[str, str]
|
|
428
|
+
) -> None:
|
|
429
|
+
gate_environment["CLAUDE_TURN_REVIEW_REPOSITORIES"] = "other/repo"
|
|
430
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
431
|
+
drop_retry(project)
|
|
432
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
433
|
+
assert review_calls(gate_environment) == []
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_should_do_nothing_inside_a_review_run(
|
|
437
|
+
project: Path, gate_environment: dict[str, str]
|
|
438
|
+
) -> None:
|
|
439
|
+
gate_environment["CLAUDE_TURN_REVIEW_CHILD"] = "1"
|
|
440
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
441
|
+
drop_retry(project)
|
|
442
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
443
|
+
assert review_calls(gate_environment) == []
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_should_allow_the_stop_when_the_review_command_fails(
|
|
447
|
+
project: Path, gate_environment: dict[str, str]
|
|
448
|
+
) -> None:
|
|
449
|
+
gate_environment["STUB_REVIEW_EXIT_CODE"] = "1"
|
|
450
|
+
run_hook(gate_environment, "UserPromptSubmit")
|
|
451
|
+
drop_retry(project)
|
|
452
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
453
|
+
assert len(review_calls(gate_environment)) == 1
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_should_start_the_turn_on_the_first_stop_without_a_prompt_snapshot(
|
|
457
|
+
project: Path, gate_environment: dict[str, str]
|
|
458
|
+
) -> None:
|
|
459
|
+
drop_retry(project)
|
|
460
|
+
assert run_hook(gate_environment, "Stop") == {}
|
|
461
|
+
(project / "app" / "src" / "service.py").write_text("def update():\n call(1)\n")
|
|
462
|
+
assert run_hook(gate_environment, "Stop")["decision"] == "block"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from turn_review_snapshot import turn_changed_paths, working_tree_snapshot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def git(repository: Path, *all_arguments: str) -> str:
|
|
10
|
+
return subprocess.run(
|
|
11
|
+
["git", *all_arguments], cwd=repository, check=True, capture_output=True, text=True
|
|
12
|
+
).stdout.strip()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def repository(tmp_path: Path) -> Path:
|
|
17
|
+
git(tmp_path, "init", "-q", "-b", "main")
|
|
18
|
+
git(tmp_path, "config", "user.email", "test@example.com")
|
|
19
|
+
git(tmp_path, "config", "user.name", "Test")
|
|
20
|
+
(tmp_path / "service.py").write_text("call()\n")
|
|
21
|
+
git(tmp_path, "add", "-A")
|
|
22
|
+
git(tmp_path, "commit", "-q", "-m", "start")
|
|
23
|
+
return tmp_path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_should_chain_a_snapshot_onto_the_previous_one(repository: Path) -> None:
|
|
27
|
+
base_commit = working_tree_snapshot(repository, None)
|
|
28
|
+
assert base_commit is not None
|
|
29
|
+
assert git(repository, "rev-parse", f"{base_commit}^") == git(repository, "rev-parse", "HEAD")
|
|
30
|
+
(repository / "service.py").write_text("call(1)\n")
|
|
31
|
+
turn_commit = working_tree_snapshot(repository, base_commit)
|
|
32
|
+
assert turn_commit is not None
|
|
33
|
+
assert git(repository, "rev-parse", f"{turn_commit}^") == base_commit
|
|
34
|
+
assert turn_changed_paths(repository, base_commit, turn_commit) == ["service.py"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_count_an_untracked_file_and_leave_the_user_index_alone(repository: Path) -> None:
|
|
38
|
+
base_commit = working_tree_snapshot(repository, None)
|
|
39
|
+
assert base_commit is not None
|
|
40
|
+
(repository / "notes.txt").write_text("new\n")
|
|
41
|
+
turn_commit = working_tree_snapshot(repository, base_commit)
|
|
42
|
+
assert turn_commit is not None
|
|
43
|
+
assert turn_changed_paths(repository, base_commit, turn_commit) == ["notes.txt"]
|
|
44
|
+
assert git(repository, "status", "--porcelain") == "?? notes.txt"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_should_return_the_base_when_nothing_changed(repository: Path) -> None:
|
|
48
|
+
base_commit = working_tree_snapshot(repository, None)
|
|
49
|
+
assert base_commit is not None
|
|
50
|
+
assert working_tree_snapshot(repository, base_commit) == base_commit
|
|
51
|
+
assert turn_changed_paths(repository, base_commit, base_commit) == []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_should_return_none_outside_a_repository(tmp_path: Path) -> None:
|
|
55
|
+
assert working_tree_snapshot(tmp_path, None) is None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_should_name_a_non_ascii_path_as_written(repository: Path) -> None:
|
|
59
|
+
base_commit = working_tree_snapshot(repository, None)
|
|
60
|
+
assert base_commit is not None
|
|
61
|
+
(repository / "caf\u00e9.py").write_text("call()\n")
|
|
62
|
+
turn_commit = working_tree_snapshot(repository, base_commit)
|
|
63
|
+
assert turn_commit is not None
|
|
64
|
+
assert turn_changed_paths(repository, base_commit, turn_commit) == ["caf\u00e9.py"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_list_a_path_whose_name_is_not_utf8(repository: Path) -> None:
|
|
68
|
+
base_commit = git(repository, "rev-parse", "HEAD")
|
|
69
|
+
blob = git(repository, "hash-object", "-w", "service.py")
|
|
70
|
+
tree = subprocess.run(
|
|
71
|
+
["git", "mktree"],
|
|
72
|
+
cwd=repository,
|
|
73
|
+
input=b"100644 blob " + blob.encode() + b"\tcaf\xe9.py\n",
|
|
74
|
+
check=True,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
).stdout.decode().strip()
|
|
77
|
+
turn_commit = git(repository, "commit-tree", tree, "-p", base_commit, "-m", "odd name")
|
|
78
|
+
assert turn_changed_paths(repository, base_commit, turn_commit) == ["caf\udce9.py"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_should_snapshot_on_a_host_with_no_git_identity(
|
|
82
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
83
|
+
) -> None:
|
|
84
|
+
empty_global_config = tmp_path / "empty-gitconfig"
|
|
85
|
+
empty_global_config.write_text("")
|
|
86
|
+
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(empty_global_config))
|
|
87
|
+
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
|
|
88
|
+
for each_name in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"):
|
|
89
|
+
monkeypatch.delenv(each_name, raising=False)
|
|
90
|
+
repository = tmp_path / "repository"
|
|
91
|
+
repository.mkdir()
|
|
92
|
+
git(repository, "init", "-q", "-b", "main")
|
|
93
|
+
(repository / "service.py").write_text("call()\n")
|
|
94
|
+
git(repository, "add", "-A")
|
|
95
|
+
git(repository, "-c", "user.name=Start", "-c", "user.email=start", "commit", "-q", "-m", "start")
|
|
96
|
+
git(repository, "config", "user.useConfigOnly", "true")
|
|
97
|
+
(repository / "service.py").write_text("call_again()\n")
|
|
98
|
+
base_commit = git(repository, "rev-parse", "HEAD")
|
|
99
|
+
turn_commit = working_tree_snapshot(repository, None)
|
|
100
|
+
assert turn_changed_paths(repository, base_commit, turn_commit) == ["service.py"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_should_keep_the_leading_space_of_a_changed_path(repository: Path) -> None:
|
|
104
|
+
base_commit = working_tree_snapshot(repository, None)
|
|
105
|
+
(repository / " notes.txt").write_text("new\n")
|
|
106
|
+
turn_commit = working_tree_snapshot(repository, base_commit)
|
|
107
|
+
assert turn_changed_paths(repository, base_commit, turn_commit) == [" notes.txt"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Read the findings out of a turn review's text, for the turn review Stop gate.
|
|
2
|
+
|
|
3
|
+
A review either prints a JSON array of findings or prose that names each
|
|
4
|
+
problem by its changed path and line::
|
|
5
|
+
|
|
6
|
+
'[{"file": "src/service.py", "line": 208, "summary": "drops the retry"}]'
|
|
7
|
+
-> ["src/service.py:208 drops the retry"]
|
|
8
|
+
"- `src/service.py:208` - drops the retry"
|
|
9
|
+
-> ["- `src/service.py:208` - drops the retry"]
|
|
10
|
+
"(none)"
|
|
11
|
+
-> []
|
|
12
|
+
|
|
13
|
+
The last non-empty array of objects with a ``file`` key wins. Its entries count
|
|
14
|
+
when ``file`` is a changed path and ``line`` is a number. With no such array,
|
|
15
|
+
each line that opens with a changed path, a colon and a line number counts,
|
|
16
|
+
after an optional list marker, backtick or emphasis mark.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from hooks_constants.turn_review_gate_constants import (
|
|
25
|
+
FINDING_LINE_PATTERN_TEMPLATE,
|
|
26
|
+
JSON_ARRAY_START,
|
|
27
|
+
JSON_FINDING_FILE_KEY,
|
|
28
|
+
JSON_FINDING_LINE_KEY,
|
|
29
|
+
JSON_FINDING_LINE_TEMPLATE,
|
|
30
|
+
JSON_FINDING_SUMMARY_KEY,
|
|
31
|
+
NO_FINDINGS_MARKER,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _json_finding_lines(review_text: str, all_changed_paths: list[str]) -> list[str] | None:
|
|
36
|
+
all_filled_arrays = _filled_finding_arrays(review_text)
|
|
37
|
+
if not all_filled_arrays:
|
|
38
|
+
return None
|
|
39
|
+
return _entries_on_changed_paths(all_filled_arrays[-1], all_changed_paths)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _filled_finding_arrays(review_text: str) -> list[list[dict[str, object]]]:
|
|
43
|
+
json_decoder = json.JSONDecoder()
|
|
44
|
+
all_finding_arrays: list[list[dict[str, object]]] = []
|
|
45
|
+
array_start = review_text.find(JSON_ARRAY_START)
|
|
46
|
+
while array_start >= 0:
|
|
47
|
+
try:
|
|
48
|
+
decoded_array, next_search_start = json_decoder.raw_decode(review_text, array_start)
|
|
49
|
+
except ValueError:
|
|
50
|
+
decoded_array, next_search_start = None, array_start + 1
|
|
51
|
+
if decoded_array and _is_finding_array(decoded_array):
|
|
52
|
+
all_finding_arrays.append(decoded_array)
|
|
53
|
+
array_start = review_text.find(JSON_ARRAY_START, next_search_start)
|
|
54
|
+
return all_finding_arrays
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_finding_array(decoded_array: object) -> bool:
|
|
58
|
+
return isinstance(decoded_array, list) and all(
|
|
59
|
+
isinstance(each_entry, dict) and JSON_FINDING_FILE_KEY in each_entry
|
|
60
|
+
for each_entry in decoded_array
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _entries_on_changed_paths(
|
|
65
|
+
all_entries: list[dict[str, object]], all_changed_paths: list[str]
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
return [
|
|
68
|
+
JSON_FINDING_LINE_TEMPLATE.format(
|
|
69
|
+
path=each_entry[JSON_FINDING_FILE_KEY],
|
|
70
|
+
line=each_entry[JSON_FINDING_LINE_KEY],
|
|
71
|
+
summary=each_entry.get(JSON_FINDING_SUMMARY_KEY, ""),
|
|
72
|
+
).strip()
|
|
73
|
+
for each_entry in all_entries
|
|
74
|
+
if each_entry.get(JSON_FINDING_FILE_KEY) in all_changed_paths
|
|
75
|
+
and isinstance(each_entry.get(JSON_FINDING_LINE_KEY), int)
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def finding_lines(review_text: str, all_changed_paths: list[str]) -> list[str]:
|
|
80
|
+
"""Return one line per finding the review reports on a changed path.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
review_text: The ``result`` text of the review run.
|
|
84
|
+
all_changed_paths: The git-relative paths the turn changed.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The finding lines, empty when the review found nothing.
|
|
88
|
+
"""
|
|
89
|
+
if review_text.strip().startswith(NO_FINDINGS_MARKER):
|
|
90
|
+
return []
|
|
91
|
+
all_json_finding_lines = _json_finding_lines(review_text, all_changed_paths)
|
|
92
|
+
if all_json_finding_lines is not None:
|
|
93
|
+
return all_json_finding_lines
|
|
94
|
+
all_patterns = [
|
|
95
|
+
re.compile(FINDING_LINE_PATTERN_TEMPLATE.format(path=re.escape(each)))
|
|
96
|
+
for each in all_changed_paths
|
|
97
|
+
]
|
|
98
|
+
return [
|
|
99
|
+
each_line.strip()
|
|
100
|
+
for each_line in review_text.splitlines()
|
|
101
|
+
if any(each_pattern.search(each_line) for each_pattern in all_patterns)
|
|
102
|
+
]
|