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