gabriel / muse public
test_cmd_bisect_hardening.py python
3,320 lines 141.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive hardening tests for ``muse bisect``.
2
3 Covers:
4 - Unit: _toml_escape, _load_state symlink guard, size cap, _save_state injection
5 - Security: branch TOML injection, symlink state file, oversized state, ANSI
6 sanitization, error routing to stderr, null bytes in refs
7 - JSON schema: all subcommands (start, bad, good, skip, log, reset, run)
8 - Integration: --json round-trips, get_bisect_next public API, session lifecycle
9 - E2E: symbol-scoped bisect, run subcommand NDJSON, reset --json, log --json
10 - Stress: 200-commit chain, concurrent read-only queries
11 """
12 from __future__ import annotations
13
14 import datetime
15 import json
16 import pathlib
17 import re
18 import threading
19 from typing import TypedDict
20
21 import pytest
22
23 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
24 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
25 from muse.core._types import Manifest, fake_id, short_id
26 from tests.cli_test_helper import CliRunner, InvokeResult
27
28 # Helpers to check store field names at import time; mypy will catch mismatches.
29 _SNAP_FIELDS: set[str] = {"snapshot_id", "manifest", "created_at"}
30 _COMMIT_FIELDS: set[str] = {"commit_id", "repo_id", "branch", "snapshot_id", "message", "committed_at"}
31
32 runner = CliRunner()
33
34 _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
35
36
37 # ---------------------------------------------------------------------------
38 # Fixtures
39 # ---------------------------------------------------------------------------
40
41
42 def _make_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
43 """Create a minimal Muse repo layout without calling muse init.
44
45 Returns (repo_root, repo_id).
46 """
47 repo_id = fake_id("repo")
48 muse = tmp_path / ".muse"
49 muse.mkdir()
50 (muse / "repo.json").write_text(
51 json.dumps({
52 "repo_id": repo_id,
53 "domain": "code",
54 "default_branch": "main",
55 "created_at": "2026-01-01T00:00:00+00:00",
56 })
57 )
58 (muse / "HEAD").write_text("ref: refs/heads/main")
59 (muse / "refs" / "heads").mkdir(parents=True)
60 (muse / "snapshots").mkdir()
61 (muse / "commits").mkdir()
62 (muse / "objects").mkdir()
63 return tmp_path, repo_id
64
65
66 def _make_commit(
67 root: pathlib.Path,
68 repo_id: str,
69 *,
70 branch: str = "main",
71 message: str = "commit",
72 parent_id: str | None = None,
73 ) -> str:
74 """Write a synthetic commit and return its commit_id."""
75 manifest: Manifest = {}
76 snap_id = compute_snapshot_id(manifest)
77 committed_at = datetime.datetime.now(datetime.timezone.utc)
78 commit_id = compute_commit_id(
79 repo_id=repo_id,
80 parent_ids=[parent_id] if parent_id else [],
81 snapshot_id=snap_id,
82 message=message,
83 committed_at_iso=committed_at.isoformat(),
84 )
85 snap = SnapshotRecord(
86 snapshot_id=snap_id,
87 manifest={},
88 created_at=committed_at,
89 )
90 write_snapshot(root, snap)
91 commit = CommitRecord(
92 commit_id=commit_id,
93 repo_id=repo_id,
94 parent_commit_id=parent_id,
95 parent2_commit_id=None,
96 snapshot_id=snap_id,
97 created_on_branch=branch,
98 message=message,
99 committed_at=committed_at,
100 )
101 write_commit(root, commit)
102 ref_path = root / ".muse" / "refs" / "heads" / branch
103 ref_path.write_text(commit_id)
104 (root / ".muse" / "HEAD").write_text(f"ref: refs/heads/{branch}")
105 return commit_id
106
107
108 def _build_chain(root: pathlib.Path, repo_id: str, n: int) -> list[str]:
109 """Create n commits (linear chain) and return their IDs oldest-first."""
110 ids: list[str] = []
111 parent: str | None = None
112 for i in range(n):
113 cid = _make_commit(root, repo_id, message=f"commit {i}", parent_id=parent)
114 ids.append(cid)
115 parent = cid
116 return ids
117
118
119 def _invoke(root: pathlib.Path, args: list[str]) -> InvokeResult:
120 return runner.invoke(None, args, env={"MUSE_REPO_ROOT": str(root)})
121
122
123 def _json_blob(output: str) -> str:
124 """Extract the first complete JSON object/array from mixed output.
125
126 Handles both compact (single-line) and pretty-printed (multi-line) JSON.
127 Falls back to line-by-line extraction for NDJSON streams.
128 """
129 stripped = output.strip()
130 # Fast path: try the whole output (works for pretty-printed single objects)
131 try:
132 json.loads(stripped)
133 return stripped
134 except json.JSONDecodeError:
135 pass
136 # Fallback: find the first JSON line (NDJSON or compact output mixed with text)
137 for line in output.splitlines():
138 s = line.strip()
139 if s.startswith("{") or s.startswith("["):
140 return s
141 return stripped
142
143
144 # ---------------------------------------------------------------------------
145 # Typed schema helpers
146 # ---------------------------------------------------------------------------
147
148
149 class _StepJson(TypedDict):
150 done: bool
151 first_bad: str | None
152 next_to_test: str | None
153 remaining_count: int
154 steps_remaining: int
155 verdict: str
156 symbol_changes: list[str]
157
158
159 class _LogEntryJson(TypedDict):
160 commit_id: str
161 verdict: str
162 timestamp: str
163
164
165 class _LogJson(TypedDict):
166 active: bool
167 entries: list[_LogEntryJson]
168
169
170 class _ResetJson(TypedDict):
171 reset: bool
172
173
174 class _RunStepJson(TypedDict):
175 step: int
176 testing: str
177 verdict: str
178 remaining_count: int
179 done: bool
180 symbol_changes: list[str]
181
182
183 class _RunDoneJson(TypedDict):
184 done: bool
185 first_bad: str | None
186 steps_taken: int
187
188
189 def _repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
190 """Alias for _make_repo for readability inside test methods."""
191 return _make_repo(tmp_path)
192
193
194 def _parse_step(output: str) -> _StepJson:
195 raw = json.loads(_json_blob(output))
196 assert isinstance(raw, dict)
197 done_val = raw["done"]
198 first_bad_val = raw["first_bad"]
199 next_to_test_val = raw["next_to_test"]
200 remaining_count_val = raw["remaining_count"]
201 steps_remaining_val = raw["steps_remaining"]
202 verdict_val = raw["verdict"]
203 symbol_changes_val = raw["symbol_changes"]
204 assert isinstance(done_val, bool)
205 assert first_bad_val is None or isinstance(first_bad_val, str)
206 assert next_to_test_val is None or isinstance(next_to_test_val, str)
207 assert isinstance(remaining_count_val, int)
208 assert isinstance(steps_remaining_val, int)
209 assert isinstance(verdict_val, str)
210 assert isinstance(symbol_changes_val, list)
211 return _StepJson(
212 done=done_val,
213 first_bad=first_bad_val,
214 next_to_test=next_to_test_val,
215 remaining_count=remaining_count_val,
216 steps_remaining=steps_remaining_val,
217 verdict=verdict_val,
218 symbol_changes=symbol_changes_val,
219 )
220
221
222 def _parse_log(output: str) -> _LogJson:
223 raw = json.loads(_json_blob(output))
224 assert isinstance(raw, dict)
225 active_val = raw["active"]
226 entries_val = raw["entries"]
227 assert isinstance(active_val, bool)
228 assert isinstance(entries_val, list)
229 return _LogJson(active=active_val, entries=entries_val)
230
231
232 def _parse_reset(output: str) -> _ResetJson:
233 raw = json.loads(_json_blob(output))
234 assert isinstance(raw, dict)
235 reset_val = raw["reset"]
236 assert isinstance(reset_val, bool)
237 return _ResetJson(reset=reset_val)
238
239
240 # ---------------------------------------------------------------------------
241 # Unit — _toml_escape
242 # ---------------------------------------------------------------------------
243
244
245 class TestTomlEscape:
246 def test_plain_string_unchanged(self) -> None:
247 from muse.core.bisect import _toml_escape
248
249 assert _toml_escape("feat/my-thing") == "feat/my-thing"
250
251 def test_double_quote_escaped(self) -> None:
252 from muse.core.bisect import _toml_escape
253
254 result = _toml_escape('branch"with"quotes')
255 # After escaping, no bare double-quotes remain (only \").
256 assert '\\"' in result
257
258 def test_backslash_escaped(self) -> None:
259 from muse.core.bisect import _toml_escape
260
261 result = _toml_escape("branch\\with\\backslash")
262 assert result == "branch\\\\with\\\\backslash"
263
264 def test_both_escaped(self) -> None:
265 from muse.core.bisect import _toml_escape
266
267 result = _toml_escape('evil"; bad_id = "hacked')
268 assert '\\"' in result
269 assert "bad_id" in result # literal text preserved, just escaped
270
271
272 # ---------------------------------------------------------------------------
273 # Unit — _load_state security
274 # ---------------------------------------------------------------------------
275
276
277 class TestLoadStateSecurity:
278 def test_symlink_state_file_rejected(self, tmp_path: pathlib.Path) -> None:
279 """A symlink at the bisect state path must be silently ignored."""
280 from muse.core.bisect import _load_state, _state_path
281
282 root, _ = _make_repo(tmp_path)
283 target = tmp_path / "real_state.toml"
284 target.write_text('bad_id = "abc"\ngood_ids = []\nskipped_ids = []\nremaining = []\nlog = []\n')
285 state_path = _state_path(root)
286 state_path.symlink_to(target)
287 result = _load_state(root)
288 assert result is None
289
290 def test_oversized_state_file_rejected(self, tmp_path: pathlib.Path) -> None:
291 """State files exceeding _MAX_STATE_BYTES must be rejected."""
292 from muse.core.bisect import _MAX_STATE_BYTES, _load_state, _state_path
293
294 root, _ = _make_repo(tmp_path)
295 state_path = _state_path(root)
296 huge = "x" * (_MAX_STATE_BYTES + 1)
297 state_path.write_text(huge)
298 result = _load_state(root)
299 assert result is None
300
301 def test_corrupt_state_returns_none(self, tmp_path: pathlib.Path) -> None:
302 from muse.core.bisect import _load_state, _state_path
303
304 root, _ = _make_repo(tmp_path)
305 state_path = _state_path(root)
306 state_path.write_text("not valid toml ]] [[[ !!!")
307 result = _load_state(root)
308 assert result is None
309
310 def test_missing_state_returns_none(self, tmp_path: pathlib.Path) -> None:
311 from muse.core.bisect import _load_state
312
313 root, _ = _make_repo(tmp_path)
314 result = _load_state(root)
315 assert result is None
316
317
318 # ---------------------------------------------------------------------------
319 # Unit — _save_state TOML injection
320 # ---------------------------------------------------------------------------
321
322
323 class TestSaveStateTomlInjection:
324 def test_branch_with_quote_survives_roundtrip(self, tmp_path: pathlib.Path) -> None:
325 """A branch name containing a double-quote must not corrupt the state file."""
326 from muse.core.bisect import BisectStateDict, _load_state, _save_state
327
328 root, _ = _make_repo(tmp_path)
329 state: BisectStateDict = {
330 "bad_id": "a" * 64,
331 "good_ids": ["b" * 64],
332 "skipped_ids": [],
333 "remaining": [],
334 "log": [],
335 "branch": 'evil"; bad_id = "injected',
336 }
337 _save_state(root, state)
338 loaded = _load_state(root)
339 assert loaded is not None
340 assert loaded.get("bad_id") == "a" * 64
341 assert loaded.get("branch") == 'evil"; bad_id = "injected'
342
343 def test_branch_with_backslash_survives_roundtrip(self, tmp_path: pathlib.Path) -> None:
344 from muse.core.bisect import BisectStateDict, _load_state, _save_state
345
346 root, _ = _make_repo(tmp_path)
347 state: BisectStateDict = {
348 "bad_id": "c" * 64,
349 "good_ids": ["d" * 64],
350 "skipped_ids": [],
351 "remaining": [],
352 "log": [],
353 "branch": "feat\\\\weird",
354 }
355 _save_state(root, state)
356 loaded = _load_state(root)
357 assert loaded is not None
358 assert loaded.get("branch") == "feat\\\\weird"
359
360 def test_symbol_filter_injection_survives_roundtrip(self, tmp_path: pathlib.Path) -> None:
361 from muse.core.bisect import BisectStateDict, _load_state, _save_state
362
363 root, _ = _make_repo(tmp_path)
364 state: BisectStateDict = {
365 "bad_id": "e" * 64,
366 "good_ids": ["f" * 64],
367 "skipped_ids": [],
368 "remaining": [],
369 "log": [],
370 "symbol_filter": 'billing.py::Invoice"; bad_id = "EVIL',
371 }
372 _save_state(root, state)
373 loaded = _load_state(root)
374 assert loaded is not None
375 assert loaded.get("bad_id") == "e" * 64
376 assert loaded.get("symbol_filter") == 'billing.py::Invoice"; bad_id = "EVIL'
377
378
379 # ---------------------------------------------------------------------------
380 # Unit — get_bisect_next public API
381 # ---------------------------------------------------------------------------
382
383
384 class TestGetBisectNext:
385 def test_no_session_returns_none(self, tmp_path: pathlib.Path) -> None:
386 from muse.core.bisect import get_bisect_next
387
388 root, _ = _make_repo(tmp_path)
389 nxt, sf = get_bisect_next(root)
390 assert nxt is None
391 assert sf == ""
392
393 def test_returns_next_after_start(self, tmp_path: pathlib.Path) -> None:
394 from muse.core.bisect import get_bisect_next, start_bisect
395
396 root, repo_id = _make_repo(tmp_path)
397 ids = _build_chain(root, repo_id, 5)
398 start_bisect(root, ids[-1], [ids[0]])
399 nxt, sf = get_bisect_next(root)
400 assert nxt is not None
401 assert nxt in ids
402 assert sf == ""
403
404 def test_returns_symbol_filter(self, tmp_path: pathlib.Path) -> None:
405 from muse.core.bisect import get_bisect_next, start_bisect
406
407 root, repo_id = _make_repo(tmp_path)
408 ids = _build_chain(root, repo_id, 4)
409 # No commits touch this symbol, so remaining will be empty.
410 start_bisect(root, ids[-1], [ids[0]], symbol_filter="no_file.py::NoSymbol")
411 nxt, sf = get_bisect_next(root)
412 # Symbol filter is preserved regardless of whether next exists.
413 assert sf == "no_file.py::NoSymbol"
414
415
416 # ---------------------------------------------------------------------------
417 # Security — CLI error routing
418 # ---------------------------------------------------------------------------
419
420
421 class TestErrorRouting:
422 def test_bad_without_session_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
423 root, _ = _make_repo(tmp_path)
424 result = _invoke(root, ["bisect", "bad"])
425 assert result.exit_code != 0
426 assert "No bisect session" in (result.stderr or result.output)
427
428 def test_good_without_session_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
429 root, _ = _make_repo(tmp_path)
430 result = _invoke(root, ["bisect", "good"])
431 assert result.exit_code != 0
432 assert "No bisect session" in (result.stderr or result.output)
433
434 def test_skip_without_session_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
435 root, _ = _make_repo(tmp_path)
436 result = _invoke(root, ["bisect", "skip"])
437 assert result.exit_code != 0
438 assert "No bisect session" in (result.stderr or result.output)
439
440 def test_run_without_session_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
441 root, _ = _make_repo(tmp_path)
442 result = _invoke(root, ["bisect", "run", "true"])
443 assert result.exit_code != 0
444 assert "No bisect session" in (result.stderr or result.output)
445
446 def test_symbol_without_double_colon_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
447 root, repo_id = _make_repo(tmp_path)
448 ids = _build_chain(root, repo_id, 2)
449 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--symbol", "no_colon_here"])
450 assert result.exit_code != 0
451 assert "❌" in (result.stderr or result.output)
452
453 def test_symbol_too_long_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
454 root, repo_id = _make_repo(tmp_path)
455 ids = _build_chain(root, repo_id, 2)
456 long_sym = "f.py::" + "x" * 600
457 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--symbol", long_sym])
458 assert result.exit_code != 0
459 assert "too long" in (result.stderr or result.output)
460
461 def test_double_start_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
462 root, repo_id = _make_repo(tmp_path)
463 ids = _build_chain(root, repo_id, 3)
464 r1 = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
465 assert r1.exit_code == 0
466 r2 = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
467 assert r2.exit_code != 0
468 assert "already active" in (r2.stderr or r2.output)
469
470
471 # ---------------------------------------------------------------------------
472 # Security — ANSI sanitization in outputs
473 # ---------------------------------------------------------------------------
474
475
476 class TestAnsiSanitization:
477 def test_ansi_in_ref_does_not_leak(self, tmp_path: pathlib.Path) -> None:
478 root, repo_id = _make_repo(tmp_path)
479 ids = _build_chain(root, repo_id, 2)
480 ansi_ref = "\x1b[31mHEAD\x1b[0m"
481 result = _invoke(root, ["bisect", "start", "--bad", ansi_ref, "--good", ids[0]])
482 assert _ANSI_RE.search(result.output) is None
483
484 def test_ansi_in_symbol_does_not_leak(self, tmp_path: pathlib.Path) -> None:
485 root, repo_id = _make_repo(tmp_path)
486 ids = _build_chain(root, repo_id, 2)
487 sym = "\x1b[31mfoo.py::Bar\x1b[0m"
488 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--symbol", sym])
489 assert _ANSI_RE.search(result.output) is None
490
491
492 # ---------------------------------------------------------------------------
493 # JSON schema — start
494 # ---------------------------------------------------------------------------
495
496
497 class TestJsonSchemaStart:
498 def test_start_json_schema(self, tmp_path: pathlib.Path) -> None:
499 root, repo_id = _make_repo(tmp_path)
500 ids = _build_chain(root, repo_id, 5)
501 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
502 assert result.exit_code == 0
503 parsed = _parse_step(result.output)
504 assert parsed["verdict"] == "started"
505 assert isinstance(parsed["done"], bool)
506 assert isinstance(parsed["remaining_count"], int)
507 assert parsed["remaining_count"] >= 0
508
509 def test_start_json_done_when_no_remaining(self, tmp_path: pathlib.Path) -> None:
510 """When bad and good are adjacent, start should report done=True immediately."""
511 root, repo_id = _make_repo(tmp_path)
512 ids = _build_chain(root, repo_id, 2)
513 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
514 assert result.exit_code == 0
515 parsed = _parse_step(result.output)
516 assert parsed["done"] is True
517 assert parsed["first_bad"] == ids[-1]
518
519 def test_start_json_symbol_changes_list(self, tmp_path: pathlib.Path) -> None:
520 root, repo_id = _make_repo(tmp_path)
521 ids = _build_chain(root, repo_id, 4)
522 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
523 parsed = _parse_step(result.output)
524 assert isinstance(parsed["symbol_changes"], list)
525
526
527 # ---------------------------------------------------------------------------
528 # JSON schema — bad / good / skip
529 # ---------------------------------------------------------------------------
530
531
532 class TestJsonSchemaBadGoodSkip:
533 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
534 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
535 assert r.exit_code == 0
536
537 def test_bad_json_schema(self, tmp_path: pathlib.Path) -> None:
538 root, repo_id = _make_repo(tmp_path)
539 ids = _build_chain(root, repo_id, 5)
540 self._start(root, ids)
541 midpoint = ids[len(ids) // 2]
542 result = _invoke(root, ["bisect", "bad", midpoint, "--json"])
543 assert result.exit_code == 0
544 parsed = _parse_step(result.output)
545 assert parsed["verdict"] == "bad"
546
547 def test_good_json_schema(self, tmp_path: pathlib.Path) -> None:
548 root, repo_id = _make_repo(tmp_path)
549 ids = _build_chain(root, repo_id, 5)
550 self._start(root, ids)
551 midpoint = ids[len(ids) // 2]
552 result = _invoke(root, ["bisect", "good", midpoint, "--json"])
553 assert result.exit_code == 0
554 parsed = _parse_step(result.output)
555 assert parsed["verdict"] == "good"
556
557 def test_skip_json_schema(self, tmp_path: pathlib.Path) -> None:
558 root, repo_id = _make_repo(tmp_path)
559 ids = _build_chain(root, repo_id, 5)
560 self._start(root, ids)
561 midpoint = ids[len(ids) // 2]
562 result = _invoke(root, ["bisect", "skip", midpoint, "--json"])
563 assert result.exit_code == 0
564 parsed = _parse_step(result.output)
565 assert parsed["verdict"] == "skip"
566
567
568 # ---------------------------------------------------------------------------
569 # JSON schema — log
570 # ---------------------------------------------------------------------------
571
572
573 class TestJsonSchemaLog:
574 def test_log_json_no_session(self, tmp_path: pathlib.Path) -> None:
575 root, _ = _make_repo(tmp_path)
576 result = _invoke(root, ["bisect", "log", "--json"])
577 assert result.exit_code == 0
578 parsed = _parse_log(result.output)
579 assert parsed["active"] is False
580 assert parsed["entries"] == []
581
582 def test_log_json_after_start(self, tmp_path: pathlib.Path) -> None:
583 root, repo_id = _make_repo(tmp_path)
584 ids = _build_chain(root, repo_id, 4)
585 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
586 result = _invoke(root, ["bisect", "log", "--json"])
587 assert result.exit_code == 0
588 parsed = _parse_log(result.output)
589 assert parsed["active"] is True
590 assert len(parsed["entries"]) >= 2
591
592 def test_log_json_entries_are_dicts(self, tmp_path: pathlib.Path) -> None:
593 root, repo_id = _make_repo(tmp_path)
594 ids = _build_chain(root, repo_id, 3)
595 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
596 result = _invoke(root, ["bisect", "log", "--json"])
597 parsed = _parse_log(result.output)
598 for entry in parsed["entries"]:
599 assert isinstance(entry, dict)
600 assert set(entry.keys()) == {"commit_id", "verdict", "timestamp"}
601
602
603 # ---------------------------------------------------------------------------
604 # JSON schema — reset
605 # ---------------------------------------------------------------------------
606
607
608 class TestJsonSchemaReset:
609 def test_reset_json_no_session(self, tmp_path: pathlib.Path) -> None:
610 root, _ = _make_repo(tmp_path)
611 result = _invoke(root, ["bisect", "reset", "--json"])
612 assert result.exit_code == 0
613 parsed = _parse_reset(result.output)
614 assert parsed["reset"] is True
615
616 def test_reset_json_with_session(self, tmp_path: pathlib.Path) -> None:
617 root, repo_id = _make_repo(tmp_path)
618 ids = _build_chain(root, repo_id, 3)
619 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
620 result = _invoke(root, ["bisect", "reset", "--json"])
621 assert result.exit_code == 0
622 parsed = _parse_reset(result.output)
623 assert parsed["reset"] is True
624
625 def test_reset_clears_active_flag(self, tmp_path: pathlib.Path) -> None:
626 root, repo_id = _make_repo(tmp_path)
627 ids = _build_chain(root, repo_id, 3)
628 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
629 _invoke(root, ["bisect", "reset", "--json"])
630 log_result = _invoke(root, ["bisect", "log", "--json"])
631 parsed = _parse_log(log_result.output)
632 assert parsed["active"] is False
633
634
635 # ---------------------------------------------------------------------------
636 # JSON schema — run (NDJSON)
637 # ---------------------------------------------------------------------------
638
639
640 class TestJsonSchemaRun:
641 def test_run_json_ndjson_format(self, tmp_path: pathlib.Path) -> None:
642 """``bisect run --json`` should emit valid NDJSON."""
643 root, repo_id = _make_repo(tmp_path)
644 ids = _build_chain(root, repo_id, 6)
645 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
646 result = _invoke(root, ["bisect", "run", "true", "--json"])
647 assert result.exit_code == 0
648 lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()]
649 assert len(lines) >= 1
650 for raw_line in lines[:-1]:
651 step_raw = json.loads(raw_line)
652 assert "step" in step_raw
653 assert "verdict" in step_raw
654 assert "testing" in step_raw
655 assert "remaining_count" in step_raw
656 assert "done" in step_raw
657 done_raw = json.loads(lines[-1])
658 done_val = done_raw["done"]
659 assert isinstance(done_val, bool)
660 steps_taken_val = done_raw["steps_taken"]
661 assert isinstance(steps_taken_val, int)
662
663 def test_run_json_done_has_first_bad(self, tmp_path: pathlib.Path) -> None:
664 """With always-good command, first_bad on the done line should be set."""
665 root, repo_id = _make_repo(tmp_path)
666 ids = _build_chain(root, repo_id, 4)
667 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
668 result = _invoke(root, ["bisect", "run", "true", "--json"])
669 assert result.exit_code == 0
670 lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()]
671 done_raw = json.loads(lines[-1])
672 done_val = done_raw["done"]
673 first_bad_val = done_raw["first_bad"]
674 if done_val:
675 assert first_bad_val is not None
676
677 def test_run_json_steps_taken_increments(self, tmp_path: pathlib.Path) -> None:
678 root, repo_id = _make_repo(tmp_path)
679 ids = _build_chain(root, repo_id, 8)
680 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
681 result = _invoke(root, ["bisect", "run", "true", "--json"])
682 lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()]
683 done_raw = json.loads(lines[-1])
684 steps_taken = done_raw["steps_taken"]
685 assert steps_taken >= 1
686
687
688 # ---------------------------------------------------------------------------
689 # Integration — session lifecycle with --json
690 # ---------------------------------------------------------------------------
691
692
693 class TestIntegrationJson:
694 def test_start_bad_good_converge(self, tmp_path: pathlib.Path) -> None:
695 """A manual bisect session with --json converges to a first_bad."""
696 root, repo_id = _make_repo(tmp_path)
697 ids = _build_chain(root, repo_id, 7)
698 r_start = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
699 assert r_start.exit_code == 0
700 step = _parse_step(r_start.output)
701 if step["done"]:
702 assert step["first_bad"] is not None
703 return
704 for _ in range(20):
705 nxt = step["next_to_test"]
706 assert nxt is not None
707 r = _invoke(root, ["bisect", "bad", nxt, "--json"])
708 assert r.exit_code == 0
709 step = _parse_step(r.output)
710 if step["done"]:
711 assert step["first_bad"] is not None
712 return
713 pytest.fail("Bisect did not converge within 20 steps")
714
715 def test_good_narrows_range(self, tmp_path: pathlib.Path) -> None:
716 root, repo_id = _make_repo(tmp_path)
717 ids = _build_chain(root, repo_id, 8)
718 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
719 midpoint = ids[len(ids) // 2]
720 r_good = _invoke(root, ["bisect", "good", midpoint, "--json"])
721 step = _parse_step(r_good.output)
722 if not step["done"]:
723 assert step["remaining_count"] < len(ids) - 2
724
725 def test_log_grows_with_verdicts(self, tmp_path: pathlib.Path) -> None:
726 root, repo_id = _make_repo(tmp_path)
727 ids = _build_chain(root, repo_id, 5)
728 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
729 midpoint = ids[len(ids) // 2]
730 _invoke(root, ["bisect", "bad", midpoint])
731 r_log = _invoke(root, ["bisect", "log", "--json"])
732 parsed = _parse_log(r_log.output)
733 # start logs 2 entries (bad+good); bad adds 1 more → at least 3.
734 assert len(parsed["entries"]) >= 3
735
736 def test_skip_excluded_from_remaining(self, tmp_path: pathlib.Path) -> None:
737 root, repo_id = _make_repo(tmp_path)
738 ids = _build_chain(root, repo_id, 6)
739 r_start = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
740 step_start = _parse_step(r_start.output)
741 if step_start["done"]:
742 return
743 nxt = step_start["next_to_test"]
744 assert nxt is not None
745 r_skip = _invoke(root, ["bisect", "skip", nxt, "--json"])
746 step_skip = _parse_step(r_skip.output)
747 if not step_skip["done"]:
748 assert step_skip["next_to_test"] != nxt
749
750
751 # ---------------------------------------------------------------------------
752 # E2E — text (non-JSON) output still works
753 # ---------------------------------------------------------------------------
754
755
756 class TestE2EText:
757 def test_start_text_output_no_json(self, tmp_path: pathlib.Path) -> None:
758 root, repo_id = _make_repo(tmp_path)
759 ids = _build_chain(root, repo_id, 4)
760 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
761 assert result.exit_code == 0
762 assert "Bisect session started" in result.output or "First bad commit" in result.output
763
764 def test_bad_text_output(self, tmp_path: pathlib.Path) -> None:
765 root, repo_id = _make_repo(tmp_path)
766 ids = _build_chain(root, repo_id, 4)
767 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
768 midpoint = ids[len(ids) // 2]
769 result = _invoke(root, ["bisect", "bad", midpoint])
770 assert result.exit_code == 0
771 assert "bad" in result.output.lower()
772
773 def test_log_text_shows_entries(self, tmp_path: pathlib.Path) -> None:
774 root, repo_id = _make_repo(tmp_path)
775 ids = _build_chain(root, repo_id, 3)
776 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
777 result = _invoke(root, ["bisect", "log"])
778 assert result.exit_code == 0
779 assert "Bisect log" in result.output
780
781 def test_reset_text_output(self, tmp_path: pathlib.Path) -> None:
782 root, repo_id = _make_repo(tmp_path)
783 ids = _build_chain(root, repo_id, 2)
784 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
785 result = _invoke(root, ["bisect", "reset"])
786 assert result.exit_code == 0
787 assert "reset" in result.output.lower()
788
789 def test_run_text_output_converges(self, tmp_path: pathlib.Path) -> None:
790 root, repo_id = _make_repo(tmp_path)
791 ids = _build_chain(root, repo_id, 5)
792 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
793 result = _invoke(root, ["bisect", "run", "true"])
794 assert result.exit_code == 0
795 assert "First bad commit" in result.output or "Bisect complete" in result.output
796
797 def test_no_good_flag_fails_clearly(self, tmp_path: pathlib.Path) -> None:
798 root, repo_id = _make_repo(tmp_path)
799 ids = _build_chain(root, repo_id, 2)
800 result = _invoke(root, ["bisect", "start", "--bad", ids[-1]])
801 assert result.exit_code != 0
802
803 def test_log_empty_when_no_session(self, tmp_path: pathlib.Path) -> None:
804 root, _ = _make_repo(tmp_path)
805 result = _invoke(root, ["bisect", "log"])
806 assert result.exit_code == 0
807 assert "No bisect log" in result.output
808
809
810 # ---------------------------------------------------------------------------
811 # E2E — symbol-scoped bisect
812 # ---------------------------------------------------------------------------
813
814
815 class TestSymbolScopedBisect:
816 def test_symbol_filter_no_matching_commits_warns(self, tmp_path: pathlib.Path) -> None:
817 root, repo_id = _make_repo(tmp_path)
818 ids = _build_chain(root, repo_id, 4)
819 result = _invoke(
820 root,
821 [
822 "bisect", "start",
823 "--bad", ids[-1],
824 "--good", ids[0],
825 "--symbol", "ghost.py::GhostFunc",
826 ],
827 )
828 assert result.exit_code == 0
829 combined = result.output + (result.stderr or "")
830 assert "No commits" in combined or "First bad" in combined
831
832 def test_symbol_filter_json_schema_preserved(self, tmp_path: pathlib.Path) -> None:
833 root, repo_id = _make_repo(tmp_path)
834 ids = _build_chain(root, repo_id, 5)
835 result = _invoke(
836 root,
837 [
838 "bisect", "start",
839 "--bad", ids[-1],
840 "--good", ids[0],
841 "--symbol", "ghost.py::GhostFunc",
842 "--json",
843 ],
844 )
845 assert result.exit_code == 0
846 parsed = _parse_step(result.output)
847 assert isinstance(parsed["symbol_changes"], list)
848
849 def test_symbol_filter_state_persisted(self, tmp_path: pathlib.Path) -> None:
850 """After start with --symbol, the symbol_filter must survive state reload."""
851 from muse.core.bisect import _load_state
852
853 root, repo_id = _make_repo(tmp_path)
854 ids = _build_chain(root, repo_id, 4)
855 _invoke(
856 root,
857 [
858 "bisect", "start",
859 "--bad", ids[-1],
860 "--good", ids[0],
861 "--symbol", "billing.py::Invoice",
862 ],
863 )
864 state = _load_state(root)
865 assert state is not None
866 assert state.get("symbol_filter") == "billing.py::Invoice"
867
868
869 # ---------------------------------------------------------------------------
870 # Stress — large commit chains
871 # ---------------------------------------------------------------------------
872
873
874 class TestStress:
875 def test_200_commit_chain_converges(self, tmp_path: pathlib.Path) -> None:
876 """Bisect over 200 commits must converge in ≤9 steps (log₂(200) ≈ 7.6)."""
877 root, repo_id = _make_repo(tmp_path)
878 ids = _build_chain(root, repo_id, 200)
879 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
880
881 steps = 0
882 for _ in range(10):
883 r = _invoke(root, ["bisect", "run", "true", "--json"])
884 assert r.exit_code == 0
885 lines = [ln.strip() for ln in r.output.strip().splitlines() if ln.strip()]
886 if lines:
887 done_raw = json.loads(lines[-1])
888 if done_raw.get("done"):
889 steps = done_raw.get("steps_taken", 0)
890 break
891 else:
892 pytest.fail("Bisect did not terminate within 10 run invocations")
893 assert steps <= 9, f"Expected ≤9 steps for 200 commits, got {steps}"
894
895 def test_concurrent_log_reads_are_safe(self, tmp_path: pathlib.Path) -> None:
896 """Concurrent reads of bisect log must not crash."""
897 root, repo_id = _make_repo(tmp_path)
898 ids = _build_chain(root, repo_id, 10)
899 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
900
901 errors: list[str] = []
902
903 def _read_log() -> None:
904 from muse.core.bisect import get_bisect_log
905 try:
906 entries = get_bisect_log(root)
907 assert isinstance(entries, list)
908 except Exception as exc:
909 errors.append(str(exc))
910
911 threads = [threading.Thread(target=_read_log) for _ in range(20)]
912 for t in threads:
913 t.start()
914 for t in threads:
915 t.join()
916
917 assert not errors, f"Concurrent read failures: {errors}"
918
919 def test_50_step_manual_bisect_json(self, tmp_path: pathlib.Path) -> None:
920 """50 mark_bad calls on a 100-commit chain must all emit valid JSON."""
921 root, repo_id = _make_repo(tmp_path)
922 ids = _build_chain(root, repo_id, 100)
923 r_start = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
924 assert r_start.exit_code == 0
925 step = _parse_step(r_start.output)
926
927 for _ in range(50):
928 if step["done"]:
929 assert step["first_bad"] is not None
930 return
931 nxt = step["next_to_test"]
932 assert nxt is not None
933 r = _invoke(root, ["bisect", "bad", nxt, "--json"])
934 assert r.exit_code == 0
935 step = _parse_step(r.output)
936
937 assert step["done"] is True
938
939
940 # ---------------------------------------------------------------------------
941 # bisect start — Extended, Security, Stress
942 # ---------------------------------------------------------------------------
943
944
945 class TestBisectStartExtended:
946 """Extended unit / integration / e2e tests for muse bisect start."""
947
948 def test_start_exits_0(self, tmp_path: pathlib.Path) -> None:
949 root, repo_id = _make_repo(tmp_path)
950 ids = _build_chain(root, repo_id, 5)
951 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
952 assert result.exit_code == 0
953
954 def test_start_j_alias_works(self, tmp_path: pathlib.Path) -> None:
955 """-j is an accepted alias for --json."""
956 root, repo_id = _make_repo(tmp_path)
957 ids = _build_chain(root, repo_id, 5)
958 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "-j"])
959 assert result.exit_code == 0
960 parsed = _parse_step(result.output)
961 assert parsed["verdict"] == "started"
962
963 def test_start_json_verdict_is_started(self, tmp_path: pathlib.Path) -> None:
964 root, repo_id = _make_repo(tmp_path)
965 ids = _build_chain(root, repo_id, 5)
966 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
967 assert result.exit_code == 0
968 assert _parse_step(result.output)["verdict"] == "started"
969
970 def test_start_json_done_false_with_remaining(self, tmp_path: pathlib.Path) -> None:
971 root, repo_id = _make_repo(tmp_path)
972 ids = _build_chain(root, repo_id, 5)
973 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
974 assert result.exit_code == 0
975 parsed = _parse_step(result.output)
976 assert parsed["done"] is False
977 assert parsed["next_to_test"] is not None
978
979 def test_start_json_done_true_when_adjacent(self, tmp_path: pathlib.Path) -> None:
980 root, repo_id = _make_repo(tmp_path)
981 ids = _build_chain(root, repo_id, 2)
982 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
983 assert result.exit_code == 0
984 parsed = _parse_step(result.output)
985 assert parsed["done"] is True
986 assert parsed["first_bad"] == ids[-1]
987
988 def test_start_json_remaining_count_positive(self, tmp_path: pathlib.Path) -> None:
989 root, repo_id = _make_repo(tmp_path)
990 ids = _build_chain(root, repo_id, 8)
991 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
992 assert result.exit_code == 0
993 assert _parse_step(result.output)["remaining_count"] > 0
994
995 def test_start_json_steps_remaining_positive(self, tmp_path: pathlib.Path) -> None:
996 root, repo_id = _make_repo(tmp_path)
997 ids = _build_chain(root, repo_id, 8)
998 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
999 assert result.exit_code == 0
1000 assert _parse_step(result.output)["steps_remaining"] > 0
1001
1002 def test_start_json_all_seven_keys(self, tmp_path: pathlib.Path) -> None:
1003 root, repo_id = _make_repo(tmp_path)
1004 ids = _build_chain(root, repo_id, 5)
1005 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1006 assert result.exit_code == 0
1007 d = json.loads(_json_blob(result.output))
1008 assert {"done", "first_bad", "next_to_test", "remaining_count",
1009 "steps_remaining", "verdict", "symbol_changes"} <= set(d.keys())
1010
1011 def test_start_multiple_good_refs(self, tmp_path: pathlib.Path) -> None:
1012 root, repo_id = _make_repo(tmp_path)
1013 ids = _build_chain(root, repo_id, 6)
1014 result = _invoke(
1015 root,
1016 ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--good", ids[1], "--json"],
1017 )
1018 assert result.exit_code == 0
1019 assert _parse_step(result.output)["verdict"] == "started"
1020
1021 def test_start_no_good_exits_1(self, tmp_path: pathlib.Path) -> None:
1022 root, repo_id = _make_repo(tmp_path)
1023 ids = _build_chain(root, repo_id, 3)
1024 result = _invoke(root, ["bisect", "start", "--bad", ids[-1]])
1025 assert result.exit_code == 1
1026
1027 def test_start_no_good_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1028 root, repo_id = _make_repo(tmp_path)
1029 ids = _build_chain(root, repo_id, 3)
1030 result = _invoke(root, ["bisect", "start", "--bad", ids[-1]])
1031 assert result.exit_code != 0
1032 combined = result.output + (result.stderr or "")
1033 assert "good" in combined.lower()
1034
1035 def test_start_double_start_exits_1(self, tmp_path: pathlib.Path) -> None:
1036 root, repo_id = _make_repo(tmp_path)
1037 ids = _build_chain(root, repo_id, 5)
1038 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1039 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1040 assert result.exit_code == 1
1041
1042 def test_start_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1043 empty = tmp_path / "not_a_repo"
1044 empty.mkdir()
1045 result = _invoke(empty, ["bisect", "start", "--bad", "abc", "--good", "def"])
1046 assert result.exit_code == 2
1047
1048 def test_start_bad_defaults_to_head(self, tmp_path: pathlib.Path) -> None:
1049 root, repo_id = _make_repo(tmp_path)
1050 ids = _build_chain(root, repo_id, 4)
1051 # HEAD points to ids[-1]; omit --bad
1052 result = _invoke(root, ["bisect", "start", "--good", ids[0], "--json"])
1053 assert result.exit_code == 0
1054
1055 def test_start_text_mentions_session_started(self, tmp_path: pathlib.Path) -> None:
1056 root, repo_id = _make_repo(tmp_path)
1057 ids = _build_chain(root, repo_id, 5)
1058 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1059 assert result.exit_code == 0
1060 assert "Bisect session started" in result.output or "First bad commit" in result.output
1061
1062 def test_start_text_no_json_object(self, tmp_path: pathlib.Path) -> None:
1063 root, repo_id = _make_repo(tmp_path)
1064 ids = _build_chain(root, repo_id, 5)
1065 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1066 assert result.exit_code == 0
1067 assert not result.output.strip().startswith("{")
1068
1069 def test_start_help_description_present(self, tmp_path: pathlib.Path) -> None:
1070 root, _ = _make_repo(tmp_path)
1071 result = _invoke(root, ["bisect", "start", "--help"])
1072 assert "Agent quickstart" in result.output or "binary" in result.output.lower()
1073
1074 def test_start_invalid_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
1075 root, repo_id = _make_repo(tmp_path)
1076 ids = _build_chain(root, repo_id, 3)
1077 result = _invoke(root, ["bisect", "start", "--bad", "nonexistent_ref_abc123", "--good", ids[0]])
1078 assert result.exit_code == 1
1079
1080
1081 class TestBisectStartSecurity:
1082 """Security hardening tests for muse bisect start."""
1083
1084 def test_start_symbol_changes_no_ansi_in_json(self, tmp_path: pathlib.Path) -> None:
1085 """symbol_changes entries are sanitized in JSON output."""
1086 from unittest.mock import patch
1087 from muse.core.bisect import BisectResult
1088 root, repo_id = _make_repo(tmp_path)
1089 ids = _build_chain(root, repo_id, 5)
1090 injected = BisectResult(
1091 done=False,
1092 first_bad=None,
1093 next_to_test=ids[2],
1094 remaining_count=3,
1095 steps_remaining=2,
1096 verdict="started",
1097 symbol_changes=["add Invoice.compute\x1b[31mred\x1b[0m"],
1098 )
1099 with patch("muse.cli.commands.bisect.start_bisect", return_value=injected):
1100 result = _invoke(
1101 root,
1102 ["bisect", "start", "--bad", ids[-1], "--good", ids[0],
1103 "--symbol", "billing.py::Invoice", "--json"],
1104 )
1105 assert result.exit_code == 0
1106 assert "\x1b" not in result.output
1107
1108 def test_start_symbol_changes_no_ansi_in_text(self, tmp_path: pathlib.Path) -> None:
1109 """symbol_changes entries are sanitized in text output."""
1110 from unittest.mock import patch
1111 from muse.core.bisect import BisectResult
1112 root, repo_id = _make_repo(tmp_path)
1113 ids = _build_chain(root, repo_id, 5)
1114 injected = BisectResult(
1115 done=False,
1116 first_bad=None,
1117 next_to_test=ids[2],
1118 remaining_count=3,
1119 steps_remaining=2,
1120 verdict="started",
1121 symbol_changes=["add Invoice.compute\x1b[31mred\x1b[0m"],
1122 )
1123 with patch("muse.cli.commands.bisect.start_bisect", return_value=injected):
1124 result = _invoke(
1125 root,
1126 ["bisect", "start", "--bad", ids[-1], "--good", ids[0],
1127 "--symbol", "billing.py::Invoice"],
1128 )
1129 assert result.exit_code == 0
1130 assert "\x1b" not in result.output
1131
1132 def test_start_symbol_missing_separator_exits_1(self, tmp_path: pathlib.Path) -> None:
1133 """--symbol without '::' separator is rejected."""
1134 root, repo_id = _make_repo(tmp_path)
1135 ids = _build_chain(root, repo_id, 3)
1136 result = _invoke(
1137 root,
1138 ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--symbol", "NoSeparator"],
1139 )
1140 assert result.exit_code == 1
1141
1142 def test_start_symbol_too_long_exits_1(self, tmp_path: pathlib.Path) -> None:
1143 """--symbol exceeding max length is rejected."""
1144 root, repo_id = _make_repo(tmp_path)
1145 ids = _build_chain(root, repo_id, 3)
1146 long_sym = "a" * 510 + "::b"
1147 result = _invoke(
1148 root,
1149 ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--symbol", long_sym],
1150 )
1151 assert result.exit_code == 1
1152
1153 def test_start_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1154 """JSON output is well-formed."""
1155 root, repo_id = _make_repo(tmp_path)
1156 ids = _build_chain(root, repo_id, 5)
1157 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1158 assert result.exit_code == 0
1159 d = json.loads(_json_blob(result.output))
1160 assert isinstance(d, dict)
1161
1162 def test_start_json_bool_fields_are_bool(self, tmp_path: pathlib.Path) -> None:
1163 """done field is always a bool, never int or string."""
1164 root, repo_id = _make_repo(tmp_path)
1165 ids = _build_chain(root, repo_id, 5)
1166 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1167 assert result.exit_code == 0
1168 d = json.loads(_json_blob(result.output))
1169 assert isinstance(d["done"], bool)
1170
1171
1172 class TestBisectStartStress:
1173 """Performance and scale tests for muse bisect start."""
1174
1175 def test_start_100_commit_chain(self, tmp_path: pathlib.Path) -> None:
1176 """Start over a 100-commit chain exits 0 and returns a midpoint."""
1177 root, repo_id = _make_repo(tmp_path)
1178 ids = _build_chain(root, repo_id, 100)
1179 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1180 assert result.exit_code == 0
1181 parsed = _parse_step(result.output)
1182 assert parsed["done"] is False
1183 assert parsed["remaining_count"] > 0
1184 assert parsed["next_to_test"] is not None
1185
1186 def test_start_performance_100_commits(self, tmp_path: pathlib.Path) -> None:
1187 """Start over 100 commits completes within 5 seconds."""
1188 import time
1189 root, repo_id = _make_repo(tmp_path)
1190 ids = _build_chain(root, repo_id, 100)
1191 t0 = time.monotonic()
1192 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1193 elapsed = time.monotonic() - t0
1194 assert result.exit_code == 0
1195 assert elapsed < 5.0, f"start over 100 commits took {elapsed:.2f}s"
1196
1197 def test_start_midpoint_is_within_range(self, tmp_path: pathlib.Path) -> None:
1198 """The suggested midpoint falls strictly between good and bad."""
1199 root, repo_id = _make_repo(tmp_path)
1200 ids = _build_chain(root, repo_id, 20)
1201 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1202 assert result.exit_code == 0
1203 parsed = _parse_step(result.output)
1204 assert parsed["next_to_test"] not in (ids[0], ids[-1])
1205
1206
1207 # ---------------------------------------------------------------------------
1208 # bisect bad — Extended, Security, Stress
1209 # ---------------------------------------------------------------------------
1210
1211
1212 class TestBisectBadExtended:
1213 """Extended unit / integration / e2e tests for muse bisect bad."""
1214
1215 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1216 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1217 assert r.exit_code == 0
1218
1219 def test_bad_exits_0(self, tmp_path: pathlib.Path) -> None:
1220 root, repo_id = _make_repo(tmp_path)
1221 ids = _build_chain(root, repo_id, 6)
1222 self._start(root, ids)
1223 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2]])
1224 assert result.exit_code == 0
1225
1226 def test_bad_j_alias_works(self, tmp_path: pathlib.Path) -> None:
1227 root, repo_id = _make_repo(tmp_path)
1228 ids = _build_chain(root, repo_id, 6)
1229 self._start(root, ids)
1230 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "-j"])
1231 assert result.exit_code == 0
1232 parsed = _parse_step(result.output)
1233 assert parsed["verdict"] == "bad"
1234
1235 def test_bad_json_verdict_is_bad(self, tmp_path: pathlib.Path) -> None:
1236 root, repo_id = _make_repo(tmp_path)
1237 ids = _build_chain(root, repo_id, 6)
1238 self._start(root, ids)
1239 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "--json"])
1240 assert result.exit_code == 0
1241 assert _parse_step(result.output)["verdict"] == "bad"
1242
1243 def test_bad_json_all_seven_keys(self, tmp_path: pathlib.Path) -> None:
1244 root, repo_id = _make_repo(tmp_path)
1245 ids = _build_chain(root, repo_id, 6)
1246 self._start(root, ids)
1247 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "--json"])
1248 assert result.exit_code == 0
1249 d = json.loads(_json_blob(result.output))
1250 assert {"done", "first_bad", "next_to_test", "remaining_count",
1251 "steps_remaining", "verdict", "symbol_changes"} <= set(d.keys())
1252
1253 def test_bad_reduces_remaining(self, tmp_path: pathlib.Path) -> None:
1254 root, repo_id = _make_repo(tmp_path)
1255 ids = _build_chain(root, repo_id, 10)
1256 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1257 before = _parse_step(r.output)["remaining_count"]
1258 mid = _parse_step(r.output)["next_to_test"]
1259 result = _invoke(root, ["bisect", "bad", mid, "--json"])
1260 assert result.exit_code == 0
1261 after = _parse_step(result.output)["remaining_count"]
1262 assert after < before
1263
1264 def test_bad_done_true_when_isolated(self, tmp_path: pathlib.Path) -> None:
1265 root, repo_id = _make_repo(tmp_path)
1266 ids = _build_chain(root, repo_id, 3)
1267 # With 3 commits: good=ids[0], bad=ids[2] → ids[1] is the only remaining
1268 self._start(root, ids)
1269 result = _invoke(root, ["bisect", "bad", ids[1], "--json"])
1270 assert result.exit_code == 0
1271 parsed = _parse_step(result.output)
1272 assert parsed["done"] is True
1273 assert parsed["first_bad"] is not None
1274
1275 def test_bad_first_bad_set_when_done(self, tmp_path: pathlib.Path) -> None:
1276 root, repo_id = _make_repo(tmp_path)
1277 ids = _build_chain(root, repo_id, 3)
1278 self._start(root, ids)
1279 result = _invoke(root, ["bisect", "bad", ids[1], "--json"])
1280 assert result.exit_code == 0
1281 parsed = _parse_step(result.output)
1282 assert parsed["done"] is True
1283 assert isinstance(parsed["first_bad"], str)
1284
1285 def test_bad_defaults_to_head(self, tmp_path: pathlib.Path) -> None:
1286 root, repo_id = _make_repo(tmp_path)
1287 ids = _build_chain(root, repo_id, 5)
1288 self._start(root, ids)
1289 # HEAD points to ids[-1] (the known-bad); marking it bad again is valid
1290 result = _invoke(root, ["bisect", "bad", "--json"])
1291 assert result.exit_code == 0
1292
1293 def test_bad_no_session_exits_1(self, tmp_path: pathlib.Path) -> None:
1294 root, _ = _make_repo(tmp_path)
1295 result = _invoke(root, ["bisect", "bad"])
1296 assert result.exit_code == 1
1297
1298 def test_bad_no_session_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1299 root, _ = _make_repo(tmp_path)
1300 result = _invoke(root, ["bisect", "bad"])
1301 assert result.exit_code != 0
1302 combined = result.output + (result.stderr or "")
1303 assert "No bisect session" in combined
1304
1305 def test_bad_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1306 empty = tmp_path / "not_a_repo"
1307 empty.mkdir()
1308 result = _invoke(empty, ["bisect", "bad"])
1309 assert result.exit_code == 2
1310
1311 def test_bad_invalid_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
1312 root, repo_id = _make_repo(tmp_path)
1313 ids = _build_chain(root, repo_id, 4)
1314 self._start(root, ids)
1315 result = _invoke(root, ["bisect", "bad", "deadbeef_nonexistent"])
1316 assert result.exit_code == 1
1317
1318 def test_bad_text_mentions_commit(self, tmp_path: pathlib.Path) -> None:
1319 root, repo_id = _make_repo(tmp_path)
1320 ids = _build_chain(root, repo_id, 5)
1321 self._start(root, ids)
1322 mid = ids[len(ids) // 2]
1323 result = _invoke(root, ["bisect", "bad", mid])
1324 assert result.exit_code == 0
1325 assert short_id(mid) in result.output
1326
1327 def test_bad_text_no_json_object(self, tmp_path: pathlib.Path) -> None:
1328 root, repo_id = _make_repo(tmp_path)
1329 ids = _build_chain(root, repo_id, 5)
1330 self._start(root, ids)
1331 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2]])
1332 assert result.exit_code == 0
1333 assert not result.output.strip().startswith("{")
1334
1335 def test_bad_help_description_present(self, tmp_path: pathlib.Path) -> None:
1336 root, _ = _make_repo(tmp_path)
1337 result = _invoke(root, ["bisect", "bad", "--help"])
1338 assert "Agent quickstart" in result.output or "regression" in result.output.lower()
1339
1340 def test_bad_advances_bisect_log(self, tmp_path: pathlib.Path) -> None:
1341 """After marking bad, the bisect log records the verdict."""
1342 from muse.core.bisect import _load_state
1343 root, repo_id = _make_repo(tmp_path)
1344 ids = _build_chain(root, repo_id, 6)
1345 self._start(root, ids)
1346 mid = ids[len(ids) // 2]
1347 _invoke(root, ["bisect", "bad", mid])
1348 state = _load_state(root)
1349 assert state is not None
1350 assert any("bad" in entry for entry in state.get("log", []))
1351
1352 def test_bad_remaining_count_not_negative(self, tmp_path: pathlib.Path) -> None:
1353 root, repo_id = _make_repo(tmp_path)
1354 ids = _build_chain(root, repo_id, 5)
1355 self._start(root, ids)
1356 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "--json"])
1357 assert result.exit_code == 0
1358 assert _parse_step(result.output)["remaining_count"] >= 0
1359
1360 def test_bad_symbol_changes_is_list(self, tmp_path: pathlib.Path) -> None:
1361 root, repo_id = _make_repo(tmp_path)
1362 ids = _build_chain(root, repo_id, 5)
1363 self._start(root, ids)
1364 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "--json"])
1365 assert result.exit_code == 0
1366 assert isinstance(_parse_step(result.output)["symbol_changes"], list)
1367
1368
1369 class TestBisectBadSecurity:
1370 """Security hardening tests for muse bisect bad."""
1371
1372 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1373 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1374 assert r.exit_code == 0
1375
1376 def test_bad_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1377 root, repo_id = _make_repo(tmp_path)
1378 ids = _build_chain(root, repo_id, 5)
1379 self._start(root, ids)
1380 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "--json"])
1381 assert result.exit_code == 0
1382 d = json.loads(_json_blob(result.output))
1383 assert isinstance(d, dict)
1384
1385 def test_bad_json_done_is_bool(self, tmp_path: pathlib.Path) -> None:
1386 root, repo_id = _make_repo(tmp_path)
1387 ids = _build_chain(root, repo_id, 5)
1388 self._start(root, ids)
1389 result = _invoke(root, ["bisect", "bad", ids[len(ids) // 2], "--json"])
1390 assert result.exit_code == 0
1391 assert isinstance(json.loads(_json_blob(result.output))["done"], bool)
1392
1393 def test_bad_symbol_changes_sanitized_in_json(self, tmp_path: pathlib.Path) -> None:
1394 """ANSI in symbol_changes entries stripped from JSON output."""
1395 from unittest.mock import patch
1396 from muse.core.bisect import BisectResult
1397 root, repo_id = _make_repo(tmp_path)
1398 ids = _build_chain(root, repo_id, 5)
1399 self._start(root, ids)
1400 injected = BisectResult(
1401 done=False,
1402 first_bad=None,
1403 next_to_test=ids[2],
1404 remaining_count=2,
1405 steps_remaining=1,
1406 verdict="bad",
1407 symbol_changes=["modify func\x1b[31mred\x1b[0m"],
1408 )
1409 with patch("muse.cli.commands.bisect.mark_bad", return_value=injected):
1410 result = _invoke(root, ["bisect", "bad", ids[2], "--json"])
1411 assert "\x1b" not in result.output
1412
1413 def test_bad_symbol_changes_sanitized_in_text(self, tmp_path: pathlib.Path) -> None:
1414 """ANSI in symbol_changes entries stripped from text output."""
1415 from unittest.mock import patch
1416 from muse.core.bisect import BisectResult
1417 root, repo_id = _make_repo(tmp_path)
1418 ids = _build_chain(root, repo_id, 5)
1419 self._start(root, ids)
1420 injected = BisectResult(
1421 done=False,
1422 first_bad=None,
1423 next_to_test=ids[2],
1424 remaining_count=2,
1425 steps_remaining=1,
1426 verdict="bad",
1427 symbol_changes=["modify func\x1b[31mred\x1b[0m"],
1428 )
1429 with patch("muse.cli.commands.bisect.mark_bad", return_value=injected):
1430 result = _invoke(root, ["bisect", "bad", ids[2]])
1431 assert "\x1b" not in result.output
1432
1433 def test_bad_error_output_to_stderr_not_stdout(self, tmp_path: pathlib.Path) -> None:
1434 """Error messages go to stderr; stdout is clean on failure."""
1435 root, _ = _make_repo(tmp_path)
1436 result = _invoke(root, ["bisect", "bad"])
1437 assert result.exit_code != 0
1438 # CliRunner mixes stderr into output; verify no JSON object was emitted
1439 assert not result.output.strip().startswith("{")
1440
1441 def test_bad_ansi_in_ref_does_not_leak_to_output(self, tmp_path: pathlib.Path) -> None:
1442 """Passing an ANSI-injected ref does not leak escape codes to stdout."""
1443 root, repo_id = _make_repo(tmp_path)
1444 ids = _build_chain(root, repo_id, 4)
1445 self._start(root, ids)
1446 result = _invoke(root, ["bisect", "bad", "\x1b[31mHEAD\x1b[0m"])
1447 # Will fail (ref not found) but must not echo ANSI to stdout
1448 assert "\x1b" not in result.output
1449
1450
1451 class TestBisectBadStress:
1452 """Performance and scale tests for muse bisect bad."""
1453
1454 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1455 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1456 assert r.exit_code == 0
1457
1458 def test_bad_on_100_commit_chain(self, tmp_path: pathlib.Path) -> None:
1459 """Marking bad on a 100-commit session exits 0 and advances the search."""
1460 root, repo_id = _make_repo(tmp_path)
1461 ids = _build_chain(root, repo_id, 100)
1462 self._start(root, ids)
1463 result = _invoke(root, ["bisect", "bad", ids[50], "--json"])
1464 assert result.exit_code == 0
1465 assert _parse_step(result.output)["remaining_count"] >= 0
1466
1467 def test_bad_performance_100_commits(self, tmp_path: pathlib.Path) -> None:
1468 """Marking bad on a 100-commit session completes within 5 seconds."""
1469 import time
1470 root, repo_id = _make_repo(tmp_path)
1471 ids = _build_chain(root, repo_id, 100)
1472 self._start(root, ids)
1473 t0 = time.monotonic()
1474 result = _invoke(root, ["bisect", "bad", ids[50], "--json"])
1475 elapsed = time.monotonic() - t0
1476 assert result.exit_code == 0
1477 assert elapsed < 5.0, f"bisect bad on 100 commits took {elapsed:.2f}s"
1478
1479 def test_bad_converges_full_session(self, tmp_path: pathlib.Path) -> None:
1480 """Marking next_to_test as bad on every step converges within log2(20) steps."""
1481 root, repo_id = _make_repo(tmp_path)
1482 ids = _build_chain(root, repo_id, 20)
1483 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1484 assert r.exit_code == 0
1485 parsed = _parse_step(r.output)
1486 done = parsed["done"]
1487 for _ in range(10):
1488 if done:
1489 break
1490 nxt = parsed["next_to_test"]
1491 assert nxt is not None
1492 next_r = _invoke(root, ["bisect", "bad", nxt, "--json"])
1493 assert next_r.exit_code == 0
1494 parsed = _parse_step(next_r.output)
1495 done = parsed["done"]
1496 assert done, "bisect did not converge within 10 bad steps on 20-commit chain"
1497
1498
1499 # ---------------------------------------------------------------------------
1500 # bisect good — Extended, Security, Stress
1501 # ---------------------------------------------------------------------------
1502
1503
1504 class TestBisectGoodExtended:
1505 """Extended unit / integration / e2e tests for muse bisect good."""
1506
1507 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1508 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1509 assert r.exit_code == 0
1510
1511 def test_good_exits_0(self, tmp_path: pathlib.Path) -> None:
1512 root, repo_id = _make_repo(tmp_path)
1513 ids = _build_chain(root, repo_id, 6)
1514 self._start(root, ids)
1515 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2]])
1516 assert result.exit_code == 0
1517
1518 def test_good_j_alias_works(self, tmp_path: pathlib.Path) -> None:
1519 root, repo_id = _make_repo(tmp_path)
1520 ids = _build_chain(root, repo_id, 6)
1521 self._start(root, ids)
1522 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "-j"])
1523 assert result.exit_code == 0
1524 assert _parse_step(result.output)["verdict"] == "good"
1525
1526 def test_good_json_verdict_is_good(self, tmp_path: pathlib.Path) -> None:
1527 root, repo_id = _make_repo(tmp_path)
1528 ids = _build_chain(root, repo_id, 6)
1529 self._start(root, ids)
1530 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "--json"])
1531 assert result.exit_code == 0
1532 assert _parse_step(result.output)["verdict"] == "good"
1533
1534 def test_good_json_all_seven_keys(self, tmp_path: pathlib.Path) -> None:
1535 root, repo_id = _make_repo(tmp_path)
1536 ids = _build_chain(root, repo_id, 6)
1537 self._start(root, ids)
1538 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "--json"])
1539 assert result.exit_code == 0
1540 d = json.loads(_json_blob(result.output))
1541 assert {"done", "first_bad", "next_to_test", "remaining_count",
1542 "steps_remaining", "verdict", "symbol_changes"} <= set(d.keys())
1543
1544 def test_good_reduces_remaining(self, tmp_path: pathlib.Path) -> None:
1545 root, repo_id = _make_repo(tmp_path)
1546 ids = _build_chain(root, repo_id, 10)
1547 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1548 before = _parse_step(r.output)["remaining_count"]
1549 mid = _parse_step(r.output)["next_to_test"]
1550 result = _invoke(root, ["bisect", "good", mid, "--json"])
1551 assert result.exit_code == 0
1552 assert _parse_step(result.output)["remaining_count"] < before
1553
1554 def test_good_done_true_when_isolated(self, tmp_path: pathlib.Path) -> None:
1555 """Marking the only remaining commit good isolates first bad immediately."""
1556 root, repo_id = _make_repo(tmp_path)
1557 ids = _build_chain(root, repo_id, 3)
1558 # good=ids[0], bad=ids[2]: ids[1] is the midpoint; marking it good resolves
1559 self._start(root, ids)
1560 result = _invoke(root, ["bisect", "good", ids[1], "--json"])
1561 assert result.exit_code == 0
1562 parsed = _parse_step(result.output)
1563 assert parsed["done"] is True
1564 assert parsed["first_bad"] == ids[2]
1565
1566 def test_good_first_bad_set_when_done(self, tmp_path: pathlib.Path) -> None:
1567 root, repo_id = _make_repo(tmp_path)
1568 ids = _build_chain(root, repo_id, 3)
1569 self._start(root, ids)
1570 result = _invoke(root, ["bisect", "good", ids[1], "--json"])
1571 assert result.exit_code == 0
1572 parsed = _parse_step(result.output)
1573 assert parsed["done"] is True
1574 assert isinstance(parsed["first_bad"], str)
1575
1576 def test_good_defaults_to_head(self, tmp_path: pathlib.Path) -> None:
1577 root, repo_id = _make_repo(tmp_path)
1578 ids = _build_chain(root, repo_id, 5)
1579 self._start(root, ids)
1580 # HEAD is ids[-1] (known bad); marking it good is legal but pushes bad boundary
1581 result = _invoke(root, ["bisect", "good", "--json"])
1582 assert result.exit_code == 0
1583
1584 def test_good_no_session_exits_1(self, tmp_path: pathlib.Path) -> None:
1585 root, _ = _make_repo(tmp_path)
1586 result = _invoke(root, ["bisect", "good"])
1587 assert result.exit_code == 1
1588
1589 def test_good_no_session_error_message(self, tmp_path: pathlib.Path) -> None:
1590 root, _ = _make_repo(tmp_path)
1591 result = _invoke(root, ["bisect", "good"])
1592 combined = result.output + (result.stderr or "")
1593 assert "No bisect session" in combined
1594
1595 def test_good_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1596 empty = tmp_path / "not_a_repo"
1597 empty.mkdir()
1598 result = _invoke(empty, ["bisect", "good"])
1599 assert result.exit_code == 2
1600
1601 def test_good_invalid_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
1602 root, repo_id = _make_repo(tmp_path)
1603 ids = _build_chain(root, repo_id, 4)
1604 self._start(root, ids)
1605 result = _invoke(root, ["bisect", "good", "deadbeef_nonexistent"])
1606 assert result.exit_code == 1
1607
1608 def test_good_text_mentions_commit(self, tmp_path: pathlib.Path) -> None:
1609 root, repo_id = _make_repo(tmp_path)
1610 ids = _build_chain(root, repo_id, 5)
1611 self._start(root, ids)
1612 mid = ids[len(ids) // 2]
1613 result = _invoke(root, ["bisect", "good", mid])
1614 assert result.exit_code == 0
1615 assert short_id(mid) in result.output
1616
1617 def test_good_text_no_json_object(self, tmp_path: pathlib.Path) -> None:
1618 root, repo_id = _make_repo(tmp_path)
1619 ids = _build_chain(root, repo_id, 5)
1620 self._start(root, ids)
1621 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2]])
1622 assert result.exit_code == 0
1623 assert not result.output.strip().startswith("{")
1624
1625 def test_good_help_description_present(self, tmp_path: pathlib.Path) -> None:
1626 root, _ = _make_repo(tmp_path)
1627 result = _invoke(root, ["bisect", "good", "--help"])
1628 assert "Agent quickstart" in result.output or "regression" in result.output.lower()
1629
1630 def test_good_advances_bisect_log(self, tmp_path: pathlib.Path) -> None:
1631 from muse.core.bisect import _load_state
1632 root, repo_id = _make_repo(tmp_path)
1633 ids = _build_chain(root, repo_id, 6)
1634 self._start(root, ids)
1635 _invoke(root, ["bisect", "good", ids[len(ids) // 2]])
1636 state = _load_state(root)
1637 assert state is not None
1638 assert any("good" in entry for entry in state.get("log", []))
1639
1640 def test_good_remaining_count_not_negative(self, tmp_path: pathlib.Path) -> None:
1641 root, repo_id = _make_repo(tmp_path)
1642 ids = _build_chain(root, repo_id, 5)
1643 self._start(root, ids)
1644 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "--json"])
1645 assert result.exit_code == 0
1646 assert _parse_step(result.output)["remaining_count"] >= 0
1647
1648 def test_good_symbol_changes_is_list(self, tmp_path: pathlib.Path) -> None:
1649 root, repo_id = _make_repo(tmp_path)
1650 ids = _build_chain(root, repo_id, 5)
1651 self._start(root, ids)
1652 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "--json"])
1653 assert result.exit_code == 0
1654 assert isinstance(_parse_step(result.output)["symbol_changes"], list)
1655
1656
1657 class TestBisectGoodSecurity:
1658 """Security hardening tests for muse bisect good."""
1659
1660 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1661 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1662 assert r.exit_code == 0
1663
1664 def test_good_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1665 root, repo_id = _make_repo(tmp_path)
1666 ids = _build_chain(root, repo_id, 5)
1667 self._start(root, ids)
1668 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "--json"])
1669 assert result.exit_code == 0
1670 assert isinstance(json.loads(_json_blob(result.output)), dict)
1671
1672 def test_good_json_done_is_bool(self, tmp_path: pathlib.Path) -> None:
1673 root, repo_id = _make_repo(tmp_path)
1674 ids = _build_chain(root, repo_id, 5)
1675 self._start(root, ids)
1676 result = _invoke(root, ["bisect", "good", ids[len(ids) // 2], "--json"])
1677 assert result.exit_code == 0
1678 assert isinstance(json.loads(_json_blob(result.output))["done"], bool)
1679
1680 def test_good_symbol_changes_sanitized_in_json(self, tmp_path: pathlib.Path) -> None:
1681 from unittest.mock import patch
1682 from muse.core.bisect import BisectResult
1683 root, repo_id = _make_repo(tmp_path)
1684 ids = _build_chain(root, repo_id, 5)
1685 self._start(root, ids)
1686 injected = BisectResult(
1687 done=False,
1688 first_bad=None,
1689 next_to_test=ids[2],
1690 remaining_count=2,
1691 steps_remaining=1,
1692 verdict="good",
1693 symbol_changes=["add func\x1b[32mgreen\x1b[0m"],
1694 )
1695 with patch("muse.cli.commands.bisect.mark_good", return_value=injected):
1696 result = _invoke(root, ["bisect", "good", ids[2], "--json"])
1697 assert "\x1b" not in result.output
1698
1699 def test_good_symbol_changes_sanitized_in_text(self, tmp_path: pathlib.Path) -> None:
1700 from unittest.mock import patch
1701 from muse.core.bisect import BisectResult
1702 root, repo_id = _make_repo(tmp_path)
1703 ids = _build_chain(root, repo_id, 5)
1704 self._start(root, ids)
1705 injected = BisectResult(
1706 done=False,
1707 first_bad=None,
1708 next_to_test=ids[2],
1709 remaining_count=2,
1710 steps_remaining=1,
1711 verdict="good",
1712 symbol_changes=["add func\x1b[32mgreen\x1b[0m"],
1713 )
1714 with patch("muse.cli.commands.bisect.mark_good", return_value=injected):
1715 result = _invoke(root, ["bisect", "good", ids[2]])
1716 assert "\x1b" not in result.output
1717
1718 def test_good_error_no_json_on_failure(self, tmp_path: pathlib.Path) -> None:
1719 root, _ = _make_repo(tmp_path)
1720 result = _invoke(root, ["bisect", "good"])
1721 assert result.exit_code != 0
1722 assert not result.output.strip().startswith("{")
1723
1724 def test_good_ansi_in_ref_does_not_leak(self, tmp_path: pathlib.Path) -> None:
1725 root, repo_id = _make_repo(tmp_path)
1726 ids = _build_chain(root, repo_id, 4)
1727 self._start(root, ids)
1728 result = _invoke(root, ["bisect", "good", "\x1b[32mHEAD\x1b[0m"])
1729 assert "\x1b" not in result.output
1730
1731
1732 class TestBisectGoodStress:
1733 """Performance and scale tests for muse bisect good."""
1734
1735 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1736 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1737 assert r.exit_code == 0
1738
1739 def test_good_on_100_commit_chain(self, tmp_path: pathlib.Path) -> None:
1740 root, repo_id = _make_repo(tmp_path)
1741 ids = _build_chain(root, repo_id, 100)
1742 self._start(root, ids)
1743 result = _invoke(root, ["bisect", "good", ids[10], "--json"])
1744 assert result.exit_code == 0
1745 assert _parse_step(result.output)["remaining_count"] >= 0
1746
1747 def test_good_performance_100_commits(self, tmp_path: pathlib.Path) -> None:
1748 import time
1749 root, repo_id = _make_repo(tmp_path)
1750 ids = _build_chain(root, repo_id, 100)
1751 self._start(root, ids)
1752 t0 = time.monotonic()
1753 result = _invoke(root, ["bisect", "good", ids[10], "--json"])
1754 elapsed = time.monotonic() - t0
1755 assert result.exit_code == 0
1756 assert elapsed < 5.0, f"bisect good on 100 commits took {elapsed:.2f}s"
1757
1758 def test_good_converges_full_session(self, tmp_path: pathlib.Path) -> None:
1759 """Marking next_to_test as good on each step converges within log2(20) steps."""
1760 root, repo_id = _make_repo(tmp_path)
1761 ids = _build_chain(root, repo_id, 20)
1762 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1763 assert r.exit_code == 0
1764 parsed = _parse_step(r.output)
1765 done = parsed["done"]
1766 for _ in range(10):
1767 if done:
1768 break
1769 nxt = parsed["next_to_test"]
1770 assert nxt is not None
1771 next_r = _invoke(root, ["bisect", "good", nxt, "--json"])
1772 assert next_r.exit_code == 0
1773 parsed = _parse_step(next_r.output)
1774 done = parsed["done"]
1775 assert done, "bisect did not converge within 10 good steps on 20-commit chain"
1776
1777
1778 # ---------------------------------------------------------------------------
1779 # bisect skip — Extended, Security, Stress
1780 # ---------------------------------------------------------------------------
1781
1782
1783 class TestBisectSkipExtended:
1784 """Extended unit / integration / e2e tests for muse bisect skip."""
1785
1786 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1787 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1788 assert r.exit_code == 0
1789
1790 def test_skip_exits_0(self, tmp_path: pathlib.Path) -> None:
1791 root, repo_id = _make_repo(tmp_path)
1792 ids = _build_chain(root, repo_id, 6)
1793 self._start(root, ids)
1794 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2]])
1795 assert result.exit_code == 0
1796
1797 def test_skip_j_alias_works(self, tmp_path: pathlib.Path) -> None:
1798 root, repo_id = _make_repo(tmp_path)
1799 ids = _build_chain(root, repo_id, 6)
1800 self._start(root, ids)
1801 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "-j"])
1802 assert result.exit_code == 0
1803 assert _parse_step(result.output)["verdict"] == "skip"
1804
1805 def test_skip_json_verdict_is_skip(self, tmp_path: pathlib.Path) -> None:
1806 root, repo_id = _make_repo(tmp_path)
1807 ids = _build_chain(root, repo_id, 6)
1808 self._start(root, ids)
1809 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "--json"])
1810 assert result.exit_code == 0
1811 assert _parse_step(result.output)["verdict"] == "skip"
1812
1813 def test_skip_json_all_seven_keys(self, tmp_path: pathlib.Path) -> None:
1814 root, repo_id = _make_repo(tmp_path)
1815 ids = _build_chain(root, repo_id, 6)
1816 self._start(root, ids)
1817 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "--json"])
1818 assert result.exit_code == 0
1819 d = json.loads(_json_blob(result.output))
1820 assert {"done", "first_bad", "next_to_test", "remaining_count",
1821 "steps_remaining", "verdict", "symbol_changes"} <= set(d.keys())
1822
1823 def test_skip_removes_commit_from_remaining(self, tmp_path: pathlib.Path) -> None:
1824 root, repo_id = _make_repo(tmp_path)
1825 ids = _build_chain(root, repo_id, 10)
1826 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
1827 before = _parse_step(r.output)["remaining_count"]
1828 mid = _parse_step(r.output)["next_to_test"]
1829 result = _invoke(root, ["bisect", "skip", mid, "--json"])
1830 assert result.exit_code == 0
1831 assert _parse_step(result.output)["remaining_count"] < before
1832
1833 def test_skip_persisted_in_state(self, tmp_path: pathlib.Path) -> None:
1834 from muse.core.bisect import _load_state
1835 root, repo_id = _make_repo(tmp_path)
1836 ids = _build_chain(root, repo_id, 6)
1837 self._start(root, ids)
1838 mid = ids[len(ids) // 2]
1839 _invoke(root, ["bisect", "skip", mid])
1840 state = _load_state(root)
1841 assert state is not None
1842 assert mid in state.get("skipped_ids", [])
1843
1844 def test_skip_defaults_to_head(self, tmp_path: pathlib.Path) -> None:
1845 root, repo_id = _make_repo(tmp_path)
1846 ids = _build_chain(root, repo_id, 5)
1847 self._start(root, ids)
1848 result = _invoke(root, ["bisect", "skip", "--json"])
1849 assert result.exit_code == 0
1850
1851 def test_skip_no_session_exits_1(self, tmp_path: pathlib.Path) -> None:
1852 root, _ = _make_repo(tmp_path)
1853 result = _invoke(root, ["bisect", "skip"])
1854 assert result.exit_code == 1
1855
1856 def test_skip_no_session_error_message(self, tmp_path: pathlib.Path) -> None:
1857 root, _ = _make_repo(tmp_path)
1858 result = _invoke(root, ["bisect", "skip"])
1859 combined = result.output + (result.stderr or "")
1860 assert "No bisect session" in combined
1861
1862 def test_skip_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1863 empty = tmp_path / "not_a_repo"
1864 empty.mkdir()
1865 result = _invoke(empty, ["bisect", "skip"])
1866 assert result.exit_code == 2
1867
1868 def test_skip_invalid_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
1869 root, repo_id = _make_repo(tmp_path)
1870 ids = _build_chain(root, repo_id, 4)
1871 self._start(root, ids)
1872 result = _invoke(root, ["bisect", "skip", "deadbeef_nonexistent"])
1873 assert result.exit_code == 1
1874
1875 def test_skip_text_mentions_commit(self, tmp_path: pathlib.Path) -> None:
1876 root, repo_id = _make_repo(tmp_path)
1877 ids = _build_chain(root, repo_id, 5)
1878 self._start(root, ids)
1879 mid = ids[len(ids) // 2]
1880 result = _invoke(root, ["bisect", "skip", mid])
1881 assert result.exit_code == 0
1882 assert short_id(mid) in result.output
1883
1884 def test_skip_text_no_json_object(self, tmp_path: pathlib.Path) -> None:
1885 root, repo_id = _make_repo(tmp_path)
1886 ids = _build_chain(root, repo_id, 5)
1887 self._start(root, ids)
1888 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2]])
1889 assert result.exit_code == 0
1890 assert not result.output.strip().startswith("{")
1891
1892 def test_skip_help_description_present(self, tmp_path: pathlib.Path) -> None:
1893 root, _ = _make_repo(tmp_path)
1894 result = _invoke(root, ["bisect", "skip", "--help"])
1895 assert "Agent quickstart" in result.output or "125" in result.output
1896
1897 def test_skip_advances_log(self, tmp_path: pathlib.Path) -> None:
1898 from muse.core.bisect import _load_state
1899 root, repo_id = _make_repo(tmp_path)
1900 ids = _build_chain(root, repo_id, 6)
1901 self._start(root, ids)
1902 _invoke(root, ["bisect", "skip", ids[len(ids) // 2]])
1903 state = _load_state(root)
1904 assert state is not None
1905 assert any("skip" in entry for entry in state.get("log", []))
1906
1907 def test_skip_remaining_count_not_negative(self, tmp_path: pathlib.Path) -> None:
1908 root, repo_id = _make_repo(tmp_path)
1909 ids = _build_chain(root, repo_id, 5)
1910 self._start(root, ids)
1911 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "--json"])
1912 assert result.exit_code == 0
1913 assert _parse_step(result.output)["remaining_count"] >= 0
1914
1915 def test_skip_symbol_changes_is_list(self, tmp_path: pathlib.Path) -> None:
1916 root, repo_id = _make_repo(tmp_path)
1917 ids = _build_chain(root, repo_id, 5)
1918 self._start(root, ids)
1919 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "--json"])
1920 assert result.exit_code == 0
1921 assert isinstance(_parse_step(result.output)["symbol_changes"], list)
1922
1923 def test_skip_multiple_commits(self, tmp_path: pathlib.Path) -> None:
1924 """Skipping several commits all land in skipped_ids."""
1925 from muse.core.bisect import _load_state
1926 root, repo_id = _make_repo(tmp_path)
1927 ids = _build_chain(root, repo_id, 8)
1928 self._start(root, ids)
1929 for idx in (2, 3, 4):
1930 r = _invoke(root, ["bisect", "skip", ids[idx]])
1931 assert r.exit_code == 0
1932 state = _load_state(root)
1933 assert state is not None
1934 skipped = state.get("skipped_ids", [])
1935 assert all(ids[i] in skipped for i in (2, 3, 4))
1936
1937
1938 class TestBisectSkipSecurity:
1939 """Security hardening tests for muse bisect skip."""
1940
1941 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
1942 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
1943 assert r.exit_code == 0
1944
1945 def test_skip_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1946 root, repo_id = _make_repo(tmp_path)
1947 ids = _build_chain(root, repo_id, 5)
1948 self._start(root, ids)
1949 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "--json"])
1950 assert result.exit_code == 0
1951 assert isinstance(json.loads(_json_blob(result.output)), dict)
1952
1953 def test_skip_json_done_is_bool(self, tmp_path: pathlib.Path) -> None:
1954 root, repo_id = _make_repo(tmp_path)
1955 ids = _build_chain(root, repo_id, 5)
1956 self._start(root, ids)
1957 result = _invoke(root, ["bisect", "skip", ids[len(ids) // 2], "--json"])
1958 assert result.exit_code == 0
1959 assert isinstance(json.loads(_json_blob(result.output))["done"], bool)
1960
1961 def test_skip_symbol_changes_sanitized_in_json(self, tmp_path: pathlib.Path) -> None:
1962 from unittest.mock import patch
1963 from muse.core.bisect import BisectResult
1964 root, repo_id = _make_repo(tmp_path)
1965 ids = _build_chain(root, repo_id, 5)
1966 self._start(root, ids)
1967 injected = BisectResult(
1968 done=False,
1969 first_bad=None,
1970 next_to_test=ids[2],
1971 remaining_count=2,
1972 steps_remaining=1,
1973 verdict="skip",
1974 symbol_changes=["modify func\x1b[33myellow\x1b[0m"],
1975 )
1976 with patch("muse.cli.commands.bisect.skip_commit", return_value=injected):
1977 result = _invoke(root, ["bisect", "skip", ids[2], "--json"])
1978 assert "\x1b" not in result.output
1979
1980 def test_skip_symbol_changes_sanitized_in_text(self, tmp_path: pathlib.Path) -> None:
1981 from unittest.mock import patch
1982 from muse.core.bisect import BisectResult
1983 root, repo_id = _make_repo(tmp_path)
1984 ids = _build_chain(root, repo_id, 5)
1985 self._start(root, ids)
1986 injected = BisectResult(
1987 done=False,
1988 first_bad=None,
1989 next_to_test=ids[2],
1990 remaining_count=2,
1991 steps_remaining=1,
1992 verdict="skip",
1993 symbol_changes=["modify func\x1b[33myellow\x1b[0m"],
1994 )
1995 with patch("muse.cli.commands.bisect.skip_commit", return_value=injected):
1996 result = _invoke(root, ["bisect", "skip", ids[2]])
1997 assert "\x1b" not in result.output
1998
1999 def test_skip_error_no_json_on_failure(self, tmp_path: pathlib.Path) -> None:
2000 root, _ = _make_repo(tmp_path)
2001 result = _invoke(root, ["bisect", "skip"])
2002 assert result.exit_code != 0
2003 assert not result.output.strip().startswith("{")
2004
2005 def test_skip_ansi_in_ref_does_not_leak(self, tmp_path: pathlib.Path) -> None:
2006 root, repo_id = _make_repo(tmp_path)
2007 ids = _build_chain(root, repo_id, 4)
2008 self._start(root, ids)
2009 result = _invoke(root, ["bisect", "skip", "\x1b[33mHEAD\x1b[0m"])
2010 assert "\x1b" not in result.output
2011
2012
2013 class TestBisectSkipStress:
2014 """Performance and scale tests for muse bisect skip."""
2015
2016 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2017 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2018 assert r.exit_code == 0
2019
2020 def test_skip_on_100_commit_chain(self, tmp_path: pathlib.Path) -> None:
2021 root, repo_id = _make_repo(tmp_path)
2022 ids = _build_chain(root, repo_id, 100)
2023 self._start(root, ids)
2024 result = _invoke(root, ["bisect", "skip", ids[50], "--json"])
2025 assert result.exit_code == 0
2026 assert _parse_step(result.output)["remaining_count"] >= 0
2027
2028 def test_skip_performance_100_commits(self, tmp_path: pathlib.Path) -> None:
2029 import time
2030 root, repo_id = _make_repo(tmp_path)
2031 ids = _build_chain(root, repo_id, 100)
2032 self._start(root, ids)
2033 t0 = time.monotonic()
2034 result = _invoke(root, ["bisect", "skip", ids[50], "--json"])
2035 elapsed = time.monotonic() - t0
2036 assert result.exit_code == 0
2037 assert elapsed < 5.0, f"bisect skip on 100 commits took {elapsed:.2f}s"
2038
2039 def test_skip_reduces_remaining_monotonically(self, tmp_path: pathlib.Path) -> None:
2040 """Each consecutive skip reduces remaining_count (non-increasing sequence)."""
2041 root, repo_id = _make_repo(tmp_path)
2042 ids = _build_chain(root, repo_id, 20)
2043 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
2044 assert r.exit_code == 0
2045 counts = [_parse_step(r.output)["remaining_count"]]
2046 cur = r
2047 for _ in range(5):
2048 parsed = _parse_step(cur.output)
2049 if parsed["done"] or parsed["next_to_test"] is None:
2050 break
2051 nxt = parsed["next_to_test"]
2052 cur = _invoke(root, ["bisect", "skip", nxt, "--json"])
2053 assert cur.exit_code == 0
2054 counts.append(_parse_step(cur.output)["remaining_count"])
2055 assert all(counts[i] >= counts[i + 1] for i in range(len(counts) - 1))
2056
2057
2058 # ---------------------------------------------------------------------------
2059 # bisect run — Extended, Security, Stress
2060 # ---------------------------------------------------------------------------
2061
2062
2063 class TestBisectRunExtended:
2064 """Extended unit / integration / e2e tests for muse bisect run."""
2065
2066 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2067 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2068 assert r.exit_code == 0
2069
2070 def test_run_exits_0_with_true(self, tmp_path: pathlib.Path) -> None:
2071 root, repo_id = _make_repo(tmp_path)
2072 ids = _build_chain(root, repo_id, 5)
2073 self._start(root, ids)
2074 result = _invoke(root, ["bisect", "run", "true"])
2075 assert result.exit_code == 0
2076
2077 def test_run_j_alias_works(self, tmp_path: pathlib.Path) -> None:
2078 root, repo_id = _make_repo(tmp_path)
2079 ids = _build_chain(root, repo_id, 5)
2080 self._start(root, ids)
2081 result = _invoke(root, ["bisect", "run", "true", "-j"])
2082 assert result.exit_code == 0
2083 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2084 assert len(lines) >= 1
2085 done_raw = json.loads(lines[-1])
2086 assert done_raw["done"] is True
2087
2088 def test_run_json_ndjson_step_keys(self, tmp_path: pathlib.Path) -> None:
2089 root, repo_id = _make_repo(tmp_path)
2090 ids = _build_chain(root, repo_id, 6)
2091 self._start(root, ids)
2092 result = _invoke(root, ["bisect", "run", "true", "--json"])
2093 assert result.exit_code == 0
2094 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2095 if len(lines) > 1:
2096 step = json.loads(lines[0])
2097 assert {"step", "testing", "verdict", "remaining_count", "done", "symbol_changes"} <= set(step.keys())
2098
2099 def test_run_json_done_line_keys(self, tmp_path: pathlib.Path) -> None:
2100 root, repo_id = _make_repo(tmp_path)
2101 ids = _build_chain(root, repo_id, 5)
2102 self._start(root, ids)
2103 result = _invoke(root, ["bisect", "run", "true", "--json"])
2104 assert result.exit_code == 0
2105 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2106 done = json.loads(lines[-1])
2107 assert set(done.keys()) == {"done", "first_bad", "steps_taken"}
2108
2109 def test_run_json_done_true_on_last_line(self, tmp_path: pathlib.Path) -> None:
2110 root, repo_id = _make_repo(tmp_path)
2111 ids = _build_chain(root, repo_id, 5)
2112 self._start(root, ids)
2113 result = _invoke(root, ["bisect", "run", "true", "--json"])
2114 assert result.exit_code == 0
2115 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2116 assert json.loads(lines[-1])["done"] is True
2117
2118 def test_run_json_steps_taken_positive(self, tmp_path: pathlib.Path) -> None:
2119 root, repo_id = _make_repo(tmp_path)
2120 ids = _build_chain(root, repo_id, 6)
2121 self._start(root, ids)
2122 result = _invoke(root, ["bisect", "run", "true", "--json"])
2123 assert result.exit_code == 0
2124 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2125 assert json.loads(lines[-1])["steps_taken"] >= 1
2126
2127 def test_run_json_verdict_good_with_true(self, tmp_path: pathlib.Path) -> None:
2128 root, repo_id = _make_repo(tmp_path)
2129 ids = _build_chain(root, repo_id, 5)
2130 self._start(root, ids)
2131 result = _invoke(root, ["bisect", "run", "true", "--json"])
2132 assert result.exit_code == 0
2133 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2134 step_lines = lines[:-1]
2135 assert all(json.loads(l)["verdict"] == "good" for l in step_lines)
2136
2137 def test_run_json_verdict_bad_with_false(self, tmp_path: pathlib.Path) -> None:
2138 root, repo_id = _make_repo(tmp_path)
2139 ids = _build_chain(root, repo_id, 5)
2140 self._start(root, ids)
2141 result = _invoke(root, ["bisect", "run", "false", "--json"])
2142 assert result.exit_code == 0
2143 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2144 step_lines = lines[:-1]
2145 assert all(json.loads(l)["verdict"] == "bad" for l in step_lines)
2146
2147 def test_run_no_session_exits_1(self, tmp_path: pathlib.Path) -> None:
2148 root, _ = _make_repo(tmp_path)
2149 result = _invoke(root, ["bisect", "run", "true"])
2150 assert result.exit_code == 1
2151
2152 def test_run_no_session_error_message(self, tmp_path: pathlib.Path) -> None:
2153 root, _ = _make_repo(tmp_path)
2154 result = _invoke(root, ["bisect", "run", "true"])
2155 combined = result.output + (result.stderr or "")
2156 assert "No bisect session" in combined
2157
2158 def test_run_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
2159 empty = tmp_path / "not_a_repo"
2160 empty.mkdir()
2161 result = _invoke(empty, ["bisect", "run", "true"])
2162 assert result.exit_code == 2
2163
2164 def test_run_text_mentions_testing(self, tmp_path: pathlib.Path) -> None:
2165 root, repo_id = _make_repo(tmp_path)
2166 ids = _build_chain(root, repo_id, 5)
2167 self._start(root, ids)
2168 result = _invoke(root, ["bisect", "run", "true"])
2169 assert result.exit_code == 0
2170 assert "Testing" in result.output or "→" in result.output
2171
2172 def test_run_text_mentions_first_bad(self, tmp_path: pathlib.Path) -> None:
2173 root, repo_id = _make_repo(tmp_path)
2174 ids = _build_chain(root, repo_id, 5)
2175 self._start(root, ids)
2176 result = _invoke(root, ["bisect", "run", "true"])
2177 assert result.exit_code == 0
2178 assert "First bad commit" in result.output or "Bisect complete" in result.output
2179
2180 def test_run_help_description_present(self, tmp_path: pathlib.Path) -> None:
2181 root, _ = _make_repo(tmp_path)
2182 result = _invoke(root, ["bisect", "run", "--help"])
2183 assert "Agent quickstart" in result.output or "125" in result.output
2184
2185 def test_run_json_step_numbers_increment(self, tmp_path: pathlib.Path) -> None:
2186 root, repo_id = _make_repo(tmp_path)
2187 ids = _build_chain(root, repo_id, 8)
2188 self._start(root, ids)
2189 result = _invoke(root, ["bisect", "run", "true", "--json"])
2190 assert result.exit_code == 0
2191 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2192 step_nums = [json.loads(l)["step"] for l in lines[:-1]]
2193 assert step_nums == list(range(1, len(step_nums) + 1))
2194
2195 def test_run_json_remaining_nonincreasing(self, tmp_path: pathlib.Path) -> None:
2196 root, repo_id = _make_repo(tmp_path)
2197 ids = _build_chain(root, repo_id, 8)
2198 self._start(root, ids)
2199 result = _invoke(root, ["bisect", "run", "true", "--json"])
2200 assert result.exit_code == 0
2201 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2202 counts = [json.loads(l)["remaining_count"] for l in lines[:-1]]
2203 assert all(counts[i] >= counts[i + 1] for i in range(len(counts) - 1))
2204
2205 def test_run_text_no_json_by_default(self, tmp_path: pathlib.Path) -> None:
2206 root, repo_id = _make_repo(tmp_path)
2207 ids = _build_chain(root, repo_id, 4)
2208 self._start(root, ids)
2209 result = _invoke(root, ["bisect", "run", "true"])
2210 assert result.exit_code == 0
2211 # Text mode should not have a JSON object on a line by itself
2212 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
2213 assert json_lines == []
2214
2215 def test_run_json_first_bad_set_on_done(self, tmp_path: pathlib.Path) -> None:
2216 root, repo_id = _make_repo(tmp_path)
2217 ids = _build_chain(root, repo_id, 5)
2218 self._start(root, ids)
2219 result = _invoke(root, ["bisect", "run", "true", "--json"])
2220 assert result.exit_code == 0
2221 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2222 done = json.loads(lines[-1])
2223 if done["done"]:
2224 assert done["first_bad"] is not None
2225
2226
2227 class TestBisectRunSecurity:
2228 """Security hardening tests for muse bisect run."""
2229
2230 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2231 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2232 assert r.exit_code == 0
2233
2234 def test_run_json_lines_are_valid_json(self, tmp_path: pathlib.Path) -> None:
2235 root, repo_id = _make_repo(tmp_path)
2236 ids = _build_chain(root, repo_id, 5)
2237 self._start(root, ids)
2238 result = _invoke(root, ["bisect", "run", "true", "--json"])
2239 assert result.exit_code == 0
2240 for line in result.output.strip().splitlines():
2241 if line.strip():
2242 assert isinstance(json.loads(line.strip()), dict)
2243
2244 def test_run_json_done_field_is_bool(self, tmp_path: pathlib.Path) -> None:
2245 root, repo_id = _make_repo(tmp_path)
2246 ids = _build_chain(root, repo_id, 5)
2247 self._start(root, ids)
2248 result = _invoke(root, ["bisect", "run", "true", "--json"])
2249 assert result.exit_code == 0
2250 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2251 for line in lines:
2252 assert isinstance(json.loads(line)["done"], bool)
2253
2254 def test_run_text_symbol_changes_sanitized(self, tmp_path: pathlib.Path) -> None:
2255 """ANSI codes in symbol_changes are stripped from text output during run."""
2256 from unittest.mock import patch
2257 from muse.core.bisect import BisectResult
2258 root, repo_id = _make_repo(tmp_path)
2259 ids = _build_chain(root, repo_id, 5)
2260 self._start(root, ids)
2261 injected_result = BisectResult(
2262 done=True,
2263 first_bad=ids[2],
2264 next_to_test=None,
2265 remaining_count=0,
2266 steps_remaining=0,
2267 verdict="bad",
2268 symbol_changes=[],
2269 )
2270 with patch("muse.cli.commands.bisect._symbol_ops_in_commit",
2271 return_value=["add func\x1b[31mred\x1b[0m"]), \
2272 patch("muse.cli.commands.bisect.get_bisect_next",
2273 side_effect=[(ids[2], "billing.py::Invoice"), (None, "")]), \
2274 patch("muse.cli.commands.bisect.run_bisect_command",
2275 return_value=injected_result):
2276 result = _invoke(root, ["bisect", "run", "true"])
2277 assert "\x1b" not in result.output
2278
2279 def test_run_error_no_json_on_failure(self, tmp_path: pathlib.Path) -> None:
2280 root, _ = _make_repo(tmp_path)
2281 result = _invoke(root, ["bisect", "run", "true"])
2282 assert result.exit_code != 0
2283 assert not result.output.strip().startswith("{")
2284
2285 def test_run_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
2286 root, repo_id = _make_repo(tmp_path)
2287 ids = _build_chain(root, repo_id, 5)
2288 self._start(root, ids)
2289 result = _invoke(root, ["bisect", "run", "true", "--json"])
2290 assert result.exit_code == 0
2291 assert "\x1b" not in result.output
2292
2293 def test_run_text_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
2294 root, repo_id = _make_repo(tmp_path)
2295 ids = _build_chain(root, repo_id, 5)
2296 self._start(root, ids)
2297 result = _invoke(root, ["bisect", "run", "true"])
2298 assert result.exit_code == 0
2299 assert "\x1b" not in result.output
2300
2301
2302 class TestBisectRunStress:
2303 """Performance and scale tests for muse bisect run."""
2304
2305 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2306 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2307 assert r.exit_code == 0
2308
2309 def test_run_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
2310 """run converges on a 50-commit chain with always-good command."""
2311 root, repo_id = _make_repo(tmp_path)
2312 ids = _build_chain(root, repo_id, 50)
2313 self._start(root, ids)
2314 result = _invoke(root, ["bisect", "run", "true", "--json"])
2315 assert result.exit_code == 0
2316 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2317 assert json.loads(lines[-1])["done"] is True
2318
2319 def test_run_performance_20_commits(self, tmp_path: pathlib.Path) -> None:
2320 """run over 20 commits completes within 10 seconds."""
2321 import time
2322 root, repo_id = _make_repo(tmp_path)
2323 ids = _build_chain(root, repo_id, 20)
2324 self._start(root, ids)
2325 t0 = time.monotonic()
2326 result = _invoke(root, ["bisect", "run", "true", "--json"])
2327 elapsed = time.monotonic() - t0
2328 assert result.exit_code == 0
2329 assert elapsed < 10.0, f"bisect run 20 commits took {elapsed:.2f}s"
2330
2331 def test_run_steps_taken_within_log2(self, tmp_path: pathlib.Path) -> None:
2332 """Steps taken should be at most log2(n)+1 for an always-good command."""
2333 import math
2334 root, repo_id = _make_repo(tmp_path)
2335 n = 32
2336 ids = _build_chain(root, repo_id, n)
2337 self._start(root, ids)
2338 result = _invoke(root, ["bisect", "run", "true", "--json"])
2339 assert result.exit_code == 0
2340 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
2341 steps_taken = json.loads(lines[-1])["steps_taken"]
2342 assert steps_taken <= int(math.log2(n)) + 2
2343
2344
2345 # ---------------------------------------------------------------------------
2346 # bisect log — Extended, Security, Stress
2347 # ---------------------------------------------------------------------------
2348
2349
2350 class TestBisectLogExtended:
2351 """Extended unit / integration / e2e tests for muse bisect log."""
2352
2353 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2354 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2355 assert r.exit_code == 0
2356
2357 def test_log_exits_0_no_session(self, tmp_path: pathlib.Path) -> None:
2358 root, _ = _make_repo(tmp_path)
2359 result = _invoke(root, ["bisect", "log"])
2360 assert result.exit_code == 0
2361
2362 def test_log_exits_0_with_session(self, tmp_path: pathlib.Path) -> None:
2363 root, repo_id = _make_repo(tmp_path)
2364 ids = _build_chain(root, repo_id, 4)
2365 self._start(root, ids)
2366 result = _invoke(root, ["bisect", "log"])
2367 assert result.exit_code == 0
2368
2369 def test_log_j_alias_works(self, tmp_path: pathlib.Path) -> None:
2370 root, _ = _make_repo(tmp_path)
2371 result = _invoke(root, ["bisect", "log", "-j"])
2372 assert result.exit_code == 0
2373 parsed = _parse_log(result.output)
2374 assert isinstance(parsed["active"], bool)
2375
2376 def test_log_json_active_false_no_session(self, tmp_path: pathlib.Path) -> None:
2377 root, _ = _make_repo(tmp_path)
2378 result = _invoke(root, ["bisect", "log", "--json"])
2379 assert result.exit_code == 0
2380 assert _parse_log(result.output)["active"] is False
2381
2382 def test_log_json_active_true_with_session(self, tmp_path: pathlib.Path) -> None:
2383 root, repo_id = _make_repo(tmp_path)
2384 ids = _build_chain(root, repo_id, 4)
2385 self._start(root, ids)
2386 result = _invoke(root, ["bisect", "log", "--json"])
2387 assert result.exit_code == 0
2388 assert _parse_log(result.output)["active"] is True
2389
2390 def test_log_json_entries_empty_no_session(self, tmp_path: pathlib.Path) -> None:
2391 root, _ = _make_repo(tmp_path)
2392 result = _invoke(root, ["bisect", "log", "--json"])
2393 assert result.exit_code == 0
2394 assert _parse_log(result.output)["entries"] == []
2395
2396 def test_log_json_entries_grow_with_verdicts(self, tmp_path: pathlib.Path) -> None:
2397 root, repo_id = _make_repo(tmp_path)
2398 ids = _build_chain(root, repo_id, 6)
2399 self._start(root, ids)
2400 after_start = len(_parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"])
2401 _invoke(root, ["bisect", "bad", ids[3]])
2402 after_bad = len(_parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"])
2403 assert after_bad > after_start
2404
2405 def test_log_json_two_keys(self, tmp_path: pathlib.Path) -> None:
2406 root, _ = _make_repo(tmp_path)
2407 result = _invoke(root, ["bisect", "log", "--json"])
2408 assert result.exit_code == 0
2409 d = json.loads(_json_blob(result.output))
2410 assert {"active", "entries"} <= set(d.keys())
2411
2412 def test_log_json_start_records_bad_and_good(self, tmp_path: pathlib.Path) -> None:
2413 root, repo_id = _make_repo(tmp_path)
2414 ids = _build_chain(root, repo_id, 4)
2415 self._start(root, ids)
2416 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
2417 verdicts = [e["verdict"] for e in entries]
2418 assert "bad" in verdicts
2419 assert "good" in verdicts
2420
2421 def test_log_json_entries_contain_commit_ids(self, tmp_path: pathlib.Path) -> None:
2422 root, repo_id = _make_repo(tmp_path)
2423 ids = _build_chain(root, repo_id, 4)
2424 self._start(root, ids)
2425 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
2426 for entry in entries:
2427 # commit_id is stored with the sha256: prefix (71 chars total)
2428 assert entry["commit_id"].startswith("sha256:")
2429
2430 def test_log_json_entries_are_dicts(self, tmp_path: pathlib.Path) -> None:
2431 root, repo_id = _make_repo(tmp_path)
2432 ids = _build_chain(root, repo_id, 3)
2433 self._start(root, ids)
2434 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
2435 for e in entries:
2436 assert isinstance(e, dict)
2437 assert "commit_id" in e
2438 assert "verdict" in e
2439 assert "timestamp" in e
2440
2441 def test_log_active_false_after_reset(self, tmp_path: pathlib.Path) -> None:
2442 root, repo_id = _make_repo(tmp_path)
2443 ids = _build_chain(root, repo_id, 3)
2444 self._start(root, ids)
2445 _invoke(root, ["bisect", "reset"])
2446 result = _invoke(root, ["bisect", "log", "--json"])
2447 assert result.exit_code == 0
2448 assert _parse_log(result.output)["active"] is False
2449
2450 def test_log_text_shows_bisect_log_header(self, tmp_path: pathlib.Path) -> None:
2451 root, repo_id = _make_repo(tmp_path)
2452 ids = _build_chain(root, repo_id, 4)
2453 self._start(root, ids)
2454 result = _invoke(root, ["bisect", "log"])
2455 assert result.exit_code == 0
2456 assert "Bisect log" in result.output
2457
2458 def test_log_text_no_session_message(self, tmp_path: pathlib.Path) -> None:
2459 root, _ = _make_repo(tmp_path)
2460 result = _invoke(root, ["bisect", "log"])
2461 assert result.exit_code == 0
2462 assert "No bisect log" in result.output
2463
2464 def test_log_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
2465 empty = tmp_path / "not_a_repo"
2466 empty.mkdir()
2467 result = _invoke(empty, ["bisect", "log"])
2468 assert result.exit_code == 2
2469
2470 def test_log_help_description_present(self, tmp_path: pathlib.Path) -> None:
2471 root, _ = _make_repo(tmp_path)
2472 result = _invoke(root, ["bisect", "log", "--help"])
2473 assert "Agent quickstart" in result.output or "verdict" in result.output.lower()
2474
2475 def test_log_text_no_json_object(self, tmp_path: pathlib.Path) -> None:
2476 root, repo_id = _make_repo(tmp_path)
2477 ids = _build_chain(root, repo_id, 4)
2478 self._start(root, ids)
2479 result = _invoke(root, ["bisect", "log"])
2480 assert result.exit_code == 0
2481 assert not any(l.strip().startswith("{") for l in result.output.splitlines())
2482
2483
2484 class TestBisectLogSecurity:
2485 """Security hardening tests for muse bisect log."""
2486
2487 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2488 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2489 assert r.exit_code == 0
2490
2491 def test_log_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
2492 root, _ = _make_repo(tmp_path)
2493 result = _invoke(root, ["bisect", "log", "--json"])
2494 assert result.exit_code == 0
2495 d = json.loads(_json_blob(result.output))
2496 assert isinstance(d, dict)
2497
2498 def test_log_json_active_is_bool(self, tmp_path: pathlib.Path) -> None:
2499 root, _ = _make_repo(tmp_path)
2500 result = _invoke(root, ["bisect", "log", "--json"])
2501 assert result.exit_code == 0
2502 assert isinstance(json.loads(_json_blob(result.output))["active"], bool)
2503
2504 def test_log_json_entries_sanitized(self, tmp_path: pathlib.Path) -> None:
2505 """ANSI codes injected into the log state are stripped from JSON output."""
2506 from muse.core.bisect import _load_state, _save_state
2507 root, repo_id = _make_repo(tmp_path)
2508 ids = _build_chain(root, repo_id, 3)
2509 self._start(root, ids)
2510 # Tamper: inject ANSI into a log entry
2511 state = _load_state(root)
2512 assert state is not None
2513 state["log"].append(f"{ids[1]} bad\x1b[31m 2026-01-01T00:00:00\x1b[0m")
2514 _save_state(root, state)
2515 result = _invoke(root, ["bisect", "log", "--json"])
2516 assert result.exit_code == 0
2517 assert "\x1b" not in result.output
2518
2519 def test_log_text_entries_sanitized(self, tmp_path: pathlib.Path) -> None:
2520 """ANSI codes in log entries are stripped from text output."""
2521 from muse.core.bisect import _load_state, _save_state
2522 root, repo_id = _make_repo(tmp_path)
2523 ids = _build_chain(root, repo_id, 3)
2524 self._start(root, ids)
2525 state = _load_state(root)
2526 assert state is not None
2527 state["log"].append(f"{ids[1]} bad\x1b[31m 2026-01-01T00:00:00\x1b[0m")
2528 _save_state(root, state)
2529 result = _invoke(root, ["bisect", "log"])
2530 assert result.exit_code == 0
2531 assert "\x1b" not in result.output
2532
2533 def test_log_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
2534 root, repo_id = _make_repo(tmp_path)
2535 ids = _build_chain(root, repo_id, 4)
2536 self._start(root, ids)
2537 result = _invoke(root, ["bisect", "log", "--json"])
2538 assert result.exit_code == 0
2539 assert "\x1b" not in result.output
2540
2541 def test_log_text_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
2542 root, repo_id = _make_repo(tmp_path)
2543 ids = _build_chain(root, repo_id, 4)
2544 self._start(root, ids)
2545 result = _invoke(root, ["bisect", "log"])
2546 assert result.exit_code == 0
2547 assert "\x1b" not in result.output
2548
2549
2550 class TestBisectLogStress:
2551 """Performance and scale tests for muse bisect log."""
2552
2553 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2554 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2555 assert r.exit_code == 0
2556
2557 def test_log_100_commit_session(self, tmp_path: pathlib.Path) -> None:
2558 """Log on a 100-step session returns all entries."""
2559 root, repo_id = _make_repo(tmp_path)
2560 ids = _build_chain(root, repo_id, 100)
2561 self._start(root, ids)
2562 # Apply 10 good verdicts to build up a log
2563 for i in range(1, 11):
2564 _invoke(root, ["bisect", "good", ids[i]])
2565 result = _invoke(root, ["bisect", "log", "--json"])
2566 assert result.exit_code == 0
2567 entries = _parse_log(result.output)["entries"]
2568 # start adds 2 entries; 10 good verdicts add 10 more
2569 assert len(entries) >= 12
2570
2571 def test_log_performance_large_session(self, tmp_path: pathlib.Path) -> None:
2572 """Log on a large session completes within 5 seconds."""
2573 import time
2574 root, repo_id = _make_repo(tmp_path)
2575 ids = _build_chain(root, repo_id, 50)
2576 self._start(root, ids)
2577 for i in range(1, 8):
2578 _invoke(root, ["bisect", "bad", ids[i]])
2579 t0 = time.monotonic()
2580 result = _invoke(root, ["bisect", "log", "--json"])
2581 elapsed = time.monotonic() - t0
2582 assert result.exit_code == 0
2583 assert elapsed < 5.0, f"bisect log took {elapsed:.2f}s"
2584
2585 def test_log_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
2586 """Concurrent log reads all return the same entry count."""
2587 root, repo_id = _make_repo(tmp_path)
2588 ids = _build_chain(root, repo_id, 20)
2589 self._start(root, ids)
2590 _invoke(root, ["bisect", "bad", ids[10]])
2591 counts: list[int] = []
2592 errors: list[str] = []
2593 lock = threading.Lock()
2594
2595 def _run() -> None:
2596 r = _invoke(root, ["bisect", "log", "--json"])
2597 with lock:
2598 if r.exit_code != 0:
2599 errors.append(r.output)
2600 return
2601 try:
2602 counts.append(len(_parse_log(r.output)["entries"]))
2603 except (json.JSONDecodeError, KeyError, ValueError) as exc:
2604 errors.append(f"parse error: {exc!r} output={r.output!r}")
2605
2606 threads = [threading.Thread(target=_run) for _ in range(8)]
2607 for t in threads:
2608 t.start()
2609 for t in threads:
2610 t.join()
2611 assert not errors
2612 assert all(c == counts[0] for c in counts)
2613
2614
2615 # ---------------------------------------------------------------------------
2616 # bisect reset — Extended, Security, Stress
2617 # ---------------------------------------------------------------------------
2618
2619
2620 class TestBisectResetExtended:
2621 """Extended unit / integration / e2e tests for muse bisect reset."""
2622
2623 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2624 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2625 assert r.exit_code == 0
2626
2627 def test_reset_exits_0_with_session(self, tmp_path: pathlib.Path) -> None:
2628 root, repo_id = _make_repo(tmp_path)
2629 ids = _build_chain(root, repo_id, 4)
2630 self._start(root, ids)
2631 assert _invoke(root, ["bisect", "reset"]).exit_code == 0
2632
2633 def test_reset_exits_0_no_session(self, tmp_path: pathlib.Path) -> None:
2634 root, _ = _make_repo(tmp_path)
2635 assert _invoke(root, ["bisect", "reset"]).exit_code == 0
2636
2637 def test_reset_j_alias_works(self, tmp_path: pathlib.Path) -> None:
2638 root, _ = _make_repo(tmp_path)
2639 result = _invoke(root, ["bisect", "reset", "-j"])
2640 assert result.exit_code == 0
2641 assert _parse_reset(result.output)["reset"] is True
2642
2643 def test_reset_json_reset_true(self, tmp_path: pathlib.Path) -> None:
2644 root, _ = _make_repo(tmp_path)
2645 result = _invoke(root, ["bisect", "reset", "--json"])
2646 assert result.exit_code == 0
2647 assert _parse_reset(result.output)["reset"] is True
2648
2649 def test_reset_json_single_key(self, tmp_path: pathlib.Path) -> None:
2650 root, _ = _make_repo(tmp_path)
2651 result = _invoke(root, ["bisect", "reset", "--json"])
2652 assert result.exit_code == 0
2653 d = json.loads(_json_blob(result.output))
2654 assert {"reset"} <= set(d.keys())
2655
2656 def test_reset_clears_active_session(self, tmp_path: pathlib.Path) -> None:
2657 root, repo_id = _make_repo(tmp_path)
2658 ids = _build_chain(root, repo_id, 4)
2659 self._start(root, ids)
2660 _invoke(root, ["bisect", "reset"])
2661 log_r = _invoke(root, ["bisect", "log", "--json"])
2662 assert _parse_log(log_r.output)["active"] is False
2663
2664 def test_reset_prevents_bad_after_reset(self, tmp_path: pathlib.Path) -> None:
2665 root, repo_id = _make_repo(tmp_path)
2666 ids = _build_chain(root, repo_id, 4)
2667 self._start(root, ids)
2668 _invoke(root, ["bisect", "reset"])
2669 result = _invoke(root, ["bisect", "bad", ids[2]])
2670 assert result.exit_code == 1
2671
2672 def test_reset_prevents_good_after_reset(self, tmp_path: pathlib.Path) -> None:
2673 root, repo_id = _make_repo(tmp_path)
2674 ids = _build_chain(root, repo_id, 4)
2675 self._start(root, ids)
2676 _invoke(root, ["bisect", "reset"])
2677 assert _invoke(root, ["bisect", "good", ids[1]]).exit_code == 1
2678
2679 def test_reset_prevents_skip_after_reset(self, tmp_path: pathlib.Path) -> None:
2680 root, repo_id = _make_repo(tmp_path)
2681 ids = _build_chain(root, repo_id, 4)
2682 self._start(root, ids)
2683 _invoke(root, ["bisect", "reset"])
2684 assert _invoke(root, ["bisect", "skip", ids[2]]).exit_code == 1
2685
2686 def test_reset_idempotent_double_reset(self, tmp_path: pathlib.Path) -> None:
2687 root, repo_id = _make_repo(tmp_path)
2688 ids = _build_chain(root, repo_id, 3)
2689 self._start(root, ids)
2690 assert _invoke(root, ["bisect", "reset"]).exit_code == 0
2691 assert _invoke(root, ["bisect", "reset"]).exit_code == 0
2692
2693 def test_reset_allows_new_session_after(self, tmp_path: pathlib.Path) -> None:
2694 root, repo_id = _make_repo(tmp_path)
2695 ids = _build_chain(root, repo_id, 5)
2696 self._start(root, ids)
2697 _invoke(root, ["bisect", "reset"])
2698 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2699 assert result.exit_code == 0
2700
2701 def test_reset_clears_log_entries(self, tmp_path: pathlib.Path) -> None:
2702 root, repo_id = _make_repo(tmp_path)
2703 ids = _build_chain(root, repo_id, 4)
2704 self._start(root, ids)
2705 _invoke(root, ["bisect", "bad", ids[2]])
2706 _invoke(root, ["bisect", "reset"])
2707 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
2708 assert entries == []
2709
2710 def test_reset_text_output_mentions_reset(self, tmp_path: pathlib.Path) -> None:
2711 root, _ = _make_repo(tmp_path)
2712 result = _invoke(root, ["bisect", "reset"])
2713 assert result.exit_code == 0
2714 assert "reset" in result.output.lower()
2715
2716 def test_reset_text_no_json_object(self, tmp_path: pathlib.Path) -> None:
2717 root, _ = _make_repo(tmp_path)
2718 result = _invoke(root, ["bisect", "reset"])
2719 assert not result.output.strip().startswith("{")
2720
2721 def test_reset_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
2722 empty = tmp_path / "not_a_repo"
2723 empty.mkdir()
2724 assert _invoke(empty, ["bisect", "reset"]).exit_code == 2
2725
2726 def test_reset_help_description_present(self, tmp_path: pathlib.Path) -> None:
2727 root, _ = _make_repo(tmp_path)
2728 result = _invoke(root, ["bisect", "reset", "--help"])
2729 assert "Agent quickstart" in result.output or "Idempotent" in result.output
2730
2731 def test_reset_json_reset_is_bool(self, tmp_path: pathlib.Path) -> None:
2732 root, _ = _make_repo(tmp_path)
2733 result = _invoke(root, ["bisect", "reset", "--json"])
2734 assert result.exit_code == 0
2735 assert isinstance(json.loads(_json_blob(result.output))["reset"], bool)
2736
2737 def test_reset_mid_session_with_verdicts(self, tmp_path: pathlib.Path) -> None:
2738 """Reset works correctly after several verdicts have been applied."""
2739 root, repo_id = _make_repo(tmp_path)
2740 ids = _build_chain(root, repo_id, 10)
2741 self._start(root, ids)
2742 _invoke(root, ["bisect", "bad", ids[7]])
2743 _invoke(root, ["bisect", "good", ids[3]])
2744 result = _invoke(root, ["bisect", "reset", "--json"])
2745 assert result.exit_code == 0
2746 assert _parse_reset(result.output)["reset"] is True
2747 assert _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["active"] is False
2748
2749
2750 class TestBisectResetSecurity:
2751 """Security hardening tests for muse bisect reset."""
2752
2753 def test_reset_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
2754 root, _ = _make_repo(tmp_path)
2755 result = _invoke(root, ["bisect", "reset", "--json"])
2756 assert result.exit_code == 0
2757 assert isinstance(json.loads(_json_blob(result.output)), dict)
2758
2759 def test_reset_json_no_ansi(self, tmp_path: pathlib.Path) -> None:
2760 root, _ = _make_repo(tmp_path)
2761 result = _invoke(root, ["bisect", "reset", "--json"])
2762 assert result.exit_code == 0
2763 assert "\x1b" not in result.output
2764
2765 def test_reset_text_no_ansi(self, tmp_path: pathlib.Path) -> None:
2766 root, _ = _make_repo(tmp_path)
2767 result = _invoke(root, ["bisect", "reset"])
2768 assert result.exit_code == 0
2769 assert "\x1b" not in result.output
2770
2771 def test_reset_state_file_removed(self, tmp_path: pathlib.Path) -> None:
2772 """After reset the state file no longer exists on disk."""
2773 from muse.core.bisect import _state_path
2774 root, repo_id = _make_repo(tmp_path)
2775 ids = _build_chain(root, repo_id, 3)
2776 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2777 assert _state_path(root).exists()
2778 _invoke(root, ["bisect", "reset"])
2779 assert not _state_path(root).exists()
2780
2781 def test_reset_no_session_state_file_absent(self, tmp_path: pathlib.Path) -> None:
2782 """Reset with no state file is a safe no-op."""
2783 from muse.core.bisect import _state_path
2784 root, _ = _make_repo(tmp_path)
2785 assert not _state_path(root).exists()
2786 result = _invoke(root, ["bisect", "reset"])
2787 assert result.exit_code == 0
2788
2789 def test_reset_json_reset_value_true(self, tmp_path: pathlib.Path) -> None:
2790 """reset field is always true, never false or a truthy int."""
2791 root, _ = _make_repo(tmp_path)
2792 result = _invoke(root, ["bisect", "reset", "--json"])
2793 assert result.exit_code == 0
2794 assert json.loads(_json_blob(result.output))["reset"] is True
2795
2796
2797 class TestBisectResetStress:
2798 """Performance and scale tests for muse bisect reset."""
2799
2800 def test_reset_after_100_commit_session(self, tmp_path: pathlib.Path) -> None:
2801 """Reset clears state from a 100-commit session instantly."""
2802 root, repo_id = _make_repo(tmp_path)
2803 ids = _build_chain(root, repo_id, 100)
2804 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2805 result = _invoke(root, ["bisect", "reset", "--json"])
2806 assert result.exit_code == 0
2807 assert _parse_reset(result.output)["reset"] is True
2808 assert _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["active"] is False
2809
2810 def test_reset_performance(self, tmp_path: pathlib.Path) -> None:
2811 """Reset completes within 2 seconds even after a large session."""
2812 import time
2813 root, repo_id = _make_repo(tmp_path)
2814 ids = _build_chain(root, repo_id, 100)
2815 _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2816 for i in range(1, 8):
2817 _invoke(root, ["bisect", "bad", ids[i]])
2818 t0 = time.monotonic()
2819 result = _invoke(root, ["bisect", "reset"])
2820 elapsed = time.monotonic() - t0
2821 assert result.exit_code == 0
2822 assert elapsed < 2.0, f"bisect reset took {elapsed:.2f}s"
2823
2824 def test_reset_cycle_10_times(self, tmp_path: pathlib.Path) -> None:
2825 """Start → reset × 10 all succeed with no state leakage."""
2826 root, repo_id = _make_repo(tmp_path)
2827 ids = _build_chain(root, repo_id, 6)
2828 for _ in range(10):
2829 r_start = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2830 assert r_start.exit_code == 0
2831 r_reset = _invoke(root, ["bisect", "reset", "--json"])
2832 assert r_reset.exit_code == 0
2833 assert _parse_reset(r_reset.output)["reset"] is True
2834
2835
2836 # ===========================================================================
2837 # New feature tests — status, structured log, timeout, symbol_changes in run
2838 # ===========================================================================
2839
2840
2841 class TestBisectStatus:
2842 """Tests for the new ``muse bisect status`` subcommand."""
2843
2844 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2845 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2846 assert r.exit_code == 0
2847
2848 # ── Unit: no session ────────────────────────────────────────────────────
2849
2850 def test_status_no_session_exits_0(self, tmp_path: pathlib.Path) -> None:
2851 root, _ = _make_repo(tmp_path)
2852 result = _invoke(root, ["bisect", "status"])
2853 assert result.exit_code == 0
2854
2855 def test_status_no_session_json_active_false(self, tmp_path: pathlib.Path) -> None:
2856 root, _ = _make_repo(tmp_path)
2857 result = _invoke(root, ["bisect", "status", "--json"])
2858 assert result.exit_code == 0
2859 d = json.loads(result.output.strip())
2860 assert d["active"] is False
2861
2862 def test_status_no_session_json_only_active_key(self, tmp_path: pathlib.Path) -> None:
2863 root, _ = _make_repo(tmp_path)
2864 result = _invoke(root, ["bisect", "status", "--json"])
2865 assert result.exit_code == 0
2866 d = json.loads(result.output.strip())
2867 assert {"active"} <= set(d.keys())
2868
2869 # ── Integration: active session ─────────────────────────────────────────
2870
2871 def test_status_active_session_exits_0(self, tmp_path: pathlib.Path) -> None:
2872 root, repo_id = _make_repo(tmp_path)
2873 ids = _build_chain(root, repo_id, 6)
2874 self._start(root, ids)
2875 result = _invoke(root, ["bisect", "status"])
2876 assert result.exit_code == 0
2877
2878 def test_status_active_json_schema(self, tmp_path: pathlib.Path) -> None:
2879 root, repo_id = _make_repo(tmp_path)
2880 ids = _build_chain(root, repo_id, 6)
2881 self._start(root, ids)
2882 result = _invoke(root, ["bisect", "status", "--json"])
2883 assert result.exit_code == 0
2884 d = json.loads(result.output.strip())
2885 assert d["active"] is True
2886 assert "bad_id" in d
2887 assert "good_ids" in d
2888 assert "remaining_count" in d
2889 assert "steps_remaining" in d
2890 assert "skipped_count" in d
2891 assert "symbol_filter" in d
2892
2893 def test_status_active_remaining_count_positive(self, tmp_path: pathlib.Path) -> None:
2894 root, repo_id = _make_repo(tmp_path)
2895 ids = _build_chain(root, repo_id, 8)
2896 self._start(root, ids)
2897 result = _invoke(root, ["bisect", "status", "--json"])
2898 d = json.loads(result.output.strip())
2899 assert d["remaining_count"] > 0
2900
2901 def test_status_bad_id_matches_session(self, tmp_path: pathlib.Path) -> None:
2902 root, repo_id = _make_repo(tmp_path)
2903 ids = _build_chain(root, repo_id, 5)
2904 self._start(root, ids)
2905 result = _invoke(root, ["bisect", "status", "--json"])
2906 d = json.loads(result.output.strip())
2907 assert d["bad_id"] == ids[-1]
2908
2909 def test_status_skipped_count_increments(self, tmp_path: pathlib.Path) -> None:
2910 root, repo_id = _make_repo(tmp_path)
2911 ids = _build_chain(root, repo_id, 8)
2912 self._start(root, ids)
2913 before = json.loads(
2914 _invoke(root, ["bisect", "status", "--json"]).output.strip()
2915 )["skipped_count"]
2916 # Skip the midpoint
2917 next_id = json.loads(
2918 _invoke(root, ["bisect", "status", "--json"]).output.strip()
2919 )
2920 _invoke(root, ["bisect", "skip", ids[len(ids) // 2]])
2921 after = json.loads(
2922 _invoke(root, ["bisect", "status", "--json"]).output.strip()
2923 )["skipped_count"]
2924 assert after == before + 1
2925
2926 def test_status_active_false_after_reset(self, tmp_path: pathlib.Path) -> None:
2927 root, repo_id = _make_repo(tmp_path)
2928 ids = _build_chain(root, repo_id, 5)
2929 self._start(root, ids)
2930 _invoke(root, ["bisect", "reset"])
2931 result = _invoke(root, ["bisect", "status", "--json"])
2932 d = json.loads(result.output.strip())
2933 assert d["active"] is False
2934
2935 # ── Security ────────────────────────────────────────────────────────────
2936
2937 def test_status_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
2938 empty = tmp_path / "not_a_repo"
2939 empty.mkdir()
2940 result = _invoke(empty, ["bisect", "status"])
2941 assert result.exit_code == 2
2942
2943 def test_status_json_is_compact(self, tmp_path: pathlib.Path) -> None:
2944 """JSON output is compact single-line."""
2945 root, repo_id = _make_repo(tmp_path)
2946 ids = _build_chain(root, repo_id, 5)
2947 self._start(root, ids)
2948 result = _invoke(root, ["bisect", "status", "--json"])
2949 assert result.exit_code == 0
2950 json.loads(result.output)
2951
2952 def test_status_json_no_ansi(self, tmp_path: pathlib.Path) -> None:
2953 root, repo_id = _make_repo(tmp_path)
2954 ids = _build_chain(root, repo_id, 5)
2955 self._start(root, ids)
2956 result = _invoke(root, ["bisect", "status", "--json"])
2957 assert "\x1b" not in result.output
2958
2959 def test_status_text_no_session_message(self, tmp_path: pathlib.Path) -> None:
2960 root, _ = _make_repo(tmp_path)
2961 result = _invoke(root, ["bisect", "status"])
2962 assert "No bisect session" in result.output or "no bisect session" in result.output.lower()
2963
2964 def test_status_text_active_shows_remaining(self, tmp_path: pathlib.Path) -> None:
2965 root, repo_id = _make_repo(tmp_path)
2966 ids = _build_chain(root, repo_id, 6)
2967 self._start(root, ids)
2968 result = _invoke(root, ["bisect", "status"])
2969 assert "remaining" in result.output.lower()
2970
2971
2972 class TestBisectLogStructured:
2973 """Tests verifying the new structured log entry schema."""
2974
2975 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
2976 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
2977 assert r.exit_code == 0
2978
2979 def test_log_entry_has_three_keys(self, tmp_path: pathlib.Path) -> None:
2980 root, repo_id = _make_repo(tmp_path)
2981 ids = _build_chain(root, repo_id, 4)
2982 self._start(root, ids)
2983 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
2984 assert len(entries) >= 2
2985 for e in entries:
2986 assert set(e.keys()) == {"commit_id", "verdict", "timestamp"}
2987
2988 def test_log_entry_verdict_values(self, tmp_path: pathlib.Path) -> None:
2989 root, repo_id = _make_repo(tmp_path)
2990 ids = _build_chain(root, repo_id, 4)
2991 self._start(root, ids)
2992 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
2993 verdicts = {e["verdict"] for e in entries}
2994 assert verdicts <= {"bad", "good", "skip"}
2995
2996 def test_log_entry_timestamp_is_iso8601(self, tmp_path: pathlib.Path) -> None:
2997 root, repo_id = _make_repo(tmp_path)
2998 ids = _build_chain(root, repo_id, 4)
2999 self._start(root, ids)
3000 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
3001 for e in entries:
3002 # ISO8601 timestamps contain 'T' separating date from time
3003 assert "T" in e["timestamp"] or e["timestamp"] == ""
3004
3005 def test_log_skip_entry_appears_after_skip(self, tmp_path: pathlib.Path) -> None:
3006 root, repo_id = _make_repo(tmp_path)
3007 ids = _build_chain(root, repo_id, 6)
3008 self._start(root, ids)
3009 _invoke(root, ["bisect", "skip", ids[2]])
3010 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
3011 verdicts = [e["verdict"] for e in entries]
3012 assert "skip" in verdicts
3013
3014 def test_log_entry_commit_ids_in_session_ids(self, tmp_path: pathlib.Path) -> None:
3015 root, repo_id = _make_repo(tmp_path)
3016 ids = _build_chain(root, repo_id, 4)
3017 self._start(root, ids)
3018 entries = _parse_log(_invoke(root, ["bisect", "log", "--json"]).output)["entries"]
3019 entry_ids = {e["commit_id"] for e in entries}
3020 # bad and good commit IDs from start should appear in log
3021 assert ids[-1] in entry_ids # bad
3022 assert ids[0] in entry_ids # good
3023
3024
3025 class TestBisectRunTimeout:
3026 """Tests for ``--timeout`` on ``muse bisect run``."""
3027
3028 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
3029 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
3030 assert r.exit_code == 0
3031
3032 def test_timeout_flag_accepted(self, tmp_path: pathlib.Path) -> None:
3033 """--timeout is a valid flag that doesn't crash the parser."""
3034 root, repo_id = _make_repo(tmp_path)
3035 ids = _build_chain(root, repo_id, 4)
3036 self._start(root, ids)
3037 result = _invoke(root, ["bisect", "run", "true", "--timeout", "30"])
3038 assert result.exit_code == 0
3039
3040 def test_timeout_fast_command_succeeds(self, tmp_path: pathlib.Path) -> None:
3041 """A command that finishes well within the timeout is treated normally."""
3042 root, repo_id = _make_repo(tmp_path)
3043 ids = _build_chain(root, repo_id, 5)
3044 self._start(root, ids)
3045 result = _invoke(root, ["bisect", "run", "true", "--timeout", "10"])
3046 assert result.exit_code == 0
3047
3048 def test_timeout_triggers_skip(self, tmp_path: pathlib.Path) -> None:
3049 """A command that exceeds --timeout is treated as skip (exit 125)."""
3050 from muse.core.bisect import run_bisect_command
3051 import tempfile
3052
3053 with tempfile.TemporaryDirectory() as td:
3054 root_path = pathlib.Path(td)
3055 # We test the core directly to avoid actually sleeping in a test.
3056 # Patch subprocess.run to raise TimeoutExpired.
3057 import unittest.mock as mock
3058 from muse.core.bisect import _SKIP_EXIT_CODE
3059 # Build a minimal state so _apply_verdict can run.
3060 import datetime
3061 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
3062 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
3063 from muse.core.bisect import start_bisect
3064
3065 repo_id = fake_id("repo")
3066 muse_dir = root_path / ".muse"
3067 muse_dir.mkdir()
3068 (muse_dir / "repo.json").write_text(json.dumps({
3069 "repo_id": repo_id, "domain": "code",
3070 "default_branch": "main", "created_at": "2026-01-01T00:00:00+00:00",
3071 }))
3072 (muse_dir / "HEAD").write_text("ref: refs/heads/main")
3073 (muse_dir / "refs" / "heads").mkdir(parents=True)
3074 (muse_dir / "snapshots").mkdir()
3075 (muse_dir / "commits").mkdir()
3076 (muse_dir / "objects").mkdir()
3077
3078 ids: list[str] = []
3079 parent = None
3080 for i in range(4):
3081 manifest = {}
3082 snap_id = compute_snapshot_id(manifest)
3083 committed_at = datetime.datetime.now(datetime.timezone.utc)
3084 commit_id = compute_commit_id(
3085 repo_id=repo_id,
3086 parent_ids=[parent] if parent else [],
3087 snapshot_id=snap_id,
3088 message=f"c{i}",
3089 committed_at_iso=committed_at.isoformat(),
3090 )
3091 write_snapshot(root_path, SnapshotRecord(snapshot_id=snap_id, manifest={}, created_at=committed_at))
3092 write_commit(root_path, CommitRecord(
3093 commit_id=commit_id, repo_id=repo_id,
3094 parent_commit_id=parent, parent2_commit_id=None,
3095 snapshot_id=snap_id, created_on_branch="main", message=f"c{i}",
3096 committed_at=committed_at,
3097 ))
3098 (muse_dir / "refs" / "heads" / "main").write_text(commit_id)
3099 ids.append(commit_id)
3100 parent = commit_id
3101
3102 start_bisect(root_path, ids[-1], [ids[0]])
3103
3104 import subprocess
3105 with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 1)):
3106 result = run_bisect_command(root_path, "sleep 99", ids[2], timeout=1)
3107 assert result.verdict == "skip"
3108
3109 def test_timeout_short_alias(self, tmp_path: pathlib.Path) -> None:
3110 """-t is the short alias for --timeout."""
3111 root, repo_id = _make_repo(tmp_path)
3112 ids = _build_chain(root, repo_id, 4)
3113 self._start(root, ids)
3114 result = _invoke(root, ["bisect", "run", "true", "-t", "10"])
3115 assert result.exit_code == 0
3116
3117
3118 class TestBisectRunStepSymbolChanges:
3119 """Tests verifying symbol_changes is present in NDJSON step lines."""
3120
3121 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
3122 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
3123 assert r.exit_code == 0
3124
3125 def test_step_json_has_symbol_changes_key(self, tmp_path: pathlib.Path) -> None:
3126 root, repo_id = _make_repo(tmp_path)
3127 ids = _build_chain(root, repo_id, 6)
3128 self._start(root, ids)
3129 result = _invoke(root, ["bisect", "run", "true", "--json"])
3130 assert result.exit_code == 0
3131 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
3132 step_lines = [l for l in lines if '"step"' in l]
3133 if step_lines:
3134 step = json.loads(step_lines[0])
3135 assert "symbol_changes" in step
3136
3137 def test_step_symbol_changes_is_list(self, tmp_path: pathlib.Path) -> None:
3138 root, repo_id = _make_repo(tmp_path)
3139 ids = _build_chain(root, repo_id, 6)
3140 self._start(root, ids)
3141 result = _invoke(root, ["bisect", "run", "true", "--json"])
3142 assert result.exit_code == 0
3143 lines = [l.strip() for l in result.output.strip().splitlines() if l.strip()]
3144 for line in lines:
3145 obj = json.loads(line)
3146 if "symbol_changes" in obj:
3147 assert isinstance(obj["symbol_changes"], list)
3148
3149 def test_step_ndjson_stays_compact(self, tmp_path: pathlib.Path) -> None:
3150 """NDJSON step lines must be single-line (not pretty-printed)."""
3151 root, repo_id = _make_repo(tmp_path)
3152 ids = _build_chain(root, repo_id, 6)
3153 self._start(root, ids)
3154 result = _invoke(root, ["bisect", "run", "true", "--json"])
3155 assert result.exit_code == 0
3156 for line in result.output.strip().splitlines():
3157 line = line.strip()
3158 if not line:
3159 continue
3160 # Every non-empty line must be valid JSON on its own
3161 obj = json.loads(line)
3162 assert isinstance(obj, dict)
3163
3164
3165 class TestBisectJsonCompact:
3166 """Tests verifying compact single-line JSON on single-object subcommands."""
3167
3168 def _start(self, root: pathlib.Path, ids: list[str]) -> None:
3169 r = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0]])
3170 assert r.exit_code == 0
3171
3172 def test_start_json_is_compact(self, tmp_path: pathlib.Path) -> None:
3173 root, repo_id = _make_repo(tmp_path)
3174 ids = _build_chain(root, repo_id, 4)
3175 result = _invoke(root, ["bisect", "start", "--bad", ids[-1], "--good", ids[0], "--json"])
3176 assert result.exit_code == 0
3177 json.loads(result.output)
3178
3179 def test_bad_json_is_compact(self, tmp_path: pathlib.Path) -> None:
3180 root, repo_id = _make_repo(tmp_path)
3181 ids = _build_chain(root, repo_id, 5)
3182 self._start(root, ids)
3183 result = _invoke(root, ["bisect", "bad", ids[-1], "--json"])
3184 assert result.exit_code == 0
3185 json.loads(result.output)
3186
3187 def test_log_json_is_compact(self, tmp_path: pathlib.Path) -> None:
3188 root, repo_id = _make_repo(tmp_path)
3189 ids = _build_chain(root, repo_id, 4)
3190 self._start(root, ids)
3191 result = _invoke(root, ["bisect", "log", "--json"])
3192 assert result.exit_code == 0
3193 json.loads(result.output)
3194
3195 def test_reset_json_is_compact(self, tmp_path: pathlib.Path) -> None:
3196 root, repo_id = _make_repo(tmp_path)
3197 result = _invoke(root, ["bisect", "reset", "--json"])
3198 assert result.exit_code == 0
3199 json.loads(result.output)
3200
3201 def test_run_json_ndjson_lines_are_compact(self, tmp_path: pathlib.Path) -> None:
3202 """run --json emits NDJSON: each line is a compact single-line JSON object."""
3203 root, repo_id = _make_repo(tmp_path)
3204 ids = _build_chain(root, repo_id, 4)
3205 self._start(root, ids)
3206 result = _invoke(root, ["bisect", "run", "true", "--json"])
3207 assert result.exit_code == 0
3208 for line in result.output.strip().splitlines():
3209 line = line.strip()
3210 if not line:
3211 continue
3212 # Single-line JSON: no embedded newlines, parseable as-is
3213 obj = json.loads(line)
3214 assert isinstance(obj, dict)
3215
3216
3217 # ---------------------------------------------------------------------------
3218 # Flag registration tests
3219 # ---------------------------------------------------------------------------
3220
3221 import argparse as _argparse
3222 from muse.cli.commands.bisect import register as _register_bisect
3223
3224
3225 def _parse_bisect(*args: str) -> _argparse.Namespace:
3226 """Build an argument parser via register() and parse args."""
3227 root_p = _argparse.ArgumentParser()
3228 subs = root_p.add_subparsers(dest="cmd")
3229 _register_bisect(subs)
3230 return root_p.parse_args(["bisect", *args])
3231
3232
3233 class TestRegisterFlags:
3234 # ── bad ─────────────────────────────────────────────────────────────────
3235 def test_bad_default_json_out_is_false(self) -> None:
3236 ns = _parse_bisect("bad")
3237 assert ns.json_out is False
3238
3239 def test_bad_json_flag_sets_json_out(self) -> None:
3240 ns = _parse_bisect("bad", "--json")
3241 assert ns.json_out is True
3242
3243 def test_bad_j_shorthand_sets_json_out(self) -> None:
3244 ns = _parse_bisect("bad", "-j")
3245 assert ns.json_out is True
3246
3247 # ── good ────────────────────────────────────────────────────────────────
3248 def test_good_default_json_out_is_false(self) -> None:
3249 ns = _parse_bisect("good")
3250 assert ns.json_out is False
3251
3252 def test_good_json_flag_sets_json_out(self) -> None:
3253 ns = _parse_bisect("good", "--json")
3254 assert ns.json_out is True
3255
3256 def test_good_j_shorthand_sets_json_out(self) -> None:
3257 ns = _parse_bisect("good", "-j")
3258 assert ns.json_out is True
3259
3260 # ── log ─────────────────────────────────────────────────────────────────
3261 def test_log_default_json_out_is_false(self) -> None:
3262 ns = _parse_bisect("log")
3263 assert ns.json_out is False
3264
3265 def test_log_j_shorthand_sets_json_out(self) -> None:
3266 ns = _parse_bisect("log", "-j")
3267 assert ns.json_out is True
3268
3269 # ── reset ────────────────────────────────────────────────────────────────
3270 def test_reset_default_json_out_is_false(self) -> None:
3271 ns = _parse_bisect("reset")
3272 assert ns.json_out is False
3273
3274 def test_reset_j_shorthand_sets_json_out(self) -> None:
3275 ns = _parse_bisect("reset", "-j")
3276 assert ns.json_out is True
3277
3278 # ── run ─────────────────────────────────────────────────────────────────
3279 def test_run_default_json_out_is_false(self) -> None:
3280 ns = _parse_bisect("run", "pytest -x")
3281 assert ns.json_out is False
3282
3283 def test_run_j_shorthand_sets_json_out(self) -> None:
3284 ns = _parse_bisect("run", "pytest -x", "-j")
3285 assert ns.json_out is True
3286
3287 # ── skip ─────────────────────────────────────────────────────────────────
3288 def test_skip_default_json_out_is_false(self) -> None:
3289 ns = _parse_bisect("skip")
3290 assert ns.json_out is False
3291
3292 def test_skip_j_shorthand_sets_json_out(self) -> None:
3293 ns = _parse_bisect("skip", "-j")
3294 assert ns.json_out is True
3295
3296 # ── start ────────────────────────────────────────────────────────────────
3297 def test_start_default_json_out_is_false(self) -> None:
3298 ns = _parse_bisect("start", "--bad", "HEAD", "--good", "v1.0.0")
3299 assert ns.json_out is False
3300
3301 def test_start_j_shorthand_sets_json_out(self) -> None:
3302 ns = _parse_bisect("start", "--bad", "HEAD", "--good", "v1.0.0", "-j")
3303 assert ns.json_out is True
3304
3305 def test_start_bad_flag(self) -> None:
3306 ns = _parse_bisect("start", "--bad", "HEAD", "--good", "v1.0.0")
3307 assert ns.bad == "HEAD"
3308
3309 def test_start_good_flag(self) -> None:
3310 ns = _parse_bisect("start", "--bad", "HEAD", "--good", "v1.0.0")
3311 assert ns.good == ["v1.0.0"]
3312
3313 # ── status ───────────────────────────────────────────────────────────────
3314 def test_status_default_json_out_is_false(self) -> None:
3315 ns = _parse_bisect("status")
3316 assert ns.json_out is False
3317
3318 def test_status_j_shorthand_sets_json_out(self) -> None:
3319 ns = _parse_bisect("status", "-j")
3320 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago