gabriel / muse public
test_cmd_checkout_symbol.py python
879 lines 33.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Tests for ``muse code checkout-symbol``.
2
3 Coverage layers
4 ---------------
5 Unit
6 _extract_lines — normal, out-of-bounds (high, low, swapped), empty source.
7 _find_symbol_in_source — hit, miss, uses repo-relative path (regression
8 guard for the absolute-path bug).
9
10 Integration (live repo, CliRunner)
11 Exits zero for valid restore.
12 JSON schema: all required keys present, correct types.
13 JSON: schema_version, branch, restored_from (8-char hex), changed, appended,
14 verified, verified_preview.
15 --dry-run: file not written, output contains diff markers.
16 --dry-run --json: diff_lines + verified_preview in JSON output.
17 No-op: symbol already matches — changed=false, file unchanged, verified=true.
18 Empty historical lines from corrupted snapshot → exits non-zero before write.
19 ADDRESS without '::' rejected (exit non-zero).
20 Path-traversal ADDRESS rejected (exit non-zero).
21 --commit invalid ref rejected (exit non-zero).
22 File not in historical snapshot exits non-zero.
23 Symbol not in historical snapshot exits non-zero.
24 Missing repo exits non-zero.
25 Text output contains expected lines.
26 Appended path: symbol absent from working tree → appended at EOF.
27
28 E2E (real symbol changes across commits)
29 Restore replaces correct lines — surrounding code unchanged.
30 Restore from HEAD~1 brings back previous implementation.
31 Bug fix: absolute-path lookup — symbol IS found in current working tree
32 (not silently appended every time).
33 No-op detection: re-running restore is idempotent.
34 Dry-run diff is accurate — applying it would yield the historical file.
35 Appended symbol can be found by parse_symbols after write.
36 File content equals expected bytes after restore.
37 verified=True on a clean restore.
38 verified_preview=True in dry-run for a valid restore.
39 verified=False triggers warning when splice is unresolvable (monkeypatched).
40 Post-write verification failure does not suppress the write.
41
42 Stress
43 Restore from commit far back in history: still correct.
44 Large file (1 000-line source): only symbol lines change.
45 Repeated restore is idempotent and fast.
46 """
47
48 from __future__ import annotations
49
50 import json
51 import pathlib
52 import textwrap
53 import time
54 from typing import TypedDict
55
56 import pytest
57 from tests.cli_test_helper import CliRunner
58
59 from muse.cli.commands.checkout_symbol import _extract_lines, _find_symbol_in_source
60 from muse.plugins.code.ast_parser import SymbolRecord, parse_symbols
61
62 cli = None
63 runner = CliRunner()
64
65
66 # ---------------------------------------------------------------------------
67 # Typed JSON payload
68 # ---------------------------------------------------------------------------
69
70
71 class _CheckoutPayload(TypedDict, total=False):
72 schema_version: str
73 address: str
74 file: str
75 branch: str
76 restored_from: str
77 dry_run: bool
78 changed: bool
79 appended: bool
80 current_start: int
81 current_end: int
82 historical_line_count: int
83 diff_lines: list[str]
84 verified: bool # present on write and no-op paths
85 verified_preview: bool # present on dry-run path only
86
87
88 # ---------------------------------------------------------------------------
89 # Helpers
90 # ---------------------------------------------------------------------------
91
92
93 def _invoke_json(args: list[str]) -> _CheckoutPayload:
94 result = runner.invoke(cli, ["code", "checkout-symbol"] + args + ["--json"])
95 assert result.exit_code == 0, result.output
96 raw: _CheckoutPayload = json.loads(result.output)
97 return raw
98
99
100 # ---------------------------------------------------------------------------
101 # Fixtures
102 # ---------------------------------------------------------------------------
103
104
105 @pytest.fixture
106 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
107 monkeypatch.chdir(tmp_path)
108 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
109 result = runner.invoke(cli, ["init", "--domain", "code"])
110 assert result.exit_code == 0, result.output
111 return tmp_path
112
113
114 @pytest.fixture
115 def two_commit_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
116 """Repo with two commits containing different implementations of compute().
117
118 commit 1 (HEAD~1): compute returns sum(items)
119 commit 2 (HEAD): compute returns sum(items) * 2
120 """
121 (repo / "billing.py").write_text(textwrap.dedent("""\
122 def header():
123 return "billing"
124
125 def compute(items):
126 return sum(items)
127
128 def footer():
129 return "end"
130 """))
131 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
132 assert r1.exit_code == 0, r1.output
133
134 (repo / "billing.py").write_text(textwrap.dedent("""\
135 def header():
136 return "billing"
137
138 def compute(items):
139 return sum(items) * 2
140
141 def footer():
142 return "end"
143 """))
144 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
145 assert r2.exit_code == 0, r2.output
146
147 return repo, "billing.py::compute", "billing.py"
148
149
150 @pytest.fixture
151 def single_commit_repo(repo: pathlib.Path) -> pathlib.Path:
152 """Minimal repo: one commit, one function."""
153 (repo / "utils.py").write_text(textwrap.dedent("""\
154 def greet(name):
155 return f"Hello, {name}"
156 """))
157 r = runner.invoke(cli, ["commit", "-m", "init"])
158 assert r.exit_code == 0, r.output
159 return repo
160
161
162 # ---------------------------------------------------------------------------
163 # Unit — _extract_lines
164 # ---------------------------------------------------------------------------
165
166
167 class TestExtractLines:
168 def _src(self, n: int = 5) -> bytes:
169 return "\n".join(f"line {i}" for i in range(1, n + 1)).encode()
170
171 def test_full_range(self) -> None:
172 src = self._src(3)
173 assert _extract_lines(src, 1, 3) == ["line 1\n", "line 2\n", "line 3"]
174
175 def test_single_line(self) -> None:
176 src = self._src(5)
177 result = _extract_lines(src, 3, 3)
178 assert len(result) == 1
179 assert "line 3" in result[0]
180
181 def test_middle_range(self) -> None:
182 src = self._src(5)
183 result = _extract_lines(src, 2, 4)
184 assert len(result) == 3
185
186 def test_out_of_bounds_end_returns_empty(self) -> None:
187 src = self._src(3)
188 result = _extract_lines(src, 1, 10)
189 assert result == []
190
191 def test_out_of_bounds_start_zero_returns_empty(self) -> None:
192 src = self._src(3)
193 result = _extract_lines(src, 0, 2)
194 assert result == []
195
196 def test_swapped_range_returns_empty(self) -> None:
197 src = self._src(5)
198 result = _extract_lines(src, 4, 2)
199 assert result == []
200
201 def test_empty_source_returns_empty(self) -> None:
202 result = _extract_lines(b"", 1, 1)
203 assert result == []
204
205 def test_last_line_no_trailing_newline(self) -> None:
206 src = b"a\nb\nc"
207 result = _extract_lines(src, 3, 3)
208 assert result == ["c"]
209
210 def test_keepends_true(self) -> None:
211 src = b"a\nb\nc\n"
212 result = _extract_lines(src, 1, 2)
213 assert result == ["a\n", "b\n"]
214
215
216 # ---------------------------------------------------------------------------
217 # Unit — _find_symbol_in_source
218 # ---------------------------------------------------------------------------
219
220
221 class TestFindSymbolInSource:
222 def _src(self) -> bytes:
223 return textwrap.dedent("""\
224 def alpha():
225 return 1
226
227 def beta():
228 return 2
229 """).encode()
230
231 def test_found_returns_record(self) -> None:
232 rec = _find_symbol_in_source(self._src(), "a.py", "a.py::alpha")
233 assert rec is not None
234 assert rec["name"] == "alpha"
235
236 def test_not_found_returns_none(self) -> None:
237 rec = _find_symbol_in_source(self._src(), "a.py", "a.py::missing")
238 assert rec is None
239
240 def test_uses_repo_relative_path_not_absolute(self) -> None:
241 """Regression guard: address must use repo-relative file_rel, not /abs/path."""
242 src = b"def fn():\n pass\n"
243 # Correct: repo-relative
244 rec_rel = _find_symbol_in_source(src, "src/mod.py", "src/mod.py::fn")
245 assert rec_rel is not None, "Should find symbol with repo-relative path"
246 # Wrong: absolute path — should NOT find it under the relative address
247 rec_abs = _find_symbol_in_source(src, "/abs/src/mod.py", "src/mod.py::fn")
248 assert rec_abs is None, "Absolute path prefix must not match relative address"
249
250 def test_second_symbol_found(self) -> None:
251 rec = _find_symbol_in_source(self._src(), "m.py", "m.py::beta")
252 assert rec is not None
253 assert rec["name"] == "beta"
254
255 def test_line_numbers_are_1_indexed(self) -> None:
256 src = b"def fn():\n return 1\n"
257 rec = _find_symbol_in_source(src, "f.py", "f.py::fn")
258 assert rec is not None
259 assert rec["lineno"] >= 1
260 assert rec["end_lineno"] >= rec["lineno"]
261
262
263 # ---------------------------------------------------------------------------
264 # Integration — basic CLI
265 # ---------------------------------------------------------------------------
266
267
268 class TestCheckoutSymbolBasic:
269 def test_restore_exits_zero(self, two_commit_repo: tuple[pathlib.Path, str, str]) -> None:
270 _, address, _ = two_commit_repo
271 result = runner.invoke(cli, [
272 "code", "checkout-symbol", address, "--commit", "HEAD~1",
273 ])
274 assert result.exit_code == 0, result.output
275
276 def test_no_address_separator_exits_nonzero(
277 self, single_commit_repo: pathlib.Path
278 ) -> None:
279 result = runner.invoke(cli, [
280 "code", "checkout-symbol", "billing_no_sep", "--commit", "HEAD",
281 ])
282 assert result.exit_code != 0
283
284 def test_path_traversal_exits_nonzero(
285 self, single_commit_repo: pathlib.Path
286 ) -> None:
287 result = runner.invoke(cli, [
288 "code", "checkout-symbol", "../../etc/passwd::fn", "--commit", "HEAD",
289 ])
290 assert result.exit_code != 0
291
292 def test_invalid_commit_ref_exits_nonzero(
293 self, single_commit_repo: pathlib.Path
294 ) -> None:
295 result = runner.invoke(cli, [
296 "code", "checkout-symbol", "utils.py::greet", "--commit", "no_such_ref",
297 ])
298 assert result.exit_code != 0
299
300 def test_file_not_in_snapshot_exits_nonzero(
301 self, single_commit_repo: pathlib.Path
302 ) -> None:
303 result = runner.invoke(cli, [
304 "code", "checkout-symbol", "nonexistent.py::fn", "--commit", "HEAD",
305 ])
306 assert result.exit_code != 0
307
308 def test_symbol_not_in_snapshot_exits_nonzero(
309 self, single_commit_repo: pathlib.Path
310 ) -> None:
311 result = runner.invoke(cli, [
312 "code", "checkout-symbol", "utils.py::no_such_fn", "--commit", "HEAD",
313 ])
314 assert result.exit_code != 0
315
316 def test_missing_repo_exits_nonzero(
317 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
318 ) -> None:
319 monkeypatch.chdir(tmp_path)
320 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
321 result = runner.invoke(cli, [
322 "code", "checkout-symbol", "utils.py::fn", "--commit", "HEAD",
323 ])
324 assert result.exit_code != 0
325
326 def test_text_output_contains_restoring(
327 self, two_commit_repo: tuple[pathlib.Path, str, str]
328 ) -> None:
329 _, address, _ = two_commit_repo
330 result = runner.invoke(cli, [
331 "code", "checkout-symbol", address, "--commit", "HEAD~1",
332 ])
333 assert result.exit_code == 0
334 assert "Restoring" in result.output or "already matches" in result.output
335
336 def test_dry_run_does_not_write_file(
337 self, two_commit_repo: tuple[pathlib.Path, str, str]
338 ) -> None:
339 repo, address, file_path = two_commit_repo
340 before = (repo / file_path).read_text()
341 result = runner.invoke(cli, [
342 "code", "checkout-symbol", address, "--commit", "HEAD~1", "--dry-run",
343 ])
344 assert result.exit_code == 0
345 after = (repo / file_path).read_text()
346 assert before == after, "dry-run must not modify the file"
347
348 def test_dry_run_output_contains_diff_markers(
349 self, two_commit_repo: tuple[pathlib.Path, str, str]
350 ) -> None:
351 _, address, _ = two_commit_repo
352 result = runner.invoke(cli, [
353 "code", "checkout-symbol", address, "--commit", "HEAD~1", "--dry-run",
354 ])
355 assert result.exit_code == 0
356 assert "---" in result.output or "+++" in result.output or "already matches" in result.output
357
358
359 # ---------------------------------------------------------------------------
360 # Integration — JSON schema
361 # ---------------------------------------------------------------------------
362
363
364 class TestCheckoutSymbolJSONSchema:
365 def test_json_has_all_required_keys(
366 self, two_commit_repo: tuple[pathlib.Path, str, str]
367 ) -> None:
368 _, address, _ = two_commit_repo
369 data = _invoke_json([address, "--commit", "HEAD~1"])
370 required = {
371 "schema_version", "address", "file", "branch", "restored_from",
372 "dry_run", "changed", "appended", "current_start", "current_end",
373 "historical_line_count", "diff_lines",
374 }
375 assert required <= data.keys()
376
377 def test_json_schema_version_nonempty(
378 self, two_commit_repo: tuple[pathlib.Path, str, str]
379 ) -> None:
380 _, address, _ = two_commit_repo
381 data = _invoke_json([address, "--commit", "HEAD~1"])
382 assert data["schema_version"]
383
384 def test_json_branch_nonempty(
385 self, two_commit_repo: tuple[pathlib.Path, str, str]
386 ) -> None:
387 _, address, _ = two_commit_repo
388 data = _invoke_json([address, "--commit", "HEAD~1"])
389 assert isinstance(data["branch"], str) and data["branch"]
390
391 def test_json_restored_from_is_short_id(
392 self, two_commit_repo: tuple[pathlib.Path, str, str]
393 ) -> None:
394 _, address, _ = two_commit_repo
395 data = _invoke_json([address, "--commit", "HEAD~1"])
396 # short_id() returns "sha256:<12 hex chars>" — 19 chars total
397 assert isinstance(data["restored_from"], str)
398 assert data["restored_from"].startswith("sha256:")
399 hex_part = data["restored_from"][len("sha256:"):]
400 assert all(c in "0123456789abcdef" for c in hex_part)
401
402 def test_json_changed_is_bool(
403 self, two_commit_repo: tuple[pathlib.Path, str, str]
404 ) -> None:
405 _, address, _ = two_commit_repo
406 data = _invoke_json([address, "--commit", "HEAD~1"])
407 assert isinstance(data["changed"], bool)
408
409 def test_json_appended_is_bool(
410 self, two_commit_repo: tuple[pathlib.Path, str, str]
411 ) -> None:
412 _, address, _ = two_commit_repo
413 data = _invoke_json([address, "--commit", "HEAD~1"])
414 assert isinstance(data["appended"], bool)
415
416 def test_json_historical_line_count_is_int(
417 self, two_commit_repo: tuple[pathlib.Path, str, str]
418 ) -> None:
419 _, address, _ = two_commit_repo
420 data = _invoke_json([address, "--commit", "HEAD~1"])
421 assert isinstance(data["historical_line_count"], int)
422 assert data["historical_line_count"] > 0
423
424 def test_json_dry_run_false_when_not_dry_run(
425 self, two_commit_repo: tuple[pathlib.Path, str, str]
426 ) -> None:
427 _, address, _ = two_commit_repo
428 data = _invoke_json([address, "--commit", "HEAD~1"])
429 assert data["dry_run"] is False
430
431 def test_json_dry_run_true_and_file_unchanged(
432 self, two_commit_repo: tuple[pathlib.Path, str, str]
433 ) -> None:
434 repo, address, file_path = two_commit_repo
435 before = (repo / file_path).read_text()
436 data = _invoke_json([address, "--commit", "HEAD~1", "--dry-run"])
437 assert data["dry_run"] is True
438 assert (repo / file_path).read_text() == before
439
440 def test_json_dry_run_includes_diff_lines(
441 self, two_commit_repo: tuple[pathlib.Path, str, str]
442 ) -> None:
443 _, address, _ = two_commit_repo
444 data = _invoke_json([address, "--commit", "HEAD~1", "--dry-run"])
445 # If there is a real change, diff_lines must be non-empty.
446 if data["changed"]:
447 assert isinstance(data["diff_lines"], list)
448 assert len(data["diff_lines"]) > 0
449
450 def test_json_diff_lines_empty_when_not_dry_run(
451 self, two_commit_repo: tuple[pathlib.Path, str, str]
452 ) -> None:
453 _, address, _ = two_commit_repo
454 data = _invoke_json([address, "--commit", "HEAD~1"])
455 assert data["diff_lines"] == []
456
457
458 # ---------------------------------------------------------------------------
459 # E2E — real symbol restoration
460 # ---------------------------------------------------------------------------
461
462
463 class TestCheckoutSymbolE2E:
464 def test_restore_brings_back_old_implementation(
465 self, two_commit_repo: tuple[pathlib.Path, str, str]
466 ) -> None:
467 repo, address, file_path = two_commit_repo
468 runner.invoke(cli, [
469 "code", "checkout-symbol", address, "--commit", "HEAD~1",
470 ])
471 content = (repo / file_path).read_text()
472 # v1 returned sum(items), v2 returned sum(items) * 2
473 assert "sum(items)" in content
474 assert "sum(items) * 2" not in content
475
476 def test_surrounding_functions_unchanged(
477 self, two_commit_repo: tuple[pathlib.Path, str, str]
478 ) -> None:
479 """Critical: surgical restore must NOT touch header() or footer()."""
480 repo, address, file_path = two_commit_repo
481 runner.invoke(cli, [
482 "code", "checkout-symbol", address, "--commit", "HEAD~1",
483 ])
484 content = (repo / file_path).read_text()
485 assert 'return "billing"' in content
486 assert 'return "end"' in content
487
488 def test_restore_is_surgical_correct_line_count(
489 self, two_commit_repo: tuple[pathlib.Path, str, str]
490 ) -> None:
491 repo, address, file_path = two_commit_repo
492 original_lines = (repo / file_path).read_text().splitlines()
493 runner.invoke(cli, [
494 "code", "checkout-symbol", address, "--commit", "HEAD~1",
495 ])
496 restored_lines = (repo / file_path).read_text().splitlines()
497 # Both versions have the same number of body lines.
498 assert len(restored_lines) == len(original_lines)
499
500 def test_regression_symbol_found_in_place_not_appended(
501 self, two_commit_repo: tuple[pathlib.Path, str, str]
502 ) -> None:
503 """Bug regression: absolute-path lookup caused symbol to never be found,
504 so every restore appended instead of replacing in-place."""
505 repo, address, file_path = two_commit_repo
506 data = _invoke_json([address, "--commit", "HEAD~1"])
507 assert data["appended"] is False, (
508 "Symbol exists in working tree — must replace in-place, not append"
509 )
510 # File must not grow: appending adds lines, replacing keeps the count.
511 content = (repo / file_path).read_text()
512 # footer() must appear exactly once (not duplicated by append).
513 assert content.count('def footer') == 1
514
515 def test_no_op_detection_changed_false(
516 self, single_commit_repo: pathlib.Path
517 ) -> None:
518 """Restoring HEAD to HEAD is a no-op."""
519 data = _invoke_json(["utils.py::greet", "--commit", "HEAD"])
520 assert data["changed"] is False
521
522 def test_no_op_file_not_written(
523 self, single_commit_repo: pathlib.Path
524 ) -> None:
525 before = (single_commit_repo / "utils.py").read_text()
526 _invoke_json(["utils.py::greet", "--commit", "HEAD"])
527 after = (single_commit_repo / "utils.py").read_text()
528 assert before == after
529
530 def test_no_op_idempotent(
531 self, two_commit_repo: tuple[pathlib.Path, str, str]
532 ) -> None:
533 repo, address, file_path = two_commit_repo
534 runner.invoke(cli, [
535 "code", "checkout-symbol", address, "--commit", "HEAD~1",
536 ])
537 content_after_first = (repo / file_path).read_text()
538 runner.invoke(cli, [
539 "code", "checkout-symbol", address, "--commit", "HEAD~1",
540 ])
541 content_after_second = (repo / file_path).read_text()
542 assert content_after_first == content_after_second
543
544 def test_dry_run_diff_matches_actual_change(
545 self, two_commit_repo: tuple[pathlib.Path, str, str]
546 ) -> None:
547 """The dry-run diff, when applied to the current file, yields the
548 result that the real restore would produce."""
549 repo, address, file_path = two_commit_repo
550 dry_data = _invoke_json([address, "--commit", "HEAD~1", "--dry-run"])
551 # Now actually restore.
552 runner.invoke(cli, [
553 "code", "checkout-symbol", address, "--commit", "HEAD~1",
554 ])
555 restored = (repo / file_path).read_text()
556 # The dry-run diff_lines are unified diff lines — their `+++` side
557 # represents the post-restore content. We verify the symbol now matches.
558 assert "sum(items)" in restored
559 assert dry_data["changed"] is True
560
561 def test_appended_when_symbol_absent_from_working_tree(
562 self, repo: pathlib.Path
563 ) -> None:
564 """Symbol exists in history but not in the current working tree → appended."""
565 (repo / "mod.py").write_text(textwrap.dedent("""\
566 def alpha():
567 return 1
568
569 def beta():
570 return 2
571 """))
572 r = runner.invoke(cli, ["commit", "-m", "add both"])
573 assert r.exit_code == 0, r.output
574
575 # Remove beta from the working tree and commit.
576 (repo / "mod.py").write_text(textwrap.dedent("""\
577 def alpha():
578 return 1
579 """))
580 r2 = runner.invoke(cli, ["commit", "-m", "remove beta"])
581 assert r2.exit_code == 0, r2.output
582
583 data = _invoke_json(["mod.py::beta", "--commit", "HEAD~1"])
584 assert data["appended"] is True
585 content = (repo / "mod.py").read_text()
586 assert "def beta" in content
587
588 def test_restored_symbol_parseable_after_write(
589 self, two_commit_repo: tuple[pathlib.Path, str, str]
590 ) -> None:
591 """After restore, parse_symbols must still find the symbol."""
592 repo, address, file_path = two_commit_repo
593 runner.invoke(cli, [
594 "code", "checkout-symbol", address, "--commit", "HEAD~1",
595 ])
596 raw = (repo / file_path).read_bytes()
597 tree = parse_symbols(raw, file_path)
598 assert address in tree, f"Symbol {address} not parseable after restore"
599
600 def test_json_current_start_matches_actual_line(
601 self, two_commit_repo: tuple[pathlib.Path, str, str]
602 ) -> None:
603 repo, address, file_path = two_commit_repo
604 data = _invoke_json([address, "--commit", "HEAD~1"])
605 if data["changed"] and not data["appended"]:
606 content = (repo / file_path).read_text().splitlines()
607 start = data["current_start"]
608 assert 1 <= start <= len(content), f"current_start {start} out of range"
609
610
611 # ---------------------------------------------------------------------------
612 # Stress
613 # ---------------------------------------------------------------------------
614
615
616 class TestCheckoutSymbolStress:
617 def test_restore_in_large_file(self, repo: pathlib.Path) -> None:
618 """1 000-line file: only the target symbol lines change."""
619 # Build a file with 200 dummy functions + our target.
620 lines = ["def fn_{}():\n return {}\n\n".format(i, i) for i in range(200)]
621 lines.insert(100, "def target():\n return 'v1'\n\n")
622 (repo / "big.py").write_text("".join(lines))
623 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
624 assert r1.exit_code == 0, r1.output
625
626 # Modify just target in v2.
627 lines2 = list(lines)
628 lines2[100] = "def target():\n return 'v2'\n\n"
629 (repo / "big.py").write_text("".join(lines2))
630 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
631 assert r2.exit_code == 0, r2.output
632
633 before_lines = (repo / "big.py").read_text().splitlines()
634 runner.invoke(cli, [
635 "code", "checkout-symbol", "big.py::target", "--commit", "HEAD~1",
636 ])
637 after_lines = (repo / "big.py").read_text().splitlines()
638
639 assert len(before_lines) == len(after_lines), "No lines should be added/removed"
640 # Only target changed.
641 assert "'v1'" in "\n".join(after_lines)
642 assert "'v2'" not in "\n".join(after_lines)
643 # All other functions intact.
644 assert sum(1 for l in after_lines if l.startswith("def fn_")) == 200
645
646 def test_repeated_restore_is_idempotent_and_fast(
647 self, two_commit_repo: tuple[pathlib.Path, str, str]
648 ) -> None:
649 repo, address, file_path = two_commit_repo
650 # First restore.
651 runner.invoke(cli, [
652 "code", "checkout-symbol", address, "--commit", "HEAD~1",
653 ])
654 content_after_first = (repo / file_path).read_text()
655
656 start = time.monotonic()
657 for _ in range(10):
658 runner.invoke(cli, [
659 "code", "checkout-symbol", address, "--commit", "HEAD~1",
660 ])
661 elapsed = time.monotonic() - start
662
663 assert (repo / file_path).read_text() == content_after_first
664 assert elapsed < 15.0, f"10 repeated restores took {elapsed:.1f}s — too slow"
665
666 def test_restore_from_far_back_in_history(self, repo: pathlib.Path) -> None:
667 """Symbol restored from a commit 10 steps back must match that version."""
668 body = (repo / "hist.py")
669 for i in range(12):
670 body.write_text(f"def fn():\n return {i}\n")
671 r = runner.invoke(cli, ["commit", "-m", f"v{i}"])
672 assert r.exit_code == 0, r.output
673
674 runner.invoke(cli, [
675 "code", "checkout-symbol", "hist.py::fn", "--commit", "HEAD~10",
676 ])
677 content = body.read_text()
678 assert "return 1" in content # HEAD is v11 (i=11), HEAD~10 is v1 (i=1)
679
680
681 # ---------------------------------------------------------------------------
682 # Verification — post-write and dry-run preview
683 # ---------------------------------------------------------------------------
684
685
686 class TestCheckoutSymbolVerification:
687 """Tests for the post-write verification and dry-run verified_preview."""
688
689 def test_json_verified_true_on_clean_restore(
690 self, two_commit_repo: tuple[pathlib.Path, str, str]
691 ) -> None:
692 _, address, _ = two_commit_repo
693 data = _invoke_json([address, "--commit", "HEAD~1"])
694 assert data["changed"] is True
695 assert data.get("verified") is True
696
697 def test_json_verified_true_on_no_op(
698 self, single_commit_repo: pathlib.Path
699 ) -> None:
700 # Non-dry-run no-op: verified field is present, no write.
701 data = _invoke_json(["utils.py::greet", "--commit", "HEAD"])
702 assert data["changed"] is False
703 assert data.get("verified") is True
704
705 def test_json_dry_run_no_op_has_verified_preview(
706 self, single_commit_repo: pathlib.Path
707 ) -> None:
708 """dry-run on a no-op must still return verified_preview, not short-circuit.
709
710 Agents routinely run --dry-run before every write. If the no-op path
711 returns early without verified_preview, those pipelines break on a
712 KeyError even though the command is logically correct.
713 """
714 data = _invoke_json(["utils.py::greet", "--commit", "HEAD", "--dry-run"])
715 # changed is False (no-op), but dry-run must still emit verified_preview.
716 assert data.get("changed") is False
717 assert "verified_preview" in data, (
718 "dry-run no-op must include verified_preview — "
719 "omitting it breaks agent pipelines that always inspect this field"
720 )
721 assert data["verified_preview"] is True
722 assert data.get("diff_lines") == []
723
724 def test_json_verified_preview_true_in_dry_run(
725 self, two_commit_repo: tuple[pathlib.Path, str, str]
726 ) -> None:
727 _, address, _ = two_commit_repo
728 data = _invoke_json([address, "--commit", "HEAD~1", "--dry-run"])
729 assert data["dry_run"] is True
730 assert "verified_preview" in data
731 assert data.get("verified_preview") is True
732
733 def test_json_verified_present_after_append(
734 self, repo: pathlib.Path
735 ) -> None:
736 """verified must be True even when the symbol is appended to EOF."""
737 (repo / "mod.py").write_text(textwrap.dedent("""\
738 def alpha():
739 return 1
740
741 def beta():
742 return 2
743 """))
744 runner.invoke(cli, ["commit", "-m", "v1"])
745 (repo / "mod.py").write_text("def alpha():\n return 1\n")
746 runner.invoke(cli, ["commit", "-m", "drop beta"])
747
748 data = _invoke_json(["mod.py::beta", "--commit", "HEAD~1"])
749 assert data.get("appended") is True
750 assert data.get("verified") is True
751
752 def test_json_no_verified_preview_on_non_dry_run(
753 self, two_commit_repo: tuple[pathlib.Path, str, str]
754 ) -> None:
755 """verified_preview must not appear on a live write — only dry-run has it."""
756 _, address, _ = two_commit_repo
757 data = _invoke_json([address, "--commit", "HEAD~1"])
758 assert "verified_preview" not in data
759
760 def test_json_no_verified_on_dry_run(
761 self, two_commit_repo: tuple[pathlib.Path, str, str]
762 ) -> None:
763 """verified (write-path field) must not appear on dry-run output."""
764 _, address, _ = two_commit_repo
765 data = _invoke_json([address, "--commit", "HEAD~1", "--dry-run"])
766 assert "verified" not in data
767
768 def test_verified_false_triggers_warning(
769 self,
770 two_commit_repo: tuple[pathlib.Path, str, str],
771 monkeypatch: pytest.MonkeyPatch,
772 capfd: pytest.CaptureFixture[str],
773 ) -> None:
774 """When the post-write parse fails, verified=false and a warning is emitted."""
775 import muse.cli.commands.checkout_symbol as cs_mod
776
777 call_count = 0
778 original = cs_mod._find_symbol_in_source
779
780 def patched(
781 source: bytes, file_rel: str, address: str
782 ) -> SymbolRecord | None:
783 nonlocal call_count
784 call_count += 1
785 # First two calls: historical lookup + current lookup — behave normally.
786 # Third call (post-write verification) — simulate parse failure.
787 if call_count >= 3:
788 return None
789 return original(source, file_rel, address)
790
791 monkeypatch.setattr(cs_mod, "_find_symbol_in_source", patched)
792
793 _, address, _ = two_commit_repo
794 result = runner.invoke(cli, [
795 "code", "checkout-symbol", address, "--commit", "HEAD~1", "--json",
796 ])
797 assert result.exit_code == 0, result.output
798 data: _CheckoutPayload = json.loads(result.output)
799 assert data.get("verified") is False
800
801 def test_verified_false_file_still_written(
802 self,
803 two_commit_repo: tuple[pathlib.Path, str, str],
804 monkeypatch: pytest.MonkeyPatch,
805 ) -> None:
806 """Verification failure must not prevent the file from being written."""
807 import muse.cli.commands.checkout_symbol as cs_mod
808
809 call_count = 0
810 original = cs_mod._find_symbol_in_source
811
812 def patched(
813 source: bytes, file_rel: str, address: str
814 ) -> SymbolRecord | None:
815 nonlocal call_count
816 call_count += 1
817 if call_count >= 3:
818 return None
819 return original(source, file_rel, address)
820
821 monkeypatch.setattr(cs_mod, "_find_symbol_in_source", patched)
822
823 repo, address, file_path = two_commit_repo
824 before = (repo / file_path).read_text()
825 runner.invoke(cli, [
826 "code", "checkout-symbol", address, "--commit", "HEAD~1",
827 ])
828 after = (repo / file_path).read_text()
829 # File must differ — verification failure must not roll back the write.
830 assert before != after
831
832 def test_empty_historical_lines_exits_before_write(
833 self,
834 two_commit_repo: tuple[pathlib.Path, str, str],
835 monkeypatch: pytest.MonkeyPatch,
836 ) -> None:
837 """A corrupted snapshot producing zero lines must abort before writing."""
838 import muse.cli.commands.checkout_symbol as cs_mod
839
840 monkeypatch.setattr(cs_mod, "_extract_lines", lambda *a, **kw: [])
841
842 repo, address, file_path = two_commit_repo
843 original_content = (repo / file_path).read_text()
844
845 result = runner.invoke(cli, [
846 "code", "checkout-symbol", address, "--commit", "HEAD~1",
847 ])
848 assert result.exit_code != 0
849 # File must be completely untouched.
850 assert (repo / file_path).read_text() == original_content
851
852 def test_text_output_warns_when_not_verified(
853 self,
854 two_commit_repo: tuple[pathlib.Path, str, str],
855 monkeypatch: pytest.MonkeyPatch,
856 ) -> None:
857 """Text mode must show a warning instead of ✅ when verification fails."""
858 import muse.cli.commands.checkout_symbol as cs_mod
859
860 call_count = 0
861 original = cs_mod._find_symbol_in_source
862
863 def patched(
864 source: bytes, file_rel: str, address: str
865 ) -> SymbolRecord | None:
866 nonlocal call_count
867 call_count += 1
868 if call_count >= 3:
869 return None
870 return original(source, file_rel, address)
871
872 monkeypatch.setattr(cs_mod, "_find_symbol_in_source", patched)
873
874 _, address, _ = two_commit_repo
875 result = runner.invoke(cli, [
876 "code", "checkout-symbol", address, "--commit", "HEAD~1",
877 ])
878 assert result.exit_code == 0
879 assert "verification failed" in result.output.lower() or "⚠️" in result.output
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago