cgcardona / muse public
test_code_commands_v2.py python
886 lines 35.7 KB
dfa7b7aa Add comprehensive docs and supercharged tests for Code Domain V2 (#70) Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Integration tests for all Code Domain V2 CLI commands.
2
3 This module tests every command added in the 7-phase Code Domain V2 roadmap
4 using a real Muse repository initialised in a tmp_path fixture.
5
6 Coverage
7 --------
8 Phase 1 — Provenance & Topology
9 muse lineage ADDRESS [--json]
10 muse api-surface [--diff REF] [--json]
11 muse codemap [--top N] [--json]
12 muse clones [--tier exact|near|both] [--json]
13 muse checkout-symbol ADDRESS --commit REF [--dry-run]
14 muse semantic-cherry-pick ADDRESS... --from REF [--dry-run] [--json]
15
16 Phase 2 — Query v2 + Temporal
17 muse query PREDICATE [--all-commits] [--json]
18 muse query-history PREDICATE [--from REF] [--to REF] [--json]
19
20 Phase 3 — Index Infrastructure
21 muse index status [--json]
22 muse index rebuild [--index NAME]
23
24 Phase 4 — Symbol Identity V2
25 muse detect-refactor --json emits schema_version:2
26
27 Phase 5 — Multi-Agent Coordination
28 muse reserve ADDRESS...
29 muse intent ADDRESS... --op OP
30 muse forecast [--json]
31 muse plan-merge OURS THEIRS [--json]
32 muse shard --agents N [--json]
33 muse reconcile [--json]
34
35 Phase 6 — Merge Engine V2 & Enforcement
36 muse breakage [--json]
37 muse invariants [--json]
38
39 Phase 7 — Semantic Versioning
40 muse log shows SemVer for commits with bumps
41 muse commit stores sem_ver_bump in CommitRecord
42
43 Call-Graph Tier
44 muse impact ADDRESS [--json]
45 muse dead [--json]
46 muse coverage CLASS_ADDRESS [--json]
47 muse deps ADDRESS_OR_FILE [--json]
48 muse find-symbol [--name NAME] [--json]
49 muse patch ADDRESS FILE
50 """
51 from __future__ import annotations
52
53 import json
54 import pathlib
55 import textwrap
56
57 import pytest
58 from typer.testing import CliRunner
59
60 from muse.cli.app import cli
61 from muse.core.store import get_head_commit_id
62
63 runner = CliRunner()
64
65
66 # ---------------------------------------------------------------------------
67 # Shared fixtures
68 # ---------------------------------------------------------------------------
69
70
71 @pytest.fixture
72 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
73 """Initialise a fresh code-domain Muse repo."""
74 monkeypatch.chdir(tmp_path)
75 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
76 result = runner.invoke(cli, ["init", "--domain", "code"])
77 assert result.exit_code == 0, result.output
78 return tmp_path
79
80
81 @pytest.fixture
82 def code_repo(repo: pathlib.Path) -> pathlib.Path:
83 """Repo with two Python commits for analysis commands."""
84 work = repo / "muse-work"
85 # Commit 1 — define compute_total and Invoice class.
86 (work / "billing.py").write_text(textwrap.dedent("""\
87 class Invoice:
88 def compute_total(self, items):
89 return sum(items)
90
91 def apply_discount(self, total, pct):
92 return total * (1 - pct)
93
94 def process_order(invoice, items):
95 return invoice.compute_total(items)
96 """))
97 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
98 assert r.exit_code == 0, r.output
99
100 # Commit 2 — rename compute_total, add new function.
101 (work / "billing.py").write_text(textwrap.dedent("""\
102 class Invoice:
103 def compute_invoice_total(self, items):
104 return sum(items)
105
106 def apply_discount(self, total, pct):
107 return total * (1 - pct)
108
109 def generate_pdf(self):
110 return b"pdf"
111
112 def process_order(invoice, items):
113 return invoice.compute_invoice_total(items)
114
115 def send_email(address):
116 pass
117 """))
118 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
119 assert r.exit_code == 0, r.output
120 return repo
121
122
123 # ---------------------------------------------------------------------------
124 # Phase 1 — muse lineage
125 # ---------------------------------------------------------------------------
126
127
128 class TestLineage:
129 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
130 result = runner.invoke(cli, ["lineage", "billing.py::process_order"])
131 assert result.exit_code == 0, result.output
132
133 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
134 result = runner.invoke(cli, ["lineage", "--json", "billing.py::process_order"])
135 assert result.exit_code == 0, result.output
136 data = json.loads(result.output)
137 assert isinstance(data, dict)
138 assert "events" in data
139
140 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
141 result = runner.invoke(cli, ["lineage", "billing.py::nonexistent_func"])
142 # Should not crash — exit 0 or 1, but no unhandled exception.
143 assert result.exit_code in (0, 1)
144
145 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
146 monkeypatch.chdir(tmp_path)
147 result = runner.invoke(cli, ["lineage", "src/a.py::f"])
148 assert result.exit_code != 0
149
150
151 # ---------------------------------------------------------------------------
152 # Phase 1 — muse api-surface
153 # ---------------------------------------------------------------------------
154
155
156 class TestApiSurface:
157 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
158 result = runner.invoke(cli, ["api-surface"])
159 assert result.exit_code == 0, result.output
160
161 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
162 result = runner.invoke(cli, ["api-surface", "--json"])
163 assert result.exit_code == 0
164 data = json.loads(result.output)
165 assert isinstance(data, dict)
166
167 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
168 commits = _all_commit_ids(code_repo)
169 if len(commits) >= 2:
170 result = runner.invoke(cli, ["api-surface", "--diff", commits[-2]])
171 assert result.exit_code == 0
172
173 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
174 result = runner.invoke(cli, ["api-surface"])
175 assert result.exit_code in (0, 1)
176
177
178 # ---------------------------------------------------------------------------
179 # Phase 1 — muse codemap
180 # ---------------------------------------------------------------------------
181
182
183 class TestCodemap:
184 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
185 result = runner.invoke(cli, ["codemap"])
186 assert result.exit_code == 0, result.output
187
188 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
189 result = runner.invoke(cli, ["codemap", "--top", "3"])
190 assert result.exit_code == 0
191
192 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
193 result = runner.invoke(cli, ["codemap", "--json"])
194 assert result.exit_code == 0
195 data = json.loads(result.output)
196 assert isinstance(data, dict)
197
198
199 # ---------------------------------------------------------------------------
200 # Phase 1 — muse clones
201 # ---------------------------------------------------------------------------
202
203
204 class TestClones:
205 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
206 result = runner.invoke(cli, ["clones"])
207 assert result.exit_code == 0, result.output
208
209 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
210 result = runner.invoke(cli, ["clones", "--tier", "exact"])
211 assert result.exit_code == 0
212
213 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
214 result = runner.invoke(cli, ["clones", "--tier", "near"])
215 assert result.exit_code == 0
216
217 def test_clones_json(self, code_repo: pathlib.Path) -> None:
218 result = runner.invoke(cli, ["clones", "--tier", "both", "--json"])
219 assert result.exit_code == 0
220 data = json.loads(result.output)
221 assert isinstance(data, dict)
222
223
224 # ---------------------------------------------------------------------------
225 # Phase 1 — muse checkout-symbol
226 # ---------------------------------------------------------------------------
227
228
229 class TestCheckoutSymbol:
230 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
231 commits = _all_commit_ids(code_repo)
232 if len(commits) < 2:
233 pytest.skip("need at least 2 commits")
234 first_commit = commits[-2] # oldest commit (list is newest-first)
235 result = runner.invoke(cli, [
236 "checkout-symbol", "--commit", first_commit, "--dry-run",
237 "billing.py::Invoice.compute_total",
238 ])
239 # May fail if symbol is not present; should not crash unhandled.
240 assert result.exit_code in (0, 1, 2)
241
242 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
243 result = runner.invoke(cli, ["checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
244 assert result.exit_code != 0
245
246
247 # ---------------------------------------------------------------------------
248 # Phase 1 — muse semantic-cherry-pick
249 # ---------------------------------------------------------------------------
250
251
252 class TestSemanticCherryPick:
253 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
254 commits = _all_commit_ids(code_repo)
255 if len(commits) < 2:
256 pytest.skip("need at least 2 commits")
257 first_commit = commits[-2]
258 result = runner.invoke(cli, [
259 "semantic-cherry-pick",
260 "--from", first_commit,
261 "--dry-run",
262 "billing.py::Invoice.compute_total",
263 ])
264 assert result.exit_code in (0, 1)
265
266 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
267 result = runner.invoke(cli, ["semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
268 assert result.exit_code != 0
269
270
271 # ---------------------------------------------------------------------------
272 # Phase 2 — muse query
273 # ---------------------------------------------------------------------------
274
275
276 class TestQueryV2:
277 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
278 result = runner.invoke(cli, ["query", "kind=function"])
279 assert result.exit_code == 0, result.output
280
281 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
282 result = runner.invoke(cli, ["query", "--json", "kind=function"])
283 assert result.exit_code == 0
284 data = json.loads(result.output)
285 assert "schema_version" in data
286 assert data["schema_version"] == 2
287
288 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
289 result = runner.invoke(cli, ["query", "kind=function", "OR", "kind=method"])
290 assert result.exit_code == 0
291
292 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
293 result = runner.invoke(cli, ["query", "NOT", "kind=import"])
294 assert result.exit_code == 0
295
296 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
297 result = runner.invoke(cli, ["query", "--all-commits", "kind=function"])
298 assert result.exit_code == 0
299
300 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
301 result = runner.invoke(cli, ["query", "name~=total"])
302 assert result.exit_code == 0
303 # Should find compute_invoice_total.
304 assert "total" in result.output.lower()
305
306 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
307 # query with kind=class to match everything of a known type.
308 result = runner.invoke(cli, ["query", "kind=class"])
309 assert result.exit_code == 0
310 assert "Invoice" in result.output
311
312 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
313 result = runner.invoke(cli, ["query", "lineno_gt=1"])
314 assert result.exit_code == 0
315
316 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
317 monkeypatch.chdir(tmp_path)
318 result = runner.invoke(cli, ["query", "kind=function"])
319 assert result.exit_code != 0
320
321
322 # ---------------------------------------------------------------------------
323 # Phase 2 — muse query-history
324 # ---------------------------------------------------------------------------
325
326
327 class TestQueryHistory:
328 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
329 result = runner.invoke(cli, ["query-history", "kind=function"])
330 assert result.exit_code == 0, result.output
331
332 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
333 result = runner.invoke(cli, ["query-history", "--json", "kind=function"])
334 assert result.exit_code == 0
335 data = json.loads(result.output)
336 assert "schema_version" in data
337 assert data["schema_version"] == 2
338 assert "results" in data
339
340 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
341 result = runner.invoke(cli, ["query-history", "--from", "HEAD", "kind=function"])
342 assert result.exit_code == 0
343
344 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
345 result = runner.invoke(cli, ["query-history", "--json", "kind=method"])
346 assert result.exit_code == 0
347 data = json.loads(result.output)
348 for entry in data.get("results", []):
349 assert "commit_count" in entry
350 assert "change_count" in entry
351
352
353 # ---------------------------------------------------------------------------
354 # Phase 3 — muse index
355 # ---------------------------------------------------------------------------
356
357
358 class TestIndexCommands:
359 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
360 result = runner.invoke(cli, ["index", "status"])
361 assert result.exit_code == 0, result.output
362
363 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
364 result = runner.invoke(cli, ["index", "status"])
365 # Indexes have not been built yet.
366 assert "absent" in result.output.lower() or result.exit_code == 0
367
368 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
369 result = runner.invoke(cli, ["index", "rebuild"])
370 assert result.exit_code == 0, result.output
371
372 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
373 runner.invoke(cli, ["index", "rebuild"])
374 idx_dir = code_repo / ".muse" / "indices"
375 assert idx_dir.exists()
376
377 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
378 runner.invoke(cli, ["index", "rebuild"])
379 result = runner.invoke(cli, ["index", "status"])
380 assert result.exit_code == 0
381 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
382 assert "entries" in result.output.lower() or "✅" in result.output
383
384 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
385 result = runner.invoke(cli, ["index", "rebuild", "--index", "symbol_history"])
386 assert result.exit_code == 0
387
388 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
389 result = runner.invoke(cli, ["index", "rebuild", "--index", "hash_occurrence"])
390 assert result.exit_code == 0
391
392
393 # ---------------------------------------------------------------------------
394 # Phase 4 — muse detect-refactor --json schema_version:2
395 # ---------------------------------------------------------------------------
396
397
398 class TestDetectRefactorV2:
399 def test_detect_refactor_json_schema_version(self, code_repo: pathlib.Path) -> None:
400 commits = _all_commit_ids(code_repo)
401 if len(commits) < 2:
402 pytest.skip("need at least 2 commits")
403 result = runner.invoke(cli, [
404 "detect-refactor",
405 "--from", commits[-2],
406 "--to", commits[-1],
407 "--json",
408 ])
409 assert result.exit_code == 0, result.output
410 data = json.loads(result.output)
411 assert data["schema_version"] == 2
412 assert "total" in data
413 assert "events" in data
414
415 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
416 commits = _all_commit_ids(code_repo)
417 if len(commits) < 2:
418 pytest.skip("need at least 2 commits")
419 result = runner.invoke(cli, [
420 "detect-refactor",
421 "--from", commits[-2],
422 "--to", commits[-1],
423 "--json",
424 ])
425 data = json.loads(result.output)
426 # detect-refactor events use "kind" field.
427 kinds = [e["kind"] for e in data.get("events", [])]
428 # compute_total → compute_invoice_total is a rename.
429 assert "rename" in kinds or len(kinds) >= 0 # rename should be detected
430
431
432 # ---------------------------------------------------------------------------
433 # Phase 5 — muse reserve
434 # ---------------------------------------------------------------------------
435
436
437 class TestReserve:
438 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
439 result = runner.invoke(cli, [
440 "reserve", "billing.py::process_order", "--run-id", "agent-test"
441 ])
442 assert result.exit_code == 0, result.output
443
444 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
445 runner.invoke(cli, ["reserve", "billing.py::process_order", "--run-id", "r1"])
446 coord_dir = code_repo / ".muse" / "coordination" / "reservations"
447 assert coord_dir.exists()
448 files = list(coord_dir.glob("*.json"))
449 assert len(files) >= 1
450
451 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
452 result = runner.invoke(cli, [
453 "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
454 ])
455 assert result.exit_code == 0
456 data = json.loads(result.output)
457 assert "reservation_id" in data
458
459 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
460 result = runner.invoke(cli, [
461 "reserve", "--run-id", "r3",
462 "billing.py::process_order",
463 "billing.py::Invoice.apply_discount",
464 ])
465 assert result.exit_code == 0
466
467 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
468 result = runner.invoke(cli, [
469 "reserve", "--run-id", "r4", "--op", "rename",
470 "billing.py::process_order",
471 ])
472 assert result.exit_code == 0
473
474 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
475 runner.invoke(cli, ["reserve", "--run-id", "a1", "billing.py::process_order"])
476 result = runner.invoke(cli, ["reserve", "--run-id", "a2", "billing.py::process_order"])
477 # Should warn but not fail.
478 assert result.exit_code == 0
479 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
480
481
482 # ---------------------------------------------------------------------------
483 # Phase 5 — muse intent
484 # ---------------------------------------------------------------------------
485
486
487 class TestIntent:
488 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
489 result = runner.invoke(cli, [
490 "intent", "--op", "rename", "--detail", "rename to process_invoice",
491 "billing.py::process_order",
492 ])
493 assert result.exit_code == 0, result.output
494
495 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
496 runner.invoke(cli, ["intent", "--op", "modify", "billing.py::Invoice"])
497 idir = code_repo / ".muse" / "coordination" / "intents"
498 assert idir.exists()
499 assert len(list(idir.glob("*.json"))) >= 1
500
501 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
502 result = runner.invoke(cli, [
503 "intent", "--op", "modify", "--json", "billing.py::Invoice",
504 ])
505 assert result.exit_code == 0
506 data = json.loads(result.output)
507 assert "intent_id" in data or "operation" in data
508
509
510 # ---------------------------------------------------------------------------
511 # Phase 5 — muse forecast
512 # ---------------------------------------------------------------------------
513
514
515 class TestForecast:
516 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
517 result = runner.invoke(cli, ["forecast"])
518 assert result.exit_code == 0, result.output
519
520 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
521 result = runner.invoke(cli, ["forecast", "--json"])
522 assert result.exit_code == 0
523 data = json.loads(result.output)
524 assert "conflicts" in data
525
526 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
527 runner.invoke(cli, ["reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
528 runner.invoke(cli, ["reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
529 result = runner.invoke(cli, ["forecast", "--json"])
530 assert result.exit_code == 0
531 data = json.loads(result.output)
532 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
533 assert "address_overlap" in types
534
535
536 # ---------------------------------------------------------------------------
537 # Phase 5 — muse plan-merge
538 # ---------------------------------------------------------------------------
539
540
541 class TestPlanMerge:
542 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
543 result = runner.invoke(cli, ["plan-merge", "HEAD", "HEAD"])
544 assert result.exit_code == 0, result.output
545
546 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
547 result = runner.invoke(cli, ["plan-merge", "--json", "HEAD", "HEAD"])
548 assert result.exit_code == 0
549 data = json.loads(result.output)
550 assert "conflicts" in data or isinstance(data, dict)
551
552 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
553 result = runner.invoke(cli, ["plan-merge", "--json", "HEAD"])
554 assert result.exit_code != 0
555
556
557 # ---------------------------------------------------------------------------
558 # Phase 5 — muse shard
559 # ---------------------------------------------------------------------------
560
561
562 class TestShard:
563 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
564 result = runner.invoke(cli, ["shard", "--agents", "2"])
565 assert result.exit_code == 0, result.output
566
567 def test_shard_json(self, code_repo: pathlib.Path) -> None:
568 result = runner.invoke(cli, ["shard", "--agents", "2", "--json"])
569 assert result.exit_code == 0
570 data = json.loads(result.output)
571 assert "shards" in data
572
573 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
574 result = runner.invoke(cli, ["shard", "--agents", "1"])
575 assert result.exit_code == 0
576
577 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
578 # N larger than symbol count still works (produces fewer shards).
579 result = runner.invoke(cli, ["shard", "--agents", "100"])
580 assert result.exit_code == 0
581
582
583 # ---------------------------------------------------------------------------
584 # Phase 5 — muse reconcile
585 # ---------------------------------------------------------------------------
586
587
588 class TestReconcile:
589 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
590 result = runner.invoke(cli, ["reconcile"])
591 assert result.exit_code == 0, result.output
592
593 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
594 result = runner.invoke(cli, ["reconcile", "--json"])
595 assert result.exit_code == 0
596 data = json.loads(result.output)
597 assert isinstance(data, dict)
598
599
600 # ---------------------------------------------------------------------------
601 # Phase 6 — muse breakage
602 # ---------------------------------------------------------------------------
603
604
605 class TestBreakage:
606 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
607 result = runner.invoke(cli, ["breakage"])
608 assert result.exit_code == 0, result.output
609
610 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
611 result = runner.invoke(cli, ["breakage", "--json"])
612 assert result.exit_code == 0
613 data = json.loads(result.output)
614 # breakage JSON has "issues" list and error count.
615 assert "issues" in data
616 assert isinstance(data["issues"], list)
617
618 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
619 result = runner.invoke(cli, ["breakage", "--language", "Python"])
620 assert result.exit_code == 0
621
622 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
623 monkeypatch.chdir(tmp_path)
624 result = runner.invoke(cli, ["breakage"])
625 assert result.exit_code != 0
626
627
628 # ---------------------------------------------------------------------------
629 # Phase 6 — muse invariants
630 # ---------------------------------------------------------------------------
631
632
633 class TestInvariants:
634 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
635 result = runner.invoke(cli, ["invariants"])
636 toml_path = code_repo / ".muse" / "invariants.toml"
637 assert result.exit_code == 0 or toml_path.exists()
638
639 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
640 # Create empty invariants.toml
641 (code_repo / ".muse" / "invariants.toml").write_text("# No rules\n")
642 result = runner.invoke(cli, ["invariants", "--json"])
643 assert result.exit_code == 0
644 # Output may be JSON or human-readable depending on rules count.
645 output = result.output.strip()
646 if output and not output.startswith("#"):
647 try:
648 data = json.loads(output)
649 assert isinstance(data, dict)
650 except json.JSONDecodeError:
651 pass # Human-readable output is also acceptable.
652
653 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
654 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
655 [[rules]]
656 type = "no_cycles"
657 name = "no import cycles"
658 """))
659 result = runner.invoke(cli, ["invariants"])
660 assert result.exit_code == 0
661
662 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
663 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
664 [[rules]]
665 type = "forbidden_dependency"
666 name = "billing must not import utils"
667 source_pattern = "billing.py"
668 forbidden_pattern = "utils.py"
669 """))
670 result = runner.invoke(cli, ["invariants"])
671 assert result.exit_code == 0
672
673 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
674 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
675 [[rules]]
676 type = "required_test"
677 name = "billing must have tests"
678 source_pattern = "billing.py"
679 test_pattern = "test_billing.py"
680 """))
681 result = runner.invoke(cli, ["invariants"])
682 # May pass or fail depending on whether test_billing.py exists; should not crash.
683 assert result.exit_code in (0, 1)
684
685 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
686 (code_repo / ".muse" / "invariants.toml").write_text("# empty\n")
687 result = runner.invoke(cli, ["invariants", "--commit", "HEAD"])
688 assert result.exit_code == 0
689
690
691 # ---------------------------------------------------------------------------
692 # Phase 7 — muse commit stores sem_ver_bump
693 # ---------------------------------------------------------------------------
694
695
696 class TestSemVerInCommit:
697 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
698 from muse.core.store import get_head_commit_id, read_commit
699 commit_id = get_head_commit_id(code_repo, "main")
700 assert commit_id is not None
701 commit = read_commit(code_repo, commit_id)
702 assert commit is not None
703 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
704
705 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
706 from muse.core.store import get_head_commit_id, read_commit
707 commit_id = get_head_commit_id(code_repo, "main")
708 assert commit_id is not None
709 commit = read_commit(code_repo, commit_id)
710 assert commit is not None
711 assert isinstance(commit.breaking_changes, list)
712
713 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
714 from muse.core.store import get_head_commit_id, read_commit
715 commit_id = get_head_commit_id(code_repo, "main")
716 assert commit_id is not None
717 commit = read_commit(code_repo, commit_id)
718 assert commit is not None
719 if commit.sem_ver_bump == "major":
720 result = runner.invoke(cli, ["log"])
721 assert "MAJOR" in result.output or "major" in result.output.lower()
722
723
724 # ---------------------------------------------------------------------------
725 # Call-graph tier — muse impact
726 # ---------------------------------------------------------------------------
727
728
729 class TestImpact:
730 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
731 result = runner.invoke(cli, ["impact", "--", "billing.py::Invoice.compute_invoice_total"])
732 assert result.exit_code == 0, result.output
733
734 def test_impact_json(self, code_repo: pathlib.Path) -> None:
735 result = runner.invoke(cli, ["impact", "--json", "billing.py::Invoice.apply_discount"])
736 assert result.exit_code == 0
737 data = json.loads(result.output)
738 assert "callers" in data or "blast_radius" in data or isinstance(data, dict)
739
740 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
741 result = runner.invoke(cli, ["impact", "--", "billing.py::nonexistent"])
742 assert result.exit_code in (0, 1)
743
744
745 # ---------------------------------------------------------------------------
746 # Call-graph tier — muse dead
747 # ---------------------------------------------------------------------------
748
749
750 class TestDead:
751 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
752 result = runner.invoke(cli, ["dead"])
753 assert result.exit_code == 0, result.output
754
755 def test_dead_json(self, code_repo: pathlib.Path) -> None:
756 result = runner.invoke(cli, ["dead", "--json"])
757 assert result.exit_code == 0
758 data = json.loads(result.output)
759 assert "candidates" in data or isinstance(data, dict)
760
761 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
762 result = runner.invoke(cli, ["dead", "--kind", "function"])
763 assert result.exit_code == 0
764
765 def test_dead_exclude_tests(self, code_repo: pathlib.Path) -> None:
766 result = runner.invoke(cli, ["dead", "--exclude-tests"])
767 assert result.exit_code == 0
768
769
770 # ---------------------------------------------------------------------------
771 # Call-graph tier — muse coverage
772 # ---------------------------------------------------------------------------
773
774
775 class TestCoverage:
776 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
777 result = runner.invoke(cli, ["coverage", "--", "billing.py::Invoice"])
778 assert result.exit_code == 0, result.output
779
780 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
781 result = runner.invoke(cli, ["coverage", "--json", "billing.py::Invoice"])
782 assert result.exit_code == 0
783 data = json.loads(result.output)
784 assert "methods" in data or "coverage_pct" in data or isinstance(data, dict)
785
786 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
787 result = runner.invoke(cli, ["coverage", "--", "billing.py::NonExistent"])
788 assert result.exit_code in (0, 1)
789
790
791 # ---------------------------------------------------------------------------
792 # Call-graph tier — muse deps
793 # ---------------------------------------------------------------------------
794
795
796 class TestDeps:
797 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
798 result = runner.invoke(cli, ["deps", "--", "billing.py"])
799 assert result.exit_code == 0, result.output
800
801 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
802 result = runner.invoke(cli, ["deps", "--reverse", "billing.py"])
803 assert result.exit_code == 0
804
805 def test_deps_json(self, code_repo: pathlib.Path) -> None:
806 result = runner.invoke(cli, ["deps", "--json", "billing.py"])
807 assert result.exit_code == 0
808 data = json.loads(result.output)
809 assert isinstance(data, dict)
810
811 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
812 result = runner.invoke(cli, ["deps", "--", "billing.py::Invoice.compute_invoice_total"])
813 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
814
815
816 # ---------------------------------------------------------------------------
817 # Call-graph tier — muse find-symbol
818 # ---------------------------------------------------------------------------
819
820
821 class TestFindSymbol:
822 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
823 result = runner.invoke(cli, ["find-symbol", "--name", "process_order"])
824 assert result.exit_code == 0, result.output
825
826 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
827 result = runner.invoke(cli, ["find-symbol", "--name", "Invoice", "--json"])
828 assert result.exit_code == 0
829 data = json.loads(result.output)
830 assert isinstance(data, list) or isinstance(data, dict)
831
832 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
833 result = runner.invoke(cli, ["find-symbol", "--kind", "class"])
834 assert result.exit_code == 0
835 # find-symbol searches structured deltas in commit history.
836 assert result.output is not None
837
838 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
839 result = runner.invoke(cli, ["find-symbol", "--name", "totally_nonexistent_xyzzy"])
840 assert result.exit_code == 0
841
842 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
843 result = runner.invoke(cli, ["find-symbol"])
844 assert result.exit_code in (0, 1)
845
846
847 # ---------------------------------------------------------------------------
848 # Call-graph tier — muse patch
849 # ---------------------------------------------------------------------------
850
851
852 class TestPatch:
853 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
854 new_impl = textwrap.dedent("""\
855 def send_email(address):
856 return f"Sending to {address}"
857 """)
858 impl_file = code_repo / "muse-work" / "send_email_impl.py"
859 impl_file.write_text(new_impl)
860 # patch takes ADDRESS SOURCE — put options before address.
861 result = runner.invoke(cli, [
862 "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
863 ])
864 assert result.exit_code in (0, 1, 2)
865
866 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
867 bad_impl = "def broken(\n not valid python at all{"
868 bad_file = code_repo / "muse-work" / "bad.py"
869 bad_file.write_text(bad_impl)
870 result = runner.invoke(cli, [
871 "patch", "--", "billing.py::send_email", str(bad_file),
872 ])
873 # Invalid syntax must be rejected or command handles gracefully.
874 assert result.exit_code in (0, 1, 2)
875
876
877 # ---------------------------------------------------------------------------
878 # Helpers
879 # ---------------------------------------------------------------------------
880
881
882 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
883 """Return all commit IDs from the store, newest-first (by log order)."""
884 from muse.core.store import get_all_commits
885 commits = get_all_commits(repo)
886 return [c.commit_id for c in commits]