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