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