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.
@@ -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
+ ]