gabriel / muse public
test_bisect.py python
705 lines 28.9 KB
Raw
1 """Tests for muse bisect — commit-level and symbol-scoped binary search."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import pathlib
8 import textwrap
9
10 import pytest
11 from tests.cli_test_helper import CliRunner
12 from muse.core.commits import CommitDict
13 from muse.domain import InsertOp, PatchOp, ReplaceOp, StructuredDelta
14 from muse.core.paths import ref_path, repo_json_path
15
16 cli = None # argparse migration — CliRunner ignores this arg
17
18 runner = CliRunner()
19
20
21 # ---------------------------------------------------------------------------
22 # Fixtures
23 # ---------------------------------------------------------------------------
24
25
26 @pytest.fixture
27 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
28 """Minimal code-domain Muse repo."""
29 monkeypatch.chdir(tmp_path)
30 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
31 r = runner.invoke(cli, ["init", "--domain", "code"])
32 assert r.exit_code == 0, r.output
33 return tmp_path
34
35
36 @pytest.fixture
37 def linear_repo(repo: pathlib.Path) -> pathlib.Path:
38 """Repo with 4 commits: c0 (good) → c1 → c2 → c3 (bad).
39
40 billing.py::compute is added in c0 and modified in c1 and c3.
41 """
42 work = repo
43
44 # c0 — genesis
45 (work / "billing.py").write_text(textwrap.dedent("""\
46 def compute(items):
47 return sum(items)
48 """))
49 r = runner.invoke(cli, ["commit", "-m", "c0: add compute"])
50 assert r.exit_code == 0, r.output
51
52 # c1 — modify compute
53 (work / "billing.py").write_text(textwrap.dedent("""\
54 def compute(items, discount=0.0):
55 return sum(items) * (1 - discount)
56 """))
57 r = runner.invoke(cli, ["commit", "-m", "c1: add discount param"])
58 assert r.exit_code == 0, r.output
59
60 # c2 — unrelated file
61 (work / "utils.py").write_text("def noop(): pass\n")
62 r = runner.invoke(cli, ["commit", "-m", "c2: add utils"])
63 assert r.exit_code == 0, r.output
64
65 # c3 — break compute
66 (work / "billing.py").write_text(textwrap.dedent("""\
67 def compute(items, discount=0.0, tax=0.0):
68 return sum(items) * (1 - discount) + tax
69 """))
70 r = runner.invoke(cli, ["commit", "-m", "c3: add tax param (bad)"])
71 assert r.exit_code == 0, r.output
72
73 return repo
74
75
76 def _commit_ids(repo: pathlib.Path) -> list[str]:
77 """Return all commit IDs oldest-first."""
78 repo_json = json.loads((repo_json_path(repo)).read_text())
79 repo_id = repo_json["repo_id"]
80 from muse.core.refs import read_current_branch
81 from muse.core.commits import resolve_commit_ref
82 from muse.plugins.code._query import walk_commits_bfs
83 branch = read_current_branch(repo)
84 head = resolve_commit_ref(repo, branch, None)
85 assert head is not None
86 commits, _ = walk_commits_bfs(repo, head.commit_id)
87 # Return oldest-first
88 return [c.commit_id for c in reversed(commits)]
89
90
91 # ---------------------------------------------------------------------------
92 # Core engine tests
93 # ---------------------------------------------------------------------------
94
95
96 class TestBisectEngine:
97 """Tests for muse.core.bisect internals."""
98
99 def test_commits_touching_symbol_filters_correctly(
100 self, linear_repo: pathlib.Path
101 ) -> None:
102 from muse.core.bisect import _commits_touching_symbol
103
104 all_ids = _commit_ids(linear_repo)
105 # billing.py::compute was touched in c1 and c3 (not c0 genesis, not c2 utils)
106 touching = _commits_touching_symbol(
107 linear_repo, all_ids, "billing.py::compute"
108 )
109 assert len(touching) >= 1
110 # Every returned commit must have a structured_delta op for this symbol
111 from muse.core.commits import read_commit
112 from muse.plugins.code._query import flat_symbol_ops
113 for cid in touching:
114 commit = read_commit(linear_repo, cid)
115 assert commit is not None and commit.structured_delta is not None
116 addrs = [op["address"] for op in flat_symbol_ops(commit.structured_delta["ops"])]
117 assert "billing.py::compute" in addrs
118
119 def test_commits_touching_symbol_excludes_genesis(
120 self, linear_repo: pathlib.Path
121 ) -> None:
122 from muse.core.bisect import _commits_touching_symbol
123 from muse.core.commits import read_commit
124
125 all_ids = _commit_ids(linear_repo)
126 # Genesis commit has no structured_delta → must be excluded
127 genesis_id = all_ids[0]
128 genesis = read_commit(linear_repo, genesis_id)
129 assert genesis is not None
130 assert genesis.structured_delta is None or genesis.parent_commit_id is None
131
132 touching = _commits_touching_symbol(
133 linear_repo, all_ids, "billing.py::compute"
134 )
135 assert genesis_id not in touching
136
137 def test_commits_touching_symbol_excludes_unrelated(
138 self, linear_repo: pathlib.Path
139 ) -> None:
140 from muse.core.bisect import _commits_touching_symbol
141
142 all_ids = _commit_ids(linear_repo)
143 # c2 only added utils.py::noop; must not appear for billing.py::compute
144 touching = _commits_touching_symbol(
145 linear_repo, all_ids, "billing.py::compute"
146 )
147 touching_set = set(touching)
148
149 from muse.core.commits import read_commit
150 for cid in all_ids:
151 commit = read_commit(linear_repo, cid)
152 if commit is None or commit.message != "c2: add utils":
153 continue
154 assert cid not in touching_set, "c2 (utils only) must not touch billing.py::compute"
155
156 def test_addr_matches_exact(self) -> None:
157 from muse.core.bisect import _addr_matches
158 assert _addr_matches("billing.py::Invoice.compute", "billing.py::Invoice.compute")
159 assert not _addr_matches("billing.py::Invoice.apply", "billing.py::Invoice.compute")
160
161 def test_addr_matches_class_prefix(self) -> None:
162 from muse.core.bisect import _addr_matches
163 # Prefix matching: filtering by class matches all its methods
164 assert _addr_matches("billing.py::Invoice.compute", "billing.py::Invoice")
165 assert _addr_matches("billing.py::Invoice.apply_discount", "billing.py::Invoice")
166 assert not _addr_matches("billing.py::OtherClass.method", "billing.py::Invoice")
167
168 def test_symbol_ops_in_commit_returns_descriptions(
169 self, linear_repo: pathlib.Path
170 ) -> None:
171 from muse.core.bisect import _symbol_ops_in_commit
172
173 all_ids = _commit_ids(linear_repo)
174 # Find a commit that touched billing.py::compute
175 from muse.core.commits import read_commit
176 from muse.plugins.code._query import flat_symbol_ops
177 for cid in all_ids:
178 commit = read_commit(linear_repo, cid)
179 if commit is None or commit.structured_delta is None:
180 continue
181 addrs = [op["address"] for op in flat_symbol_ops(commit.structured_delta["ops"])]
182 if "billing.py::compute" in addrs:
183 descriptions = _symbol_ops_in_commit(linear_repo, cid, "billing.py::compute")
184 assert len(descriptions) >= 1
185 # Each description contains the address
186 assert any("billing.py::compute" in d for d in descriptions)
187 break
188
189 def test_symbol_ops_in_commit_empty_for_unrelated(
190 self, linear_repo: pathlib.Path
191 ) -> None:
192 from muse.core.bisect import _symbol_ops_in_commit
193 from muse.core.commits import read_commit
194
195 all_ids = _commit_ids(linear_repo)
196 for cid in all_ids:
197 commit = read_commit(linear_repo, cid)
198 if commit is not None and commit.message == "c2: add utils":
199 descriptions = _symbol_ops_in_commit(linear_repo, cid, "billing.py::compute")
200 assert descriptions == []
201 break
202
203 def test_start_bisect_builds_remaining(self, linear_repo: pathlib.Path) -> None:
204 from muse.core.bisect import start_bisect
205
206 all_ids = _commit_ids(linear_repo)
207 bad_id = all_ids[-1] # c3
208 good_id = all_ids[0] # c0
209
210 result = start_bisect(linear_repo, bad_id, [good_id])
211 assert not result.done
212 assert result.next_to_test is not None
213 assert result.remaining_count >= 1
214
215 def test_start_bisect_with_symbol_filter_reduces_remaining(
216 self, linear_repo: pathlib.Path
217 ) -> None:
218 from muse.core.bisect import start_bisect, _commits_touching_symbol
219
220 all_ids = _commit_ids(linear_repo)
221 bad_id = all_ids[-1]
222 good_id = all_ids[0]
223
224 result_plain = start_bisect(linear_repo, bad_id, [good_id])
225 result_sym = start_bisect(linear_repo, bad_id, [good_id], symbol_filter="billing.py::compute")
226
227 # Symbol-scoped bisect must have <= remaining commits than unfiltered
228 assert result_sym.remaining_count <= result_plain.remaining_count
229
230 def test_start_bisect_symbol_filter_persisted(
231 self, linear_repo: pathlib.Path
232 ) -> None:
233 from muse.core.bisect import start_bisect, _load_state
234
235 all_ids = _commit_ids(linear_repo)
236 start_bisect(linear_repo, all_ids[-1], [all_ids[0]], symbol_filter="billing.py::compute")
237 state = _load_state(linear_repo)
238 assert state is not None
239 assert state.get("symbol_filter") == "billing.py::compute"
240
241 def test_start_bisect_symbol_filter_populates_symbol_changes(
242 self, linear_repo: pathlib.Path
243 ) -> None:
244 from muse.core.bisect import start_bisect
245
246 all_ids = _commit_ids(linear_repo)
247 result = start_bisect(
248 linear_repo, all_ids[-1], [all_ids[0]], symbol_filter="billing.py::compute"
249 )
250 if not result.done and result.next_to_test:
251 # symbol_changes should be populated (non-empty) because next_to_test
252 # touches billing.py::compute (it's in the filtered set)
253 assert isinstance(result.symbol_changes, list)
254
255 def test_apply_verdict_preserves_symbol_filter(
256 self, linear_repo: pathlib.Path
257 ) -> None:
258 from muse.core.bisect import start_bisect, mark_good, _load_state
259
260 all_ids = _commit_ids(linear_repo)
261 result = start_bisect(
262 linear_repo, all_ids[-1], [all_ids[0]], symbol_filter="billing.py::compute"
263 )
264 if result.done or result.next_to_test is None:
265 return # too few commits, skip
266 mark_good(linear_repo, result.next_to_test)
267 state = _load_state(linear_repo)
268 assert state is not None
269 assert state.get("symbol_filter") == "billing.py::compute"
270
271 def test_bisect_converges_to_first_bad(self, linear_repo: pathlib.Path) -> None:
272 from muse.core.bisect import start_bisect, mark_bad, mark_good
273
274 all_ids = _commit_ids(linear_repo)
275 bad_id = all_ids[-1]
276 good_id = all_ids[0]
277
278 result = start_bisect(linear_repo, bad_id, [good_id])
279 max_steps = 20 # safety ceiling
280 for _ in range(max_steps):
281 if result.done:
282 break
283 assert result.next_to_test is not None
284 # Simplified oracle: all commits after c0 are "bad" in our fixture
285 result = mark_bad(linear_repo, result.next_to_test)
286 assert result.done
287 assert result.first_bad is not None
288
289 def test_skip_commit_removes_from_remaining(self, linear_repo: pathlib.Path) -> None:
290 from muse.core.bisect import start_bisect, skip_commit, _load_state
291
292 all_ids = _commit_ids(linear_repo)
293 result = start_bisect(linear_repo, all_ids[-1], [all_ids[0]])
294 if result.done or result.next_to_test is None:
295 return
296 skip_target = result.next_to_test
297 skip_commit(linear_repo, skip_target)
298 state = _load_state(linear_repo)
299 assert state is not None
300 assert skip_target not in state.get("remaining", [])
301
302 def test_reset_clears_state(self, linear_repo: pathlib.Path) -> None:
303 from muse.core.bisect import start_bisect, reset_bisect, is_bisect_active
304
305 all_ids = _commit_ids(linear_repo)
306 start_bisect(linear_repo, all_ids[-1], [all_ids[0]])
307 assert is_bisect_active(linear_repo)
308 reset_bisect(linear_repo)
309 assert not is_bisect_active(linear_repo)
310
311
312 # ---------------------------------------------------------------------------
313 # CLI tests — baseline muse bisect commands
314 # ---------------------------------------------------------------------------
315
316
317 class TestBisectCLI:
318 """End-to-end CLI tests for muse bisect."""
319
320 def test_bisect_start_exits_zero(self, linear_repo: pathlib.Path) -> None:
321 ids = _commit_ids(linear_repo)
322 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
323 assert r.exit_code == 0, r.output
324 # Clean up
325 runner.invoke(cli, ["bisect", "reset"])
326
327 def test_bisect_start_shows_next_to_test(self, linear_repo: pathlib.Path) -> None:
328 ids = _commit_ids(linear_repo)
329 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
330 assert r.exit_code == 0, r.output
331 assert "Next to test" in r.output or "First bad commit" in r.output
332 runner.invoke(cli, ["bisect", "reset"])
333
334 def test_bisect_start_requires_good(self, linear_repo: pathlib.Path) -> None:
335 ids = _commit_ids(linear_repo)
336 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1]])
337 assert r.exit_code != 0
338
339 def test_bisect_bad_good_cycle(self, linear_repo: pathlib.Path) -> None:
340 ids = _commit_ids(linear_repo)
341 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
342 # Mark the midpoint as bad, keep narrowing
343 from muse.core.bisect import _load_state
344 state = _load_state(linear_repo)
345 assert state is not None
346 remaining = state.get("remaining", [])
347 if remaining:
348 mid = remaining[len(remaining) // 2]
349 r = runner.invoke(cli, ["bisect", "bad", mid])
350 assert r.exit_code == 0, r.output
351 runner.invoke(cli, ["bisect", "reset"])
352
353 def test_bisect_commands_require_active_session(self, repo: pathlib.Path) -> None:
354 for sub in ("bad", "good", "skip"):
355 r = runner.invoke(cli, ["bisect", sub])
356 assert r.exit_code != 0
357
358 def test_bisect_double_start_fails(self, linear_repo: pathlib.Path) -> None:
359 ids = _commit_ids(linear_repo)
360 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
361 r = runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
362 assert r.exit_code != 0
363 runner.invoke(cli, ["bisect", "reset"])
364
365 def test_bisect_log_shows_entries(self, linear_repo: pathlib.Path) -> None:
366 ids = _commit_ids(linear_repo)
367 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
368 r = runner.invoke(cli, ["bisect", "log"])
369 assert r.exit_code == 0, r.output
370 assert "bad" in r.output or "good" in r.output
371 runner.invoke(cli, ["bisect", "reset"])
372
373 def test_bisect_log_empty_without_session(self, repo: pathlib.Path) -> None:
374 r = runner.invoke(cli, ["bisect", "log"])
375 assert r.exit_code == 0
376 assert "No bisect log" in r.output or r.output.strip() == ""
377
378 def test_bisect_reset_exits_zero(self, linear_repo: pathlib.Path) -> None:
379 ids = _commit_ids(linear_repo)
380 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
381 r = runner.invoke(cli, ["bisect", "reset"])
382 assert r.exit_code == 0, r.output
383
384 def test_bisect_reset_clears_active(self, linear_repo: pathlib.Path) -> None:
385 ids = _commit_ids(linear_repo)
386 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
387 runner.invoke(cli, ["bisect", "reset"])
388 from muse.core.bisect import is_bisect_active
389 assert not is_bisect_active(linear_repo)
390
391 def test_bisect_run_command_auto_bisect(self, linear_repo: pathlib.Path) -> None:
392 """Auto-bisect with a command that always says good — terminates cleanly."""
393 ids = _commit_ids(linear_repo)
394 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
395 # "exit 0" always → all commits good → bad_id is first bad immediately
396 r = runner.invoke(cli, ["bisect", "run", "true"])
397 assert r.exit_code == 0, r.output
398 assert "First bad commit" in r.output or "complete" in r.output.lower()
399 runner.invoke(cli, ["bisect", "reset"])
400
401 def test_bisect_requires_repo(
402 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
403 ) -> None:
404 monkeypatch.chdir(tmp_path)
405 r = runner.invoke(cli, ["bisect", "start", "--bad", "abc", "--good", "def"])
406 assert r.exit_code != 0
407
408
409 # ---------------------------------------------------------------------------
410 # CLI tests — --symbol flag
411 # ---------------------------------------------------------------------------
412
413
414 class TestBisectSymbol:
415 """Tests for the --symbol scoping feature on muse bisect start."""
416
417 def test_symbol_start_exits_zero(self, linear_repo: pathlib.Path) -> None:
418 ids = _commit_ids(linear_repo)
419 r = runner.invoke(cli, [
420 "bisect", "start",
421 "--bad", ids[-1],
422 "--good", ids[0],
423 "--symbol", "billing.py::compute",
424 ])
425 assert r.exit_code == 0, r.output
426 runner.invoke(cli, ["bisect", "reset"])
427
428 def test_symbol_start_shows_session_started(self, linear_repo: pathlib.Path) -> None:
429 ids = _commit_ids(linear_repo)
430 r = runner.invoke(cli, [
431 "bisect", "start",
432 "--bad", ids[-1],
433 "--good", ids[0],
434 "--symbol", "billing.py::compute",
435 ])
436 assert "Bisect session started" in r.output
437 assert "billing.py::compute" in r.output
438 runner.invoke(cli, ["bisect", "reset"])
439
440 def test_symbol_reduces_remaining_vs_plain(self, linear_repo: pathlib.Path) -> None:
441 """Symbol-scoped bisect must have fewer or equal remaining commits."""
442 ids = _commit_ids(linear_repo)
443
444 runner.invoke(cli, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
445 from muse.core.bisect import _load_state
446 plain_state = _load_state(linear_repo)
447 plain_remaining = len(plain_state.get("remaining", [])) if plain_state else 0
448 runner.invoke(cli, ["bisect", "reset"])
449
450 runner.invoke(cli, [
451 "bisect", "start",
452 "--bad", ids[-1],
453 "--good", ids[0],
454 "--symbol", "billing.py::compute",
455 ])
456 sym_state = _load_state(linear_repo)
457 sym_remaining = len(sym_state.get("remaining", [])) if sym_state else 0
458 runner.invoke(cli, ["bisect", "reset"])
459
460 assert sym_remaining <= plain_remaining
461
462 def test_symbol_filter_persisted_in_state(self, linear_repo: pathlib.Path) -> None:
463 ids = _commit_ids(linear_repo)
464 runner.invoke(cli, [
465 "bisect", "start",
466 "--bad", ids[-1],
467 "--good", ids[0],
468 "--symbol", "billing.py::compute",
469 ])
470 from muse.core.bisect import _load_state
471 state = _load_state(linear_repo)
472 assert state is not None
473 assert state.get("symbol_filter") == "billing.py::compute"
474 runner.invoke(cli, ["bisect", "reset"])
475
476 def test_symbol_filter_persisted_after_mark_good(self, linear_repo: pathlib.Path) -> None:
477 """symbol_filter must survive a mark-good verdict."""
478 ids = _commit_ids(linear_repo)
479 runner.invoke(cli, [
480 "bisect", "start",
481 "--bad", ids[-1],
482 "--good", ids[0],
483 "--symbol", "billing.py::compute",
484 ])
485 from muse.core.bisect import _load_state
486 state = _load_state(linear_repo)
487 if state and state.get("remaining"):
488 mid = state["remaining"][len(state["remaining"]) // 2]
489 runner.invoke(cli, ["bisect", "good", mid])
490 state2 = _load_state(linear_repo)
491 if state2:
492 assert state2.get("symbol_filter") == "billing.py::compute"
493 runner.invoke(cli, ["bisect", "reset"])
494
495 def test_symbol_shows_changes_in_output(self, linear_repo: pathlib.Path) -> None:
496 """When next_to_test touches the symbol, output shows symbol changes."""
497 ids = _commit_ids(linear_repo)
498 r = runner.invoke(cli, [
499 "bisect", "start",
500 "--bad", ids[-1],
501 "--good", ids[0],
502 "--symbol", "billing.py::compute",
503 ])
504 assert r.exit_code == 0, r.output
505 # If there are commits to test, the output should mention the symbol
506 # or at least the next-to-test prompt.
507 assert "Next to test" in r.output or "First bad commit" in r.output
508 runner.invoke(cli, ["bisect", "reset"])
509
510 def test_symbol_invalid_no_double_colon(self, linear_repo: pathlib.Path) -> None:
511 """--symbol without '::' is an invalid address and must be rejected."""
512 ids = _commit_ids(linear_repo)
513 r = runner.invoke(cli, [
514 "bisect", "start",
515 "--bad", ids[-1],
516 "--good", ids[0],
517 "--symbol", "billing.py",
518 ])
519 assert r.exit_code != 0
520
521 def test_symbol_too_long_rejected(self, linear_repo: pathlib.Path) -> None:
522 """--symbol longer than 500 chars must be rejected."""
523 ids = _commit_ids(linear_repo)
524 long_addr = f"billing.py::{'x' * 500}"
525 r = runner.invoke(cli, [
526 "bisect", "start",
527 "--bad", ids[-1],
528 "--good", ids[0],
529 "--symbol", long_addr,
530 ])
531 assert r.exit_code != 0
532
533 def test_symbol_nonexistent_warns(self, linear_repo: pathlib.Path) -> None:
534 """--symbol with no matching commits shows a warning, exits zero."""
535 ids = _commit_ids(linear_repo)
536 r = runner.invoke(cli, [
537 "bisect", "start",
538 "--bad", ids[-1],
539 "--good", ids[0],
540 "--symbol", "billing.py::nonexistent_symbol_xyz",
541 ])
542 assert r.exit_code == 0
543 # Should warn that no commits matched
544 assert "No commits" in r.output or "First bad commit" in r.output
545 runner.invoke(cli, ["bisect", "reset"])
546
547 def test_symbol_class_prefix_matches_methods(self, repo: pathlib.Path) -> None:
548 """--symbol billing.py::Invoice matches Invoice.compute and Invoice.apply."""
549 # Build a repo with a class that has methods.
550 (repo / "billing.py").write_text(textwrap.dedent("""\
551 class Invoice:
552 def compute(self, items):
553 return sum(items)
554 def apply_discount(self, total, pct):
555 return total * (1 - pct)
556 """))
557 r = runner.invoke(cli, ["commit", "-m", "add Invoice class"])
558 assert r.exit_code == 0, r.output
559
560 (repo / "billing.py").write_text(textwrap.dedent("""\
561 class Invoice:
562 def compute(self, items, discount=0.0):
563 return sum(items) * (1 - discount)
564 def apply_discount(self, total, pct):
565 return total * (1 - pct)
566 """))
567 r = runner.invoke(cli, ["commit", "-m", "add discount to compute"])
568 assert r.exit_code == 0, r.output
569
570 from muse.core.bisect import _commits_touching_symbol
571
572 ids = _commit_ids(repo)
573 # Filter by class prefix — should include the commit touching Invoice.compute
574 touching_class = _commits_touching_symbol(repo, ids, "billing.py::Invoice")
575 touching_method = _commits_touching_symbol(repo, ids, "billing.py::Invoice.compute")
576 # Class prefix must be a superset
577 assert set(touching_method) <= set(touching_class)
578
579 def test_symbol_with_run_command(self, linear_repo: pathlib.Path) -> None:
580 """bisect run with --symbol filter uses symbol-narrowed remaining list."""
581 ids = _commit_ids(linear_repo)
582 runner.invoke(cli, [
583 "bisect", "start",
584 "--bad", ids[-1],
585 "--good", ids[0],
586 "--symbol", "billing.py::compute",
587 ])
588 # Use "true" (always exits 0 = good) to drive the bisect to completion.
589 r = runner.invoke(cli, ["bisect", "run", "true"])
590 assert r.exit_code == 0, r.output
591 assert "First bad commit" in r.output or "complete" in r.output.lower()
592 runner.invoke(cli, ["bisect", "reset"])
593
594 def test_symbol_state_roundtrip(self, linear_repo: pathlib.Path) -> None:
595 """Symbol filter survives save → load roundtrip through TOML."""
596 from muse.core.bisect import _save_state, _load_state, BisectStateDict
597
598 state: BisectStateDict = {
599 "bad_id": "ab" * 32,
600 "good_ids": ["cd" * 32],
601 "skipped_ids": [],
602 "remaining": [],
603 "log": [],
604 "symbol_filter": "billing.py::Invoice.compute_total",
605 }
606 _save_state(linear_repo, state)
607 loaded = _load_state(linear_repo)
608 assert loaded is not None
609 assert loaded.get("symbol_filter") == "billing.py::Invoice.compute_total"
610
611 def test_symbol_filter_special_chars_survive_toml(
612 self, linear_repo: pathlib.Path
613 ) -> None:
614 """Symbol addresses with dots survive TOML serialisation."""
615 from muse.core.bisect import _save_state, _load_state, BisectStateDict
616
617 addr = 'billing.py::Invoice.compute_total'
618 state: BisectStateDict = {
619 "bad_id": "ab" * 32,
620 "good_ids": ["cd" * 32],
621 "skipped_ids": [],
622 "remaining": [],
623 "log": [],
624 "symbol_filter": addr,
625 }
626 _save_state(linear_repo, state)
627 loaded = _load_state(linear_repo)
628 assert loaded is not None
629 assert loaded.get("symbol_filter") == addr
630
631 def test_symbol_filter_with_merge_commit(self, repo: pathlib.Path) -> None:
632 """_commits_touching_symbol finds events on feature-branch commits (parent2)."""
633 # Genesis commit
634 (repo / "core.py").write_text("def bedrock():\n return 42\n")
635 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
636 assert r.exit_code == 0, r.output
637
638 repo_json = json.loads((repo_json_path(repo)).read_text())
639 repo_id = repo_json["repo_id"]
640 from muse.core.refs import read_current_branch
641 from muse.core.commits import resolve_commit_ref
642 branch = read_current_branch(repo)
643 head_commit = resolve_commit_ref(repo, branch, None)
644 assert head_commit is not None
645 head_id = head_commit.commit_id
646
647 now = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
648 from muse.core.ids import hash_commit as _cid
649 feature_snap_id = head_commit.snapshot_id
650 merge_snap_id = head_commit.snapshot_id
651 feature_id = _cid(parent_ids=[head_id], snapshot_id=feature_snap_id, message="Modify bedrock on feature", committed_at_iso=now.isoformat(), author="test")
652 merge_id = _cid(parent_ids=[head_id, feature_id], snapshot_id=merge_snap_id, message="Merge feature", committed_at_iso=now.isoformat(), author="test")
653
654 bedrock_delta = StructuredDelta(
655 domain="code",
656 ops=[PatchOp(
657 op="patch", address="core.py",
658 child_ops=[ReplaceOp(
659 op="replace", address="core.py::bedrock",
660 old_content_id="a" * 64, new_content_id="b" * 64,
661 old_summary="function bedrock",
662 new_summary="function bedrock (modified)", position=None,
663 )],
664 child_domain="code", child_summary="bedrock modified",
665 )],
666 summary="bedrock modified",
667 )
668 feature_body: CommitDict = {
669 "commit_id": feature_id,
670 "repo_id": repo_id,
671 "branch": "feat/bedrock",
672 "snapshot_id": head_commit.snapshot_id,
673 "message": "Modify bedrock on feature",
674 "committed_at": now.isoformat(),
675 "parent_commit_id": head_id,
676 "parent2_commit_id": None,
677 "author": "test",
678 "metadata": {},
679 "structured_delta": bedrock_delta,
680 }
681 merge_body: CommitDict = {
682 "commit_id": merge_id,
683 "repo_id": repo_id,
684 "branch": branch,
685 "snapshot_id": head_commit.snapshot_id,
686 "message": "Merge feature",
687 "committed_at": now.isoformat(),
688 "parent_commit_id": head_id,
689 "parent2_commit_id": feature_id,
690 "author": "test",
691 "metadata": {},
692 "structured_delta": None,
693 }
694 from muse.core.commits import (
695 CommitRecord,
696 write_commit,
697 )
698 write_commit(repo, CommitRecord.from_dict(feature_body))
699 write_commit(repo, CommitRecord.from_dict(merge_body))
700 (ref_path(repo, branch)).write_text(merge_id)
701
702 all_ids = _commit_ids(repo)
703 from muse.core.bisect import _commits_touching_symbol
704 touching = _commits_touching_symbol(repo, all_ids, "core.py::bedrock")
705 assert feature_id in touching, "feature-branch commit must be found by _commits_touching_symbol"
File History 1 commit