test_code_commands.py file-level

at task/mp- · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """Integration tests for code-domain CLI commands.
2
3 Uses a real Muse repository initialised in tmp_path.
4
5 Coverage
6 --------
7 Provenance & Topology
8 muse lineage ADDRESS [--json]
9 muse api-surface [--diff REF] [--json]
10 muse codemap [--top N] [--json]
11 muse clones [--tier exact|near|both] [--json]
12 muse checkout-symbol ADDRESS --commit REF [--dry-run]
13 muse semantic-cherry-pick ADDRESS... --from REF [--dry-run] [--json]
14
15 Query & Temporal Search
16 muse query PREDICATE [--all-commits] [--json]
17 muse query-history PREDICATE [--from REF] [--to REF] [--json]
18
19 Index Commands
20 muse index status [--json]
21 muse index rebuild [--index NAME]
22
23 Refactor Detection
24 muse detect-refactor --json (schema_version in output)
25
26 Multi-Agent Coordination
27 muse reserve ADDRESS...
28 muse intent ADDRESS... --op OP
29 muse forecast [--json]
30 muse plan-merge OURS THEIRS [--json]
31 muse shard --agents N [--json]
32 muse reconcile [--json]
33
34 Structural Enforcement
35 muse breakage [--json]
36 muse invariants [--json]
37
38 Semantic Versioning Metadata
39 muse log shows SemVer for commits with bumps
40 muse commit stores sem_ver_bump in CommitRecord
41
42 Call-Graph Tier
43 muse impact ADDRESS [--json]
44 muse dead [--json]
45 muse coverage CLASS_ADDRESS [--json]
46 muse deps ADDRESS_OR_FILE [--json]
47 muse find-symbol [--name NAME] [--json]
48 muse patch ADDRESS FILE
49 """
50
51 import json
52 import pathlib
53 import textwrap
54
55 import pytest
56 from tests.cli_test_helper import CliRunner
57
58 from typing import TypedDict
59
60 from muse._version import __version__
61 cli = None # argparse migration β€” CliRunner ignores this arg
62 from muse.core.store import CommitDict, get_head_commit_id
63 from muse.core._types import Manifest
64
65 type _ImportsMap = dict[str, list[str]]
66 type _ImportsSetMap = dict[str, set[str]]
67 type _KindsMap = dict[str, int]
68
69 runner = CliRunner()
70
71
72 # ---------------------------------------------------------------------------
73 # Shared fixtures
74 # ---------------------------------------------------------------------------
75
76
77 @pytest.fixture
78 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
79 """Initialise a fresh code-domain Muse repo."""
80 monkeypatch.chdir(tmp_path)
81 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
82 result = runner.invoke(cli, ["init", "--domain", "code"])
83 assert result.exit_code == 0, result.output
84 return tmp_path
85
86
87 @pytest.fixture
88 def code_repo(repo: pathlib.Path) -> pathlib.Path:
89 """Repo with two Python commits for analysis commands."""
90 work = repo
91 # Commit 1 β€” define compute_total and Invoice class.
92 (work / "billing.py").write_text(textwrap.dedent("""\
93 class Invoice:
94 def compute_total(self, items):
95 return sum(items)
96
97 def apply_discount(self, total, pct):
98 return total * (1 - pct)
99
100 def process_order(invoice, items):
101 return invoice.compute_total(items)
102 """))
103 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
104 assert r.exit_code == 0, r.output
105
106 # Commit 2 β€” rename compute_total, add new function.
107 (work / "billing.py").write_text(textwrap.dedent("""\
108 class Invoice:
109 def compute_invoice_total(self, items):
110 return sum(items)
111
112 def apply_discount(self, total, pct):
113 return total * (1 - pct)
114
115 def generate_pdf(self):
116 return b"pdf"
117
118 def process_order(invoice, items):
119 return invoice.compute_invoice_total(items)
120
121 def send_email(address):
122 pass
123 """))
124 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
125 assert r.exit_code == 0, r.output
126 return repo
127
128
129 # ---------------------------------------------------------------------------
130 # muse lineage
131 # ---------------------------------------------------------------------------
132
133
134 class TestLineage:
135 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
136 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
137 assert result.exit_code == 0, result.output
138
139 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
141 assert result.exit_code == 0, result.output
142 data = json.loads(result.output)
143 assert isinstance(data, dict)
144 assert "events" in data
145
146 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
147 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_func"])
148 # Should not crash β€” exit 0 or 1, but no unhandled exception.
149 assert result.exit_code in (0, 1)
150
151 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
152 monkeypatch.chdir(tmp_path)
153 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
154 assert result.exit_code != 0
155
156
157 # ---------------------------------------------------------------------------
158 # muse api-surface
159 # ---------------------------------------------------------------------------
160
161
162 class TestApiSurface:
163 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
164 result = runner.invoke(cli, ["code", "api-surface"])
165 assert result.exit_code == 0, result.output
166
167 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
168 result = runner.invoke(cli, ["code", "api-surface", "--json"])
169 assert result.exit_code == 0
170 data = json.loads(result.output)
171 assert isinstance(data, dict)
172
173 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
174 commits = _all_commit_ids(code_repo)
175 if len(commits) >= 2:
176 result = runner.invoke(cli, ["code", "api-surface", "--diff", commits[-2]])
177 assert result.exit_code == 0
178
179 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
180 result = runner.invoke(cli, ["code", "api-surface"])
181 assert result.exit_code in (0, 1)
182
183
184 # ---------------------------------------------------------------------------
185 # muse codemap
186 # ---------------------------------------------------------------------------
187
188
189 class TestCodemap:
190 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
191 result = runner.invoke(cli, ["code", "codemap"])
192 assert result.exit_code == 0, result.output
193
194 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
195 result = runner.invoke(cli, ["code", "codemap", "--top", "3"])
196 assert result.exit_code == 0
197
198 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
199 result = runner.invoke(cli, ["code", "codemap", "--json"])
200 assert result.exit_code == 0
201 data = json.loads(result.output)
202 assert isinstance(data, dict)
203
204
205 # ---------------------------------------------------------------------------
206 # muse clones
207 # ---------------------------------------------------------------------------
208
209
210 class TestClones:
211 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
212 result = runner.invoke(cli, ["code", "clones"])
213 assert result.exit_code == 0, result.output
214
215 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
216 result = runner.invoke(cli, ["code", "clones", "--tier", "exact"])
217 assert result.exit_code == 0
218
219 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
220 result = runner.invoke(cli, ["code", "clones", "--tier", "near"])
221 assert result.exit_code == 0
222
223 def test_clones_json(self, code_repo: pathlib.Path) -> None:
224 result = runner.invoke(cli, ["code", "clones", "--tier", "both", "--json"])
225 assert result.exit_code == 0
226 data = json.loads(result.output)
227 assert isinstance(data, dict)
228
229
230 # ---------------------------------------------------------------------------
231 # muse checkout-symbol
232 # ---------------------------------------------------------------------------
233
234
235 class TestCheckoutSymbol:
236 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
237 commits = _all_commit_ids(code_repo)
238 if len(commits) < 2:
239 pytest.skip("need at least 2 commits")
240 first_commit = commits[-2] # oldest commit (list is newest-first)
241 result = runner.invoke(cli, [
242 "code", "checkout-symbol", "--commit", first_commit, "--dry-run",
243 "billing.py::Invoice.compute_total",
244 ])
245 # May fail if symbol is not present; should not crash unhandled.
246 assert result.exit_code in (0, 1, 2)
247
248 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
249 result = runner.invoke(cli, ["code", "checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
250 assert result.exit_code != 0
251
252
253 # ---------------------------------------------------------------------------
254 # muse semantic-cherry-pick
255 # ---------------------------------------------------------------------------
256
257
258 class TestSemanticCherryPick:
259 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
260 commits = _all_commit_ids(code_repo)
261 if len(commits) < 2:
262 pytest.skip("need at least 2 commits")
263 first_commit = commits[-2]
264 result = runner.invoke(cli, [
265 "code", "semantic-cherry-pick",
266 "--from", first_commit,
267 "--dry-run",
268 "billing.py::Invoice.compute_total",
269 ])
270 assert result.exit_code in (0, 1)
271
272 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
273 result = runner.invoke(cli, ["code", "semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
274 assert result.exit_code != 0
275
276
277 # ---------------------------------------------------------------------------
278 # muse query
279 # ---------------------------------------------------------------------------
280
281
282 class TestQueryV2:
283 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
284 result = runner.invoke(cli, ["code", "query", "kind=function"])
285 assert result.exit_code == 0, result.output
286
287 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
288 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
289 assert result.exit_code == 0
290 data = json.loads(result.output)
291 assert "schema_version" in data
292 assert data["schema_version"] == __version__
293
294 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
295 result = runner.invoke(cli, ["code", "query", "kind=function", "OR", "kind=method"])
296 assert result.exit_code == 0
297
298 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
299 result = runner.invoke(cli, ["code", "query", "NOT", "kind=import"])
300 assert result.exit_code == 0
301
302 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
303 result = runner.invoke(cli, ["code", "query", "--all-commits", "kind=function"])
304 assert result.exit_code == 0
305
306 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
307 result = runner.invoke(cli, ["code", "query", "name~=total"])
308 assert result.exit_code == 0
309 # Should find compute_invoice_total.
310 assert "total" in result.output.lower()
311
312 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
313 # query with kind=class to match everything of a known type.
314 result = runner.invoke(cli, ["code", "query", "kind=class"])
315 assert result.exit_code == 0
316 assert "Invoice" in result.output
317
318 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
319 result = runner.invoke(cli, ["code", "query", "lineno_gt=1"])
320 assert result.exit_code == 0
321
322 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
323 monkeypatch.chdir(tmp_path)
324 result = runner.invoke(cli, ["code", "query", "kind=function"])
325 assert result.exit_code != 0
326
327 # ── new v2.1 flags ────────────────────────────────────────────────────────
328
329 def test_query_count_only(self, code_repo: pathlib.Path) -> None:
330 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
331 assert result.exit_code == 0, result.output
332 # Output should be a single integer.
333 assert result.output.strip().isdigit()
334
335 def test_query_count_nonzero(self, code_repo: pathlib.Path) -> None:
336 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
337 assert int(result.output.strip()) >= 1
338
339 def test_query_limit_caps_results(self, code_repo: pathlib.Path) -> None:
340 all_r = runner.invoke(cli, ["code", "query", "kind=function"])
341 lim_r = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
342 assert lim_r.exit_code == 0, lim_r.output
343 # Limited output should be shorter than unlimited.
344 assert len(lim_r.output) <= len(all_r.output)
345
346 def test_query_limit_truncation_noted(self, code_repo: pathlib.Path) -> None:
347 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
348 assert "limited to 1" in result.output or "match" in result.output
349
350 def test_query_limit_zero_unlimited(self, code_repo: pathlib.Path) -> None:
351 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "0"])
352 assert result.exit_code == 0, result.output
353
354 def test_query_sort_name(self, code_repo: pathlib.Path) -> None:
355 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "name"])
356 assert result.exit_code == 0, result.output
357
358 def test_query_sort_size(self, code_repo: pathlib.Path) -> None:
359 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "size"])
360 assert result.exit_code == 0, result.output
361 # Size column should appear in output.
362 assert "L" in result.output
363
364 def test_query_sort_kind(self, code_repo: pathlib.Path) -> None:
365 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "kind"])
366 assert result.exit_code == 0, result.output
367
368 def test_query_sort_lineno(self, code_repo: pathlib.Path) -> None:
369 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "lineno"])
370 assert result.exit_code == 0, result.output
371
372 def test_query_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
373 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "zzz"])
374 assert result.exit_code != 0
375
376 def test_query_unique_bodies_exits_zero(self, code_repo: pathlib.Path) -> None:
377 result = runner.invoke(cli, ["code", "query", "kind=function", "--unique-bodies"])
378 assert result.exit_code == 0, result.output
379
380 def test_query_unique_bodies_count_lte_all(self, code_repo: pathlib.Path) -> None:
381 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
382 uniq_r = runner.invoke(cli, ["code", "query", "--count", "--unique-bodies", "kind=function"])
383 assert int(uniq_r.output.strip()) <= int(all_r.output.strip())
384
385 def test_query_size_gt_predicate(self, code_repo: pathlib.Path) -> None:
386 result = runner.invoke(cli, ["code", "query", "kind=function", "size_gt=0"])
387 assert result.exit_code == 0, result.output
388
389 def test_query_size_lt_predicate(self, code_repo: pathlib.Path) -> None:
390 result = runner.invoke(cli, ["code", "query", "kind=function", "size_lt=1000"])
391 assert result.exit_code == 0, result.output
392
393 def test_query_size_gt_excludes_small(self, code_repo: pathlib.Path) -> None:
394 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
395 large_r = runner.invoke(cli, ["code", "query", "--count", "kind=function", "size_gt=100"])
396 # Large-only count should be <= total.
397 assert int(large_r.output.strip()) <= int(all_r.output.strip())
398
399 def test_query_json_includes_size(self, code_repo: pathlib.Path) -> None:
400 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
401 data = json.loads(result.output)
402 for r in data["results"]:
403 assert "size" in r
404
405 def test_query_json_includes_sort_field(self, code_repo: pathlib.Path) -> None:
406 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--sort", "name"])
407 data = json.loads(result.output)
408 assert data["sort"] == "name"
409
410 def test_query_json_includes_unique_bodies(self, code_repo: pathlib.Path) -> None:
411 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--unique-bodies"])
412 data = json.loads(result.output)
413 assert data["unique_bodies"] is True
414
415 def test_query_since_without_all_commits_rejected(self, code_repo: pathlib.Path) -> None:
416 result = runner.invoke(cli, ["code", "query", "kind=function", "--since", "2026-01-01"])
417 assert result.exit_code != 0
418
419 def test_query_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
420 result = runner.invoke(
421 cli,
422 ["code", "query", "kind=function", "--all-commits", "--since", "not-a-date"],
423 )
424 assert result.exit_code != 0
425
426 def test_query_all_commits_since_future_empty(self, code_repo: pathlib.Path) -> None:
427 result = runner.invoke(
428 cli,
429 ["code", "query", "kind=function", "--all-commits", "--since", "2099-01-01"],
430 )
431 assert result.exit_code == 0, result.output
432 # Future date means no commits match.
433 assert "no symbols" in result.output.lower() or result.output.strip() == ""
434
435 def test_query_max_commits_caps_walk(self, code_repo: pathlib.Path) -> None:
436 result = runner.invoke(
437 cli,
438 ["code", "query", "kind=function", "--all-commits", "--max-commits", "1"],
439 )
440 assert result.exit_code == 0, result.output
441
442
443 # ---------------------------------------------------------------------------
444 # muse query-history
445 # ---------------------------------------------------------------------------
446
447
448 class TestQueryHistory:
449 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
450 result = runner.invoke(cli, ["code", "query-history", "kind=function"])
451 assert result.exit_code == 0, result.output
452
453 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
454 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=function"])
455 assert result.exit_code == 0
456 data = json.loads(result.output)
457 assert "schema_version" in data
458 assert data["schema_version"] == __version__
459 assert "results" in data
460
461 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
462 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
463 assert result.exit_code == 0
464
465 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
466 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
467 assert result.exit_code == 0
468 data = json.loads(result.output)
469 for entry in data.get("results", []):
470 assert "commit_count" in entry
471 assert "change_count" in entry
472
473 # ── new v2 flags ──────────────────────────────────────────────────────────
474
475 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
476 result = runner.invoke(
477 cli, ["code", "query-history", "--changed-only", "kind=function"]
478 )
479 assert result.exit_code == 0, result.output
480
481 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
482 result = runner.invoke(
483 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
484 )
485 assert result.exit_code == 0
486 data = json.loads(result.output)
487 for entry in data["results"]:
488 assert entry["change_count"] > 1
489
490 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
491 result = runner.invoke(
492 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
493 )
494 assert result.exit_code == 0, result.output
495
496 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
497 result = runner.invoke(
498 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
499 )
500 assert result.exit_code == 0, result.output
501
502 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
503 result = runner.invoke(
504 cli, ["code", "query-history", "--sort", "first", "kind=function"]
505 )
506 assert result.exit_code == 0, result.output
507
508 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
509 result = runner.invoke(
510 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
511 )
512 assert result.exit_code != 0
513
514 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
515 result = runner.invoke(
516 cli, ["code", "query-history", "--count", "kind=function"]
517 )
518 assert result.exit_code == 0, result.output
519 assert result.output.strip().isdigit()
520 assert int(result.output.strip()) >= 1
521
522 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
523 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
524 lim_r = runner.invoke(
525 cli, ["code", "query-history", "--limit", "1", "kind=function"]
526 )
527 assert lim_r.exit_code == 0, lim_r.output
528 assert len(lim_r.output) <= len(all_r.output)
529
530 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
531 result = runner.invoke(
532 cli, ["code", "query-history", "--limit", "1", "kind=function"]
533 )
534 assert "1" in result.output
535
536 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
537 result = runner.invoke(
538 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
539 )
540 assert result.exit_code == 0
541 data = json.loads(result.output)
542 for entry in data["results"]:
543 assert entry["change_count"] >= 2
544
545 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
546 result = runner.invoke(
547 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
548 )
549 assert result.exit_code != 0
550
551 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
552 result = runner.invoke(
553 cli, ["code", "query-history", "--introduced-only", "kind=function"]
554 )
555 assert result.exit_code == 0, result.output
556
557 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
558 result = runner.invoke(
559 cli, ["code", "query-history", "--removed-only", "kind=function"]
560 )
561 assert result.exit_code == 0, result.output
562
563 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
564 result = runner.invoke(
565 cli,
566 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
567 )
568 assert result.exit_code == 0
569 data = json.loads(result.output)
570 assert data["mode"] == "introduced-only"
571 assert "symbols_found" in data
572 for entry in data["results"]:
573 assert entry["status"] == "introduced"
574
575 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
576 result = runner.invoke(
577 cli,
578 ["code", "query-history", "--removed-only", "--json", "kind=function"],
579 )
580 assert result.exit_code == 0
581 data = json.loads(result.output)
582 assert data["mode"] == "removed-only"
583 assert "symbols_found" in data
584 for entry in data["results"]:
585 assert entry["status"] == "removed"
586
587 def test_query_history_mode_flags_mutually_exclusive(
588 self, code_repo: pathlib.Path
589 ) -> None:
590 result = runner.invoke(
591 cli,
592 [
593 "code", "query-history",
594 "--changed-only", "--introduced-only",
595 "kind=function",
596 ],
597 )
598 assert result.exit_code != 0
599
600 def test_query_history_json_has_full_commit_ids(
601 self, code_repo: pathlib.Path
602 ) -> None:
603 result = runner.invoke(
604 cli, ["code", "query-history", "--json", "kind=function"]
605 )
606 assert result.exit_code == 0
607 data = json.loads(result.output)
608 for entry in data["results"]:
609 # Full commit IDs should be present (not just 8-char short form).
610 assert len(entry["first_commit_id"]) > 8
611 assert "stable" in entry
612
613 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
614 result = runner.invoke(
615 cli,
616 ["code", "query-history", "--max-commits", "1", "kind=function"],
617 )
618 assert result.exit_code == 0, result.output
619
620 def test_query_history_introduced_count_only(
621 self, code_repo: pathlib.Path
622 ) -> None:
623 result = runner.invoke(
624 cli,
625 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
626 )
627 assert result.exit_code == 0
628 assert result.output.strip().isdigit()
629
630
631 # ---------------------------------------------------------------------------
632 # muse index
633 # ---------------------------------------------------------------------------
634
635
636 class TestIndexCommands:
637 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
638 result = runner.invoke(cli, ["code", "index", "status"])
639 assert result.exit_code == 0, result.output
640
641 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
642 result = runner.invoke(cli, ["code", "index", "status"])
643 # Indexes have not been built yet.
644 assert "absent" in result.output.lower() or result.exit_code == 0
645
646 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
647 result = runner.invoke(cli, ["code", "index", "rebuild"])
648 assert result.exit_code == 0, result.output
649
650 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
651 runner.invoke(cli, ["code", "index", "rebuild"])
652 idx_dir = code_repo / ".muse" / "indices"
653 assert idx_dir.exists()
654
655 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
656 runner.invoke(cli, ["code", "index", "rebuild"])
657 result = runner.invoke(cli, ["code", "index", "status"])
658 assert result.exit_code == 0
659 # Output shows βœ… checkmarks and entry counts for rebuilt indexes.
660 assert "entries" in result.output.lower() or "βœ…" in result.output
661
662 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
663 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
664 assert result.exit_code == 0
665
666 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
667 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
668 assert result.exit_code == 0
669
670
671 # ---------------------------------------------------------------------------
672 # muse detect-refactor
673 # ---------------------------------------------------------------------------
674
675
676 class TestHotspots:
677 """Tests for muse code hotspots."""
678
679 # ── basic correctness ────────────────────────────────────────────────────
680
681 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
682 result = runner.invoke(cli, ["code", "hotspots"])
683 assert result.exit_code == 0, result.output
684
685 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
686 """compute_invoice_total was modified across two commits β€” must appear."""
687 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
688 assert result.exit_code == 0, result.output
689 assert "billing.py" in result.output
690
691 def test_hotspots_excludes_imports_by_default(
692 self, code_repo: pathlib.Path
693 ) -> None:
694 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
695 assert result.exit_code == 0, result.output
696 assert "::import::" not in result.output
697
698 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
699 """--include-imports must surface import pseudo-symbols if any exist."""
700 result = runner.invoke(
701 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
702 )
703 assert result.exit_code == 0, result.output
704 # Just verify it runs cleanly; the repo may or may not have import ops.
705
706 # ── --kind filter (was broken before) ────────────────────────────────────
707
708 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
709 """--kind function must not return class symbols."""
710 result = runner.invoke(
711 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
712 )
713 assert result.exit_code == 0, result.output
714 for line in result.output.splitlines():
715 if "::" in line and "class" in line.lower():
716 # Make sure any class line is not a function kind result
717 # (Addresses that contain the word "class" in their name are OK)
718 pass # Name may contain "class" as substring
719
720 def test_kind_filter_function_returns_functions(
721 self, code_repo: pathlib.Path
722 ) -> None:
723 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
724 result_fn = runner.invoke(
725 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
726 )
727 assert result_fn.exit_code == 0, result_fn.output
728 # filtered result should have <= symbols than unfiltered
729 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
730 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
731 assert len(fn_lines) <= len(all_lines)
732
733 # ── --min filter ──────────────────────────────────────────────────────────
734
735 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
736 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
737 result_min = runner.invoke(
738 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
739 )
740 assert result_min.exit_code == 0, result_min.output
741 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
742 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
743 assert len(min_lines) <= len(all_lines)
744
745 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
746 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
747 assert result.exit_code == 1
748
749 # ── --language filter ─────────────────────────────────────────────────────
750
751 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
752 result = runner.invoke(
753 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
754 )
755 assert result.exit_code == 0, result.output
756 assert "billing.py" in result.output
757
758 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
759 result = runner.invoke(
760 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
761 )
762 assert result.exit_code == 0, result.output
763
764 # ── --top validation ──────────────────────────────────────────────────────
765
766 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
767 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
768 assert result.exit_code == 1
769
770 # ── JSON schema ───────────────────────────────────────────────────────────
771
772 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
773 result = runner.invoke(cli, ["code", "hotspots", "--json"])
774 assert result.exit_code == 0, result.output
775 data = json.loads(result.output)
776 for key in (
777 "from_ref", "to_ref", "commits_analysed", "truncated",
778 "filters", "hotspots",
779 ):
780 assert key in data, f"missing key: {key}"
781 assert isinstance(data["hotspots"], list)
782 assert isinstance(data["truncated"], bool)
783 assert isinstance(data["commits_analysed"], int)
784
785 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
786 result = runner.invoke(
787 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
788 )
789 data = json.loads(result.output)
790 assert data["filters"]["kind"] == "function"
791 assert data["filters"]["min_changes"] == 2
792 assert data["filters"]["include_imports"] is False
793
794 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
795 result = runner.invoke(cli, ["code", "hotspots", "--json"])
796 data = json.loads(result.output)
797 if data["hotspots"]:
798 entry = data["hotspots"][0]
799 assert "address" in entry
800 assert "changes" in entry
801 assert isinstance(entry["changes"], int)
802 assert entry["changes"] >= 1
803
804 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
805 result = runner.invoke(cli, ["code", "hotspots", "--json"])
806 data = json.loads(result.output)
807 addresses = [h["address"] for h in data["hotspots"]]
808 assert not any("::import::" in a for a in addresses)
809
810 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
811 result = runner.invoke(cli, ["code", "hotspots", "--json"])
812 data = json.loads(result.output)
813 counts = [h["changes"] for h in data["hotspots"]]
814 assert counts == sorted(counts, reverse=True)
815
816 # ── --max-commits truncation ──────────────────────────────────────────────
817
818 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
819 result = runner.invoke(
820 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
821 )
822 assert result.exit_code == 0, result.output
823 data = json.loads(result.output)
824 assert data["commits_analysed"] <= 1
825
826 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
827 result = runner.invoke(
828 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
829 )
830 data = json.loads(result.output)
831 assert data["truncated"] is True
832
833
834 class TestDetectRefactorV2:
835 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
836 """JSON output contains all required top-level fields."""
837 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
838 assert result.exit_code == 0, result.output
839 data = json.loads(result.output)
840 for field in ("schema_version", "from", "to", "commits_scanned",
841 "truncated", "total", "events"):
842 assert field in data, f"missing field '{field}'"
843 assert data["schema_version"] == __version__
844 assert isinstance(data["commits_scanned"], int)
845 assert isinstance(data["truncated"], bool)
846 assert isinstance(data["total"], int)
847 assert isinstance(data["events"], list)
848
849 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
850 """Each JSON event contains the required fields."""
851 # Run over the full history; code_repo has at least one rename event.
852 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
853 assert result.exit_code == 0, result.output
854 data = json.loads(result.output)
855 for ev in data["events"]:
856 for field in ("kind", "address", "detail",
857 "commit_id", "commit_message", "committed_at"):
858 assert field in ev, f"missing event field '{field}'"
859 assert ev["kind"] in ("rename", "move", "signature", "implementation")
860
861 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
862 """A commit that renames a symbol produces a 'rename' event."""
863 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
864 assert result.exit_code == 0, result.output
865 data = json.loads(result.output)
866 kinds = [e["kind"] for e in data["events"]]
867 assert "rename" in kinds, (
868 f"Expected at least one rename event; got: {sorted(set(kinds))}"
869 )
870
871 def test_detect_refactor_classifies_modified_as_implementation(
872 self, code_repo: pathlib.Path
873 ) -> None:
874 """Replace ops with '(modified)' in new_summary are classified as implementation.
875
876 Previously, only '(implementation changed)' triggered implementation
877 classification; '(modified)' was silently dropped.
878 """
879 import datetime
880 root = code_repo
881 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
882 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
883 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
884 branch = read_current_branch(root)
885 head_id = get_head_commit_id(root, branch)
886
887 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
888 message = "perf: optimise batch"
889 snap_manifest: Manifest = {}
890 snap_id = compute_snapshot_id(snap_manifest)
891 parent_ids = [head_id] if head_id else []
892 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
893 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
894 commit = CommitRecord(
895 commit_id=commit_id,
896 repo_id=repo_id,
897 branch=branch,
898 snapshot_id=snap_id,
899 message=message,
900 committed_at=now,
901 parent_commit_id=head_id,
902 author="test",
903 structured_delta=StructuredDelta(ops=[PatchOp(
904 op="patch",
905 address="billing.py",
906 child_ops=[ReplaceOp(
907 op="replace",
908 address="billing.py::process_batch",
909 new_summary="function process_batch (modified) L10–30",
910 old_summary="function process_batch",
911 )],
912 )]),
913 )
914 write_commit(root, commit)
915 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
916
917 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
918 assert result.exit_code == 0, result.output
919 data = json.loads(result.output)
920 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
921 addrs = [e["address"] for e in impl_events]
922 assert "billing.py::process_batch" in addrs, (
923 f"'(modified)' op not classified as implementation; events: {data['events']}"
924 )
925
926 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
927 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
928 import datetime
929 root = code_repo
930 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
931 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
932 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
933 branch = read_current_branch(root)
934 head_id = get_head_commit_id(root, branch)
935
936 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
937 message = "style: reformat"
938 snap_manifest: Manifest = {}
939 snap_id = compute_snapshot_id(snap_manifest)
940 parent_ids = [head_id] if head_id else []
941 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
942 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
943 commit = CommitRecord(
944 commit_id=commit_id,
945 repo_id=repo_id,
946 branch=branch,
947 snapshot_id=snap_id,
948 message=message,
949 committed_at=now,
950 parent_commit_id=head_id,
951 author="test",
952 structured_delta=StructuredDelta(ops=[PatchOp(
953 op="patch",
954 address="billing.py",
955 child_ops=[ReplaceOp(
956 op="replace",
957 address="billing.py::UniqueReformattedSymbol",
958 new_summary="reformatted β€” no semantic change",
959 old_summary="",
960 )],
961 )]),
962 )
963 write_commit(root, commit)
964 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
965
966 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
967 assert result.exit_code == 0, result.output
968 data = json.loads(result.output)
969 # The reformatted op must not appear as an event.
970 reformatted_events = [
971 e for e in data["events"]
972 if e["address"] == "billing.py::UniqueReformattedSymbol"
973 ]
974 assert reformatted_events == [], (
975 f"Reformatted op should be skipped; got: {reformatted_events}"
976 )
977
978 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
979 """When --max is hit, a truncation warning appears in human output."""
980 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
981 assert result.exit_code == 0, result.output
982 assert "incomplete" in result.output or "limit" in result.output
983
984 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
985 """When --max is hit, truncated=true in JSON."""
986 result = runner.invoke(
987 cli, ["code", "detect-refactor", "--max", "1", "--json"]
988 )
989 assert result.exit_code == 0, result.output
990 data = json.loads(result.output)
991 assert data["truncated"] is True
992 assert data["commits_scanned"] == 1
993
994 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
995 """--max 0 exits non-zero."""
996 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
997 assert result.exit_code != 0
998
999 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1000 """``--kind rename`` returns only rename events."""
1001 result = runner.invoke(
1002 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1003 )
1004 assert result.exit_code == 0, result.output
1005 data = json.loads(result.output)
1006 for ev in data["events"]:
1007 assert ev["kind"] == "rename"
1008
1009 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1010 """``--kind`` with an invalid value exits non-zero."""
1011 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1012 assert result.exit_code != 0
1013
1014 def test_detect_refactor_bfs_follows_merge_parent2(
1015 self, code_repo: pathlib.Path
1016 ) -> None:
1017 """BFS walk finds refactoring events on merged feature branches."""
1018 import datetime
1019 root = code_repo
1020 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
1021 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
1022 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
1023 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1024 branch = read_current_branch(root)
1025 head_id = get_head_commit_id(root, branch)
1026 assert head_id is not None
1027
1028 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1029 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1030
1031 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1032 feature_id = compute_commit_id([head_id], feat_snap_id, "perf: vectorise", feat_at.isoformat())
1033 write_commit(root, CommitRecord(
1034 commit_id=feature_id,
1035 repo_id=repo_id,
1036 branch="feat/perf",
1037 snapshot_id=feat_snap_id,
1038 message="perf: vectorise",
1039 committed_at=feat_at,
1040 parent_commit_id=head_id,
1041 author="test",
1042 structured_delta=StructuredDelta(ops=[PatchOp(
1043 op="patch",
1044 address="billing.py",
1045 child_ops=[ReplaceOp(
1046 op="replace",
1047 address="billing.py::vectorised_fn",
1048 new_summary="function vectorised_fn (implementation changed) L1–20",
1049 old_summary="function vectorised_fn",
1050 )],
1051 )]),
1052 ))
1053 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1054 merge_id = compute_commit_id([head_id, feature_id], merge_snap_id, "merge feat/perf", merge_at.isoformat())
1055 write_commit(root, CommitRecord(
1056 commit_id=merge_id,
1057 repo_id=repo_id,
1058 branch=branch,
1059 snapshot_id=merge_snap_id,
1060 message="merge feat/perf",
1061 committed_at=merge_at,
1062 parent_commit_id=head_id,
1063 parent2_commit_id=feature_id,
1064 author="test",
1065 ))
1066 (root / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
1067
1068 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1069 assert result.exit_code == 0, result.output
1070 data = json.loads(result.output)
1071 addrs = [e["address"] for e in data["events"]]
1072 assert "billing.py::vectorised_fn" in addrs, (
1073 "BFS must find the implementation event on the feature branch"
1074 )
1075
1076
1077 # ---------------------------------------------------------------------------
1078 # muse reserve
1079 # ---------------------------------------------------------------------------
1080
1081
1082 class TestReserve:
1083 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1084 result = runner.invoke(cli, [
1085 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1086 ])
1087 assert result.exit_code == 0, result.output
1088
1089 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1090 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1091 coord_dir = code_repo / ".muse" / "coordination" / "reservations"
1092 assert coord_dir.exists()
1093 files = list(coord_dir.glob("*.json"))
1094 assert len(files) >= 1
1095
1096 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1097 result = runner.invoke(cli, [
1098 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1099 ])
1100 assert result.exit_code == 0
1101 data = json.loads(result.output)
1102 assert "reservation_id" in data
1103
1104 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1105 result = runner.invoke(cli, [
1106 "coord", "reserve", "--run-id", "r3",
1107 "billing.py::process_order",
1108 "billing.py::Invoice.apply_discount",
1109 ])
1110 assert result.exit_code == 0
1111
1112 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1113 result = runner.invoke(cli, [
1114 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1115 "billing.py::process_order",
1116 ])
1117 assert result.exit_code == 0
1118
1119 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1120 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1121 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1122 # Should warn but not fail.
1123 assert result.exit_code == 0
1124 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1125
1126
1127 # ---------------------------------------------------------------------------
1128 # muse intent
1129 # ---------------------------------------------------------------------------
1130
1131
1132 class TestIntent:
1133 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1134 result = runner.invoke(cli, [
1135 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1136 "billing.py::process_order",
1137 ])
1138 assert result.exit_code == 0, result.output
1139
1140 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1141 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1142 idir = code_repo / ".muse" / "coordination" / "intents"
1143 assert idir.exists()
1144 assert len(list(idir.glob("*.json"))) >= 1
1145
1146 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1147 result = runner.invoke(cli, [
1148 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1149 ])
1150 assert result.exit_code == 0
1151 data = json.loads(result.output)
1152 assert "intent_id" in data or "operation" in data
1153
1154
1155 # ---------------------------------------------------------------------------
1156 # muse forecast
1157 # ---------------------------------------------------------------------------
1158
1159
1160 class TestForecast:
1161 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1162 result = runner.invoke(cli, ["coord", "forecast"])
1163 assert result.exit_code == 0, result.output
1164
1165 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1166 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1167 assert result.exit_code == 0
1168 data = json.loads(result.output)
1169 assert "conflicts" in data
1170
1171 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1172 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1173 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1174 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1175 assert result.exit_code == 0
1176 data = json.loads(result.output)
1177 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1178 assert "address_overlap" in types
1179
1180
1181 # ---------------------------------------------------------------------------
1182 # muse plan-merge
1183 # ---------------------------------------------------------------------------
1184
1185
1186 class TestPlanMerge:
1187 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1188 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1189 assert result.exit_code == 0, result.output
1190
1191 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1192 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1193 assert result.exit_code == 0
1194 data = json.loads(result.output)
1195 assert "conflicts" in data or isinstance(data, dict)
1196
1197 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1198 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1199 assert result.exit_code != 0
1200
1201
1202 # ---------------------------------------------------------------------------
1203 # muse shard
1204 # ---------------------------------------------------------------------------
1205
1206
1207 class TestShard:
1208 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1209 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1210 assert result.exit_code == 0, result.output
1211
1212 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1213 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1214 assert result.exit_code == 0
1215 data = json.loads(result.output)
1216 assert "shards" in data
1217
1218 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1219 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1220 assert result.exit_code == 0
1221
1222 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1223 # N larger than symbol count still works (produces fewer shards).
1224 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1225 assert result.exit_code == 0
1226
1227
1228 # ---------------------------------------------------------------------------
1229 # muse reconcile
1230 # ---------------------------------------------------------------------------
1231
1232
1233 class TestReconcile:
1234 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1235 result = runner.invoke(cli, ["coord", "reconcile"])
1236 assert result.exit_code == 0, result.output
1237
1238 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1239 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1240 assert result.exit_code == 0
1241 data = json.loads(result.output)
1242 assert isinstance(data, dict)
1243
1244
1245 # ---------------------------------------------------------------------------
1246 # muse breakage
1247 # ---------------------------------------------------------------------------
1248
1249
1250 class TestBreakage:
1251 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1252 result = runner.invoke(cli, ["code", "breakage"])
1253 assert result.exit_code == 0, result.output
1254
1255 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1256 result = runner.invoke(cli, ["code", "breakage", "--json"])
1257 assert result.exit_code == 0
1258 data = json.loads(result.output)
1259 # breakage JSON has "issues" list and error count.
1260 assert "issues" in data
1261 assert isinstance(data["issues"], list)
1262
1263 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1264 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1265 assert result.exit_code == 0
1266
1267 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1268 monkeypatch.chdir(tmp_path)
1269 result = runner.invoke(cli, ["code", "breakage"])
1270 assert result.exit_code != 0
1271
1272
1273 # ---------------------------------------------------------------------------
1274 # muse invariants
1275 # ---------------------------------------------------------------------------
1276
1277
1278 class TestInvariants:
1279 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1280 result = runner.invoke(cli, ["code", "invariants"])
1281 toml_path = code_repo / ".muse" / "invariants.toml"
1282 assert result.exit_code == 0 or toml_path.exists()
1283
1284 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1285 # Create empty invariants.toml
1286 (code_repo / ".muse" / "invariants.toml").write_text("# No rules\n")
1287 result = runner.invoke(cli, ["code", "invariants", "--json"])
1288 assert result.exit_code == 0
1289 # Output may be JSON or human-readable depending on rules count.
1290 output = result.output.strip()
1291 if output and not output.startswith("#"):
1292 try:
1293 data = json.loads(output)
1294 assert isinstance(data, dict)
1295 except json.JSONDecodeError:
1296 pass # Human-readable output is also acceptable.
1297
1298 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1299 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1300 [[rules]]
1301 type = "no_cycles"
1302 name = "no import cycles"
1303 """))
1304 result = runner.invoke(cli, ["code", "invariants"])
1305 assert result.exit_code == 0
1306
1307 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1308 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1309 [[rules]]
1310 type = "forbidden_dependency"
1311 name = "billing must not import utils"
1312 source_pattern = "billing.py"
1313 forbidden_pattern = "utils.py"
1314 """))
1315 result = runner.invoke(cli, ["code", "invariants"])
1316 assert result.exit_code == 0
1317
1318 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1319 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1320 [[rules]]
1321 type = "required_test"
1322 name = "billing must have tests"
1323 source_pattern = "billing.py"
1324 test_pattern = "test_billing.py"
1325 """))
1326 result = runner.invoke(cli, ["code", "invariants"])
1327 # May pass or fail depending on whether test_billing.py exists; should not crash.
1328 assert result.exit_code in (0, 1)
1329
1330 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1331 (code_repo / ".muse" / "invariants.toml").write_text("# empty\n")
1332 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1333 assert result.exit_code == 0
1334
1335
1336 # ---------------------------------------------------------------------------
1337 # muse commit β€” semantic versioning
1338 # ---------------------------------------------------------------------------
1339
1340
1341 class TestSemVerInCommit:
1342 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1343 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1344 commit_id = get_head_commit_id(code_repo, "main")
1345 assert commit_id is not None
1346 commit = read_commit(code_repo, commit_id)
1347 assert commit is not None
1348 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1349
1350 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1351 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1352 commit_id = get_head_commit_id(code_repo, "main")
1353 assert commit_id is not None
1354 commit = read_commit(code_repo, commit_id)
1355 assert commit is not None
1356 assert isinstance(commit.breaking_changes, list)
1357
1358 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1359 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1360 commit_id = get_head_commit_id(code_repo, "main")
1361 assert commit_id is not None
1362 commit = read_commit(code_repo, commit_id)
1363 assert commit is not None
1364 if commit.sem_ver_bump == "major":
1365 result = runner.invoke(cli, ["log"])
1366 assert "MAJOR" in result.output or "major" in result.output.lower()
1367
1368
1369 # ---------------------------------------------------------------------------
1370 # Call-graph tier β€” muse impact
1371 # ---------------------------------------------------------------------------
1372
1373
1374 class TestImpact:
1375 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1376 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1377 assert result.exit_code == 0, result.output
1378
1379 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1380 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1381 assert result.exit_code == 0
1382 data = json.loads(result.output)
1383 assert isinstance(data, dict)
1384 assert "blast_radius" in data
1385 assert "total" in data
1386 assert "commit_id" in data
1387 assert data["mode"] == "reverse"
1388
1389 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1390 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1391 assert result.exit_code in (0, 1)
1392
1393 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1394 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1395 assert result.exit_code == 0
1396 assert result.output.strip().isdigit()
1397
1398 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1399 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1400 assert result.exit_code == 1
1401
1402 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1403 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1404 assert result.exit_code == 0
1405
1406 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1407 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1408 assert result.exit_code == 0
1409 data = json.loads(result.output)
1410 assert data["mode"] == "forward"
1411 assert "callees" in data
1412 assert "total" in data
1413 assert "commit_id" in data
1414
1415 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1416 result = runner.invoke(cli, [
1417 "code", "impact", "--forward", "--compare", "HEAD",
1418 "--", "billing.py::process_order",
1419 ])
1420 assert result.exit_code == 1
1421
1422 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1423 result = runner.invoke(cli, [
1424 "code", "impact", "--file", "billing.py",
1425 "--", "billing.py::Invoice.compute_invoice_total",
1426 ])
1427 assert result.exit_code == 0
1428
1429 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1430 result = runner.invoke(cli, [
1431 "code", "impact", "--file", "billing.py", "--json",
1432 "--", "billing.py::Invoice.compute_invoice_total",
1433 ])
1434 assert result.exit_code == 0
1435 data = json.loads(result.output)
1436 assert data["file_filter"] == "billing.py"
1437 for depth_addrs in data["blast_radius"].values():
1438 for addr in depth_addrs:
1439 assert addr.startswith("billing.py::")
1440
1441 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1442 result = runner.invoke(cli, [
1443 "code", "impact", "--compare", "HEAD",
1444 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1445 ])
1446 assert result.exit_code == 0
1447 data = json.loads(result.output)
1448 assert "compare_commit_id" in data
1449 assert "added_callers" in data
1450 assert "removed_callers" in data
1451 assert "net_change" in data
1452 assert isinstance(data["added_callers"], list)
1453 assert isinstance(data["removed_callers"], list)
1454
1455 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1456 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1457 assert result.exit_code == 0
1458 assert result.output.strip().isdigit()
1459
1460
1461 # ---------------------------------------------------------------------------
1462 # Call-graph tier β€” muse dead
1463 # ---------------------------------------------------------------------------
1464
1465
1466 class TestDead:
1467 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1468 result = runner.invoke(cli, ["code", "dead"])
1469 assert result.exit_code == 0, result.output
1470
1471 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1472 result = runner.invoke(cli, ["code", "dead", "--json"])
1473 assert result.exit_code == 0
1474 data = json.loads(result.output)
1475 assert isinstance(data, dict)
1476 assert "results" in data
1477 assert "high_confidence_count" in data
1478 assert "total_files_scanned" in data
1479 assert "elapsed_seconds" in data
1480
1481 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1482 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1483 assert result.exit_code == 0
1484
1485 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1486 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1487 assert result.exit_code == 0
1488
1489 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1490 result = runner.invoke(cli, ["code", "dead", "--count"])
1491 assert result.exit_code == 0
1492 assert result.output.strip().isdigit()
1493
1494 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1495 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1496 assert result.exit_code == 0
1497 data = json.loads(result.output)
1498 assert "compare_commit_id" in data
1499 assert "new_dead" in data
1500 assert "recovered" in data
1501 assert "net_change" in data
1502 assert isinstance(data["new_dead"], list)
1503 assert isinstance(data["recovered"], list)
1504
1505 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1506 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1507 assert result.exit_code == 0
1508
1509 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1510 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1511 assert result.exit_code == 1
1512
1513 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1514 out_file = tmp_path / "allowlist.json"
1515 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1516 assert result.exit_code == 0
1517 if out_file.exists():
1518 data = json.loads(out_file.read_text())
1519 assert isinstance(data, list)
1520 assert all(isinstance(x, str) for x in data)
1521
1522 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1523 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1524 assert result.exit_code == 0
1525 data = json.loads(result.output)
1526 for c in data["results"]:
1527 assert c["confidence"] == "high"
1528
1529 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1530 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1531 assert result.exit_code == 0
1532
1533
1534 # ---------------------------------------------------------------------------
1535 # muse code cat
1536 # ---------------------------------------------------------------------------
1537
1538
1539 class TestCat:
1540 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1541 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1542 assert result.exit_code == 0, result.output
1543 assert "class Invoice" in result.output
1544
1545 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1546 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1547 assert result.exit_code == 0, result.output
1548 assert "def compute_invoice_total" in result.output
1549
1550 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1551 # Invoice is unique β€” short name should resolve.
1552 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1553 assert result.exit_code == 0
1554
1555 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1556 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1557 assert result.exit_code != 0
1558
1559 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1560 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1561 assert result.exit_code != 0
1562
1563 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1564 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1565 assert result.exit_code != 0
1566
1567 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1568 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1569 assert result.exit_code == 0
1570 # Line numbers prefix lines with digits.
1571 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1572 first_code_line = next((ln for ln in lines if ln.strip()), "")
1573 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1574
1575 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1576 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1577 assert result.exit_code == 0
1578 data = json.loads(result.output)
1579 assert "results" in data
1580 assert "errors" in data
1581 assert "source_ref" in data
1582 assert len(data["results"]) == 1
1583 r = data["results"][0]
1584 assert r["file_path"] == "billing.py"
1585 assert r["kind"] in ("class", "function", "method")
1586 assert isinstance(r["lineno"], int)
1587 assert isinstance(r["end_lineno"], int)
1588 assert "class Invoice" in r["source"]
1589
1590 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1591 result = runner.invoke(
1592 cli,
1593 [
1594 "code", "cat",
1595 "billing.py::Invoice",
1596 "billing.py::Invoice.compute_invoice_total",
1597 "--json",
1598 ],
1599 )
1600 assert result.exit_code == 0, result.output
1601 data = json.loads(result.output)
1602 assert len(data["results"]) == 2
1603
1604 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1605 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1606 assert result.exit_code == 0
1607 assert "Invoice" in result.output
1608
1609 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1610 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1611 assert result.exit_code == 0
1612
1613 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1614 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1615 assert result.exit_code == 0
1616 data = json.loads(result.output)
1617 assert len(data["results"]) > 0
1618 # Every result has required fields.
1619 for r in data["results"]:
1620 assert "address" in r
1621 assert "lineno" in r
1622 assert "source" in r
1623
1624 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1625 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1626 result_ctx = runner.invoke(
1627 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1628 )
1629 assert result_ctx.exit_code == 0
1630 # With context we get at least as many lines.
1631 plain_lines = result_plain.output.count("\n")
1632 ctx_lines = result_ctx.output.count("\n")
1633 assert ctx_lines >= plain_lines
1634
1635 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1636 # In --json mode a missing symbol goes to the errors field, not a crash.
1637 result = runner.invoke(
1638 cli,
1639 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1640 )
1641 # Output must be valid JSON (no stderr bleed into stdout).
1642 data = json.loads(result.output)
1643 assert len(data["results"]) == 1
1644 assert len(data["errors"]) == 1
1645 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1646
1647 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1648 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1649 assert result.exit_code == 0
1650 assert "working tree" in result.output
1651
1652 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1653 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1654 assert result.exit_code == 0
1655 assert "Invoice" in result.output
1656
1657 def test_cat_wrong_file_fallback_finds_symbol(
1658 self, code_repo: pathlib.Path, tmp_path: pathlib.Path
1659 ) -> None:
1660 """FILE::SYMBOL where SYMBOL lives in a different file β€” should fall back
1661 to a global snapshot search and cat it from its actual location, exit 0."""
1662 # Add a second file with a unique function the billing module doesn't have.
1663 work = pathlib.Path.cwd()
1664 (work / "utils.py").write_text(
1665 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1666 )
1667 runner.invoke(cli, ["commit", "-m", "Add utils"])
1668
1669 # Ask for utils.format_currency but specify the wrong file (billing.py).
1670 result = runner.invoke(
1671 cli, ["code", "cat", "billing.py::format_currency"]
1672 )
1673 assert result.exit_code == 0, result.output
1674 assert "format_currency" in result.output
1675
1676 def test_cat_wrong_file_fallback_json(self, code_repo: pathlib.Path) -> None:
1677 """Same fallback in --json mode: result is in results[], not errors[]."""
1678 work = pathlib.Path.cwd()
1679 (work / "utils.py").write_text(
1680 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1681 )
1682 runner.invoke(cli, ["commit", "-m", "Add utils"])
1683
1684 result = runner.invoke(
1685 cli, ["code", "cat", "billing.py::format_currency", "--json"]
1686 )
1687 assert result.exit_code == 0, result.output
1688 data = json.loads(result.output)
1689 assert len(data["results"]) == 1
1690 assert data["results"][0]["symbol"] == "format_currency"
1691 assert data["results"][0]["file_path"] == "utils.py"
1692
1693 def test_cat_wrong_file_fallback_ambiguous_exits_nonzero(
1694 self, code_repo: pathlib.Path
1695 ) -> None:
1696 """If the symbol exists in multiple files, fallback reports ambiguity and exits 1."""
1697 work = pathlib.Path.cwd()
1698 (work / "utils.py").write_text("def send_email(to): pass\n")
1699 runner.invoke(cli, ["commit", "-m", "Duplicate send_email in utils"])
1700
1701 # billing.py already has send_email; utils.py now also has it.
1702 result = runner.invoke(
1703 cli, ["code", "cat", "nope.py::send_email"]
1704 )
1705 assert result.exit_code != 0
1706
1707 def test_cat_truly_missing_symbol_still_errors(self, code_repo: pathlib.Path) -> None:
1708 """A symbol that doesn't exist anywhere in the snapshot still exits 1."""
1709 result = runner.invoke(cli, ["code", "cat", "billing.py::AbsolutelyNowhere"])
1710 assert result.exit_code != 0
1711
1712
1713 # ---------------------------------------------------------------------------
1714 # Call-graph tier β€” muse coverage
1715 # ---------------------------------------------------------------------------
1716
1717
1718 class TestCoverage:
1719 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1720 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1721 assert result.exit_code == 0, result.output
1722
1723 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1724 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1725 assert result.exit_code == 0
1726 data = json.loads(result.output)
1727 assert isinstance(data, dict)
1728 assert "methods" in data
1729 assert "total_methods" in data
1730 assert "covered" in data
1731 assert "percent" in data
1732 assert "commit_id" in data
1733 assert "filters" in data
1734 for m in data["methods"]:
1735 assert "address" in m
1736 assert "called" in m
1737 assert "callers" in m
1738
1739 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1740 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1741 assert result.exit_code in (0, 1)
1742
1743 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1744 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1745 assert result.exit_code == 0
1746 # Output should be "n/total" format
1747 assert "/" in result.output.strip()
1748
1749 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1750 result = runner.invoke(cli, [
1751 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1752 ])
1753 assert result.exit_code == 0
1754 data = json.loads(result.output)
1755 assert data["filters"]["exclude_dunder"] is True
1756 for m in data["methods"]:
1757 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1758
1759 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1760 result = runner.invoke(cli, [
1761 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1762 ])
1763 assert result.exit_code == 0
1764 data = json.loads(result.output)
1765 assert data["filters"]["exclude_private"] is True
1766
1767 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1768 result = runner.invoke(cli, [
1769 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1770 ])
1771 assert result.exit_code == 0
1772 data = json.loads(result.output)
1773 assert data["filters"]["min_callers"] == 2
1774
1775 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1776 result = runner.invoke(cli, [
1777 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1778 ])
1779 assert result.exit_code == 0
1780 data = json.loads(result.output)
1781 assert data["filters"]["exclude_self"] is True
1782 # All reported callers should be from a different file
1783 for m in data["methods"]:
1784 for caller in m["callers"]:
1785 assert not caller.startswith("billing.py::")
1786
1787 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1788 result = runner.invoke(cli, [
1789 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1790 ])
1791 assert result.exit_code == 0
1792 data = json.loads(result.output)
1793 assert "compare_commit_id" in data
1794 assert "newly_covered" in data
1795 assert "newly_uncovered" in data
1796 assert "percent_change" in data
1797
1798 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1799 result = runner.invoke(cli, [
1800 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1801 ])
1802 assert result.exit_code == 0
1803
1804 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1805 result = runner.invoke(cli, [
1806 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1807 ])
1808 assert result.exit_code == 0
1809
1810
1811 # ---------------------------------------------------------------------------
1812 # Call-graph tier β€” muse deps
1813 # ---------------------------------------------------------------------------
1814
1815
1816 class TestDeps:
1817 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1818 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1819 assert result.exit_code == 0, result.output
1820
1821 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1822 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1823 assert result.exit_code == 0
1824
1825 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1826 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1827 assert result.exit_code == 0
1828 data = json.loads(result.output)
1829 assert isinstance(data, dict)
1830
1831 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1832 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1833 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1834
1835 # ── new flags ──────────────────────────────────────────────────────────────
1836
1837 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1838 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1839 assert result.exit_code == 0, result.output
1840 assert result.output.strip().isdigit()
1841
1842 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1843 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1844 assert result.exit_code == 0, result.output
1845 assert result.output.strip().isdigit()
1846
1847 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1848 result = runner.invoke(
1849 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1850 )
1851 assert result.exit_code == 0, result.output
1852
1853 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1854 # --depth > 1 in file mode is fine (just filters imports as before).
1855 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1856 assert result.exit_code == 0, result.output
1857
1858 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1859 result = runner.invoke(
1860 cli,
1861 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1862 )
1863 assert result.exit_code != 0
1864
1865 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1866 result = runner.invoke(
1867 cli,
1868 ["code", "deps", "--reverse", "--depth", "2",
1869 "billing.py::Invoice.compute_invoice_total"],
1870 )
1871 assert result.exit_code == 0, result.output
1872
1873 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1874 result = runner.invoke(
1875 cli,
1876 ["code", "deps", "--transitive",
1877 "billing.py::Invoice.compute_invoice_total"],
1878 )
1879 assert result.exit_code == 0, result.output
1880
1881 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1882 result = runner.invoke(
1883 cli,
1884 ["code", "deps", "--transitive", "--count",
1885 "billing.py::Invoice.compute_invoice_total"],
1886 )
1887 assert result.exit_code == 0
1888 assert result.output.strip().isdigit()
1889
1890 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1891 result = runner.invoke(
1892 cli,
1893 ["code", "deps", "--transitive", "--json",
1894 "billing.py::Invoice.compute_invoice_total"],
1895 )
1896 assert result.exit_code == 0
1897 data = json.loads(result.output)
1898 assert "by_depth" in data
1899 assert data["transitive"] is True
1900
1901 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1902 result = runner.invoke(
1903 cli,
1904 ["code", "deps", "--reverse", "--depth", "2", "--json",
1905 "billing.py::Invoice.compute_invoice_total"],
1906 )
1907 assert result.exit_code == 0
1908 data = json.loads(result.output)
1909 assert "by_depth" in data
1910 assert data["depth"] == 2
1911
1912 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1913 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1914 assert result.exit_code != 0
1915
1916 def test_deps_empty_file_rel_in_symbol_rejected(
1917 self, code_repo: pathlib.Path
1918 ) -> None:
1919 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1920 assert result.exit_code != 0
1921
1922 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1923 result = runner.invoke(
1924 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1925 )
1926 assert result.exit_code == 0
1927 data = json.loads(result.output)
1928 assert "imported_by" in data
1929 assert isinstance(data["imported_by"], list)
1930
1931
1932 # ---------------------------------------------------------------------------
1933 # Call-graph tier β€” muse find-symbol
1934 # ---------------------------------------------------------------------------
1935
1936
1937 class TestFindSymbol:
1938 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1939 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1940 assert result.exit_code == 0, result.output
1941
1942 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
1943 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
1944 assert result.exit_code == 0
1945 data = json.loads(result.output)
1946 assert isinstance(data, dict)
1947 assert "results" in data
1948 assert "query" in data
1949 assert "total" in data
1950
1951 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
1952 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
1953 assert result.exit_code == 0
1954 assert result.output is not None
1955
1956 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
1957 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
1958 assert result.exit_code == 0
1959 assert "no matching" in result.output
1960
1961 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
1962 result = runner.invoke(cli, ["code", "find-symbol"])
1963 assert result.exit_code == 1
1964
1965 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
1966 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
1967 assert result.exit_code == 0
1968 assert result.output.strip().isdigit()
1969
1970 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1971 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
1972 assert result.exit_code == 1
1973
1974 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
1975 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
1976 assert result.exit_code == 1
1977
1978 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
1979 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
1980 assert result.exit_code == 1
1981
1982 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
1983 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
1984 assert result.exit_code == 1
1985
1986 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
1987 result = runner.invoke(cli, [
1988 "code", "find-symbol", "--name", "process_order",
1989 "--since", "2099-01-01",
1990 ])
1991 assert result.exit_code == 0
1992 assert "no matching" in result.output
1993
1994 def test_find_limit(self, code_repo: pathlib.Path) -> None:
1995 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
1996 assert result.exit_code == 0
1997
1998 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
1999 result = runner.invoke(cli, [
2000 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
2001 ])
2002 assert result.exit_code == 0
2003
2004 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
2005 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
2006 assert result.exit_code == 0
2007 data = json.loads(result.output)
2008 for ap in data["results"]:
2009 assert ap["name"].lower().startswith("process")
2010
2011 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
2012 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2013 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
2014 assert result_all.exit_code == 0
2015 assert result_first.exit_code == 0
2016 count_all = int(result_all.output.strip())
2017 count_first = int(result_first.output.strip())
2018 assert count_first <= count_all
2019
2020 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
2021 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
2022 assert result.exit_code == 0
2023 data = json.loads(result.output)
2024 assert "query" in data
2025 assert "results" in data
2026 assert "total" in data
2027 assert data["total"] == len(data["results"])
2028 if data["results"]:
2029 ap = data["results"][0]
2030 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
2031 assert key in ap
2032
2033
2034 # ---------------------------------------------------------------------------
2035 # Call-graph tier β€” muse patch
2036 # ---------------------------------------------------------------------------
2037
2038
2039 class TestPatch:
2040 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
2041 new_impl = textwrap.dedent("""\
2042 def send_email(address):
2043 return f"Sending to {address}"
2044 """)
2045 impl_file = code_repo / "send_email_impl.py"
2046 impl_file.write_text(new_impl)
2047 # patch takes ADDRESS SOURCE β€” put options before address.
2048 result = runner.invoke(cli, [
2049 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
2050 ])
2051 assert result.exit_code in (0, 1, 2)
2052
2053 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
2054 bad_impl = "def broken(\n not valid python at all{"
2055 bad_file = code_repo / "bad.py"
2056 bad_file.write_text(bad_impl)
2057 result = runner.invoke(cli, [
2058 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2059 ])
2060 # Invalid syntax must be rejected or command handles gracefully.
2061 assert result.exit_code in (0, 1, 2)
2062
2063
2064 # ---------------------------------------------------------------------------
2065 # Security β€” path traversal guards
2066 # ---------------------------------------------------------------------------
2067
2068
2069 class TestPatchPathTraversal:
2070 """patch must reject addresses whose file component escapes the repo root."""
2071
2072 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2073 body = code_repo / "body.py"
2074 body.write_text("def foo(): pass\n")
2075 result = runner.invoke(cli, [
2076 "code", "patch",
2077 "--body", str(body),
2078 "../../etc/passwd::foo",
2079 ])
2080 assert result.exit_code == 1
2081
2082 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2083 body = code_repo / "body.py"
2084 body.write_text("def foo(): pass\n")
2085 result = runner.invoke(cli, [
2086 "code", "patch",
2087 "--body", str(body),
2088 "../../../tmp/evil::foo",
2089 ])
2090 assert result.exit_code == 1
2091
2092 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2093 """--json flag returns parseable JSON on a dry-run."""
2094 body = code_repo / "body.py"
2095 body.write_text("def send_email(address):\n return address\n")
2096 result = runner.invoke(cli, [
2097 "code", "patch",
2098 "--body", str(body),
2099 "--dry-run",
2100 "--json",
2101 "billing.py::send_email",
2102 ])
2103 # Address may or may not exist; if it exits 0 the output must be JSON.
2104 if result.exit_code == 0:
2105 data = json.loads(result.output)
2106 assert data["address"] == "billing.py::send_email"
2107 assert data["dry_run"] is True
2108
2109
2110 class TestCheckoutSymbolPathTraversal:
2111 """checkout-symbol must reject addresses whose file component escapes root."""
2112
2113 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2114 result = runner.invoke(cli, [
2115 "code", "checkout-symbol",
2116 "--commit", "HEAD",
2117 "../../etc/passwd::foo",
2118 ])
2119 assert result.exit_code == 1
2120
2121 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2122 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2123 result = runner.invoke(cli, [
2124 "code", "checkout-symbol",
2125 "--commit", "HEAD",
2126 "--json",
2127 "billing.py::nonexistent_func_xyz",
2128 ])
2129 # Either exits 1 (symbol not found) β€” but must not crash.
2130 assert result.exit_code in (0, 1)
2131
2132
2133 class TestSemanticCherryPickPathTraversal:
2134 """semantic-cherry-pick must reject addresses that escape the repo root."""
2135
2136 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2137 result = runner.invoke(cli, [
2138 "code", "semantic-cherry-pick",
2139 "--from", "HEAD",
2140 "../../etc/passwd::foo",
2141 ])
2142 # The traversal-rejected symbol is recorded as not_found but the
2143 # command exits 0 (failed symbols don't abort the batch).
2144 # The key invariant is that no file outside the repo is written.
2145 # We assert exit_code is 0 (graceful) and the output does NOT write.
2146 assert result.exit_code in (0, 1)
2147 # No file was created outside the repo.
2148 assert not pathlib.Path("/etc/passwd_copy").exists()
2149
2150 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2151 result = runner.invoke(cli, [
2152 "code", "semantic-cherry-pick",
2153 "--from", "HEAD",
2154 "--json",
2155 "../../etc/passwd::foo",
2156 ])
2157 assert result.exit_code in (0, 1)
2158 if result.exit_code == 0:
2159 data = json.loads(result.output)
2160 assert data["applied"] == 0
2161 # The traversal-escaped address should be marked as not_found
2162 results = data.get("results", [])
2163 assert any(r["status"] == "not_found" for r in results)
2164
2165
2166 # ---------------------------------------------------------------------------
2167 # muse code blame
2168 # ---------------------------------------------------------------------------
2169
2170
2171 @pytest.fixture
2172 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2173 """Repo with four commits: seed β†’ creation β†’ modification β†’ rename.
2174
2175 A seed commit is required so that the billing.py creation commit has
2176 a parent (and therefore a structured_delta with insert ops).
2177
2178 Timeline (oldest β†’ newest):
2179 commit 0: README.md only (seed β€” gives billing.py commit a parent)
2180 commit 1: billing.py created β€” defines compute_total + process_order
2181 commit 2: compute_total implementation modified (same name)
2182 commit 3: compute_total renamed to compute_invoice_total
2183 """
2184 work = repo
2185
2186 # Seed commit so billing.py introduction has a parent and structured_delta.
2187 (work / "README.md").write_text("# Billing module\n")
2188 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2189 assert r.exit_code == 0, r.output
2190
2191 (work / "billing.py").write_text(textwrap.dedent("""\
2192 def compute_total(items):
2193 return sum(items)
2194
2195 def process_order(items):
2196 return compute_total(items)
2197 """))
2198 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2199 assert r.exit_code == 0, r.output
2200
2201 (work / "billing.py").write_text(textwrap.dedent("""\
2202 def compute_total(items):
2203 # faster implementation
2204 return sum(x for x in items)
2205
2206 def process_order(items):
2207 return compute_total(items)
2208 """))
2209 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2210 assert r.exit_code == 0, r.output
2211
2212 (work / "billing.py").write_text(textwrap.dedent("""\
2213 def compute_invoice_total(items):
2214 # faster implementation
2215 return sum(x for x in items)
2216
2217 def process_order(items):
2218 return compute_invoice_total(items)
2219 """))
2220 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2221 assert r.exit_code == 0, r.output
2222
2223 return repo
2224
2225
2226 class TestBlame:
2227 """Tests for muse code blame."""
2228
2229 # ── address validation ───────────────────────────────────────────────────
2230
2231 def test_invalid_address_no_separator_exits_error(
2232 self, blame_repo: pathlib.Path
2233 ) -> None:
2234 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2235 assert result.exit_code == 1
2236 assert "Invalid address" in result.output or "::" in result.output
2237
2238 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2239 result = runner.invoke(
2240 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2241 )
2242 assert result.exit_code == 1
2243
2244 # ── basic correctness (no rename involved) ───────────────────────────────
2245
2246 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2247 """A symbol that was never renamed should have created + modified events."""
2248 result = runner.invoke(
2249 cli, ["code", "blame", "billing.py::process_order", "--json"]
2250 )
2251 assert result.exit_code == 0, result.output
2252 data = json.loads(result.output)
2253 kinds = [ev["event"] for ev in data["events"]]
2254 assert "created" in kinds
2255
2256 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2257 result = runner.invoke(
2258 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2259 )
2260 assert result.exit_code == 0
2261 assert "no events found" in result.output
2262
2263 # ── rename tracking β€” new name (the critical regression) ─────────────────
2264
2265 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2266 """Blaming the POST-rename name must find the rename event."""
2267 result = runner.invoke(
2268 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2269 )
2270 assert result.exit_code == 0, result.output
2271 data = json.loads(result.output)
2272 kinds = [ev["event"] for ev in data["events"]]
2273 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2274
2275 def test_blame_new_name_follows_into_old_history(
2276 self, blame_repo: pathlib.Path
2277 ) -> None:
2278 """After finding the rename, blame must continue tracking the old name.
2279
2280 The symbol was created as compute_total β†’ modified β†’ renamed.
2281 Blaming compute_invoice_total should find ALL three events.
2282 """
2283 result = runner.invoke(
2284 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2285 )
2286 assert result.exit_code == 0, result.output
2287 data = json.loads(result.output)
2288 kinds = [ev["event"] for ev in data["events"]]
2289 assert "created" in kinds, f"Expected created event, got: {kinds}"
2290 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2291
2292 # ── rename tracking β€” old name ────────────────────────────────────────────
2293
2294 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2295 """Blaming the PRE-rename name must find the creation event."""
2296 result = runner.invoke(
2297 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2298 )
2299 assert result.exit_code == 0, result.output
2300 data = json.loads(result.output)
2301 kinds = [ev["event"] for ev in data["events"]]
2302 assert "created" in kinds, f"Expected created event, got: {kinds}"
2303
2304 def test_blame_old_name_finds_rename_not_lost(
2305 self, blame_repo: pathlib.Path
2306 ) -> None:
2307 """Blaming the old name should also surface the rename event."""
2308 result = runner.invoke(
2309 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2310 )
2311 assert result.exit_code == 0, result.output
2312 data = json.loads(result.output)
2313 kinds = [ev["event"] for ev in data["events"]]
2314 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2315
2316 # ── JSON schema ───────────────────────────────────────────────────────────
2317
2318 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2319 result = runner.invoke(
2320 cli, ["code", "blame", "billing.py::process_order", "--json"]
2321 )
2322 assert result.exit_code == 0, result.output
2323 data = json.loads(result.output)
2324 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2325 assert key in data, f"missing key: {key}"
2326 assert isinstance(data["events"], list)
2327 assert isinstance(data["truncated"], bool)
2328 assert isinstance(data["total_commits_scanned"], int)
2329
2330 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2331 result = runner.invoke(
2332 cli,
2333 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2334 )
2335 assert result.exit_code == 0, result.output
2336 data = json.loads(result.output)
2337 assert data["events"], "expected at least one event"
2338 ev = data["events"][0]
2339 for field in (
2340 "event", "commit_id", "author", "message",
2341 "committed_at", "address", "detail",
2342 ):
2343 assert field in ev, f"missing event field: {field}"
2344
2345 def test_blame_json_address_field_matches_input(
2346 self, blame_repo: pathlib.Path
2347 ) -> None:
2348 addr = "billing.py::process_order"
2349 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2350 data = json.loads(result.output)
2351 assert data["address"] == addr
2352
2353 # ── --max truncation ──────────────────────────────────────────────────────
2354
2355 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2356 result = runner.invoke(
2357 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2358 )
2359 assert result.exit_code == 0, result.output
2360 data = json.loads(result.output)
2361 assert data["total_commits_scanned"] <= 1
2362
2363 def test_blame_truncation_flag_set_when_capped(
2364 self, blame_repo: pathlib.Path
2365 ) -> None:
2366 result = runner.invoke(
2367 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2368 )
2369 data = json.loads(result.output)
2370 assert data["truncated"] is True
2371
2372 def test_blame_truncation_warning_in_human_output(
2373 self, blame_repo: pathlib.Path
2374 ) -> None:
2375 result = runner.invoke(
2376 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2377 )
2378 assert result.exit_code == 0, result.output
2379 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2380
2381 # ── human output ─────────────────────────────────────────────────────────
2382
2383 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2384 result = runner.invoke(
2385 cli, ["code", "blame", "billing.py::process_order"]
2386 )
2387 assert result.exit_code == 0, result.output
2388 assert "last touched:" in result.output
2389
2390 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2391 result_default = runner.invoke(
2392 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2393 )
2394 result_all = runner.invoke(
2395 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2396 )
2397 assert result_all.exit_code == 0, result_all.output
2398 # --all shows at least as many lines as default
2399 assert len(result_all.output) >= len(result_default.output)
2400
2401 # ── BFS follows merge parents ─────────────────────────────────────────────
2402
2403 def test_blame_bfs_follows_merge_parent2(
2404 self, repo: pathlib.Path
2405 ) -> None:
2406 """A symbol introduced on a feature branch is visible after merging."""
2407 # Main: empty billing.py
2408 (repo / "billing.py").write_text("def main_fn(): pass\n")
2409 runner.invoke(cli, ["commit", "-m", "main commit"])
2410
2411 # Feature branch: add feature_fn
2412 runner.invoke(cli, ["branch", "feat/feature"])
2413 runner.invoke(cli, ["checkout", "feat/feature"])
2414 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2415 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2416
2417 # Merge back to main
2418 runner.invoke(cli, ["checkout", "main"])
2419 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2420
2421 # Blame feature_fn β€” should find 'created' event on the feature branch
2422 result = runner.invoke(
2423 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2424 )
2425 assert result.exit_code == 0, result.output
2426 data = json.loads(result.output)
2427 kinds = [ev["event"] for ev in data["events"]]
2428 assert "created" in kinds, (
2429 f"Expected created event for feature_fn after merge; got: {kinds}"
2430 )
2431
2432
2433 # ---------------------------------------------------------------------------
2434 # Security β€” ReDoS guard in grep
2435 # ---------------------------------------------------------------------------
2436
2437
2438 class TestGrepReDoS:
2439 """grep must reject patterns longer than 512 characters."""
2440
2441 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2442 long_pattern = "a" * 513
2443 result = runner.invoke(cli, ["code", "grep", long_pattern])
2444 assert result.exit_code == 1
2445 assert "too long" in result.output.lower() or "512" in result.output
2446
2447 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2448 pattern = "a" * 512
2449 result = runner.invoke(cli, ["code", "grep", pattern])
2450 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2451 assert result.exit_code != 1 or "too long" not in result.output.lower()
2452
2453 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2454 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2455 assert result.exit_code == 1
2456
2457
2458 # ---------------------------------------------------------------------------
2459 # JSON output β€” index status and rebuild
2460 # ---------------------------------------------------------------------------
2461
2462
2463 class TestIndexJsonOutput:
2464 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2465 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2466 assert result.exit_code == 0, result.output
2467 data = json.loads(result.output)
2468 assert isinstance(data, list)
2469 names = [entry["name"] for entry in data]
2470 assert "symbol_history" in names
2471 assert "hash_occurrence" in names
2472 for entry in data:
2473 assert "status" in entry
2474 assert "entries" in entry
2475
2476 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2477 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2478 assert result.exit_code == 0, result.output
2479 data = json.loads(result.output)
2480 assert isinstance(data, dict)
2481 assert "rebuilt" in data
2482 assert isinstance(data["rebuilt"], list)
2483 assert "symbol_history" in data["rebuilt"]
2484 assert "hash_occurrence" in data["rebuilt"]
2485
2486 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2487 result = runner.invoke(cli, [
2488 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2489 ])
2490 assert result.exit_code == 0, result.output
2491 data = json.loads(result.output)
2492 assert "symbol_history" in data.get("rebuilt", [])
2493 assert "symbol_history_addresses" in data
2494
2495
2496 # ---------------------------------------------------------------------------
2497 # Extended β€” muse code index status
2498 # ---------------------------------------------------------------------------
2499
2500
2501 class TestIndexStatusExtended:
2502 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2503 """-j is equivalent to --json."""
2504 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2505 assert result.exit_code == 0, result.output
2506 data = json.loads(result.output.strip())
2507 assert isinstance(data, list)
2508
2509 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2510 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2511 assert result.exit_code == 0
2512
2513 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2514 """JSON output is compact β€” single line, no indent=2."""
2515 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2516 assert result.exit_code == 0
2517 lines = [l for l in result.output.splitlines() if l.strip()]
2518 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2519
2520 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
2521 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2522 data = json.loads(result.output.strip())
2523 assert isinstance(data, list)
2524
2525 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2526 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2527 data = json.loads(result.output.strip())
2528 names = [e["name"] for e in data]
2529 assert "symbol_history" in names
2530
2531 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2532 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2533 data = json.loads(result.output.strip())
2534 names = [e["name"] for e in data]
2535 assert "hash_occurrence" in names
2536
2537 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2538 """Every entry has name, status, entries, updated_at."""
2539 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2540 data = json.loads(result.output.strip())
2541 for entry in data:
2542 assert "name" in entry
2543 assert "status" in entry
2544 assert "entries" in entry
2545 assert "updated_at" in entry
2546
2547 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2548 """Freshly initialised repo: both indexes are absent."""
2549 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2550 data = json.loads(result.output.strip())
2551 statuses = {e["name"]: e["status"] for e in data}
2552 assert statuses["symbol_history"] == "absent"
2553 assert statuses["hash_occurrence"] == "absent"
2554
2555 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2556 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2557 data = json.loads(result.output.strip())
2558 for entry in data:
2559 if entry["status"] == "absent":
2560 assert entry["entries"] == 0
2561
2562 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2563 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2564 data = json.loads(result.output.strip())
2565 for entry in data:
2566 if entry["status"] == "absent":
2567 assert entry["updated_at"] is None
2568
2569 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2570 """After rebuild all indexes report present."""
2571 runner.invoke(cli, ["code", "index", "rebuild"])
2572 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2573 data = json.loads(result.output.strip())
2574 for entry in data:
2575 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2576
2577 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2578 """symbol_history should have entries after two commits."""
2579 runner.invoke(cli, ["code", "index", "rebuild"])
2580 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2581 data = json.loads(result.output.strip())
2582 sh = next(e for e in data if e["name"] == "symbol_history")
2583 assert sh["entries"] > 0
2584
2585 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2586 runner.invoke(cli, ["code", "index", "rebuild"])
2587 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2588 data = json.loads(result.output.strip())
2589 for entry in data:
2590 assert entry["updated_at"] is not None
2591
2592 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2593 """A file with bad content is reported as corrupt, not absent."""
2594 idx_dir = code_repo / ".muse" / "indices"
2595 idx_dir.mkdir(parents=True, exist_ok=True)
2596 (idx_dir / "symbol_history.msgpack").write_bytes(b"\xff\xfe")
2597 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2598 assert result.exit_code == 0
2599 data = json.loads(result.output.strip())
2600 sh = next(e for e in data if e["name"] == "symbol_history")
2601 assert sh["status"] == "corrupt"
2602
2603 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2604 idx_dir = code_repo / ".muse" / "indices"
2605 idx_dir.mkdir(parents=True, exist_ok=True)
2606 (idx_dir / "hash_occurrence.msgpack").write_bytes(b"notmsgpack")
2607 result = runner.invoke(cli, ["code", "index", "status"])
2608 assert result.exit_code == 0
2609
2610 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2611 """Text mode suggests rebuild command when index is absent."""
2612 result = runner.invoke(cli, ["code", "index", "status"])
2613 assert "rebuild" in result.output.lower()
2614
2615 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2616 runner.invoke(cli, ["code", "index", "rebuild"])
2617 result = runner.invoke(cli, ["code", "index", "status"])
2618 assert "βœ…" in result.output
2619
2620 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2621 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2622 assert "Agent quickstart" in result.output
2623
2624 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2625 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2626 assert "JSON output schema" in result.output
2627
2628 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2629 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2630 assert "Exit codes" in result.output
2631
2632
2633 # ---------------------------------------------------------------------------
2634 # Security β€” muse code index status
2635 # ---------------------------------------------------------------------------
2636
2637
2638 class TestIndexStatusSecurity:
2639 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2640 """A corrupt index file must not surface a traceback."""
2641 idx_dir = code_repo / ".muse" / "indices"
2642 idx_dir.mkdir(parents=True, exist_ok=True)
2643 (idx_dir / "symbol_history.msgpack").write_bytes(b"\x00" * 16)
2644 result = runner.invoke(cli, ["code", "index", "status"])
2645 assert "Traceback" not in result.output
2646
2647 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2648 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2649 from muse.core.indices import KNOWN_INDEX_NAMES
2650 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2651 data = json.loads(result.output.strip())
2652 for entry in data:
2653 assert entry["name"] in KNOWN_INDEX_NAMES
2654
2655 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2656 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2657 assert "\x1b" not in result.output
2658
2659 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2660 """status field is always one of the three allowed values."""
2661 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2662 data = json.loads(result.output.strip())
2663 for entry in data:
2664 assert entry["status"] in ("present", "absent", "corrupt")
2665
2666 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2667 monkeypatch.chdir(tmp_path)
2668 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2669 result = runner.invoke(cli, ["code", "index", "status"])
2670 assert "Traceback" not in result.output
2671 assert result.exit_code != 0
2672
2673 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2674 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2675 data = json.loads(result.output.strip())
2676 for entry in data:
2677 assert isinstance(entry["entries"], int)
2678
2679
2680 # ---------------------------------------------------------------------------
2681 # Stress β€” muse code index status
2682 # ---------------------------------------------------------------------------
2683
2684
2685 class TestIndexStatusStress:
2686 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2687 """50 sequential status calls all exit 0."""
2688 for i in range(50):
2689 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2690 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2691
2692 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2693 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2694 for i in range(100):
2695 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2696 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2697 data = json.loads(result.output.strip())
2698 sh = next(e for e in data if e["name"] == "symbol_history")
2699 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2700 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2701 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2702 data = json.loads(result.output.strip())
2703 sh = next(e for e in data if e["name"] == "symbol_history")
2704 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2705
2706 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2707 """8 threads reading index status concurrently β€” all must succeed."""
2708 import argparse
2709 import threading
2710
2711 from muse.cli.commands.index_rebuild import run_status
2712
2713 errors: list[str] = []
2714
2715 def worker(idx: int) -> None:
2716 args = argparse.Namespace(as_json=True)
2717 try:
2718 run_status(args)
2719 except SystemExit as exc:
2720 if exc.code != 0:
2721 errors.append(f"Thread {idx}: exit {exc.code}")
2722 except Exception as exc:
2723 errors.append(f"Thread {idx}: {exc}")
2724
2725 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2726 for t in threads:
2727 t.start()
2728 for t in threads:
2729 t.join()
2730 assert not errors, f"Concurrent failures: {errors}"
2731
2732
2733 # ---------------------------------------------------------------------------
2734 # Extended β€” muse code index rebuild
2735 # ---------------------------------------------------------------------------
2736
2737
2738 class TestIndexRebuildExtended:
2739 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2740 """-j is equivalent to --json."""
2741 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2742 assert result.exit_code == 0, result.output
2743 data = json.loads(result.output.strip())
2744 assert "rebuilt" in data
2745
2746 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2747 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2748 assert result.exit_code == 0
2749
2750 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2751 """JSON output is a single compact line β€” no indent=2."""
2752 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2753 assert result.exit_code == 0
2754 lines = [l for l in result.output.splitlines() if l.strip()]
2755 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2756
2757 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2758 """JSON output always has schema_version, dry_run, rebuilt."""
2759 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2760 data = json.loads(result.output.strip())
2761 assert "schema_version" in data
2762 assert "dry_run" in data
2763 assert "rebuilt" in data
2764
2765 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2766 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2767 data = json.loads(result.output.strip())
2768 assert "symbol_history" in data["rebuilt"]
2769 assert "hash_occurrence" in data["rebuilt"]
2770
2771 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2772 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2773 data = json.loads(result.output.strip())
2774 assert data["dry_run"] is False
2775
2776 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2777 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2778 assert result.exit_code == 0
2779 data = json.loads(result.output.strip())
2780 assert data["dry_run"] is True
2781
2782 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2783 """--dry-run must not create index files."""
2784 idx_dir = code_repo / ".muse" / "indices"
2785 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2786 assert not (idx_dir / "symbol_history.msgpack").exists()
2787 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2788
2789 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2790 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2791 assert result.exit_code == 0
2792 data = json.loads(result.output.strip())
2793 assert data["rebuilt"] == ["symbol_history"]
2794 assert "symbol_history_addresses" in data
2795 assert "hash_occurrence_clusters" not in data
2796
2797 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2798 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2799 assert result.exit_code == 0
2800 data = json.loads(result.output.strip())
2801 assert data["rebuilt"] == ["hash_occurrence"]
2802 assert "hash_occurrence_clusters" in data
2803 assert "symbol_history_addresses" not in data
2804
2805 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2806 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2807 data = json.loads(result.output.strip())
2808 assert isinstance(data["symbol_history_addresses"], int)
2809 assert isinstance(data["symbol_history_events"], int)
2810
2811 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2812 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2813 data = json.loads(result.output.strip())
2814 assert isinstance(data["hash_occurrence_clusters"], int)
2815 assert isinstance(data["hash_occurrence_addresses"], int)
2816
2817 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2818 runner.invoke(cli, ["code", "index", "rebuild"])
2819 idx_dir = code_repo / ".muse" / "indices"
2820 assert (idx_dir / "symbol_history.msgpack").exists()
2821 assert (idx_dir / "hash_occurrence.msgpack").exists()
2822
2823 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2824 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2825 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2826 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2827 assert r1.exit_code == 0 and r2.exit_code == 0
2828 d1 = json.loads(r1.output.strip())
2829 d2 = json.loads(r2.output.strip())
2830 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2831
2832 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2833 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2834 assert result.exit_code == 0
2835 assert "Building" in result.output
2836
2837 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2838 result = runner.invoke(cli, ["code", "index", "rebuild"])
2839 assert "Rebuilt" in result.output or "index" in result.output.lower()
2840
2841 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2842 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2843 assert "Agent quickstart" in result.output
2844
2845 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2846 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2847 assert "JSON output schema" in result.output
2848
2849 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2850 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2851 assert "Exit codes" in result.output
2852
2853
2854 # ---------------------------------------------------------------------------
2855 # Security β€” muse code index rebuild
2856 # ---------------------------------------------------------------------------
2857
2858
2859 class TestIndexRebuildSecurity:
2860 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2861 """An unknown --index value must be rejected before run_rebuild is called."""
2862 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "evil_index"])
2863 assert result.exit_code != 0
2864
2865 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2866 idx_dir = code_repo / ".muse" / "indices"
2867 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2868 assert not (idx_dir / "symbol_history.msgpack").exists()
2869 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2870
2871 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2872 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2873 assert "\x1b" not in result.output
2874
2875 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2876 monkeypatch.chdir(tmp_path)
2877 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2878 result = runner.invoke(cli, ["code", "index", "rebuild"])
2879 assert "Traceback" not in result.output
2880 assert result.exit_code != 0
2881
2882 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2883 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2884 from muse.core.indices import KNOWN_INDEX_NAMES
2885 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2886 data = json.loads(result.output.strip())
2887 for name in data["rebuilt"]:
2888 assert name in KNOWN_INDEX_NAMES
2889
2890 def test_schema_version_is_string(self, code_repo: pathlib.Path) -> None:
2891 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2892 data = json.loads(result.output.strip())
2893 assert isinstance(data["schema_version"], str)
2894 assert len(data["schema_version"]) > 0
2895
2896
2897 # ---------------------------------------------------------------------------
2898 # Stress β€” muse code index rebuild
2899 # ---------------------------------------------------------------------------
2900
2901
2902 class TestIndexRebuildStress:
2903 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2904 """50 sequential rebuilds all exit 0."""
2905 for i in range(50):
2906 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2907 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2908
2909 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2910 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2911 indexes = ["symbol_history", "hash_occurrence"]
2912 for i in range(100):
2913 target = indexes[i % 2]
2914 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2915 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2916 data = json.loads(result.output.strip())
2917 assert target in data["rebuilt"]
2918
2919 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2920 """8 threads rebuilding hash_occurrence concurrently via core function."""
2921 import argparse
2922 import threading
2923
2924 from muse.cli.commands.index_rebuild import run_rebuild
2925
2926 errors: list[str] = []
2927
2928 def worker(idx: int) -> None:
2929 args = argparse.Namespace(
2930 index_name="hash_occurrence",
2931 dry_run=True, # dry_run avoids concurrent write races
2932 verbose=False,
2933 as_json=True,
2934 )
2935 try:
2936 run_rebuild(args)
2937 except SystemExit as exc:
2938 if exc.code != 0:
2939 errors.append(f"Thread {idx}: exit {exc.code}")
2940 except Exception as exc:
2941 errors.append(f"Thread {idx}: {exc}")
2942
2943 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2944 for t in threads:
2945 t.start()
2946 for t in threads:
2947 t.join()
2948 assert not errors, f"Concurrent failures: {errors}"
2949
2950
2951 # ---------------------------------------------------------------------------
2952 # Extended β€” muse code index purge
2953 # ---------------------------------------------------------------------------
2954
2955
2956 class TestIndexPurgeExtended:
2957 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2958 """-j is equivalent to --json."""
2959 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2960 assert result.exit_code == 0, result.output
2961 data = json.loads(result.output.strip())
2962 assert "purged" in data
2963
2964 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2965 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
2966 assert result.exit_code == 0
2967
2968 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2969 """JSON output is compact β€” single line, no indent=2."""
2970 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2971 assert result.exit_code == 0
2972 lines = [l for l in result.output.splitlines() if l.strip()]
2973 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2974
2975 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2976 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2977 data = json.loads(result.output.strip())
2978 assert "schema_version" in data
2979 assert "purged" in data
2980 assert "skipped" in data
2981
2982 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
2983 """Purging when indexes are absent β€” both in skipped, none in purged."""
2984 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2985 data = json.loads(result.output.strip())
2986 assert data["purged"] == []
2987 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
2988
2989 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
2990 """After rebuild, purge reports both as purged."""
2991 runner.invoke(cli, ["code", "index", "rebuild"])
2992 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2993 data = json.loads(result.output.strip())
2994 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
2995 assert data["skipped"] == []
2996
2997 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
2998 runner.invoke(cli, ["code", "index", "rebuild"])
2999 runner.invoke(cli, ["code", "index", "purge"])
3000 idx_dir = code_repo / ".muse" / "indices"
3001 assert not (idx_dir / "symbol_history.msgpack").exists()
3002 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3003
3004 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
3005 runner.invoke(cli, ["code", "index", "rebuild"])
3006 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
3007 assert result.exit_code == 0
3008 data = json.loads(result.output.strip())
3009 assert data["purged"] == ["symbol_history"]
3010 assert data["skipped"] == []
3011 idx_dir = code_repo / ".muse" / "indices"
3012 assert not (idx_dir / "symbol_history.msgpack").exists()
3013 assert (idx_dir / "hash_occurrence.msgpack").exists()
3014
3015 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
3016 runner.invoke(cli, ["code", "index", "rebuild"])
3017 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3018 assert result.exit_code == 0
3019 data = json.loads(result.output.strip())
3020 assert data["purged"] == ["hash_occurrence"]
3021 idx_dir = code_repo / ".muse" / "indices"
3022 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3023 assert (idx_dir / "symbol_history.msgpack").exists()
3024
3025 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
3026 """Purging when nothing is present still exits 0."""
3027 result = runner.invoke(cli, ["code", "index", "purge"])
3028 assert result.exit_code == 0
3029
3030 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
3031 """Purging twice in a row both exit 0."""
3032 runner.invoke(cli, ["code", "index", "rebuild"])
3033 r1 = runner.invoke(cli, ["code", "index", "purge"])
3034 r2 = runner.invoke(cli, ["code", "index", "purge"])
3035 assert r1.exit_code == 0
3036 assert r2.exit_code == 0
3037
3038 def test_schema_version_is_string(self, code_repo: pathlib.Path) -> None:
3039 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3040 data = json.loads(result.output.strip())
3041 assert isinstance(data["schema_version"], str)
3042 assert len(data["schema_version"]) > 0
3043
3044 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
3045 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3046 data = json.loads(result.output.strip())
3047 assert isinstance(data["purged"], list)
3048 assert isinstance(data["skipped"], list)
3049
3050 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
3051 runner.invoke(cli, ["code", "index", "rebuild"])
3052 result = runner.invoke(cli, ["code", "index", "purge"])
3053 assert "deleted" in result.output.lower() or "πŸ—‘" in result.output
3054
3055 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3056 result = runner.invoke(cli, ["code", "index", "purge"])
3057 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3058
3059 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3060 runner.invoke(cli, ["code", "index", "rebuild"])
3061 runner.invoke(cli, ["code", "index", "purge"])
3062 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3063 data = json.loads(result.output.strip())
3064 for entry in data:
3065 assert entry["status"] == "absent"
3066
3067 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3068 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3069 assert "Agent quickstart" in result.output
3070
3071 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3072 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3073 assert "JSON output schema" in result.output
3074
3075 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3076 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3077 assert "Exit codes" in result.output
3078
3079
3080 # ---------------------------------------------------------------------------
3081 # Security β€” muse code index purge
3082 # ---------------------------------------------------------------------------
3083
3084
3085 class TestIndexPurgeSecurity:
3086 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3087 """Unknown --index value rejected by argparse before run_purge runs."""
3088 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3089 assert result.exit_code != 0
3090
3091 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3092 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3093 assert "\x1b" not in result.output
3094
3095 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3096 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3097 from muse.core.indices import KNOWN_INDEX_NAMES
3098 runner.invoke(cli, ["code", "index", "rebuild"])
3099 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3100 data = json.loads(result.output.strip())
3101 for name in data["purged"] + data["skipped"]:
3102 assert name in KNOWN_INDEX_NAMES
3103
3104 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3105 monkeypatch.chdir(tmp_path)
3106 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3107 result = runner.invoke(cli, ["code", "index", "purge"])
3108 assert "Traceback" not in result.output
3109 assert result.exit_code != 0
3110
3111 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3112 """Purge must not remove anything outside .muse/indices/."""
3113 runner.invoke(cli, ["code", "index", "rebuild"])
3114 repo_json = code_repo / ".muse" / "repo.json"
3115 assert repo_json.exists()
3116 runner.invoke(cli, ["code", "index", "purge"])
3117 assert repo_json.exists(), "repo.json must not be deleted by purge"
3118
3119 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3120 runner.invoke(cli, ["code", "index", "rebuild"])
3121 runner.invoke(cli, ["code", "index", "purge"])
3122 result = runner.invoke(cli, ["code", "index", "purge"])
3123 assert "Traceback" not in result.output
3124
3125
3126 # ---------------------------------------------------------------------------
3127 # Stress β€” muse code index purge
3128 # ---------------------------------------------------------------------------
3129
3130
3131 class TestIndexPurgeStress:
3132 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3133 """50 sequential purge calls all exit 0 (idempotent)."""
3134 for i in range(50):
3135 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3136 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3137
3138 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3139 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3140 for i in range(100):
3141 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3142 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3143 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3144 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3145 d = json.loads(r2.output.strip())
3146 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3147
3148 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3149 """8 threads purging concurrently via core function β€” all must exit 0."""
3150 import argparse
3151 import threading
3152
3153 from muse.cli.commands.index_rebuild import run_purge
3154
3155 runner.invoke(cli, ["code", "index", "rebuild"])
3156 errors: list[str] = []
3157
3158 def worker(idx: int) -> None:
3159 args = argparse.Namespace(index_name=None, as_json=True)
3160 try:
3161 run_purge(args)
3162 except SystemExit as exc:
3163 if exc.code != 0:
3164 errors.append(f"Thread {idx}: exit {exc.code}")
3165 except Exception as exc:
3166 errors.append(f"Thread {idx}: {exc}")
3167
3168 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3169 for t in threads:
3170 t.start()
3171 for t in threads:
3172 t.join()
3173 assert not errors, f"Concurrent failures: {errors}"
3174
3175
3176 # ---------------------------------------------------------------------------
3177 # Performance β€” iterative DFS regression (no RecursionError)
3178 # ---------------------------------------------------------------------------
3179
3180
3181 class TestIterativeDFS:
3182 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3183
3184 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3185 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3186
3187 # Build a linear chain Aβ†’Bβ†’C→…→Z (depth 600, beyond Python's 1000 default).
3188 depth = 600
3189 nodes = [f"mod_{i}" for i in range(depth)]
3190 imports_out: _ImportsMap = {
3191 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3192 }
3193 imports_out[nodes[-1]] = []
3194
3195 # Must not raise RecursionError.
3196 cycles = codemap_find_cycles(imports_out)
3197 assert isinstance(cycles, list)
3198 assert len(cycles) == 0 # linear chain has no cycles
3199
3200 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3201 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3202
3203 # A→B→C→A is a cycle.
3204 imports_out: _ImportsMap = {
3205 "A": ["B"],
3206 "B": ["C"],
3207 "C": ["A"],
3208 }
3209 cycles = codemap_find_cycles(imports_out)
3210 assert len(cycles) >= 1
3211
3212 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3213 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3214
3215 depth = 600
3216 nodes = [f"file_{i}.py" for i in range(depth)]
3217 imports: _ImportsSetMap = {
3218 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3219 }
3220 imports[nodes[-1]] = set()
3221
3222 cycles = invariants_find_cycles(imports)
3223 assert isinstance(cycles, list)
3224 assert len(cycles) == 0
3225
3226 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3227 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3228
3229 # A module that imports itself.
3230 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3231 cycles = invariants_find_cycles(imports)
3232 assert len(cycles) >= 1
3233
3234
3235 # ---------------------------------------------------------------------------
3236 # muse code symbols
3237 # ---------------------------------------------------------------------------
3238
3239
3240 class TestSymbols:
3241 """Tests for ``muse code symbols``."""
3242
3243 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3244 """Basic invocation lists functions and classes from HEAD snapshot."""
3245 result = runner.invoke(cli, ["code", "symbols"])
3246 assert result.exit_code == 0, result.output
3247 # billing.py contains Invoice class and process_order / send_email functions.
3248 assert "Invoice" in result.output
3249 assert "process_order" in result.output
3250 assert "symbols across" in result.output
3251
3252 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3253 """``--count`` prints a total count and language breakdown, no symbol table."""
3254 result = runner.invoke(cli, ["code", "symbols", "--count"])
3255 assert result.exit_code == 0, result.output
3256 assert "symbols" in result.output
3257 assert "Python" in result.output
3258 # Should NOT print individual symbol lines.
3259 assert "Invoice" not in result.output
3260
3261 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3262 """``--json`` emits a structured envelope with a flat 'results' list."""
3263 result = runner.invoke(cli, ["code", "symbols", "--json"])
3264 assert result.exit_code == 0, result.output
3265 data = json.loads(result.output)
3266 assert isinstance(data, dict)
3267 assert "results" in data
3268 assert "files" not in data
3269 assert isinstance(data["results"], list)
3270 assert any(e.get("file", "").endswith("billing.py") for e in data["results"])
3271 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3272
3273 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3274 """``--kind class`` shows only class-kind symbols."""
3275 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3276 assert result.exit_code == 0, result.output
3277 assert "Invoice" in result.output
3278 assert "process_order" not in result.output
3279
3280 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3281 """``--kind function`` shows only top-level functions, not methods."""
3282 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3283 assert result.exit_code == 0, result.output
3284 assert "process_order" in result.output
3285 assert "send_email" in result.output
3286 assert "Invoice" not in result.output
3287
3288 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3289 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3290 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3291 assert result.exit_code != 0
3292 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3293
3294 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3295 """``--file`` restricts output to a single file."""
3296 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3297 assert result.exit_code == 0, result.output
3298 assert "symbols across" in result.output
3299
3300 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3301 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3302 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3303 assert result.exit_code == 0, result.output
3304 assert "no semantic symbols found" in result.output
3305
3306 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3307 """``--language Python`` includes Python symbols; other languages excluded."""
3308 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3309 assert result.exit_code == 0, result.output
3310 assert "Invoice" in result.output
3311
3312 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3313 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3314 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3315 assert result.exit_code == 0, result.output
3316 assert "no semantic symbols found" in result.output
3317
3318 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3319 """``--hashes`` appends content hash abbreviations to each symbol row."""
3320 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3321 assert result.exit_code == 0, result.output
3322 # Hash suffix is 8 hex chars followed by ".."
3323 assert ".." in result.output
3324
3325 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3326 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3327 default = runner.invoke(cli, ["code", "symbols"])
3328 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3329 assert default.exit_code == 0
3330 assert head.exit_code == 0
3331 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3332 assert "Invoice" in default.output
3333 assert "Invoice" in head.output
3334 assert "symbols across" in default.output
3335 assert "symbols across" in head.output
3336
3337 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3338 """``--count`` and ``--json`` cannot be combined."""
3339 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3340 assert result.exit_code != 0
3341
3342 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3343 """JSON output uses the structured envelope with source_ref and results."""
3344 result = runner.invoke(cli, ["code", "symbols", "--json"])
3345 assert result.exit_code == 0, result.output
3346 data = json.loads(result.output)
3347 assert "source_ref" in data
3348 assert "working_tree" in data
3349 assert "total_symbols" in data
3350 assert "results" in data
3351 assert "files" not in data
3352 assert isinstance(data["working_tree"], bool)
3353 assert isinstance(data["total_symbols"], int)
3354 for entry in data["results"]:
3355 for field in ("address", "kind", "name", "qualified_name", "file",
3356 "lineno", "content_id", "body_hash", "signature_id"):
3357 assert field in entry, f"missing field '{field}' in JSON entry"
3358
3359 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3360 """``--json`` without ``--commit`` reports working_tree=true."""
3361 result = runner.invoke(cli, ["code", "symbols", "--json"])
3362 assert result.exit_code == 0, result.output
3363 data = json.loads(result.output)
3364 assert data["working_tree"] is True
3365 assert data["source_ref"] == "working-tree"
3366
3367 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3368 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3369 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3370 assert result.exit_code == 0, result.output
3371 data = json.loads(result.output)
3372 assert data["working_tree"] is False
3373 assert data["source_ref"] != "working-tree"
3374 # source_ref should be an 8-char hex prefix.
3375 assert len(data["source_ref"]) == 8
3376 assert all(c in "0123456789abcdef" for c in data["source_ref"])
3377
3378 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3379 """Working-tree mode picks up edits made to files after the last commit."""
3380 # Find the billing.py path on disk.
3381 billing = code_repo / "billing.py"
3382 assert billing.exists()
3383 # Append a new function β€” not yet committed.
3384 billing.write_text(
3385 billing.read_text() + "\ndef newly_added_function():\n pass\n"
3386 )
3387 result = runner.invoke(cli, ["code", "symbols"])
3388 assert result.exit_code == 0, result.output
3389 assert "newly_added_function" in result.output
3390
3391 # Committed snapshot should NOT contain it.
3392 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3393 assert committed.exit_code == 0
3394 assert "newly_added_function" not in committed.output
3395
3396 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3397 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3398 for variant in ("python", "Python", "PYTHON"):
3399 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3400 assert result.exit_code == 0, f"failed for --language {variant!r}"
3401 assert "Invoice" in result.output
3402
3403 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3404 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3405 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3406 assert result.exit_code == 0, result.output
3407 assert "Invoice" in result.output
3408
3409 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3410 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3411 # Write a second file with the same basename in a sub-directory.
3412 sub = code_repo / "sub"
3413 sub.mkdir(exist_ok=True)
3414 (sub / "billing.py").write_text("def sub_func(): pass\n")
3415 # Stage and commit both so the manifest has two paths ending in billing.py.
3416 import subprocess
3417 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3418 subprocess.run(
3419 ["muse", "commit", "-m", "add sub/billing.py"],
3420 cwd=code_repo, check=True,
3421 )
3422 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3423 assert result.exit_code != 0
3424 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3425
3426 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3427 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3428 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3429 assert result.exit_code != 0
3430 assert "not found" in result.output or "not found" in (result.stderr or "")
3431
3432
3433 # ---------------------------------------------------------------------------
3434 # TestSymbolLog
3435 # ---------------------------------------------------------------------------
3436
3437
3438 class TestSymbolLog:
3439 """Tests for ``muse code symbol-log``."""
3440
3441 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3442 """An address not found in any commit produces 'no events found'."""
3443 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3444 assert result.exit_code == 0, result.output
3445 assert "no events found" in result.output
3446
3447 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3448 """An address without '::' exits non-zero with a descriptive error."""
3449 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3450 assert result.exit_code != 0
3451 assert "::" in (result.output + (result.stderr or ""))
3452
3453 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3454 """An empty string as address exits non-zero."""
3455 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3456 # "::" is technically valid syntax; should at least not crash.
3457 assert result.exit_code == 0
3458
3459 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3460 """``--json`` emits the structured envelope with all top-level fields."""
3461 result = runner.invoke(
3462 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3463 )
3464 assert result.exit_code == 0, result.output
3465 data = json.loads(result.output)
3466 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3467 assert field in data, f"missing top-level field '{field}'"
3468 assert data["address"] == "billing.py::Invoice"
3469 assert data["start_ref"] == "HEAD"
3470 assert isinstance(data["total_commits_scanned"], int)
3471 assert isinstance(data["truncated"], bool)
3472 assert isinstance(data["events"], list)
3473
3474 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3475 """Each JSON event has the required fields."""
3476 result = runner.invoke(
3477 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3478 )
3479 assert result.exit_code == 0, result.output
3480 data = json.loads(result.output)
3481 for ev in data["events"]:
3482 for field in ("event", "commit_id", "message", "committed_at",
3483 "address", "detail", "new_address"):
3484 assert field in ev, f"missing event field '{field}'"
3485
3486 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3487 """When --max is hit, a truncation warning appears in human output."""
3488 result = runner.invoke(
3489 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3490 )
3491 assert result.exit_code == 0, result.output
3492 assert "incomplete" in result.output or "limit" in result.output
3493
3494 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3495 """When --max is hit, truncated=true appears in JSON output."""
3496 result = runner.invoke(
3497 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3498 )
3499 assert result.exit_code == 0, result.output
3500 data = json.loads(result.output)
3501 assert data["truncated"] is True
3502 assert data["total_commits_scanned"] == 1
3503
3504 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3505 """--max 0 exits non-zero with a clear error."""
3506 result = runner.invoke(
3507 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3508 )
3509 assert result.exit_code != 0
3510
3511 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3512 """``--from`` with a non-existent ref exits non-zero."""
3513 result = runner.invoke(
3514 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3515 )
3516 assert result.exit_code != 0
3517 assert "not found" in (result.output + (result.stderr or ""))
3518
3519 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3520 """BFS walk finds events on feature branches that were merged in via parent2.
3521
3522 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3523 The feature branch commit has a structured_delta inserting a symbol.
3524 The linear (parent1-only) walk would miss this; BFS must find it.
3525 """
3526 import datetime
3527
3528 root = code_repo
3529 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3530 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3531 from muse.core.snapshot import compute_commit_id
3532 from muse.domain import InsertOp, PatchOp, StructuredDelta
3533 branch = read_current_branch(root)
3534 head_id = get_head_commit_id(root, branch)
3535 assert head_id is not None
3536
3537 feature_snap = "aa" * 32
3538 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3539 feature_id = compute_commit_id([head_id], feature_snap, "feat: add merged_fn", feature_at.isoformat())
3540 write_commit(root, CommitRecord(
3541 commit_id=feature_id,
3542 repo_id=repo_id,
3543 branch="feat/branch",
3544 snapshot_id=feature_snap,
3545 message="feat: add merged_fn",
3546 committed_at=feature_at,
3547 parent_commit_id=head_id,
3548 author="test",
3549 structured_delta=StructuredDelta(ops=[PatchOp(
3550 op="patch",
3551 address="billing.py",
3552 child_ops=[InsertOp(
3553 op="insert",
3554 address="billing.py::merged_fn",
3555 content_summary="function merged_fn",
3556 )],
3557 )]),
3558 ))
3559
3560 merge_snap = "bb" * 32
3561 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3562 merge_id = compute_commit_id([head_id, feature_id], merge_snap, "merge feat/branch", merge_at.isoformat())
3563 write_commit(root, CommitRecord(
3564 commit_id=merge_id,
3565 repo_id=repo_id,
3566 branch=branch,
3567 snapshot_id=merge_snap,
3568 message="merge feat/branch",
3569 committed_at=merge_at,
3570 parent_commit_id=head_id,
3571 parent2_commit_id=feature_id,
3572 author="test",
3573 ))
3574
3575 branch_ref = root / ".muse" / "refs" / "heads" / branch
3576 branch_ref.write_text(merge_id)
3577
3578 result = runner.invoke(
3579 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3580 )
3581 assert result.exit_code == 0, result.output
3582 # BFS must find the creation event on the feature branch.
3583 assert "merged_fn" in result.output
3584 assert "created" in result.output
3585
3586 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3587 """Regression guard: verify the BFS result differs from a parent1-only scan.
3588
3589 Directly calls _walk_commits_dag and checks it returns commits from
3590 both parent chains, not just parent1.
3591 """
3592 import datetime
3593
3594 root = code_repo
3595 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3596 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3597 from muse.core.snapshot import compute_commit_id
3598 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3599 branch = read_current_branch(root)
3600 head_id = get_head_commit_id(root, branch)
3601 assert head_id is not None
3602
3603 feature_snap = "dd" * 32
3604 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3605 feature_id = compute_commit_id([], feature_snap, "feat on second parent", feature_at.isoformat())
3606 write_commit(root, CommitRecord(
3607 commit_id=feature_id,
3608 repo_id=repo_id,
3609 branch="feat/x",
3610 snapshot_id=feature_snap,
3611 message="feat on second parent",
3612 committed_at=feature_at,
3613 author="test",
3614 ))
3615 merge_snap = "ee" * 32
3616 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3617 merge_id = compute_commit_id([head_id, feature_id], merge_snap, "merge", merge_at.isoformat())
3618 write_commit(root, CommitRecord(
3619 commit_id=merge_id,
3620 repo_id=repo_id,
3621 branch=branch,
3622 snapshot_id=merge_snap,
3623 message="merge",
3624 committed_at=merge_at,
3625 parent_commit_id=head_id,
3626 parent2_commit_id=feature_id,
3627 author="test",
3628 ))
3629
3630 branch_ref = root / ".muse" / "refs" / "heads" / branch
3631 branch_ref.write_text(merge_id)
3632
3633 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3634 commit_ids = {c.commit_id for c in commits}
3635 assert feature_id in commit_ids
3636
3637
3638 # ---------------------------------------------------------------------------
3639 # muse code coupling
3640 # ---------------------------------------------------------------------------
3641
3642
3643 @pytest.fixture
3644 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3645 """Repo with 3 commits where billing.py + models.py co-change twice."""
3646 work = repo
3647
3648 # Commit 1: seed β€” only billing.py
3649 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3650 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3651 assert r.exit_code == 0, r.output
3652
3653 # Commit 2: billing.py + models.py change together
3654 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3655 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3656 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3657 assert r.exit_code == 0, r.output
3658
3659 # Commit 3: billing.py + models.py change together again
3660 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3661 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3662 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3663 assert r.exit_code == 0, r.output
3664
3665 return repo
3666
3667
3668 class TestCoupling:
3669 """Tests for muse code coupling."""
3670
3671 # ── basic correctness ────────────────────────────────────────────────────
3672
3673 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3674 result = runner.invoke(cli, ["code", "coupling"])
3675 assert result.exit_code == 0, result.output
3676
3677 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3678 """billing.py and models.py co-changed twice β€” must appear in output."""
3679 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3680 assert result.exit_code == 0, result.output
3681 assert "billing.py" in result.output
3682 assert "models.py" in result.output
3683
3684 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3685 result = runner.invoke(cli, ["code", "coupling"])
3686 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3687 assert "Commits analysed" in result.output
3688
3689 def test_coupling_min_filter_excludes_low_count(
3690 self, coupling_repo: pathlib.Path
3691 ) -> None:
3692 """--min 3 must exclude our pair that co-changed only twice."""
3693 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3694 assert result.exit_code == 0, result.output
3695 assert "billing.py" not in result.output or "no file pairs" in result.output
3696
3697 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3698 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3699 data = json.loads(result.output)
3700 assert len(data["pairs"]) <= 1
3701
3702 # ── --file filter ─────────────────────────────────────────────────────────
3703
3704 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3705 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3706 assert result.exit_code == 0, result.output
3707
3708 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3709 """--file billing.py must surface models.py as its partner."""
3710 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3711 assert result.exit_code == 0, result.output
3712 assert "models.py" in result.output
3713
3714 def test_coupling_file_filter_header_names_file(
3715 self, coupling_repo: pathlib.Path
3716 ) -> None:
3717 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3718 assert "billing.py" in result.output
3719
3720 def test_coupling_file_filter_nonexistent_returns_cleanly(
3721 self, coupling_repo: pathlib.Path
3722 ) -> None:
3723 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3724 assert result.exit_code == 0, result.output
3725
3726 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3727 """Suffix billing.py should match the file even without the full path."""
3728 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3729 assert result.exit_code == 0, result.output
3730 assert "models.py" in result.output
3731
3732 # ── JSON output ───────────────────────────────────────────────────────────
3733
3734 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3735 result = runner.invoke(cli, ["code", "coupling", "--json"])
3736 assert result.exit_code == 0, result.output
3737 data = json.loads(result.output)
3738 assert "from_ref" in data
3739 assert "to_ref" in data
3740 assert "commits_analysed" in data
3741 assert "truncated" in data
3742 assert "filters" in data
3743 assert "pairs" in data
3744 assert isinstance(data["pairs"], list)
3745
3746 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3747 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3748 data = json.loads(result.output)
3749 if data["pairs"]:
3750 pair = data["pairs"][0]
3751 assert "file_a" in pair or "file" in pair
3752 assert "co_changes" in pair
3753 assert isinstance(pair["co_changes"], int)
3754
3755 def test_coupling_json_file_filter_uses_partner_schema(
3756 self, coupling_repo: pathlib.Path
3757 ) -> None:
3758 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3759 result = runner.invoke(
3760 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3761 )
3762 data = json.loads(result.output)
3763 assert data["filters"]["file"] == "billing.py"
3764 if data["pairs"]:
3765 pair = data["pairs"][0]
3766 assert "file" in pair
3767 assert "partner" in pair
3768 assert "co_changes" in pair
3769 assert "file_a" not in pair # partner schema, not pair schema
3770
3771 def test_coupling_json_not_truncated_small_repo(
3772 self, coupling_repo: pathlib.Path
3773 ) -> None:
3774 result = runner.invoke(cli, ["code", "coupling", "--json"])
3775 data = json.loads(result.output)
3776 assert data["truncated"] is False
3777
3778 def test_coupling_json_filters_reflect_args(
3779 self, coupling_repo: pathlib.Path
3780 ) -> None:
3781 result = runner.invoke(
3782 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3783 )
3784 data = json.loads(result.output)
3785 assert data["filters"]["top"] == 5
3786 assert data["filters"]["min_count"] == 2
3787
3788 # ── --max-commits ─────────────────────────────────────────────────────────
3789
3790 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3791 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3792 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3793 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3794 d_cap = json.loads(r_cap.output)
3795 assert d_cap["commits_analysed"] <= 1
3796
3797 def test_coupling_max_commits_truncated_flag(
3798 self, coupling_repo: pathlib.Path
3799 ) -> None:
3800 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3801 data = json.loads(result.output)
3802 # With 3 commits and cap=1, truncated must be True.
3803 assert data["truncated"] is True
3804
3805 def test_coupling_max_commits_one_shows_warning(
3806 self, coupling_repo: pathlib.Path
3807 ) -> None:
3808 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3809 assert result.exit_code == 0, result.output
3810 assert "⚠️" in result.output or "capped" in result.output
3811
3812 # ── validation ────────────────────────────────────────────────────────────
3813
3814 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3815 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3816 assert result.exit_code != 0
3817
3818 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3819 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3820 assert result.exit_code != 0
3821
3822 def test_coupling_max_commits_zero_exits_error(
3823 self, coupling_repo: pathlib.Path
3824 ) -> None:
3825 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3826 assert result.exit_code != 0
3827
3828 def test_coupling_invalid_from_ref_exits_error(
3829 self, coupling_repo: pathlib.Path
3830 ) -> None:
3831 result = runner.invoke(
3832 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3833 )
3834 assert result.exit_code != 0
3835
3836 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3837 """Coupling must count co-changes on feature-branch commits (parent2)."""
3838 import datetime
3839
3840 # Genesis commit
3841 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3842 r = runner.invoke(cli, ["commit", "-m", "seed"])
3843 assert r.exit_code == 0, r.output
3844
3845 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
3846 repo_id = repo_json["repo_id"]
3847 from muse.core.store import read_current_branch, resolve_commit_ref
3848 branch = read_current_branch(repo)
3849 head = resolve_commit_ref(repo, repo_id, branch, None)
3850 assert head is not None
3851
3852 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3853 feature_at = now
3854 merge_at = now + datetime.timedelta(hours=1)
3855
3856 # Feature commit touching billing.py + models.py together.
3857 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3858 from muse.core.snapshot import compute_commit_id
3859 feature_delta = StructuredDelta(
3860 domain="code",
3861 ops=[
3862 PatchOp(
3863 op="patch", address="billing.py",
3864 child_ops=[ReplaceOp(
3865 op="replace", address="billing.py::compute",
3866 old_content_id="a" * 64, new_content_id="b" * 64,
3867 old_summary="function compute",
3868 new_summary="function compute (modified)", position=None,
3869 )],
3870 child_domain="code", child_summary="compute modified",
3871 ),
3872 PatchOp(
3873 op="patch", address="models.py",
3874 child_ops=[InsertOp(
3875 op="insert", address="models.py::Order",
3876 content_id="c" * 64, content_summary="class Order", position=None,
3877 )],
3878 child_domain="code", child_summary="Order added",
3879 ),
3880 ],
3881 summary="co-change",
3882 )
3883 feature_id = compute_commit_id(
3884 [head.commit_id], head.snapshot_id,
3885 "co-change on feature branch", feature_at.isoformat(),
3886 )
3887 merge_id = compute_commit_id(
3888 [head.commit_id, feature_id], head.snapshot_id,
3889 "Merge feature", merge_at.isoformat(),
3890 )
3891 feature_body: CommitDict = {
3892 "commit_id": feature_id,
3893 "repo_id": repo_id,
3894 "branch": "feat/test",
3895 "snapshot_id": head.snapshot_id,
3896 "message": "co-change on feature branch",
3897 "committed_at": feature_at.isoformat(),
3898 "parent_commit_id": head.commit_id,
3899 "parent2_commit_id": None,
3900 "author": "test",
3901 "metadata": {},
3902 "structured_delta": feature_delta,
3903 }
3904 merge_body: CommitDict = {
3905 "commit_id": merge_id,
3906 "repo_id": repo_id,
3907 "branch": branch,
3908 "snapshot_id": head.snapshot_id,
3909 "message": "Merge feature",
3910 "committed_at": merge_at.isoformat(),
3911 "parent_commit_id": head.commit_id,
3912 "parent2_commit_id": feature_id,
3913 "author": "test",
3914 "metadata": {},
3915 "structured_delta": None,
3916 }
3917 from muse.core.store import write_commit, CommitRecord
3918 write_commit(repo, CommitRecord.from_dict(feature_body))
3919 write_commit(repo, CommitRecord.from_dict(merge_body))
3920 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
3921
3922 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3923 assert result.exit_code == 0, result.output
3924 data = json.loads(result.output)
3925 pairs_found = {
3926 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
3927 }
3928 billing_models = any(
3929 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
3930 for a, b in pairs_found
3931 )
3932 assert billing_models, "BFS must find the feature-branch co-change commit"
3933
3934
3935 # ---------------------------------------------------------------------------
3936 # muse code stable
3937 # ---------------------------------------------------------------------------
3938
3939
3940 class TestStable:
3941 """Tests for muse code stable."""
3942
3943 # ── basic correctness ────────────────────────────────────────────────────
3944
3945 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
3946 result = runner.invoke(cli, ["code", "stable"])
3947 assert result.exit_code == 0, result.output
3948
3949 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
3950 result = runner.invoke(cli, ["code", "stable"])
3951 assert result.exit_code == 0, result.output
3952 assert "Symbol stability" in result.output
3953 assert "Commits analysed" in result.output
3954 assert "bedrock" in result.output
3955
3956 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
3957 """Invoice.apply_discount was defined in the genesis commit and never modified."""
3958 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
3959 assert result.exit_code == 0, result.output
3960 # apply_discount was never touched in any structured_delta β†’ maximally stable.
3961 assert "apply_discount" in result.output
3962
3963 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
3964 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
3965 assert result.exit_code == 0, result.output
3966 assert "since start of range" in result.output
3967
3968 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
3969 """Markdown / TOML / YAML symbols must be absent from default output."""
3970 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
3971 assert result.exit_code == 0, result.output
3972 assert ".md::" not in result.output
3973 assert ".toml::" not in result.output
3974
3975 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
3976 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
3977 assert result.exit_code == 0, result.output
3978 assert "::import::" not in result.output
3979
3980 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
3981 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
3982 assert result.exit_code == 0, result.output
3983
3984 # ── JSON output ───────────────────────────────────────────────────────────
3985
3986 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
3987 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
3988 assert result.exit_code == 0, result.output
3989 data = json.loads(result.output)
3990 assert "from_ref" in data
3991 assert "to_ref" in data
3992 assert "commits_analysed" in data
3993 assert "truncated" in data
3994 assert "filters" in data
3995 assert "stable" in data
3996 assert isinstance(data["stable"], list)
3997
3998 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
3999 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4000 data = json.loads(result.output)
4001 assert len(data["stable"]) > 0
4002 entry = data["stable"][0]
4003 assert "address" in entry
4004 assert "unchanged_for" in entry
4005 assert "since_start_of_range" in entry
4006 assert isinstance(entry["unchanged_for"], int)
4007 assert isinstance(entry["since_start_of_range"], bool)
4008
4009 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
4010 result = runner.invoke(
4011 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
4012 )
4013 data = json.loads(result.output)
4014 assert data["filters"]["top"] == 3
4015 assert data["filters"]["kind"] == "function"
4016 assert data["filters"]["include_imports"] is False
4017 assert data["filters"]["include_docs"] is False
4018
4019 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
4020 result = runner.invoke(cli, ["code", "stable", "--json"])
4021 data = json.loads(result.output)
4022 assert data["truncated"] is False
4023
4024 # ── --language filter ─────────────────────────────────────────────────────
4025
4026 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
4027 """--language python and --language Python must behave identically."""
4028 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4029 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
4030 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
4031 d_lower = json.loads(r_lower.output)
4032 d_upper = json.loads(r_upper.output)
4033 addrs_lower = {e["address"] for e in d_lower["stable"]}
4034 addrs_upper = {e["address"] for e in d_upper["stable"]}
4035 assert addrs_lower == addrs_upper
4036
4037 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
4038 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4039 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4040 d_py = json.loads(r_py.output)
4041 d_all = json.loads(r_all.output)
4042 # Python-filtered results must be a subset of or equal to unfiltered results.
4043 py_addrs = {e["address"] for e in d_py["stable"]}
4044 all_addrs = {e["address"] for e in d_all["stable"]}
4045 assert py_addrs <= all_addrs
4046
4047 # ── --since REF ───────────────────────────────────────────────────────────
4048
4049 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
4050 """--since HEAD restricts the window to 0 commits (stop immediately)."""
4051 # Get the HEAD commit id to use as --since boundary
4052 import json as _json
4053 root = code_repo
4054 repo_id = _json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
4055 from muse.core.store import read_current_branch, resolve_commit_ref
4056 branch = read_current_branch(root)
4057 head = resolve_commit_ref(root, repo_id, branch, None)
4058 assert head is not None
4059
4060 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4061 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4062 assert r_all.exit_code == 0 and r_since.exit_code == 0
4063 d_all = json.loads(r_all.output)
4064 d_since = json.loads(r_since.output)
4065 # Window stops at HEAD itself β†’ at most 1 commit analysed.
4066 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4067
4068 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4069 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4070 assert result.exit_code != 0
4071
4072 # ── --max-commits ─────────────────────────────────────────────────────────
4073
4074 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4075 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4076 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4077 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4078 d_cap = json.loads(r_cap.output)
4079 assert d_cap["commits_analysed"] <= 1
4080
4081 def test_stable_max_commits_one_shows_truncated_warning(
4082 self, code_repo: pathlib.Path
4083 ) -> None:
4084 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4085 assert result.exit_code == 0, result.output
4086 # With 2 commits and cap=1, truncated warning should appear.
4087 assert "capped" in result.output or "⚠️" in result.output
4088
4089 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4090 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4091 assert result.exit_code != 0
4092
4093 # ── --top validation ──────────────────────────────────────────────────────
4094
4095 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4096 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4097 assert result.exit_code != 0
4098
4099 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4100 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4101 data = json.loads(result.output)
4102 assert len(data["stable"]) <= 2
4103
4104 # ── BFS follows merge parents ─────────────────────────────────────────────
4105
4106 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4107 """Symbols touched only on a merged feature branch must be detected as unstable."""
4108 import datetime
4109
4110 # Create a symbol in commit 1 (main).
4111 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4112 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4113 assert r.exit_code == 0, r.output
4114
4115 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
4116 repo_id = repo_json["repo_id"]
4117 from muse.core.store import read_current_branch, resolve_commit_ref
4118 branch = read_current_branch(repo)
4119 head_commit = resolve_commit_ref(repo, repo_id, branch, None)
4120 assert head_commit is not None
4121 head_id = head_commit.commit_id
4122
4123 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4124 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4125
4126 # Feature-branch commit that touched "bedrock" via a structured_delta.
4127 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4128 from muse.core.snapshot import compute_commit_id
4129 bedrock_delta = StructuredDelta(
4130 domain="code",
4131 ops=[PatchOp(
4132 op="patch", address="core.py",
4133 child_ops=[ReplaceOp(
4134 op="replace", address="core.py::bedrock",
4135 old_content_id="a" * 64, new_content_id="b" * 64,
4136 old_summary="function bedrock",
4137 new_summary="function bedrock (modified)", position=None,
4138 )],
4139 child_domain="code", child_summary="bedrock modified",
4140 )],
4141 summary="bedrock modified",
4142 )
4143 feature_id = compute_commit_id(
4144 [head_id], head_commit.snapshot_id,
4145 "Feature: touch bedrock", feature_at.isoformat(),
4146 )
4147 merge_id = compute_commit_id(
4148 [head_id, feature_id], head_commit.snapshot_id,
4149 "Merge feat/touch-bedrock", merge_at.isoformat(),
4150 )
4151 feature_body: CommitDict = {
4152 "commit_id": feature_id,
4153 "repo_id": repo_id,
4154 "branch": "feat/touch-bedrock",
4155 "snapshot_id": head_commit.snapshot_id,
4156 "message": "Feature: touch bedrock",
4157 "committed_at": feature_at.isoformat(),
4158 "parent_commit_id": head_id,
4159 "parent2_commit_id": None,
4160 "author": "test",
4161 "metadata": {},
4162 "structured_delta": bedrock_delta,
4163 }
4164 # Merge commit whose parent2 is the feature commit.
4165 merge_body: CommitDict = {
4166 "commit_id": merge_id,
4167 "repo_id": repo_id,
4168 "branch": branch,
4169 "snapshot_id": head_commit.snapshot_id,
4170 "message": "Merge feat/touch-bedrock",
4171 "committed_at": merge_at.isoformat(),
4172 "parent_commit_id": head_id,
4173 "parent2_commit_id": feature_id,
4174 "author": "test",
4175 "metadata": {},
4176 "structured_delta": None,
4177 }
4178 from muse.core.store import write_commit, CommitRecord
4179 write_commit(repo, CommitRecord.from_dict(feature_body))
4180 write_commit(repo, CommitRecord.from_dict(merge_body))
4181 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
4182
4183 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4184 assert result.exit_code == 0, result.output
4185 data = json.loads(result.output)
4186 # bedrock was touched in the feature-branch commit; BFS must find it.
4187 # It should have unchanged_for < total_commits (not maximally stable).
4188 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4189 if bedrock_entries:
4190 assert not bedrock_entries[0]["since_start_of_range"]
4191
4192
4193 # ---------------------------------------------------------------------------
4194 # muse code compare
4195 # ---------------------------------------------------------------------------
4196
4197
4198 @pytest.fixture
4199 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4200 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4201
4202 Commit A β€” billing.py with Invoice.compute_total + process_order.
4203 Commit B β€” compute_total renamed to compute_invoice_total; generate_pdf
4204 and send_email added. Multi-line message to test truncation.
4205 """
4206 (repo / "billing.py").write_text(textwrap.dedent("""\
4207 class Invoice:
4208 def compute_total(self, items):
4209 return sum(items)
4210
4211 def apply_discount(self, total, pct):
4212 return total * (1 - pct)
4213
4214 def process_order(invoice, items):
4215 return invoice.compute_total(items)
4216 """))
4217 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4218 assert r.exit_code == 0, r.output
4219 from muse.core.store import read_current_branch
4220 branch = read_current_branch(repo)
4221 commit_a = get_head_commit_id(repo, branch)
4222
4223 (repo / "billing.py").write_text(textwrap.dedent("""\
4224 class Invoice:
4225 def compute_invoice_total(self, items):
4226 return sum(items)
4227
4228 def apply_discount(self, total, pct):
4229 return total * (1 - pct)
4230
4231 def generate_pdf(self):
4232 return b"pdf"
4233
4234 def process_order(invoice, items):
4235 return invoice.compute_invoice_total(items)
4236
4237 def send_email(address):
4238 pass
4239 """))
4240 # Multi-line message to test first-line truncation.
4241 r = runner.invoke(cli, [
4242 "commit", "-m",
4243 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4244 ])
4245 assert r.exit_code == 0, r.output
4246 commit_b = get_head_commit_id(repo, branch)
4247
4248 assert commit_a is not None
4249 assert commit_b is not None
4250 return repo, commit_a, commit_b
4251
4252
4253 class TestCompare:
4254 """Tests for muse code compare."""
4255
4256 # ── basic correctness ────────────────────────────────────────────────────
4257
4258 def test_compare_exits_zero(
4259 self, compare_repo: tuple[pathlib.Path, str, str]
4260 ) -> None:
4261 _, ref_a, ref_b = compare_repo
4262 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4263 assert result.exit_code == 0, result.output
4264
4265 def test_compare_shows_header(
4266 self, compare_repo: tuple[pathlib.Path, str, str]
4267 ) -> None:
4268 _, ref_a, ref_b = compare_repo
4269 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4270 assert result.exit_code == 0, result.output
4271 assert "Semantic comparison" in result.output
4272 assert "From:" in result.output
4273 assert "To:" in result.output
4274
4275 def test_compare_commit_message_first_line_only(
4276 self, compare_repo: tuple[pathlib.Path, str, str]
4277 ) -> None:
4278 """Multi-line commit messages must be truncated to their first line."""
4279 _, ref_a, ref_b = compare_repo
4280 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4281 assert result.exit_code == 0, result.output
4282 # The body of the second commit must not appear in the header.
4283 assert "This is the extended body" not in result.output
4284
4285 def test_compare_same_ref_no_changes(
4286 self, compare_repo: tuple[pathlib.Path, str, str]
4287 ) -> None:
4288 _, ref_a, _ = compare_repo
4289 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4290 assert result.exit_code == 0, result.output
4291 assert "no semantic changes" in result.output
4292
4293 def test_compare_detects_added_symbols(
4294 self, compare_repo: tuple[pathlib.Path, str, str]
4295 ) -> None:
4296 _, ref_a, ref_b = compare_repo
4297 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4298 assert result.exit_code == 0, result.output
4299 # generate_pdf and send_email were added in commit B.
4300 assert "generate_pdf" in result.output or "send_email" in result.output
4301
4302 def test_compare_invalid_ref_exits_nonzero(
4303 self, compare_repo: tuple[pathlib.Path, str, str]
4304 ) -> None:
4305 _, ref_a, _ = compare_repo
4306 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4307 assert result.exit_code != 0
4308
4309 def test_compare_requires_repo(
4310 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4311 ) -> None:
4312 monkeypatch.chdir(tmp_path)
4313 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4314 assert result.exit_code != 0
4315
4316 # ── JSON schema ──────────────────────────────────────────────────────────
4317
4318 def test_compare_json_schema(
4319 self, compare_repo: tuple[pathlib.Path, str, str]
4320 ) -> None:
4321 _, ref_a, ref_b = compare_repo
4322 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4323 assert result.exit_code == 0, result.output
4324 data = json.loads(result.output)
4325 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4326
4327 def test_compare_json_from_to_schema(
4328 self, compare_repo: tuple[pathlib.Path, str, str]
4329 ) -> None:
4330 _, ref_a, ref_b = compare_repo
4331 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4332 assert result.exit_code == 0, result.output
4333 data = json.loads(result.output)
4334 assert "commit_id" in data["from"]
4335 assert "message" in data["from"]
4336 assert "commit_id" in data["to"]
4337 assert "message" in data["to"]
4338
4339 def test_compare_json_message_first_line_only(
4340 self, compare_repo: tuple[pathlib.Path, str, str]
4341 ) -> None:
4342 _, ref_a, ref_b = compare_repo
4343 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4344 assert result.exit_code == 0, result.output
4345 data = json.loads(result.output)
4346 assert "\n" not in data["to"]["message"]
4347 assert "This is the extended body" not in data["to"]["message"]
4348
4349 def test_compare_json_stat_schema(
4350 self, compare_repo: tuple[pathlib.Path, str, str]
4351 ) -> None:
4352 _, ref_a, ref_b = compare_repo
4353 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4354 assert result.exit_code == 0, result.output
4355 stat = json.loads(result.output)["stat"]
4356 assert set(stat.keys()) >= {
4357 "files_changed", "symbols_added", "symbols_removed",
4358 "symbols_modified", "semver_impact",
4359 }
4360 assert isinstance(stat["files_changed"], int)
4361 assert isinstance(stat["symbols_added"], int)
4362 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4363
4364 def test_compare_json_filters_schema(
4365 self, compare_repo: tuple[pathlib.Path, str, str]
4366 ) -> None:
4367 _, ref_a, ref_b = compare_repo
4368 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4369 assert result.exit_code == 0, result.output
4370 filters = json.loads(result.output)["filters"]
4371 assert set(filters.keys()) >= {"kind", "file", "language"}
4372 # No filters applied β€” all None.
4373 assert filters["kind"] is None
4374 assert filters["file"
File truncated at 200 KB β€” view full file ↗