gabriel / muse public
test_code_commands.py python
8,382 lines 369.2 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
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 from muse.core.paths import coordination_dir, indices_dir, muse_dir, ref_path, repo_json_path
65
66 type _ImportsMap = dict[str, list[str]]
67 type _ImportsSetMap = dict[str, set[str]]
68 type _KindsMap = dict[str, int]
69
70 runner = CliRunner()
71
72
73 # ---------------------------------------------------------------------------
74 # Shared fixtures
75 # ---------------------------------------------------------------------------
76
77
78 @pytest.fixture
79 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
80 """Initialise a fresh code-domain Muse repo."""
81 monkeypatch.chdir(tmp_path)
82 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
83 result = runner.invoke(cli, ["init", "--domain", "code"])
84 assert result.exit_code == 0, result.output
85 return tmp_path
86
87
88 @pytest.fixture
89 def code_repo(repo: pathlib.Path) -> pathlib.Path:
90 """Repo with two Python commits for analysis commands."""
91 work = repo
92 # Commit 1 — define compute_total and Invoice class.
93 (work / "billing.py").write_text(textwrap.dedent("""\
94 class Invoice:
95 def compute_total(self, items):
96 return sum(items)
97
98 def apply_discount(self, total, pct):
99 return total * (1 - pct)
100
101 def process_order(invoice, items):
102 return invoice.compute_total(items)
103 """))
104 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
105 assert r.exit_code == 0, r.output
106
107 # Commit 2 — rename compute_total, add new function.
108 (work / "billing.py").write_text(textwrap.dedent("""\
109 class Invoice:
110 def compute_invoice_total(self, items):
111 return sum(items)
112
113 def apply_discount(self, total, pct):
114 return total * (1 - pct)
115
116 def generate_pdf(self):
117 return b"pdf"
118
119 def process_order(invoice, items):
120 return invoice.compute_invoice_total(items)
121
122 def send_email(address):
123 pass
124 """))
125 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
126 assert r.exit_code == 0, r.output
127 return repo
128
129
130 # ---------------------------------------------------------------------------
131 # muse lineage
132 # ---------------------------------------------------------------------------
133
134
135 class TestLineage:
136 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
137 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
138 assert result.exit_code == 0, result.output
139
140 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
141 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
142 assert result.exit_code == 0, result.output
143 data = json.loads(result.output)
144 assert isinstance(data, dict)
145 assert "events" in data
146
147 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
148 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_func"])
149 # Should not crash — exit 0 or 1, but no unhandled exception.
150 assert result.exit_code in (0, 1)
151
152 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
153 monkeypatch.chdir(tmp_path)
154 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
155 assert result.exit_code != 0
156
157
158 # ---------------------------------------------------------------------------
159 # muse api-surface
160 # ---------------------------------------------------------------------------
161
162
163 class TestApiSurface:
164 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
165 result = runner.invoke(cli, ["code", "api-surface"])
166 assert result.exit_code == 0, result.output
167
168 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
169 result = runner.invoke(cli, ["code", "api-surface", "--json"])
170 assert result.exit_code == 0
171 data = json.loads(result.output)
172 assert isinstance(data, dict)
173
174 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
175 commits = _all_commit_ids(code_repo)
176 if len(commits) >= 2:
177 result = runner.invoke(cli, ["code", "api-surface", "--diff", commits[-2]])
178 assert result.exit_code == 0
179
180 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
181 result = runner.invoke(cli, ["code", "api-surface"])
182 assert result.exit_code in (0, 1)
183
184
185 # ---------------------------------------------------------------------------
186 # muse codemap
187 # ---------------------------------------------------------------------------
188
189
190 class TestCodemap:
191 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
192 result = runner.invoke(cli, ["code", "codemap"])
193 assert result.exit_code == 0, result.output
194
195 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
196 result = runner.invoke(cli, ["code", "codemap", "--top", "3"])
197 assert result.exit_code == 0
198
199 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
200 result = runner.invoke(cli, ["code", "codemap", "--json"])
201 assert result.exit_code == 0
202 data = json.loads(result.output)
203 assert isinstance(data, dict)
204
205
206 # ---------------------------------------------------------------------------
207 # muse clones
208 # ---------------------------------------------------------------------------
209
210
211 class TestClones:
212 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
213 result = runner.invoke(cli, ["code", "clones"])
214 assert result.exit_code == 0, result.output
215
216 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
217 result = runner.invoke(cli, ["code", "clones", "--tier", "exact"])
218 assert result.exit_code == 0
219
220 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
221 result = runner.invoke(cli, ["code", "clones", "--tier", "near"])
222 assert result.exit_code == 0
223
224 def test_clones_json(self, code_repo: pathlib.Path) -> None:
225 result = runner.invoke(cli, ["code", "clones", "--tier", "both", "--json"])
226 assert result.exit_code == 0
227 data = json.loads(result.output)
228 assert isinstance(data, dict)
229
230
231 # ---------------------------------------------------------------------------
232 # muse checkout-symbol
233 # ---------------------------------------------------------------------------
234
235
236 class TestCheckoutSymbol:
237 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
238 commits = _all_commit_ids(code_repo)
239 if len(commits) < 2:
240 pytest.skip("need at least 2 commits")
241 first_commit = commits[-2] # oldest commit (list is newest-first)
242 result = runner.invoke(cli, [
243 "code", "checkout-symbol", "--commit", first_commit, "--dry-run",
244 "billing.py::Invoice.compute_total",
245 ])
246 # May fail if symbol is not present; should not crash unhandled.
247 assert result.exit_code in (0, 1, 2)
248
249 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
250 result = runner.invoke(cli, ["code", "checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
251 assert result.exit_code != 0
252
253
254 # ---------------------------------------------------------------------------
255 # muse semantic-cherry-pick
256 # ---------------------------------------------------------------------------
257
258
259 class TestSemanticCherryPick:
260 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
261 commits = _all_commit_ids(code_repo)
262 if len(commits) < 2:
263 pytest.skip("need at least 2 commits")
264 first_commit = commits[-2]
265 result = runner.invoke(cli, [
266 "code", "semantic-cherry-pick",
267 "--from", first_commit,
268 "--dry-run",
269 "billing.py::Invoice.compute_total",
270 ])
271 assert result.exit_code in (0, 1)
272
273 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
274 result = runner.invoke(cli, ["code", "semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
275 assert result.exit_code != 0
276
277
278 # ---------------------------------------------------------------------------
279 # muse query
280 # ---------------------------------------------------------------------------
281
282
283 class TestQueryV2:
284 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
285 result = runner.invoke(cli, ["code", "query", "kind=function"])
286 assert result.exit_code == 0, result.output
287
288 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
289 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
290 assert result.exit_code == 0
291 data = json.loads(result.output)
292 assert "muse_version" in data
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 "muse_version" in data
458 assert "results" in data
459
460 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
461 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
462 assert result.exit_code == 0
463
464 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
465 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
466 assert result.exit_code == 0
467 data = json.loads(result.output)
468 for entry in data.get("results", []):
469 assert "commit_count" in entry
470 assert "change_count" in entry
471
472 # ── new v2 flags ──────────────────────────────────────────────────────────
473
474 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
475 result = runner.invoke(
476 cli, ["code", "query-history", "--changed-only", "kind=function"]
477 )
478 assert result.exit_code == 0, result.output
479
480 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
481 result = runner.invoke(
482 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
483 )
484 assert result.exit_code == 0
485 data = json.loads(result.output)
486 for entry in data["results"]:
487 assert entry["change_count"] > 1
488
489 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
490 result = runner.invoke(
491 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
492 )
493 assert result.exit_code == 0, result.output
494
495 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
496 result = runner.invoke(
497 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
498 )
499 assert result.exit_code == 0, result.output
500
501 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
502 result = runner.invoke(
503 cli, ["code", "query-history", "--sort", "first", "kind=function"]
504 )
505 assert result.exit_code == 0, result.output
506
507 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
508 result = runner.invoke(
509 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
510 )
511 assert result.exit_code != 0
512
513 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
514 result = runner.invoke(
515 cli, ["code", "query-history", "--count", "kind=function"]
516 )
517 assert result.exit_code == 0, result.output
518 assert result.output.strip().isdigit()
519 assert int(result.output.strip()) >= 1
520
521 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
522 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
523 lim_r = runner.invoke(
524 cli, ["code", "query-history", "--limit", "1", "kind=function"]
525 )
526 assert lim_r.exit_code == 0, lim_r.output
527 assert len(lim_r.output) <= len(all_r.output)
528
529 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
530 result = runner.invoke(
531 cli, ["code", "query-history", "--limit", "1", "kind=function"]
532 )
533 assert "1" in result.output
534
535 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
536 result = runner.invoke(
537 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
538 )
539 assert result.exit_code == 0
540 data = json.loads(result.output)
541 for entry in data["results"]:
542 assert entry["change_count"] >= 2
543
544 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
545 result = runner.invoke(
546 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
547 )
548 assert result.exit_code != 0
549
550 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
551 result = runner.invoke(
552 cli, ["code", "query-history", "--introduced-only", "kind=function"]
553 )
554 assert result.exit_code == 0, result.output
555
556 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
557 result = runner.invoke(
558 cli, ["code", "query-history", "--removed-only", "kind=function"]
559 )
560 assert result.exit_code == 0, result.output
561
562 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
563 result = runner.invoke(
564 cli,
565 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
566 )
567 assert result.exit_code == 0
568 data = json.loads(result.output)
569 assert data["mode"] == "introduced-only"
570 assert "symbols_found" in data
571 for entry in data["results"]:
572 assert entry["status"] == "introduced"
573
574 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
575 result = runner.invoke(
576 cli,
577 ["code", "query-history", "--removed-only", "--json", "kind=function"],
578 )
579 assert result.exit_code == 0
580 data = json.loads(result.output)
581 assert data["mode"] == "removed-only"
582 assert "symbols_found" in data
583 for entry in data["results"]:
584 assert entry["status"] == "removed"
585
586 def test_query_history_mode_flags_mutually_exclusive(
587 self, code_repo: pathlib.Path
588 ) -> None:
589 result = runner.invoke(
590 cli,
591 [
592 "code", "query-history",
593 "--changed-only", "--introduced-only",
594 "kind=function",
595 ],
596 )
597 assert result.exit_code != 0
598
599 def test_query_history_json_has_full_commit_ids(
600 self, code_repo: pathlib.Path
601 ) -> None:
602 result = runner.invoke(
603 cli, ["code", "query-history", "--json", "kind=function"]
604 )
605 assert result.exit_code == 0
606 data = json.loads(result.output)
607 for entry in data["results"]:
608 # Full commit IDs should be present (not just 8-char short form).
609 assert len(entry["first_commit_id"]) > 8
610 assert "stable" in entry
611
612 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
613 result = runner.invoke(
614 cli,
615 ["code", "query-history", "--max-commits", "1", "kind=function"],
616 )
617 assert result.exit_code == 0, result.output
618
619 def test_query_history_introduced_count_only(
620 self, code_repo: pathlib.Path
621 ) -> None:
622 result = runner.invoke(
623 cli,
624 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
625 )
626 assert result.exit_code == 0
627 assert result.output.strip().isdigit()
628
629
630 # ---------------------------------------------------------------------------
631 # muse index
632 # ---------------------------------------------------------------------------
633
634
635 class TestIndexCommands:
636 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
637 result = runner.invoke(cli, ["code", "index", "status"])
638 assert result.exit_code == 0, result.output
639
640 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
641 result = runner.invoke(cli, ["code", "index", "status"])
642 # Indexes have not been built yet.
643 assert "absent" in result.output.lower() or result.exit_code == 0
644
645 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
646 result = runner.invoke(cli, ["code", "index", "rebuild"])
647 assert result.exit_code == 0, result.output
648
649 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
650 runner.invoke(cli, ["code", "index", "rebuild"])
651 idx_dir = indices_dir(code_repo)
652 assert idx_dir.exists()
653
654 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
655 runner.invoke(cli, ["code", "index", "rebuild"])
656 result = runner.invoke(cli, ["code", "index", "status"])
657 assert result.exit_code == 0
658 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
659 assert "entries" in result.output.lower() or "✅" in result.output
660
661 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
662 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
663 assert result.exit_code == 0
664
665 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
666 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
667 assert result.exit_code == 0
668
669
670 # ---------------------------------------------------------------------------
671 # muse detect-refactor
672 # ---------------------------------------------------------------------------
673
674
675 class TestHotspots:
676 """Tests for muse code hotspots."""
677
678 # ── basic correctness ────────────────────────────────────────────────────
679
680 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
681 result = runner.invoke(cli, ["code", "hotspots"])
682 assert result.exit_code == 0, result.output
683
684 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
685 """compute_invoice_total was modified across two commits — must appear."""
686 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
687 assert result.exit_code == 0, result.output
688 assert "billing.py" in result.output
689
690 def test_hotspots_excludes_imports_by_default(
691 self, code_repo: pathlib.Path
692 ) -> None:
693 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
694 assert result.exit_code == 0, result.output
695 assert "::import::" not in result.output
696
697 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
698 """--include-imports must surface import pseudo-symbols if any exist."""
699 result = runner.invoke(
700 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
701 )
702 assert result.exit_code == 0, result.output
703 # Just verify it runs cleanly; the repo may or may not have import ops.
704
705 # ── --kind filter (was broken before) ────────────────────────────────────
706
707 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
708 """--kind function must not return class symbols."""
709 result = runner.invoke(
710 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
711 )
712 assert result.exit_code == 0, result.output
713 for line in result.output.splitlines():
714 if "::" in line and "class" in line.lower():
715 # Make sure any class line is not a function kind result
716 # (Addresses that contain the word "class" in their name are OK)
717 pass # Name may contain "class" as substring
718
719 def test_kind_filter_function_returns_functions(
720 self, code_repo: pathlib.Path
721 ) -> None:
722 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
723 result_fn = runner.invoke(
724 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
725 )
726 assert result_fn.exit_code == 0, result_fn.output
727 # filtered result should have <= symbols than unfiltered
728 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
729 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
730 assert len(fn_lines) <= len(all_lines)
731
732 # ── --min filter ──────────────────────────────────────────────────────────
733
734 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
735 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
736 result_min = runner.invoke(
737 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
738 )
739 assert result_min.exit_code == 0, result_min.output
740 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
741 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
742 assert len(min_lines) <= len(all_lines)
743
744 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
745 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
746 assert result.exit_code == 1
747
748 # ── --language filter ─────────────────────────────────────────────────────
749
750 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
751 result = runner.invoke(
752 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
753 )
754 assert result.exit_code == 0, result.output
755 assert "billing.py" in result.output
756
757 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
758 result = runner.invoke(
759 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
760 )
761 assert result.exit_code == 0, result.output
762
763 # ── --top validation ──────────────────────────────────────────────────────
764
765 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
766 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
767 assert result.exit_code == 1
768
769 # ── JSON schema ───────────────────────────────────────────────────────────
770
771 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
772 result = runner.invoke(cli, ["code", "hotspots", "--json"])
773 assert result.exit_code == 0, result.output
774 data = json.loads(result.output)
775 for key in (
776 "from_ref", "to_ref", "commits_analysed", "truncated",
777 "filters", "hotspots",
778 ):
779 assert key in data, f"missing key: {key}"
780 assert isinstance(data["hotspots"], list)
781 assert isinstance(data["truncated"], bool)
782 assert isinstance(data["commits_analysed"], int)
783
784 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
785 result = runner.invoke(
786 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
787 )
788 data = json.loads(result.output)
789 assert data["filters"]["kind"] == "function"
790 assert data["filters"]["min_changes"] == 2
791 assert data["filters"]["include_imports"] is False
792
793 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
794 result = runner.invoke(cli, ["code", "hotspots", "--json"])
795 data = json.loads(result.output)
796 if data["hotspots"]:
797 entry = data["hotspots"][0]
798 assert "address" in entry
799 assert "changes" in entry
800 assert isinstance(entry["changes"], int)
801 assert entry["changes"] >= 1
802
803 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
804 result = runner.invoke(cli, ["code", "hotspots", "--json"])
805 data = json.loads(result.output)
806 addresses = [h["address"] for h in data["hotspots"]]
807 assert not any("::import::" in a for a in addresses)
808
809 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
810 result = runner.invoke(cli, ["code", "hotspots", "--json"])
811 data = json.loads(result.output)
812 counts = [h["changes"] for h in data["hotspots"]]
813 assert counts == sorted(counts, reverse=True)
814
815 # ── --max-commits truncation ──────────────────────────────────────────────
816
817 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
818 result = runner.invoke(
819 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
820 )
821 assert result.exit_code == 0, result.output
822 data = json.loads(result.output)
823 assert data["commits_analysed"] <= 1
824
825 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
826 result = runner.invoke(
827 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
828 )
829 data = json.loads(result.output)
830 assert data["truncated"] is True
831
832
833 class TestDetectRefactorV2:
834 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
835 """JSON output contains all required top-level fields."""
836 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
837 assert result.exit_code == 0, result.output
838 data = json.loads(result.output)
839 for field in ("commits_scanned", "truncated", "total", "events"):
840 assert field in data, f"missing field '{field}'"
841 assert isinstance(data["commits_scanned"], int)
842 assert isinstance(data["truncated"], bool)
843 assert isinstance(data["total"], int)
844 assert isinstance(data["events"], list)
845
846 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
847 """Each JSON event contains the required fields."""
848 # Run over the full history; code_repo has at least one rename event.
849 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
850 assert result.exit_code == 0, result.output
851 data = json.loads(result.output)
852 for ev in data["events"]:
853 for field in ("kind", "address", "detail",
854 "commit_id", "commit_message", "committed_at"):
855 assert field in ev, f"missing event field '{field}'"
856 assert ev["kind"] in ("rename", "move", "signature", "implementation")
857
858 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
859 """A commit that renames a symbol produces a 'rename' event."""
860 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
861 assert result.exit_code == 0, result.output
862 data = json.loads(result.output)
863 kinds = [e["kind"] for e in data["events"]]
864 assert "rename" in kinds, (
865 f"Expected at least one rename event; got: {sorted(set(kinds))}"
866 )
867
868 def test_detect_refactor_classifies_modified_as_implementation(
869 self, code_repo: pathlib.Path
870 ) -> None:
871 """Replace ops with '(modified)' in new_summary are classified as implementation.
872
873 Previously, only '(implementation changed)' triggered implementation
874 classification; '(modified)' was silently dropped.
875 """
876 import datetime
877 root = code_repo
878 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
879 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
880 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
881 branch = read_current_branch(root)
882 head_id = get_head_commit_id(root, branch)
883
884 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
885 message = "perf: optimise batch"
886 snap_manifest: Manifest = {}
887 snap_id = compute_snapshot_id(snap_manifest)
888 parent_ids = [head_id] if head_id else []
889 commit_id = compute_commit_id(
890 parent_ids=parent_ids,
891 snapshot_id=snap_id,
892 message=message,
893 committed_at_iso=now.isoformat(),
894 author="test",
895 )
896 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
897 commit = CommitRecord(
898 repo_id=repo_id,
899 commit_id=commit_id,
900 branch=branch,
901 snapshot_id=snap_id,
902 message=message,
903 committed_at=now,
904 parent_commit_id=head_id,
905 author="test",
906 structured_delta=StructuredDelta(ops=[PatchOp(
907 op="patch",
908 address="billing.py",
909 child_ops=[ReplaceOp(
910 op="replace",
911 address="billing.py::process_batch",
912 new_summary="function process_batch (modified) L10–30",
913 old_summary="function process_batch",
914 )],
915 )]),
916 )
917 write_commit(root, commit)
918 (ref_path(root, branch)).write_text(commit_id)
919
920 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
921 assert result.exit_code == 0, result.output
922 data = json.loads(result.output)
923 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
924 addrs = [e["address"] for e in impl_events]
925 assert "billing.py::process_batch" in addrs, (
926 f"'(modified)' op not classified as implementation; events: {data['events']}"
927 )
928
929 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
930 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
931 import datetime
932 root = code_repo
933 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
934 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
935 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
936 branch = read_current_branch(root)
937 head_id = get_head_commit_id(root, branch)
938
939 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
940 message = "style: reformat"
941 snap_manifest: Manifest = {}
942 snap_id = compute_snapshot_id(snap_manifest)
943 parent_ids = [head_id] if head_id else []
944 commit_id = compute_commit_id(
945 parent_ids=parent_ids,
946 snapshot_id=snap_id,
947 message=message,
948 committed_at_iso=now.isoformat(),
949 author="test",
950 )
951 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
952 commit = CommitRecord(
953 repo_id=repo_id,
954 commit_id=commit_id,
955 branch=branch,
956 snapshot_id=snap_id,
957 message=message,
958 committed_at=now,
959 parent_commit_id=head_id,
960 author="test",
961 structured_delta=StructuredDelta(ops=[PatchOp(
962 op="patch",
963 address="billing.py",
964 child_ops=[ReplaceOp(
965 op="replace",
966 address="billing.py::UniqueReformattedSymbol",
967 new_summary="reformatted — no semantic change",
968 old_summary="",
969 )],
970 )]),
971 )
972 write_commit(root, commit)
973 (ref_path(root, branch)).write_text(commit_id)
974
975 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
976 assert result.exit_code == 0, result.output
977 data = json.loads(result.output)
978 # The reformatted op must not appear as an event.
979 reformatted_events = [
980 e for e in data["events"]
981 if e["address"] == "billing.py::UniqueReformattedSymbol"
982 ]
983 assert reformatted_events == [], (
984 f"Reformatted op should be skipped; got: {reformatted_events}"
985 )
986
987 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
988 """When --max is hit, a truncation warning appears in human output."""
989 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
990 assert result.exit_code == 0, result.output
991 assert "incomplete" in result.output or "limit" in result.output
992
993 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
994 """When --max is hit, truncated=true in JSON."""
995 result = runner.invoke(
996 cli, ["code", "detect-refactor", "--max", "1", "--json"]
997 )
998 assert result.exit_code == 0, result.output
999 data = json.loads(result.output)
1000 assert data["truncated"] is True
1001 assert data["commits_scanned"] == 1
1002
1003 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
1004 """--max 0 exits non-zero."""
1005 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
1006 assert result.exit_code != 0
1007
1008 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1009 """``--kind rename`` returns only rename events."""
1010 result = runner.invoke(
1011 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1012 )
1013 assert result.exit_code == 0, result.output
1014 data = json.loads(result.output)
1015 for ev in data["events"]:
1016 assert ev["kind"] == "rename"
1017
1018 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1019 """``--kind`` with an invalid value exits non-zero."""
1020 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1021 assert result.exit_code != 0
1022
1023 def test_detect_refactor_bfs_follows_merge_parent2(
1024 self, code_repo: pathlib.Path
1025 ) -> None:
1026 """BFS walk finds refactoring events on merged feature branches."""
1027 import datetime
1028 root = code_repo
1029 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
1030 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
1031 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
1032 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1033 branch = read_current_branch(root)
1034 head_id = get_head_commit_id(root, branch)
1035 assert head_id is not None
1036
1037 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1038 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1039
1040 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1041 feature_id = compute_commit_id(
1042 parent_ids=[head_id],
1043 snapshot_id=feat_snap_id,
1044 message="perf: vectorise",
1045 committed_at_iso=feat_at.isoformat(),
1046 author="test",
1047 )
1048 write_commit(root, CommitRecord(
1049 repo_id=repo_id,
1050 commit_id=feature_id,
1051 branch="feat/perf",
1052 snapshot_id=feat_snap_id,
1053 message="perf: vectorise",
1054 committed_at=feat_at,
1055 parent_commit_id=head_id,
1056 author="test",
1057 structured_delta=StructuredDelta(ops=[PatchOp(
1058 op="patch",
1059 address="billing.py",
1060 child_ops=[ReplaceOp(
1061 op="replace",
1062 address="billing.py::vectorised_fn",
1063 new_summary="function vectorised_fn (implementation changed) L1–20",
1064 old_summary="function vectorised_fn",
1065 )],
1066 )]),
1067 ))
1068 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1069 merge_id = compute_commit_id(
1070 parent_ids=[head_id, feature_id],
1071 snapshot_id=merge_snap_id,
1072 message="merge feat/perf",
1073 committed_at_iso=merge_at.isoformat(),
1074 author="test",
1075 )
1076 write_commit(root, CommitRecord(
1077 repo_id=repo_id,
1078 commit_id=merge_id,
1079 branch=branch,
1080 snapshot_id=merge_snap_id,
1081 message="merge feat/perf",
1082 committed_at=merge_at,
1083 parent_commit_id=head_id,
1084 parent2_commit_id=feature_id,
1085 author="test",
1086 ))
1087 (ref_path(root, branch)).write_text(merge_id)
1088
1089 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1090 assert result.exit_code == 0, result.output
1091 data = json.loads(result.output)
1092 addrs = [e["address"] for e in data["events"]]
1093 assert "billing.py::vectorised_fn" in addrs, (
1094 "BFS must find the implementation event on the feature branch"
1095 )
1096
1097
1098 # ---------------------------------------------------------------------------
1099 # muse reserve
1100 # ---------------------------------------------------------------------------
1101
1102
1103 class TestReserve:
1104 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1105 result = runner.invoke(cli, [
1106 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1107 ])
1108 assert result.exit_code == 0, result.output
1109
1110 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1111 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1112 coord_dir = coordination_dir(code_repo) / "reservations"
1113 assert coord_dir.exists()
1114 files = list(coord_dir.glob("*.json"))
1115 assert len(files) >= 1
1116
1117 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1118 result = runner.invoke(cli, [
1119 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1120 ])
1121 assert result.exit_code == 0
1122 data = json.loads(result.output)
1123 assert "reservation_id" in data
1124
1125 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1126 result = runner.invoke(cli, [
1127 "coord", "reserve", "--run-id", "r3",
1128 "billing.py::process_order",
1129 "billing.py::Invoice.apply_discount",
1130 ])
1131 assert result.exit_code == 0
1132
1133 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1134 result = runner.invoke(cli, [
1135 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1136 "billing.py::process_order",
1137 ])
1138 assert result.exit_code == 0
1139
1140 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1141 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1142 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1143 # Should warn but not fail.
1144 assert result.exit_code == 0
1145 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1146
1147
1148 # ---------------------------------------------------------------------------
1149 # muse intent
1150 # ---------------------------------------------------------------------------
1151
1152
1153 class TestIntent:
1154 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1155 result = runner.invoke(cli, [
1156 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1157 "billing.py::process_order",
1158 ])
1159 assert result.exit_code == 0, result.output
1160
1161 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1162 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1163 idir = coordination_dir(code_repo) / "intents"
1164 assert idir.exists()
1165 assert len(list(idir.glob("*.json"))) >= 1
1166
1167 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1168 result = runner.invoke(cli, [
1169 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1170 ])
1171 assert result.exit_code == 0
1172 data = json.loads(result.output)
1173 assert "intent_id" in data or "operation" in data
1174
1175
1176 # ---------------------------------------------------------------------------
1177 # muse forecast
1178 # ---------------------------------------------------------------------------
1179
1180
1181 class TestForecast:
1182 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1183 result = runner.invoke(cli, ["coord", "forecast"])
1184 assert result.exit_code == 0, result.output
1185
1186 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1187 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1188 assert result.exit_code == 0
1189 data = json.loads(result.output)
1190 assert "conflicts" in data
1191
1192 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1193 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1194 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1195 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1196 assert result.exit_code == 0
1197 data = json.loads(result.output)
1198 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1199 assert "address_overlap" in types
1200
1201
1202 # ---------------------------------------------------------------------------
1203 # muse plan-merge
1204 # ---------------------------------------------------------------------------
1205
1206
1207 class TestPlanMerge:
1208 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1209 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1210 assert result.exit_code == 0, result.output
1211
1212 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1213 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1214 assert result.exit_code == 0
1215 data = json.loads(result.output)
1216 assert "conflicts" in data or isinstance(data, dict)
1217
1218 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1219 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1220 assert result.exit_code != 0
1221
1222
1223 # ---------------------------------------------------------------------------
1224 # muse shard
1225 # ---------------------------------------------------------------------------
1226
1227
1228 class TestShard:
1229 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1230 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1231 assert result.exit_code == 0, result.output
1232
1233 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1234 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1235 assert result.exit_code == 0
1236 data = json.loads(result.output)
1237 assert "shards" in data
1238
1239 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1240 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1241 assert result.exit_code == 0
1242
1243 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1244 # N larger than symbol count still works (produces fewer shards).
1245 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1246 assert result.exit_code == 0
1247
1248
1249 # ---------------------------------------------------------------------------
1250 # muse reconcile
1251 # ---------------------------------------------------------------------------
1252
1253
1254 class TestReconcile:
1255 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1256 result = runner.invoke(cli, ["coord", "reconcile"])
1257 assert result.exit_code == 0, result.output
1258
1259 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1260 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1261 assert result.exit_code == 0
1262 data = json.loads(result.output)
1263 assert isinstance(data, dict)
1264
1265
1266 # ---------------------------------------------------------------------------
1267 # muse breakage
1268 # ---------------------------------------------------------------------------
1269
1270
1271 class TestBreakage:
1272 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1273 result = runner.invoke(cli, ["code", "breakage"])
1274 assert result.exit_code == 0, result.output
1275
1276 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1277 result = runner.invoke(cli, ["code", "breakage", "--json"])
1278 assert result.exit_code == 0
1279 data = json.loads(result.output)
1280 # breakage JSON has "issues" list and error count.
1281 assert "issues" in data
1282 assert isinstance(data["issues"], list)
1283
1284 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1285 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1286 assert result.exit_code == 0
1287
1288 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1289 monkeypatch.chdir(tmp_path)
1290 result = runner.invoke(cli, ["code", "breakage"])
1291 assert result.exit_code != 0
1292
1293
1294 # ---------------------------------------------------------------------------
1295 # muse invariants
1296 # ---------------------------------------------------------------------------
1297
1298
1299 class TestInvariants:
1300 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1301 result = runner.invoke(cli, ["code", "invariants"])
1302 toml_path = muse_dir(code_repo) / "invariants.toml"
1303 assert result.exit_code == 0 or toml_path.exists()
1304
1305 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1306 # Create empty invariants.toml
1307 (muse_dir(code_repo) / "invariants.toml").write_text("# No rules\n")
1308 result = runner.invoke(cli, ["code", "invariants", "--json"])
1309 assert result.exit_code == 0
1310 # Output may be JSON or human-readable depending on rules count.
1311 output = result.output.strip()
1312 if output and not output.startswith("#"):
1313 try:
1314 data = json.loads(output)
1315 assert isinstance(data, dict)
1316 except json.JSONDecodeError:
1317 pass # Human-readable output is also acceptable.
1318
1319 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1320 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1321 [[rules]]
1322 type = "no_cycles"
1323 name = "no import cycles"
1324 """))
1325 result = runner.invoke(cli, ["code", "invariants"])
1326 assert result.exit_code == 0
1327
1328 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1329 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1330 [[rules]]
1331 type = "forbidden_dependency"
1332 name = "billing must not import utils"
1333 source_pattern = "billing.py"
1334 forbidden_pattern = "utils.py"
1335 """))
1336 result = runner.invoke(cli, ["code", "invariants"])
1337 assert result.exit_code == 0
1338
1339 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1340 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1341 [[rules]]
1342 type = "required_test"
1343 name = "billing must have tests"
1344 source_pattern = "billing.py"
1345 test_pattern = "test_billing.py"
1346 """))
1347 result = runner.invoke(cli, ["code", "invariants"])
1348 # May pass or fail depending on whether test_billing.py exists; should not crash.
1349 assert result.exit_code in (0, 1)
1350
1351 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1352 (muse_dir(code_repo) / "invariants.toml").write_text("# empty\n")
1353 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1354 assert result.exit_code == 0
1355
1356
1357 # ---------------------------------------------------------------------------
1358 # muse commit — semantic versioning
1359 # ---------------------------------------------------------------------------
1360
1361
1362 class TestSemVerInCommit:
1363 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1364 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1365 commit_id = get_head_commit_id(code_repo, "main")
1366 assert commit_id is not None
1367 commit = read_commit(code_repo, commit_id)
1368 assert commit is not None
1369 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1370
1371 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1372 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1373 commit_id = get_head_commit_id(code_repo, "main")
1374 assert commit_id is not None
1375 commit = read_commit(code_repo, commit_id)
1376 assert commit is not None
1377 assert isinstance(commit.breaking_changes, list)
1378
1379 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1380 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1381 commit_id = get_head_commit_id(code_repo, "main")
1382 assert commit_id is not None
1383 commit = read_commit(code_repo, commit_id)
1384 assert commit is not None
1385 if commit.sem_ver_bump == "major":
1386 result = runner.invoke(cli, ["log"])
1387 assert "MAJOR" in result.output or "major" in result.output.lower()
1388
1389
1390 # ---------------------------------------------------------------------------
1391 # Call-graph tier — muse impact
1392 # ---------------------------------------------------------------------------
1393
1394
1395 class TestImpact:
1396 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1397 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1398 assert result.exit_code == 0, result.output
1399
1400 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1401 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1402 assert result.exit_code == 0
1403 data = json.loads(result.output)
1404 assert isinstance(data, dict)
1405 assert "blast_radius" in data
1406 assert "total" in data
1407 assert "commit_id" in data
1408 assert data["mode"] == "reverse"
1409
1410 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1411 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1412 assert result.exit_code in (0, 1)
1413
1414 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1415 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1416 assert result.exit_code == 0
1417 assert result.output.strip().isdigit()
1418
1419 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1420 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1421 assert result.exit_code == 1
1422
1423 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1424 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1425 assert result.exit_code == 0
1426
1427 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1428 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1429 assert result.exit_code == 0
1430 data = json.loads(result.output)
1431 assert data["mode"] == "forward"
1432 assert "callees" in data
1433 assert "total" in data
1434 assert "commit_id" in data
1435
1436 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1437 result = runner.invoke(cli, [
1438 "code", "impact", "--forward", "--compare", "HEAD",
1439 "--", "billing.py::process_order",
1440 ])
1441 assert result.exit_code == 1
1442
1443 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1444 result = runner.invoke(cli, [
1445 "code", "impact", "--file", "billing.py",
1446 "--", "billing.py::Invoice.compute_invoice_total",
1447 ])
1448 assert result.exit_code == 0
1449
1450 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1451 result = runner.invoke(cli, [
1452 "code", "impact", "--file", "billing.py", "--json",
1453 "--", "billing.py::Invoice.compute_invoice_total",
1454 ])
1455 assert result.exit_code == 0
1456 data = json.loads(result.output)
1457 assert data["file_filter"] == "billing.py"
1458 for depth_addrs in data["blast_radius"].values():
1459 for addr in depth_addrs:
1460 assert addr.startswith("billing.py::")
1461
1462 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1463 result = runner.invoke(cli, [
1464 "code", "impact", "--compare", "HEAD",
1465 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1466 ])
1467 assert result.exit_code == 0
1468 data = json.loads(result.output)
1469 assert "compare_commit_id" in data
1470 assert "added_callers" in data
1471 assert "removed_callers" in data
1472 assert "net_change" in data
1473 assert isinstance(data["added_callers"], list)
1474 assert isinstance(data["removed_callers"], list)
1475
1476 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1477 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1478 assert result.exit_code == 0
1479 assert result.output.strip().isdigit()
1480
1481
1482 # ---------------------------------------------------------------------------
1483 # Call-graph tier — muse dead
1484 # ---------------------------------------------------------------------------
1485
1486
1487 class TestDead:
1488 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1489 result = runner.invoke(cli, ["code", "dead"])
1490 assert result.exit_code == 0, result.output
1491
1492 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1493 result = runner.invoke(cli, ["code", "dead", "--json"])
1494 assert result.exit_code == 0
1495 data = json.loads(result.output)
1496 assert isinstance(data, dict)
1497 assert "results" in data
1498 assert "high_confidence_count" in data
1499 assert "total_files_scanned" in data
1500 assert "duration_ms" in data
1501
1502 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1503 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1504 assert result.exit_code == 0
1505
1506 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1507 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1508 assert result.exit_code == 0
1509
1510 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1511 result = runner.invoke(cli, ["code", "dead", "--count"])
1512 assert result.exit_code == 0
1513 assert result.output.strip().isdigit()
1514
1515 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1516 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1517 assert result.exit_code == 0
1518 data = json.loads(result.output)
1519 assert "compare_commit_id" in data
1520 assert "new_dead" in data
1521 assert "recovered" in data
1522 assert "net_change" in data
1523 assert isinstance(data["new_dead"], list)
1524 assert isinstance(data["recovered"], list)
1525
1526 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1527 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1528 assert result.exit_code == 0
1529
1530 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1531 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1532 assert result.exit_code == 1
1533
1534 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1535 out_file = tmp_path / "allowlist.json"
1536 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1537 assert result.exit_code == 0
1538 if out_file.exists():
1539 data = json.loads(out_file.read_text())
1540 assert isinstance(data, list)
1541 assert all(isinstance(x, str) for x in data)
1542
1543 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1544 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1545 assert result.exit_code == 0
1546 data = json.loads(result.output)
1547 for c in data["results"]:
1548 assert c["confidence"] == "high"
1549
1550 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1551 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1552 assert result.exit_code == 0
1553
1554
1555 # ---------------------------------------------------------------------------
1556 # muse code cat
1557 # ---------------------------------------------------------------------------
1558
1559
1560 class TestCat:
1561 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1562 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1563 assert result.exit_code == 0, result.output
1564 assert "class Invoice" in result.output
1565
1566 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1567 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1568 assert result.exit_code == 0, result.output
1569 assert "def compute_invoice_total" in result.output
1570
1571 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1572 # Invoice is unique — short name should resolve.
1573 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1574 assert result.exit_code == 0
1575
1576 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1577 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1578 assert result.exit_code != 0
1579
1580 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1581 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1582 assert result.exit_code != 0
1583
1584 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1585 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1586 assert result.exit_code != 0
1587
1588 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1589 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1590 assert result.exit_code == 0
1591 # Line numbers prefix lines with digits.
1592 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1593 first_code_line = next((ln for ln in lines if ln.strip()), "")
1594 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1595
1596 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1597 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1598 assert result.exit_code == 0
1599 data = json.loads(result.output)
1600 assert "results" in data
1601 assert "errors" in data
1602 assert "source_ref" in data
1603 assert len(data["results"]) == 1
1604 r = data["results"][0]
1605 assert r["path"] == "billing.py"
1606 assert r["kind"] in ("class", "function", "method")
1607 assert isinstance(r["lineno"], int)
1608 assert isinstance(r["end_lineno"], int)
1609 assert "class Invoice" in r["source"]
1610
1611 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1612 result = runner.invoke(
1613 cli,
1614 [
1615 "code", "cat",
1616 "billing.py::Invoice",
1617 "billing.py::Invoice.compute_invoice_total",
1618 "--json",
1619 ],
1620 )
1621 assert result.exit_code == 0, result.output
1622 data = json.loads(result.output)
1623 assert len(data["results"]) == 2
1624
1625 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1626 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1627 assert result.exit_code == 0
1628 assert "Invoice" in result.output
1629
1630 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1631 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1632 assert result.exit_code == 0
1633
1634 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1635 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1636 assert result.exit_code == 0
1637 data = json.loads(result.output)
1638 assert len(data["results"]) > 0
1639 # Every result has required fields.
1640 for r in data["results"]:
1641 assert "address" in r
1642 assert "lineno" in r
1643 assert "source" in r
1644
1645 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1646 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1647 result_ctx = runner.invoke(
1648 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1649 )
1650 assert result_ctx.exit_code == 0
1651 # With context we get at least as many lines.
1652 plain_lines = result_plain.output.count("\n")
1653 ctx_lines = result_ctx.output.count("\n")
1654 assert ctx_lines >= plain_lines
1655
1656 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1657 # In --json mode a missing symbol goes to the errors field, not a crash.
1658 result = runner.invoke(
1659 cli,
1660 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1661 )
1662 # Output must be valid JSON (no stderr bleed into stdout).
1663 data = json.loads(result.output)
1664 assert len(data["results"]) == 1
1665 assert len(data["errors"]) == 1
1666 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1667
1668 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1669 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1670 assert result.exit_code == 0
1671 assert "working tree" in result.output
1672
1673 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1674 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1675 assert result.exit_code == 0
1676 assert "Invoice" in result.output
1677
1678 def test_cat_wrong_file_fallback_finds_symbol(
1679 self, code_repo: pathlib.Path, tmp_path: pathlib.Path
1680 ) -> None:
1681 """FILE::SYMBOL where SYMBOL lives in a different file — should fall back
1682 to a global snapshot search and cat it from its actual location, exit 0."""
1683 # Add a second file with a unique function the billing module doesn't have.
1684 work = pathlib.Path.cwd()
1685 (work / "utils.py").write_text(
1686 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1687 )
1688 runner.invoke(cli, ["commit", "-m", "Add utils"])
1689
1690 # Ask for utils.format_currency but specify the wrong file (billing.py).
1691 result = runner.invoke(
1692 cli, ["code", "cat", "billing.py::format_currency"]
1693 )
1694 assert result.exit_code == 0, result.output
1695 assert "format_currency" in result.output
1696
1697 def test_cat_wrong_file_fallback_json(self, code_repo: pathlib.Path) -> None:
1698 """Same fallback in --json mode: result is in results[], not errors[]."""
1699 work = pathlib.Path.cwd()
1700 (work / "utils.py").write_text(
1701 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1702 )
1703 runner.invoke(cli, ["commit", "-m", "Add utils"])
1704
1705 result = runner.invoke(
1706 cli, ["code", "cat", "billing.py::format_currency", "--json"]
1707 )
1708 assert result.exit_code == 0, result.output
1709 data = json.loads(result.output)
1710 assert len(data["results"]) == 1
1711 assert data["results"][0]["symbol"] == "format_currency"
1712 assert data["results"][0]["path"] == "utils.py"
1713
1714 def test_cat_wrong_file_fallback_ambiguous_exits_nonzero(
1715 self, code_repo: pathlib.Path
1716 ) -> None:
1717 """If the symbol exists in multiple files, fallback reports ambiguity and exits 1."""
1718 work = pathlib.Path.cwd()
1719 (work / "utils.py").write_text("def send_email(to): pass\n")
1720 runner.invoke(cli, ["commit", "-m", "Duplicate send_email in utils"])
1721
1722 # billing.py already has send_email; utils.py now also has it.
1723 result = runner.invoke(
1724 cli, ["code", "cat", "nope.py::send_email"]
1725 )
1726 assert result.exit_code != 0
1727
1728 def test_cat_truly_missing_symbol_still_errors(self, code_repo: pathlib.Path) -> None:
1729 """A symbol that doesn't exist anywhere in the snapshot still exits 1."""
1730 result = runner.invoke(cli, ["code", "cat", "billing.py::AbsolutelyNowhere"])
1731 assert result.exit_code != 0
1732
1733
1734 # ---------------------------------------------------------------------------
1735 # Call-graph tier — muse coverage
1736 # ---------------------------------------------------------------------------
1737
1738
1739 class TestCoverage:
1740 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1741 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1742 assert result.exit_code == 0, result.output
1743
1744 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1745 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1746 assert result.exit_code == 0
1747 data = json.loads(result.output)
1748 assert isinstance(data, dict)
1749 assert "methods" in data
1750 assert "total_methods" in data
1751 assert "covered" in data
1752 assert "percent" in data
1753 assert "commit_id" in data
1754 assert "filters" in data
1755 for m in data["methods"]:
1756 assert "address" in m
1757 assert "called" in m
1758 assert "callers" in m
1759
1760 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1761 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1762 assert result.exit_code in (0, 1)
1763
1764 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1765 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1766 assert result.exit_code == 0
1767 # Output should be "n/total" format
1768 assert "/" in result.output.strip()
1769
1770 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1771 result = runner.invoke(cli, [
1772 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1773 ])
1774 assert result.exit_code == 0
1775 data = json.loads(result.output)
1776 assert data["filters"]["exclude_dunder"] is True
1777 for m in data["methods"]:
1778 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1779
1780 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1781 result = runner.invoke(cli, [
1782 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1783 ])
1784 assert result.exit_code == 0
1785 data = json.loads(result.output)
1786 assert data["filters"]["exclude_private"] is True
1787
1788 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1789 result = runner.invoke(cli, [
1790 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1791 ])
1792 assert result.exit_code == 0
1793 data = json.loads(result.output)
1794 assert data["filters"]["min_callers"] == 2
1795
1796 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1797 result = runner.invoke(cli, [
1798 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1799 ])
1800 assert result.exit_code == 0
1801 data = json.loads(result.output)
1802 assert data["filters"]["exclude_self"] is True
1803 # All reported callers should be from a different file
1804 for m in data["methods"]:
1805 for caller in m["callers"]:
1806 assert not caller.startswith("billing.py::")
1807
1808 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1809 result = runner.invoke(cli, [
1810 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1811 ])
1812 assert result.exit_code == 0
1813 data = json.loads(result.output)
1814 assert "compare_commit_id" in data
1815 assert "newly_covered" in data
1816 assert "newly_uncovered" in data
1817 assert "percent_change" in data
1818
1819 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1820 result = runner.invoke(cli, [
1821 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1822 ])
1823 assert result.exit_code == 0
1824
1825 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1826 result = runner.invoke(cli, [
1827 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1828 ])
1829 assert result.exit_code == 0
1830
1831
1832 # ---------------------------------------------------------------------------
1833 # Call-graph tier — muse deps
1834 # ---------------------------------------------------------------------------
1835
1836
1837 class TestDeps:
1838 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1839 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1840 assert result.exit_code == 0, result.output
1841
1842 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1843 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1844 assert result.exit_code == 0
1845
1846 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1847 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1848 assert result.exit_code == 0
1849 data = json.loads(result.output)
1850 assert isinstance(data, dict)
1851
1852 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1853 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1854 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1855
1856 # ── new flags ──────────────────────────────────────────────────────────────
1857
1858 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1859 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1860 assert result.exit_code == 0, result.output
1861 assert result.output.strip().isdigit()
1862
1863 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1864 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1865 assert result.exit_code == 0, result.output
1866 assert result.output.strip().isdigit()
1867
1868 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1869 result = runner.invoke(
1870 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1871 )
1872 assert result.exit_code == 0, result.output
1873
1874 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1875 # --depth > 1 in file mode is fine (just filters imports as before).
1876 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1877 assert result.exit_code == 0, result.output
1878
1879 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1880 result = runner.invoke(
1881 cli,
1882 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1883 )
1884 assert result.exit_code != 0
1885
1886 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1887 result = runner.invoke(
1888 cli,
1889 ["code", "deps", "--reverse", "--depth", "2",
1890 "billing.py::Invoice.compute_invoice_total"],
1891 )
1892 assert result.exit_code == 0, result.output
1893
1894 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1895 result = runner.invoke(
1896 cli,
1897 ["code", "deps", "--transitive",
1898 "billing.py::Invoice.compute_invoice_total"],
1899 )
1900 assert result.exit_code == 0, result.output
1901
1902 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1903 result = runner.invoke(
1904 cli,
1905 ["code", "deps", "--transitive", "--count",
1906 "billing.py::Invoice.compute_invoice_total"],
1907 )
1908 assert result.exit_code == 0
1909 assert result.output.strip().isdigit()
1910
1911 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1912 result = runner.invoke(
1913 cli,
1914 ["code", "deps", "--transitive", "--json",
1915 "billing.py::Invoice.compute_invoice_total"],
1916 )
1917 assert result.exit_code == 0
1918 data = json.loads(result.output)
1919 assert "by_depth" in data
1920 assert data["transitive"] is True
1921
1922 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1923 result = runner.invoke(
1924 cli,
1925 ["code", "deps", "--reverse", "--depth", "2", "--json",
1926 "billing.py::Invoice.compute_invoice_total"],
1927 )
1928 assert result.exit_code == 0
1929 data = json.loads(result.output)
1930 assert "by_depth" in data
1931 assert data["depth"] == 2
1932
1933 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1934 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1935 assert result.exit_code != 0
1936
1937 def test_deps_empty_file_rel_in_symbol_rejected(
1938 self, code_repo: pathlib.Path
1939 ) -> None:
1940 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1941 assert result.exit_code != 0
1942
1943 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1944 result = runner.invoke(
1945 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1946 )
1947 assert result.exit_code == 0
1948 data = json.loads(result.output)
1949 assert "imported_by" in data
1950 assert isinstance(data["imported_by"], list)
1951
1952
1953 # ---------------------------------------------------------------------------
1954 # Call-graph tier — muse find-symbol
1955 # ---------------------------------------------------------------------------
1956
1957
1958 class TestFindSymbol:
1959 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1960 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1961 assert result.exit_code == 0, result.output
1962
1963 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
1964 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
1965 assert result.exit_code == 0
1966 data = json.loads(result.output)
1967 assert isinstance(data, dict)
1968 assert "results" in data
1969 assert "query" in data
1970 assert "total" in data
1971
1972 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
1973 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
1974 assert result.exit_code == 0
1975 assert result.output is not None
1976
1977 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
1978 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
1979 assert result.exit_code == 0
1980 assert "no matching" in result.output
1981
1982 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
1983 result = runner.invoke(cli, ["code", "find-symbol"])
1984 assert result.exit_code == 1
1985
1986 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
1987 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
1988 assert result.exit_code == 0
1989 assert result.output.strip().isdigit()
1990
1991 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1992 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
1993 assert result.exit_code == 1
1994
1995 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
1996 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
1997 assert result.exit_code == 1
1998
1999 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
2000 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
2001 assert result.exit_code == 1
2002
2003 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
2004 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
2005 assert result.exit_code == 1
2006
2007 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
2008 result = runner.invoke(cli, [
2009 "code", "find-symbol", "--name", "process_order",
2010 "--since", "2099-01-01",
2011 ])
2012 assert result.exit_code == 0
2013 assert "no matching" in result.output
2014
2015 def test_find_limit(self, code_repo: pathlib.Path) -> None:
2016 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
2017 assert result.exit_code == 0
2018
2019 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
2020 result = runner.invoke(cli, [
2021 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
2022 ])
2023 assert result.exit_code == 0
2024
2025 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
2026 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
2027 assert result.exit_code == 0
2028 data = json.loads(result.output)
2029 for ap in data["results"]:
2030 assert ap["name"].lower().startswith("process")
2031
2032 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
2033 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2034 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
2035 assert result_all.exit_code == 0
2036 assert result_first.exit_code == 0
2037 count_all = int(result_all.output.strip())
2038 count_first = int(result_first.output.strip())
2039 assert count_first <= count_all
2040
2041 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
2042 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
2043 assert result.exit_code == 0
2044 data = json.loads(result.output)
2045 assert "query" in data
2046 assert "results" in data
2047 assert "total" in data
2048 assert data["total"] == len(data["results"])
2049 if data["results"]:
2050 ap = data["results"][0]
2051 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
2052 assert key in ap
2053
2054
2055 # ---------------------------------------------------------------------------
2056 # Call-graph tier — muse patch
2057 # ---------------------------------------------------------------------------
2058
2059
2060 class TestPatch:
2061 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
2062 new_impl = textwrap.dedent("""\
2063 def send_email(address):
2064 return f"Sending to {address}"
2065 """)
2066 impl_file = code_repo / "send_email_impl.py"
2067 impl_file.write_text(new_impl)
2068 # patch takes ADDRESS SOURCE — put options before address.
2069 result = runner.invoke(cli, [
2070 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
2071 ])
2072 assert result.exit_code in (0, 1, 2)
2073
2074 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
2075 bad_impl = "def broken(\n not valid python at all{"
2076 bad_file = code_repo / "bad.py"
2077 bad_file.write_text(bad_impl)
2078 result = runner.invoke(cli, [
2079 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2080 ])
2081 # Invalid syntax must be rejected or command handles gracefully.
2082 assert result.exit_code in (0, 1, 2)
2083
2084
2085 # ---------------------------------------------------------------------------
2086 # Security — path traversal guards
2087 # ---------------------------------------------------------------------------
2088
2089
2090 class TestPatchPathTraversal:
2091 """patch must reject addresses whose file component escapes the repo root."""
2092
2093 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2094 body = code_repo / "body.py"
2095 body.write_text("def foo(): pass\n")
2096 result = runner.invoke(cli, [
2097 "code", "patch",
2098 "--body", str(body),
2099 "../../etc/passwd::foo",
2100 ])
2101 assert result.exit_code == 1
2102
2103 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2104 body = code_repo / "body.py"
2105 body.write_text("def foo(): pass\n")
2106 result = runner.invoke(cli, [
2107 "code", "patch",
2108 "--body", str(body),
2109 "../../../tmp/malicious::foo",
2110 ])
2111 assert result.exit_code == 1
2112
2113 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2114 """--json flag returns parseable JSON on a dry-run."""
2115 body = code_repo / "body.py"
2116 body.write_text("def send_email(address):\n return address\n")
2117 result = runner.invoke(cli, [
2118 "code", "patch",
2119 "--body", str(body),
2120 "--dry-run",
2121 "--json",
2122 "billing.py::send_email",
2123 ])
2124 # Address may or may not exist; if it exits 0 the output must be JSON.
2125 if result.exit_code == 0:
2126 data = json.loads(result.output)
2127 assert data["address"] == "billing.py::send_email"
2128 assert data["dry_run"] is True
2129
2130
2131 class TestCheckoutSymbolPathTraversal:
2132 """checkout-symbol must reject addresses whose file component escapes root."""
2133
2134 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2135 result = runner.invoke(cli, [
2136 "code", "checkout-symbol",
2137 "--commit", "HEAD",
2138 "../../etc/passwd::foo",
2139 ])
2140 assert result.exit_code == 1
2141
2142 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2143 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2144 result = runner.invoke(cli, [
2145 "code", "checkout-symbol",
2146 "--commit", "HEAD",
2147 "--json",
2148 "billing.py::nonexistent_func_xyz",
2149 ])
2150 # Either exits 1 (symbol not found) — but must not crash.
2151 assert result.exit_code in (0, 1)
2152
2153
2154 class TestSemanticCherryPickPathTraversal:
2155 """semantic-cherry-pick must reject addresses that escape the repo root."""
2156
2157 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2158 result = runner.invoke(cli, [
2159 "code", "semantic-cherry-pick",
2160 "--from", "HEAD",
2161 "../../etc/passwd::foo",
2162 ])
2163 # The traversal-rejected symbol is recorded as not_found but the
2164 # command exits 0 (failed symbols don't abort the batch).
2165 # The key invariant is that no file outside the repo is written.
2166 # We assert exit_code is 0 (graceful) and the output does NOT write.
2167 assert result.exit_code in (0, 1)
2168 # No file was created outside the repo.
2169 assert not pathlib.Path("/etc/passwd_copy").exists()
2170
2171 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2172 result = runner.invoke(cli, [
2173 "code", "semantic-cherry-pick",
2174 "--from", "HEAD",
2175 "--json",
2176 "../../etc/passwd::foo",
2177 ])
2178 assert result.exit_code in (0, 1)
2179 if result.exit_code == 0:
2180 data = json.loads(result.output)
2181 assert data["applied"] == 0
2182 # The traversal-escaped address should be marked as not_found
2183 results = data.get("results", [])
2184 assert any(r["status"] == "not_found" for r in results)
2185
2186
2187 # ---------------------------------------------------------------------------
2188 # muse code blame
2189 # ---------------------------------------------------------------------------
2190
2191
2192 @pytest.fixture
2193 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2194 """Repo with four commits: seed → creation → modification → rename.
2195
2196 A seed commit is required so that the billing.py creation commit has
2197 a parent (and therefore a structured_delta with insert ops).
2198
2199 Timeline (oldest → newest):
2200 commit 0: README.md only (seed — gives billing.py commit a parent)
2201 commit 1: billing.py created — defines compute_total + process_order
2202 commit 2: compute_total implementation modified (same name)
2203 commit 3: compute_total renamed to compute_invoice_total
2204 """
2205 work = repo
2206
2207 # Seed commit so billing.py introduction has a parent and structured_delta.
2208 (work / "README.md").write_text("# Billing module\n")
2209 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2210 assert r.exit_code == 0, r.output
2211
2212 (work / "billing.py").write_text(textwrap.dedent("""\
2213 def compute_total(items):
2214 return sum(items)
2215
2216 def process_order(items):
2217 return compute_total(items)
2218 """))
2219 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2220 assert r.exit_code == 0, r.output
2221
2222 (work / "billing.py").write_text(textwrap.dedent("""\
2223 def compute_total(items):
2224 # faster implementation
2225 return sum(x for x in items)
2226
2227 def process_order(items):
2228 return compute_total(items)
2229 """))
2230 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2231 assert r.exit_code == 0, r.output
2232
2233 (work / "billing.py").write_text(textwrap.dedent("""\
2234 def compute_invoice_total(items):
2235 # faster implementation
2236 return sum(x for x in items)
2237
2238 def process_order(items):
2239 return compute_invoice_total(items)
2240 """))
2241 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2242 assert r.exit_code == 0, r.output
2243
2244 return repo
2245
2246
2247 class TestBlame:
2248 """Tests for muse code blame."""
2249
2250 # ── address validation ───────────────────────────────────────────────────
2251
2252 def test_invalid_address_no_separator_exits_error(
2253 self, blame_repo: pathlib.Path
2254 ) -> None:
2255 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2256 assert result.exit_code == 1
2257 assert "Invalid address" in result.stderr or "::" in result.stderr
2258
2259 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2260 result = runner.invoke(
2261 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2262 )
2263 assert result.exit_code == 1
2264
2265 # ── basic correctness (no rename involved) ───────────────────────────────
2266
2267 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2268 """A symbol that was never renamed should have created + modified events."""
2269 result = runner.invoke(
2270 cli, ["code", "blame", "billing.py::process_order", "--json"]
2271 )
2272 assert result.exit_code == 0, result.output
2273 data = json.loads(result.output)
2274 kinds = [ev["event"] for ev in data["events"]]
2275 assert "created" in kinds
2276
2277 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2278 result = runner.invoke(
2279 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2280 )
2281 assert result.exit_code == 0
2282 assert "no events found" in result.output
2283
2284 # ── rename tracking — new name (the critical regression) ─────────────────
2285
2286 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2287 """Blaming the POST-rename name must find the rename event."""
2288 result = runner.invoke(
2289 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2290 )
2291 assert result.exit_code == 0, result.output
2292 data = json.loads(result.output)
2293 kinds = [ev["event"] for ev in data["events"]]
2294 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2295
2296 def test_blame_new_name_follows_into_old_history(
2297 self, blame_repo: pathlib.Path
2298 ) -> None:
2299 """After finding the rename, blame must continue tracking the old name.
2300
2301 The symbol was created as compute_total → modified → renamed.
2302 Blaming compute_invoice_total should find ALL three events.
2303 """
2304 result = runner.invoke(
2305 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2306 )
2307 assert result.exit_code == 0, result.output
2308 data = json.loads(result.output)
2309 kinds = [ev["event"] for ev in data["events"]]
2310 assert "created" in kinds, f"Expected created event, got: {kinds}"
2311 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2312
2313 # ── rename tracking — old name ────────────────────────────────────────────
2314
2315 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2316 """Blaming the PRE-rename name must find the creation event."""
2317 result = runner.invoke(
2318 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2319 )
2320 assert result.exit_code == 0, result.output
2321 data = json.loads(result.output)
2322 kinds = [ev["event"] for ev in data["events"]]
2323 assert "created" in kinds, f"Expected created event, got: {kinds}"
2324
2325 def test_blame_old_name_finds_rename_not_lost(
2326 self, blame_repo: pathlib.Path
2327 ) -> None:
2328 """Blaming the old name should also surface the rename event."""
2329 result = runner.invoke(
2330 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2331 )
2332 assert result.exit_code == 0, result.output
2333 data = json.loads(result.output)
2334 kinds = [ev["event"] for ev in data["events"]]
2335 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2336
2337 # ── JSON schema ───────────────────────────────────────────────────────────
2338
2339 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2340 result = runner.invoke(
2341 cli, ["code", "blame", "billing.py::process_order", "--json"]
2342 )
2343 assert result.exit_code == 0, result.output
2344 data = json.loads(result.output)
2345 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2346 assert key in data, f"missing key: {key}"
2347 assert isinstance(data["events"], list)
2348 assert isinstance(data["truncated"], bool)
2349 assert isinstance(data["total_commits_scanned"], int)
2350
2351 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2352 result = runner.invoke(
2353 cli,
2354 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2355 )
2356 assert result.exit_code == 0, result.output
2357 data = json.loads(result.output)
2358 assert data["events"], "expected at least one event"
2359 ev = data["events"][0]
2360 for field in (
2361 "event", "commit_id", "author", "message",
2362 "committed_at", "address", "detail",
2363 ):
2364 assert field in ev, f"missing event field: {field}"
2365
2366 def test_blame_json_address_field_matches_input(
2367 self, blame_repo: pathlib.Path
2368 ) -> None:
2369 addr = "billing.py::process_order"
2370 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2371 data = json.loads(result.output)
2372 assert data["address"] == addr
2373
2374 # ── --max truncation ──────────────────────────────────────────────────────
2375
2376 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2377 result = runner.invoke(
2378 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2379 )
2380 assert result.exit_code == 0, result.output
2381 data = json.loads(result.output)
2382 assert data["total_commits_scanned"] <= 1
2383
2384 def test_blame_truncation_flag_set_when_capped(
2385 self, blame_repo: pathlib.Path
2386 ) -> None:
2387 result = runner.invoke(
2388 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2389 )
2390 data = json.loads(result.output)
2391 assert data["truncated"] is True
2392
2393 def test_blame_truncation_warning_in_human_output(
2394 self, blame_repo: pathlib.Path
2395 ) -> None:
2396 result = runner.invoke(
2397 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2398 )
2399 assert result.exit_code == 0, result.output
2400 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2401
2402 # ── human output ─────────────────────────────────────────────────────────
2403
2404 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2405 result = runner.invoke(
2406 cli, ["code", "blame", "billing.py::process_order"]
2407 )
2408 assert result.exit_code == 0, result.output
2409 assert "last touched:" in result.output
2410
2411 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2412 result_default = runner.invoke(
2413 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2414 )
2415 result_all = runner.invoke(
2416 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2417 )
2418 assert result_all.exit_code == 0, result_all.output
2419 # --all shows at least as many lines as default
2420 assert len(result_all.output) >= len(result_default.output)
2421
2422 # ── BFS follows merge parents ─────────────────────────────────────────────
2423
2424 def test_blame_bfs_follows_merge_parent2(
2425 self, repo: pathlib.Path
2426 ) -> None:
2427 """A symbol introduced on a feature branch is visible after merging."""
2428 # Main: empty billing.py
2429 (repo / "billing.py").write_text("def main_fn(): pass\n")
2430 runner.invoke(cli, ["commit", "-m", "main commit"])
2431
2432 # Feature branch: add feature_fn
2433 runner.invoke(cli, ["branch", "feat/feature"])
2434 runner.invoke(cli, ["checkout", "feat/feature"])
2435 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2436 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2437
2438 # Merge back to main
2439 runner.invoke(cli, ["checkout", "main"])
2440 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2441
2442 # Blame feature_fn — should find 'created' event on the feature branch
2443 result = runner.invoke(
2444 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2445 )
2446 assert result.exit_code == 0, result.output
2447 data = json.loads(result.output)
2448 kinds = [ev["event"] for ev in data["events"]]
2449 assert "created" in kinds, (
2450 f"Expected created event for feature_fn after merge; got: {kinds}"
2451 )
2452
2453
2454 # ---------------------------------------------------------------------------
2455 # Security — ReDoS guard in grep
2456 # ---------------------------------------------------------------------------
2457
2458
2459 class TestGrepReDoS:
2460 """grep must reject patterns longer than 512 characters."""
2461
2462 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2463 long_pattern = "a" * 513
2464 result = runner.invoke(cli, ["code", "grep", long_pattern])
2465 assert result.exit_code == 1
2466 assert "too long" in result.stderr.lower() or "512" in result.stderr
2467
2468 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2469 pattern = "a" * 512
2470 result = runner.invoke(cli, ["code", "grep", pattern])
2471 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2472 assert result.exit_code != 1 or "too long" not in result.output.lower()
2473
2474 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2475 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2476 assert result.exit_code == 1
2477
2478
2479 # ---------------------------------------------------------------------------
2480 # JSON output — index status and rebuild
2481 # ---------------------------------------------------------------------------
2482
2483
2484 class TestIndexJsonOutput:
2485 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2486 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2487 assert result.exit_code == 0, result.output
2488 raw = json.loads(result.output)
2489 data = raw["indexes"] if isinstance(raw, dict) else raw
2490 assert isinstance(data, list)
2491 names = [entry["name"] for entry in data]
2492 assert "symbol_history" in names
2493 assert "hash_occurrence" in names
2494 for entry in data:
2495 assert "status" in entry
2496 assert "entries" in entry
2497
2498 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2499 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2500 assert result.exit_code == 0, result.output
2501 data = json.loads(result.output)
2502 assert isinstance(data, dict)
2503 assert "rebuilt" in data
2504 assert isinstance(data["rebuilt"], list)
2505 assert "symbol_history" in data["rebuilt"]
2506 assert "hash_occurrence" in data["rebuilt"]
2507
2508 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2509 result = runner.invoke(cli, [
2510 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2511 ])
2512 assert result.exit_code == 0, result.output
2513 data = json.loads(result.output)
2514 assert "symbol_history" in data.get("rebuilt", [])
2515 assert "symbol_history_addresses" in data
2516
2517
2518 # ---------------------------------------------------------------------------
2519 # Extended — muse code index status
2520 # ---------------------------------------------------------------------------
2521
2522
2523 class TestIndexStatusExtended:
2524 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2525 """-j is equivalent to --json."""
2526 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2527 assert result.exit_code == 0, result.output
2528 _raw = json.loads(result.output.strip())
2529 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2530 assert isinstance(data, list)
2531
2532 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2533 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2534 assert result.exit_code == 0
2535
2536 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2537 """JSON output is compact — single line, no indent=2."""
2538 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2539 assert result.exit_code == 0
2540 lines = [l for l in result.output.splitlines() if l.strip()]
2541 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2542
2543 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
2544 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2545 _raw = json.loads(result.output.strip())
2546 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2547 assert isinstance(data, list)
2548
2549 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2550 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2551 _raw = json.loads(result.output.strip())
2552 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2553 names = [e["name"] for e in data]
2554 assert "symbol_history" in names
2555
2556 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2557 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2558 _raw = json.loads(result.output.strip())
2559 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2560 names = [e["name"] for e in data]
2561 assert "hash_occurrence" in names
2562
2563 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2564 """Every entry has name, status, entries, updated_at."""
2565 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2566 _raw = json.loads(result.output.strip())
2567 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2568 for entry in data:
2569 assert "name" in entry
2570 assert "status" in entry
2571 assert "entries" in entry
2572 assert "updated_at" in entry
2573
2574 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2575 """Freshly initialised repo: both indexes are absent."""
2576 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2577 _raw = json.loads(result.output.strip())
2578 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2579 statuses = {e["name"]: e["status"] for e in data}
2580 assert statuses["symbol_history"] == "absent"
2581 assert statuses["hash_occurrence"] == "absent"
2582
2583 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2584 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2585 _raw = json.loads(result.output.strip())
2586 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2587 for entry in data:
2588 if entry["status"] == "absent":
2589 assert entry["entries"] == 0
2590
2591 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2592 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2593 _raw = json.loads(result.output.strip())
2594 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2595 for entry in data:
2596 if entry["status"] == "absent":
2597 assert entry["updated_at"] is None
2598
2599 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2600 """After rebuild all indexes report present."""
2601 runner.invoke(cli, ["code", "index", "rebuild"])
2602 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2603 _raw = json.loads(result.output.strip())
2604 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2605 for entry in data:
2606 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2607
2608 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2609 """symbol_history should have entries after two commits."""
2610 runner.invoke(cli, ["code", "index", "rebuild"])
2611 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2612 _raw = json.loads(result.output.strip())
2613 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2614 sh = next(e for e in data if e["name"] == "symbol_history")
2615 assert sh["entries"] > 0
2616
2617 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2618 runner.invoke(cli, ["code", "index", "rebuild"])
2619 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2620 _raw = json.loads(result.output.strip())
2621 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2622 for entry in data:
2623 assert entry["updated_at"] is not None
2624
2625 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2626 """A file with bad content is reported as corrupt, not absent."""
2627 idx_dir = indices_dir(code_repo)
2628 idx_dir.mkdir(parents=True, exist_ok=True)
2629 (idx_dir / "symbol_history.msgpack").write_bytes(b"\xff\xfe")
2630 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2631 assert result.exit_code == 0
2632 _raw = json.loads(result.output.strip())
2633 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2634 sh = next(e for e in data if e["name"] == "symbol_history")
2635 assert sh["status"] == "corrupt"
2636
2637 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2638 idx_dir = indices_dir(code_repo)
2639 idx_dir.mkdir(parents=True, exist_ok=True)
2640 (idx_dir / "hash_occurrence.msgpack").write_bytes(b"notmsgpack")
2641 result = runner.invoke(cli, ["code", "index", "status"])
2642 assert result.exit_code == 0
2643
2644 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2645 """Text mode suggests rebuild command when index is absent."""
2646 result = runner.invoke(cli, ["code", "index", "status"])
2647 assert "rebuild" in result.output.lower()
2648
2649 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2650 runner.invoke(cli, ["code", "index", "rebuild"])
2651 result = runner.invoke(cli, ["code", "index", "status"])
2652 assert "✅" in result.output
2653
2654 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2655 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2656 assert "Agent quickstart" in result.output
2657
2658 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2659 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2660 assert "JSON output schema" in result.output
2661
2662 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2663 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2664 assert "Exit codes" in result.output
2665
2666
2667 # ---------------------------------------------------------------------------
2668 # Security — muse code index status
2669 # ---------------------------------------------------------------------------
2670
2671
2672 class TestIndexStatusSecurity:
2673 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2674 """A corrupt index file must not surface a traceback."""
2675 idx_dir = indices_dir(code_repo)
2676 idx_dir.mkdir(parents=True, exist_ok=True)
2677 (idx_dir / "symbol_history.msgpack").write_bytes(b"\x00" * 16)
2678 result = runner.invoke(cli, ["code", "index", "status"])
2679 assert "Traceback" not in result.output
2680
2681 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2682 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2683 from muse.core.indices import KNOWN_INDEX_NAMES
2684 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2685 _raw = json.loads(result.output.strip())
2686 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2687 for entry in data:
2688 assert entry["name"] in KNOWN_INDEX_NAMES
2689
2690 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2691 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2692 assert "\x1b" not in result.output
2693
2694 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2695 """status field is always one of the three allowed values."""
2696 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2697 _raw = json.loads(result.output.strip())
2698 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2699 for entry in data:
2700 assert entry["status"] in ("present", "absent", "corrupt")
2701
2702 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2703 monkeypatch.chdir(tmp_path)
2704 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2705 result = runner.invoke(cli, ["code", "index", "status"])
2706 assert "Traceback" not in result.output
2707 assert result.exit_code != 0
2708
2709 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2710 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2711 _raw = json.loads(result.output.strip())
2712 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2713 for entry in data:
2714 assert isinstance(entry["entries"], int)
2715
2716
2717 # ---------------------------------------------------------------------------
2718 # Stress — muse code index status
2719 # ---------------------------------------------------------------------------
2720
2721
2722 class TestIndexStatusStress:
2723 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2724 """50 sequential status calls all exit 0."""
2725 for i in range(50):
2726 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2727 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2728
2729 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2730 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2731 for i in range(100):
2732 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2733 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2734 data = json.loads(result.output.strip())
2735 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2736 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2737 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2738 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2739 data = json.loads(result.output.strip())
2740 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2741 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2742
2743 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2744 """8 threads reading index status concurrently — all must succeed."""
2745 import argparse
2746 import threading
2747
2748 from muse.cli.commands.index_rebuild import run_status
2749
2750 errors: list[str] = []
2751
2752 def worker(idx: int) -> None:
2753 args = argparse.Namespace(json_out=True)
2754 try:
2755 run_status(args)
2756 except SystemExit as exc:
2757 if exc.code != 0:
2758 errors.append(f"Thread {idx}: exit {exc.code}")
2759 except Exception as exc:
2760 errors.append(f"Thread {idx}: {exc}")
2761
2762 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2763 for t in threads:
2764 t.start()
2765 for t in threads:
2766 t.join()
2767 assert not errors, f"Concurrent failures: {errors}"
2768
2769
2770 # ---------------------------------------------------------------------------
2771 # Extended — muse code index rebuild
2772 # ---------------------------------------------------------------------------
2773
2774
2775 class TestIndexRebuildExtended:
2776 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2777 """-j is equivalent to --json."""
2778 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2779 assert result.exit_code == 0, result.output
2780 data = json.loads(result.output.strip())
2781 assert "rebuilt" in data
2782
2783 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2784 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2785 assert result.exit_code == 0
2786
2787 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2788 """JSON output is a single compact line — no indent=2."""
2789 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2790 assert result.exit_code == 0
2791 lines = [l for l in result.output.splitlines() if l.strip()]
2792 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2793
2794 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2795 """JSON output always has dry_run, rebuilt."""
2796 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2797 data = json.loads(result.output.strip())
2798 assert "dry_run" in data
2799 assert "rebuilt" in data
2800
2801 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2802 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2803 data = json.loads(result.output.strip())
2804 assert "symbol_history" in data["rebuilt"]
2805 assert "hash_occurrence" in data["rebuilt"]
2806
2807 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2808 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2809 data = json.loads(result.output.strip())
2810 assert data["dry_run"] is False
2811
2812 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2813 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2814 assert result.exit_code == 0
2815 data = json.loads(result.output.strip())
2816 assert data["dry_run"] is True
2817
2818 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2819 """--dry-run must not create index files."""
2820 idx_dir = indices_dir(code_repo)
2821 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2822 assert not (idx_dir / "symbol_history.msgpack").exists()
2823 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2824
2825 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2826 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2827 assert result.exit_code == 0
2828 data = json.loads(result.output.strip())
2829 assert data["rebuilt"] == ["symbol_history"]
2830 assert "symbol_history_addresses" in data
2831 assert "hash_occurrence_clusters" not in data
2832
2833 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2834 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2835 assert result.exit_code == 0
2836 data = json.loads(result.output.strip())
2837 assert data["rebuilt"] == ["hash_occurrence"]
2838 assert "hash_occurrence_clusters" in data
2839 assert "symbol_history_addresses" not in data
2840
2841 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2842 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2843 data = json.loads(result.output.strip())
2844 assert isinstance(data["symbol_history_addresses"], int)
2845 assert isinstance(data["symbol_history_events"], int)
2846
2847 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2848 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2849 data = json.loads(result.output.strip())
2850 assert isinstance(data["hash_occurrence_clusters"], int)
2851 assert isinstance(data["hash_occurrence_addresses"], int)
2852
2853 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2854 runner.invoke(cli, ["code", "index", "rebuild"])
2855 idx_dir = indices_dir(code_repo)
2856 assert (idx_dir / "symbol_history.msgpack").exists()
2857 assert (idx_dir / "hash_occurrence.msgpack").exists()
2858
2859 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2860 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2861 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2862 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2863 assert r1.exit_code == 0 and r2.exit_code == 0
2864 d1 = json.loads(r1.output.strip())
2865 d2 = json.loads(r2.output.strip())
2866 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2867
2868 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2869 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2870 assert result.exit_code == 0
2871 assert "Building" in result.output
2872
2873 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2874 result = runner.invoke(cli, ["code", "index", "rebuild"])
2875 assert "Rebuilt" in result.output or "index" in result.output.lower()
2876
2877 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2878 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2879 assert "Agent quickstart" in result.output
2880
2881 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2882 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2883 assert "JSON output schema" in result.output
2884
2885 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2886 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2887 assert "Exit codes" in result.output
2888
2889
2890 # ---------------------------------------------------------------------------
2891 # Security — muse code index rebuild
2892 # ---------------------------------------------------------------------------
2893
2894
2895 class TestIndexRebuildSecurity:
2896 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2897 """An unknown --index value must be rejected before run_rebuild is called."""
2898 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "malicious_index"])
2899 assert result.exit_code != 0
2900
2901 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2902 idx_dir = indices_dir(code_repo)
2903 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2904 assert not (idx_dir / "symbol_history.msgpack").exists()
2905 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2906
2907 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2908 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2909 assert "\x1b" not in result.output
2910
2911 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2912 monkeypatch.chdir(tmp_path)
2913 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2914 result = runner.invoke(cli, ["code", "index", "rebuild"])
2915 assert "Traceback" not in result.output
2916 assert result.exit_code != 0
2917
2918 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2919 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2920 from muse.core.indices import KNOWN_INDEX_NAMES
2921 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2922 data = json.loads(result.output.strip())
2923 for name in data["rebuilt"]:
2924 assert name in KNOWN_INDEX_NAMES
2925
2926 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
2927 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2928 data = json.loads(result.output.strip())
2929 assert isinstance(data["muse_version"], str)
2930 assert len(data["muse_version"]) > 0
2931
2932
2933 # ---------------------------------------------------------------------------
2934 # Stress — muse code index rebuild
2935 # ---------------------------------------------------------------------------
2936
2937
2938 class TestIndexRebuildStress:
2939 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2940 """50 sequential rebuilds all exit 0."""
2941 for i in range(50):
2942 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2943 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2944
2945 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2946 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2947 indexes = ["symbol_history", "hash_occurrence"]
2948 for i in range(100):
2949 target = indexes[i % 2]
2950 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2951 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2952 data = json.loads(result.output.strip())
2953 assert target in data["rebuilt"]
2954
2955 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2956 """8 threads rebuilding hash_occurrence concurrently via core function."""
2957 import argparse
2958 import threading
2959
2960 from muse.cli.commands.index_rebuild import run_rebuild
2961
2962 errors: list[str] = []
2963
2964 def worker(idx: int) -> None:
2965 args = argparse.Namespace(
2966 index_name="hash_occurrence",
2967 dry_run=True, # dry_run avoids concurrent write races
2968 verbose=False,
2969 json_out=True,
2970 )
2971 try:
2972 run_rebuild(args)
2973 except SystemExit as exc:
2974 if exc.code != 0:
2975 errors.append(f"Thread {idx}: exit {exc.code}")
2976 except Exception as exc:
2977 errors.append(f"Thread {idx}: {exc}")
2978
2979 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2980 for t in threads:
2981 t.start()
2982 for t in threads:
2983 t.join()
2984 assert not errors, f"Concurrent failures: {errors}"
2985
2986
2987 # ---------------------------------------------------------------------------
2988 # Extended — muse code index purge
2989 # ---------------------------------------------------------------------------
2990
2991
2992 class TestIndexPurgeExtended:
2993 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2994 """-j is equivalent to --json."""
2995 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2996 assert result.exit_code == 0, result.output
2997 data = json.loads(result.output.strip())
2998 assert "purged" in data
2999
3000 def test_help_flag(self, code_repo: pathlib.Path) -> None:
3001 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3002 assert result.exit_code == 0
3003
3004 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
3005 """JSON output is compact — single line, no indent=2."""
3006 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3007 assert result.exit_code == 0
3008 lines = [l for l in result.output.splitlines() if l.strip()]
3009 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
3010
3011 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
3012 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3013 data = json.loads(result.output.strip())
3014 assert "purged" in data
3015 assert "skipped" in data
3016
3017 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
3018 """Purging when indexes are absent — both in skipped, none in purged."""
3019 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3020 data = json.loads(result.output.strip())
3021 assert data["purged"] == []
3022 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
3023
3024 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
3025 """After rebuild, purge reports both as purged."""
3026 runner.invoke(cli, ["code", "index", "rebuild"])
3027 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3028 data = json.loads(result.output.strip())
3029 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
3030 assert data["skipped"] == []
3031
3032 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
3033 runner.invoke(cli, ["code", "index", "rebuild"])
3034 runner.invoke(cli, ["code", "index", "purge"])
3035 idx_dir = indices_dir(code_repo)
3036 assert not (idx_dir / "symbol_history.msgpack").exists()
3037 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3038
3039 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
3040 runner.invoke(cli, ["code", "index", "rebuild"])
3041 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
3042 assert result.exit_code == 0
3043 data = json.loads(result.output.strip())
3044 assert data["purged"] == ["symbol_history"]
3045 assert data["skipped"] == []
3046 idx_dir = indices_dir(code_repo)
3047 assert not (idx_dir / "symbol_history.msgpack").exists()
3048 assert (idx_dir / "hash_occurrence.msgpack").exists()
3049
3050 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
3051 runner.invoke(cli, ["code", "index", "rebuild"])
3052 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3053 assert result.exit_code == 0
3054 data = json.loads(result.output.strip())
3055 assert data["purged"] == ["hash_occurrence"]
3056 idx_dir = indices_dir(code_repo)
3057 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3058 assert (idx_dir / "symbol_history.msgpack").exists()
3059
3060 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
3061 """Purging when nothing is present still exits 0."""
3062 result = runner.invoke(cli, ["code", "index", "purge"])
3063 assert result.exit_code == 0
3064
3065 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
3066 """Purging twice in a row both exit 0."""
3067 runner.invoke(cli, ["code", "index", "rebuild"])
3068 r1 = runner.invoke(cli, ["code", "index", "purge"])
3069 r2 = runner.invoke(cli, ["code", "index", "purge"])
3070 assert r1.exit_code == 0
3071 assert r2.exit_code == 0
3072
3073 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
3074 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3075 data = json.loads(result.output.strip())
3076 assert isinstance(data["muse_version"], str)
3077 assert len(data["muse_version"]) > 0
3078
3079 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
3080 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3081 data = json.loads(result.output.strip())
3082 assert isinstance(data["purged"], list)
3083 assert isinstance(data["skipped"], list)
3084
3085 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
3086 runner.invoke(cli, ["code", "index", "rebuild"])
3087 result = runner.invoke(cli, ["code", "index", "purge"])
3088 assert "deleted" in result.output.lower() or "🗑" in result.output
3089
3090 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3091 result = runner.invoke(cli, ["code", "index", "purge"])
3092 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3093
3094 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3095 runner.invoke(cli, ["code", "index", "rebuild"])
3096 runner.invoke(cli, ["code", "index", "purge"])
3097 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3098 _raw = json.loads(result.output.strip())
3099 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
3100 for entry in data:
3101 assert entry["status"] == "absent"
3102
3103 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3104 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3105 assert "Agent quickstart" in result.output
3106
3107 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3108 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3109 assert "JSON output schema" in result.output
3110
3111 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3112 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3113 assert "Exit codes" in result.output
3114
3115
3116 # ---------------------------------------------------------------------------
3117 # Security — muse code index purge
3118 # ---------------------------------------------------------------------------
3119
3120
3121 class TestIndexPurgeSecurity:
3122 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3123 """Unknown --index value rejected by argparse before run_purge runs."""
3124 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3125 assert result.exit_code != 0
3126
3127 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3128 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3129 assert "\x1b" not in result.output
3130
3131 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3132 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3133 from muse.core.indices import KNOWN_INDEX_NAMES
3134 runner.invoke(cli, ["code", "index", "rebuild"])
3135 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3136 data = json.loads(result.output.strip())
3137 for name in data["purged"] + data["skipped"]:
3138 assert name in KNOWN_INDEX_NAMES
3139
3140 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3141 monkeypatch.chdir(tmp_path)
3142 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3143 result = runner.invoke(cli, ["code", "index", "purge"])
3144 assert "Traceback" not in result.output
3145 assert result.exit_code != 0
3146
3147 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3148 """Purge must not remove anything outside .muse/indices/."""
3149 runner.invoke(cli, ["code", "index", "rebuild"])
3150 repo_json = repo_json_path(code_repo)
3151 assert repo_json.exists()
3152 runner.invoke(cli, ["code", "index", "purge"])
3153 assert repo_json.exists(), "repo.json must not be deleted by purge"
3154
3155 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3156 runner.invoke(cli, ["code", "index", "rebuild"])
3157 runner.invoke(cli, ["code", "index", "purge"])
3158 result = runner.invoke(cli, ["code", "index", "purge"])
3159 assert "Traceback" not in result.output
3160
3161
3162 # ---------------------------------------------------------------------------
3163 # Stress — muse code index purge
3164 # ---------------------------------------------------------------------------
3165
3166
3167 class TestIndexPurgeStress:
3168 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3169 """50 sequential purge calls all exit 0 (idempotent)."""
3170 for i in range(50):
3171 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3172 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3173
3174 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3175 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3176 for i in range(100):
3177 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3178 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3179 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3180 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3181 d = json.loads(r2.output.strip())
3182 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3183
3184 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3185 """8 threads purging concurrently via core function — all must exit 0."""
3186 import argparse
3187 import threading
3188
3189 from muse.cli.commands.index_rebuild import run_purge
3190
3191 runner.invoke(cli, ["code", "index", "rebuild"])
3192 errors: list[str] = []
3193
3194 def worker(idx: int) -> None:
3195 args = argparse.Namespace(index_name=None, json_out=True)
3196 try:
3197 run_purge(args)
3198 except SystemExit as exc:
3199 if exc.code != 0:
3200 errors.append(f"Thread {idx}: exit {exc.code}")
3201 except Exception as exc:
3202 errors.append(f"Thread {idx}: {exc}")
3203
3204 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3205 for t in threads:
3206 t.start()
3207 for t in threads:
3208 t.join()
3209 assert not errors, f"Concurrent failures: {errors}"
3210
3211
3212 # ---------------------------------------------------------------------------
3213 # Performance — iterative DFS regression (no RecursionError)
3214 # ---------------------------------------------------------------------------
3215
3216
3217 class TestIterativeDFS:
3218 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3219
3220 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3221 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3222
3223 # Build a linear chain A→B→C→…→Z (depth 600, beyond Python's 1000 default).
3224 depth = 600
3225 nodes = [f"mod_{i}" for i in range(depth)]
3226 imports_out: _ImportsMap = {
3227 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3228 }
3229 imports_out[nodes[-1]] = []
3230
3231 # Must not raise RecursionError.
3232 cycles = codemap_find_cycles(imports_out)
3233 assert isinstance(cycles, list)
3234 assert len(cycles) == 0 # linear chain has no cycles
3235
3236 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3237 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3238
3239 # A→B→C→A is a cycle.
3240 imports_out: _ImportsMap = {
3241 "A": ["B"],
3242 "B": ["C"],
3243 "C": ["A"],
3244 }
3245 cycles = codemap_find_cycles(imports_out)
3246 assert len(cycles) >= 1
3247
3248 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3249 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3250
3251 depth = 600
3252 nodes = [f"file_{i}.py" for i in range(depth)]
3253 imports: _ImportsSetMap = {
3254 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3255 }
3256 imports[nodes[-1]] = set()
3257
3258 cycles = invariants_find_cycles(imports)
3259 assert isinstance(cycles, list)
3260 assert len(cycles) == 0
3261
3262 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3263 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3264
3265 # A module that imports itself.
3266 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3267 cycles = invariants_find_cycles(imports)
3268 assert len(cycles) >= 1
3269
3270
3271 # ---------------------------------------------------------------------------
3272 # muse code symbols
3273 # ---------------------------------------------------------------------------
3274
3275
3276 class TestSymbols:
3277 """Tests for ``muse code symbols``."""
3278
3279 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3280 """Basic invocation lists functions and classes from HEAD snapshot."""
3281 result = runner.invoke(cli, ["code", "symbols"])
3282 assert result.exit_code == 0, result.output
3283 # billing.py contains Invoice class and process_order / send_email functions.
3284 assert "Invoice" in result.output
3285 assert "process_order" in result.output
3286 assert "symbols across" in result.output
3287
3288 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3289 """``--count`` prints a total count and language breakdown, no symbol table."""
3290 result = runner.invoke(cli, ["code", "symbols", "--count"])
3291 assert result.exit_code == 0, result.output
3292 assert "symbols" in result.output
3293 assert "Python" in result.output
3294 # Should NOT print individual symbol lines.
3295 assert "Invoice" not in result.output
3296
3297 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3298 """``--json`` emits a structured envelope with a flat 'results' list."""
3299 result = runner.invoke(cli, ["code", "symbols", "--json"])
3300 assert result.exit_code == 0, result.output
3301 data = json.loads(result.output)
3302 assert isinstance(data, dict)
3303 assert "results" in data
3304 assert "files" not in data
3305 assert isinstance(data["results"], list)
3306 assert any(e.get("address", "").startswith("billing.py") for e in data["results"])
3307 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3308
3309 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3310 """``--kind class`` shows only class-kind symbols."""
3311 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3312 assert result.exit_code == 0, result.output
3313 assert "Invoice" in result.output
3314 assert "process_order" not in result.output
3315
3316 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3317 """``--kind function`` shows only top-level functions, not methods."""
3318 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3319 assert result.exit_code == 0, result.output
3320 assert "process_order" in result.output
3321 assert "send_email" in result.output
3322 assert "Invoice" not in result.output
3323
3324 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3325 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3326 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3327 assert result.exit_code != 0
3328 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3329
3330 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3331 """``--file`` restricts output to a single file."""
3332 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3333 assert result.exit_code == 0, result.output
3334 assert "symbols across" in result.output
3335
3336 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3337 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3338 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3339 assert result.exit_code == 0, result.output
3340 assert "no semantic symbols found" in result.output
3341
3342 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3343 """``--language Python`` includes Python symbols; other languages excluded."""
3344 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3345 assert result.exit_code == 0, result.output
3346 assert "Invoice" in result.output
3347
3348 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3349 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3350 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3351 assert result.exit_code == 0, result.output
3352 assert "no semantic symbols found" in result.output
3353
3354 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3355 """``--hashes`` appends content hash abbreviations to each symbol row."""
3356 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3357 assert result.exit_code == 0, result.output
3358 # Hash suffix is 8 hex chars followed by ".."
3359 assert ".." in result.output
3360
3361 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3362 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3363 default = runner.invoke(cli, ["code", "symbols"])
3364 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3365 assert default.exit_code == 0
3366 assert head.exit_code == 0
3367 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3368 assert "Invoice" in default.output
3369 assert "Invoice" in head.output
3370 assert "symbols across" in default.output
3371 assert "symbols across" in head.output
3372
3373 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3374 """``--count`` and ``--json`` cannot be combined."""
3375 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3376 assert result.exit_code != 0
3377
3378 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3379 """JSON output uses the structured envelope with source_ref and results."""
3380 result = runner.invoke(cli, ["code", "symbols", "--json"])
3381 assert result.exit_code == 0, result.output
3382 data = json.loads(result.output)
3383 assert "source_ref" in data
3384 assert "working_tree" in data
3385 assert "total_symbols" in data
3386 assert "results" in data
3387 assert "files" not in data
3388 assert isinstance(data["working_tree"], bool)
3389 assert isinstance(data["total_symbols"], int)
3390 for entry in data["results"]:
3391 for field in ("address", "kind", "name", "qualified_name",
3392 "lineno", "content_id", "body_hash", "signature_id"):
3393 assert field in entry, f"missing field '{field}' in JSON entry"
3394
3395 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3396 """``--json`` without ``--commit`` reports working_tree=true."""
3397 result = runner.invoke(cli, ["code", "symbols", "--json"])
3398 assert result.exit_code == 0, result.output
3399 data = json.loads(result.output)
3400 assert data["working_tree"] is True
3401 assert data["source_ref"] == "working-tree"
3402
3403 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3404 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3405 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3406 assert result.exit_code == 0, result.output
3407 data = json.loads(result.output)
3408 assert data["working_tree"] is False
3409 assert data["source_ref"] != "working-tree"
3410 # source_ref is a prefixed short commit id (e.g. "sha256:<12hex>")
3411 assert data["source_ref"].startswith("sha256:")
3412
3413 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3414 """Working-tree mode picks up edits made to files after the last commit."""
3415 # Find the billing.py path on disk.
3416 billing = code_repo / "billing.py"
3417 assert billing.exists()
3418 # Append a new function — not yet committed.
3419 billing.write_text(
3420 f"{billing.read_text()}\ndef newly_added_function():\n pass\n"
3421 )
3422 result = runner.invoke(cli, ["code", "symbols"])
3423 assert result.exit_code == 0, result.output
3424 assert "newly_added_function" in result.output
3425
3426 # Committed snapshot should NOT contain it.
3427 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3428 assert committed.exit_code == 0
3429 assert "newly_added_function" not in committed.output
3430
3431 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3432 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3433 for variant in ("python", "Python", "PYTHON"):
3434 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3435 assert result.exit_code == 0, f"failed for --language {variant!r}"
3436 assert "Invoice" in result.output
3437
3438 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3439 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3440 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3441 assert result.exit_code == 0, result.output
3442 assert "Invoice" in result.output
3443
3444 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3445 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3446 # Write a second file with the same basename in a sub-directory.
3447 sub = code_repo / "sub"
3448 sub.mkdir(exist_ok=True)
3449 (sub / "billing.py").write_text("def sub_func(): pass\n")
3450 # Stage and commit both so the manifest has two paths ending in billing.py.
3451 import subprocess
3452 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3453 subprocess.run(
3454 ["muse", "commit", "-m", "add sub/billing.py"],
3455 cwd=code_repo, check=True,
3456 )
3457 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3458 assert result.exit_code != 0
3459 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3460
3461 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3462 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3463 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3464 assert result.exit_code != 0
3465 assert "not found" in result.stderr
3466
3467
3468 # ---------------------------------------------------------------------------
3469 # TestSymbolLog
3470 # ---------------------------------------------------------------------------
3471
3472
3473 class TestSymbolLog:
3474 """Tests for ``muse code symbol-log``."""
3475
3476 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3477 """An address not found in any commit produces 'no events found'."""
3478 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3479 assert result.exit_code == 0, result.output
3480 assert "no events found" in result.output
3481
3482 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3483 """An address without '::' exits non-zero with a descriptive error."""
3484 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3485 assert result.exit_code != 0
3486 assert "::" in (result.output + (result.stderr or ""))
3487
3488 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3489 """An empty string as address exits non-zero."""
3490 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3491 # "::" is technically valid syntax; should at least not crash.
3492 assert result.exit_code == 0
3493
3494 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3495 """``--json`` emits the structured envelope with all top-level fields."""
3496 result = runner.invoke(
3497 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3498 )
3499 assert result.exit_code == 0, result.output
3500 data = json.loads(result.output)
3501 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3502 assert field in data, f"missing top-level field '{field}'"
3503 assert data["address"] == "billing.py::Invoice"
3504 assert data["start_ref"] == "HEAD"
3505 assert isinstance(data["total_commits_scanned"], int)
3506 assert isinstance(data["truncated"], bool)
3507 assert isinstance(data["events"], list)
3508
3509 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3510 """Each JSON event has the required fields."""
3511 result = runner.invoke(
3512 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3513 )
3514 assert result.exit_code == 0, result.output
3515 data = json.loads(result.output)
3516 for ev in data["events"]:
3517 for field in ("event", "commit_id", "message", "committed_at",
3518 "address", "detail", "new_address"):
3519 assert field in ev, f"missing event field '{field}'"
3520
3521 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3522 """When --max is hit, a truncation warning appears in human output."""
3523 result = runner.invoke(
3524 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3525 )
3526 assert result.exit_code == 0, result.output
3527 assert "incomplete" in result.output or "limit" in result.output
3528
3529 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3530 """When --max is hit, truncated=true appears in JSON output."""
3531 result = runner.invoke(
3532 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3533 )
3534 assert result.exit_code == 0, result.output
3535 data = json.loads(result.output)
3536 assert data["truncated"] is True
3537 assert data["total_commits_scanned"] == 1
3538
3539 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3540 """--max 0 exits non-zero with a clear error."""
3541 result = runner.invoke(
3542 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3543 )
3544 assert result.exit_code != 0
3545
3546 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3547 """``--from`` with a non-existent ref exits non-zero."""
3548 result = runner.invoke(
3549 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3550 )
3551 assert result.exit_code != 0
3552 assert "not found" in result.stderr
3553
3554 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3555 """BFS walk finds events on feature branches that were merged in via parent2.
3556
3557 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3558 The feature branch commit has a structured_delta inserting a symbol.
3559 The linear (parent1-only) walk would miss this; BFS must find it.
3560 """
3561 import datetime
3562
3563 root = code_repo
3564 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
3565 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3566 from muse.core.snapshot import compute_commit_id
3567 from muse.domain import InsertOp, PatchOp, StructuredDelta
3568 branch = read_current_branch(root)
3569 head_id = get_head_commit_id(root, branch)
3570 assert head_id is not None
3571
3572 feature_snap = "aa" * 32
3573 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3574 feature_id = compute_commit_id(
3575 parent_ids=[head_id],
3576 snapshot_id=feature_snap,
3577 message="feat: add merged_fn",
3578 committed_at_iso=feature_at.isoformat(),
3579 author="test",
3580 )
3581 write_commit(root, CommitRecord(
3582 repo_id=repo_id,
3583 commit_id=feature_id,
3584 branch="feat/branch",
3585 snapshot_id=feature_snap,
3586 message="feat: add merged_fn",
3587 committed_at=feature_at,
3588 parent_commit_id=head_id,
3589 author="test",
3590 structured_delta=StructuredDelta(ops=[PatchOp(
3591 op="patch",
3592 address="billing.py",
3593 child_ops=[InsertOp(
3594 op="insert",
3595 address="billing.py::merged_fn",
3596 content_summary="function merged_fn",
3597 )],
3598 )]),
3599 ))
3600
3601 merge_snap = "bb" * 32
3602 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3603 merge_id = compute_commit_id(
3604 parent_ids=[head_id, feature_id],
3605 snapshot_id=merge_snap,
3606 message="merge feat/branch",
3607 committed_at_iso=merge_at.isoformat(),
3608 author="test",
3609 )
3610 write_commit(root, CommitRecord(
3611 repo_id=repo_id,
3612 commit_id=merge_id,
3613 branch=branch,
3614 snapshot_id=merge_snap,
3615 message="merge feat/branch",
3616 committed_at=merge_at,
3617 parent_commit_id=head_id,
3618 parent2_commit_id=feature_id,
3619 author="test",
3620 ))
3621
3622 branch_ref = ref_path(root, branch)
3623 branch_ref.write_text(merge_id)
3624
3625 result = runner.invoke(
3626 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3627 )
3628 assert result.exit_code == 0, result.output
3629 # BFS must find the creation event on the feature branch.
3630 assert "merged_fn" in result.output
3631 assert "created" in result.output
3632
3633 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3634 """Regression guard: verify the BFS result differs from a parent1-only scan.
3635
3636 Directly calls _walk_commits_dag and checks it returns commits from
3637 both parent chains, not just parent1.
3638 """
3639 import datetime
3640
3641 root = code_repo
3642 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
3643 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3644 from muse.core.snapshot import compute_commit_id
3645 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3646 branch = read_current_branch(root)
3647 head_id = get_head_commit_id(root, branch)
3648 assert head_id is not None
3649
3650 feature_snap = "dd" * 32
3651 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3652 feature_id = compute_commit_id(
3653 parent_ids=[],
3654 snapshot_id=feature_snap,
3655 message="feat on second parent",
3656 committed_at_iso=feature_at.isoformat(),
3657 author="test",
3658 )
3659 write_commit(root, CommitRecord(
3660 repo_id=repo_id,
3661 commit_id=feature_id,
3662 branch="feat/x",
3663 snapshot_id=feature_snap,
3664 message="feat on second parent",
3665 committed_at=feature_at,
3666 author="test",
3667 ))
3668 merge_snap = "ee" * 32
3669 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3670 merge_id = compute_commit_id(
3671 parent_ids=[head_id, feature_id],
3672 snapshot_id=merge_snap,
3673 message="merge",
3674 committed_at_iso=merge_at.isoformat(),
3675 author="test",
3676 )
3677 write_commit(root, CommitRecord(
3678 repo_id=repo_id,
3679 commit_id=merge_id,
3680 branch=branch,
3681 snapshot_id=merge_snap,
3682 message="merge",
3683 committed_at=merge_at,
3684 parent_commit_id=head_id,
3685 parent2_commit_id=feature_id,
3686 author="test",
3687 ))
3688
3689 branch_ref = ref_path(root, branch)
3690 branch_ref.write_text(merge_id)
3691
3692 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3693 commit_ids = {c.commit_id for c in commits}
3694 assert feature_id in commit_ids
3695
3696
3697 # ---------------------------------------------------------------------------
3698 # muse code coupling
3699 # ---------------------------------------------------------------------------
3700
3701
3702 @pytest.fixture
3703 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3704 """Repo with 3 commits where billing.py + models.py co-change twice."""
3705 work = repo
3706
3707 # Commit 1: seed — only billing.py
3708 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3709 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3710 assert r.exit_code == 0, r.output
3711
3712 # Commit 2: billing.py + models.py change together
3713 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3714 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3715 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3716 assert r.exit_code == 0, r.output
3717
3718 # Commit 3: billing.py + models.py change together again
3719 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3720 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3721 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3722 assert r.exit_code == 0, r.output
3723
3724 return repo
3725
3726
3727 class TestCoupling:
3728 """Tests for muse code coupling."""
3729
3730 # ── basic correctness ────────────────────────────────────────────────────
3731
3732 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3733 result = runner.invoke(cli, ["code", "coupling"])
3734 assert result.exit_code == 0, result.output
3735
3736 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3737 """billing.py and models.py co-changed twice — must appear in output."""
3738 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3739 assert result.exit_code == 0, result.output
3740 assert "billing.py" in result.output
3741 assert "models.py" in result.output
3742
3743 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3744 result = runner.invoke(cli, ["code", "coupling"])
3745 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3746 assert "Commits analysed" in result.output
3747
3748 def test_coupling_min_filter_excludes_low_count(
3749 self, coupling_repo: pathlib.Path
3750 ) -> None:
3751 """--min 3 must exclude our pair that co-changed only twice."""
3752 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3753 assert result.exit_code == 0, result.output
3754 assert "billing.py" not in result.output or "no file pairs" in result.output
3755
3756 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3757 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3758 data = json.loads(result.output)
3759 assert len(data["pairs"]) <= 1
3760
3761 # ── --file filter ─────────────────────────────────────────────────────────
3762
3763 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3764 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3765 assert result.exit_code == 0, result.output
3766
3767 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3768 """--file billing.py must surface models.py as its partner."""
3769 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3770 assert result.exit_code == 0, result.output
3771 assert "models.py" in result.output
3772
3773 def test_coupling_file_filter_header_names_file(
3774 self, coupling_repo: pathlib.Path
3775 ) -> None:
3776 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3777 assert "billing.py" in result.output
3778
3779 def test_coupling_file_filter_nonexistent_returns_cleanly(
3780 self, coupling_repo: pathlib.Path
3781 ) -> None:
3782 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3783 assert result.exit_code == 0, result.output
3784
3785 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3786 """Suffix billing.py should match the file even without the full path."""
3787 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3788 assert result.exit_code == 0, result.output
3789 assert "models.py" in result.output
3790
3791 # ── JSON output ───────────────────────────────────────────────────────────
3792
3793 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3794 result = runner.invoke(cli, ["code", "coupling", "--json"])
3795 assert result.exit_code == 0, result.output
3796 data = json.loads(result.output)
3797 assert "from_ref" in data
3798 assert "to_ref" in data
3799 assert "commits_analysed" in data
3800 assert "truncated" in data
3801 assert "filters" in data
3802 assert "pairs" in data
3803 assert isinstance(data["pairs"], list)
3804
3805 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3806 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3807 data = json.loads(result.output)
3808 if data["pairs"]:
3809 pair = data["pairs"][0]
3810 assert "file_a" in pair or "file" in pair
3811 assert "co_changes" in pair
3812 assert isinstance(pair["co_changes"], int)
3813
3814 def test_coupling_json_file_filter_uses_partner_schema(
3815 self, coupling_repo: pathlib.Path
3816 ) -> None:
3817 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3818 result = runner.invoke(
3819 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3820 )
3821 data = json.loads(result.output)
3822 assert data["filters"]["file"] == "billing.py"
3823 if data["pairs"]:
3824 pair = data["pairs"][0]
3825 assert "file" in pair
3826 assert "partner" in pair
3827 assert "co_changes" in pair
3828 assert "file_a" not in pair # partner schema, not pair schema
3829
3830 def test_coupling_json_not_truncated_small_repo(
3831 self, coupling_repo: pathlib.Path
3832 ) -> None:
3833 result = runner.invoke(cli, ["code", "coupling", "--json"])
3834 data = json.loads(result.output)
3835 assert data["truncated"] is False
3836
3837 def test_coupling_json_filters_reflect_args(
3838 self, coupling_repo: pathlib.Path
3839 ) -> None:
3840 result = runner.invoke(
3841 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3842 )
3843 data = json.loads(result.output)
3844 assert data["filters"]["top"] == 5
3845 assert data["filters"]["min_count"] == 2
3846
3847 # ── --max-commits ─────────────────────────────────────────────────────────
3848
3849 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3850 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3851 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3852 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3853 d_cap = json.loads(r_cap.output)
3854 assert d_cap["commits_analysed"] <= 1
3855
3856 def test_coupling_max_commits_truncated_flag(
3857 self, coupling_repo: pathlib.Path
3858 ) -> None:
3859 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3860 data = json.loads(result.output)
3861 # With 3 commits and cap=1, truncated must be True.
3862 assert data["truncated"] is True
3863
3864 def test_coupling_max_commits_one_shows_warning(
3865 self, coupling_repo: pathlib.Path
3866 ) -> None:
3867 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3868 assert result.exit_code == 0, result.output
3869 assert "⚠️" in result.output or "capped" in result.output
3870
3871 # ── validation ────────────────────────────────────────────────────────────
3872
3873 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3874 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3875 assert result.exit_code != 0
3876
3877 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3878 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3879 assert result.exit_code != 0
3880
3881 def test_coupling_max_commits_zero_exits_error(
3882 self, coupling_repo: pathlib.Path
3883 ) -> None:
3884 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3885 assert result.exit_code != 0
3886
3887 def test_coupling_invalid_from_ref_exits_error(
3888 self, coupling_repo: pathlib.Path
3889 ) -> None:
3890 result = runner.invoke(
3891 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3892 )
3893 assert result.exit_code != 0
3894
3895 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3896 """Coupling must count co-changes on feature-branch commits (parent2)."""
3897 import datetime
3898
3899 # Genesis commit
3900 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3901 r = runner.invoke(cli, ["commit", "-m", "seed"])
3902 assert r.exit_code == 0, r.output
3903
3904 repo_json = json.loads((repo_json_path(repo)).read_text())
3905 repo_id = repo_json["repo_id"]
3906 from muse.core.store import read_current_branch, resolve_commit_ref
3907 branch = read_current_branch(repo)
3908 head = resolve_commit_ref(repo, repo_id, branch, None)
3909 assert head is not None
3910
3911 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3912 feature_at = now
3913 merge_at = now + datetime.timedelta(hours=1)
3914
3915 # Feature commit touching billing.py + models.py together.
3916 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3917 from muse.core.snapshot import compute_commit_id
3918 feature_delta = StructuredDelta(
3919 domain="code",
3920 ops=[
3921 PatchOp(
3922 op="patch", address="billing.py",
3923 child_ops=[ReplaceOp(
3924 op="replace", address="billing.py::compute",
3925 old_content_id="a" * 64, new_content_id="b" * 64,
3926 old_summary="function compute",
3927 new_summary="function compute (modified)", position=None,
3928 )],
3929 child_domain="code", child_summary="compute modified",
3930 ),
3931 PatchOp(
3932 op="patch", address="models.py",
3933 child_ops=[InsertOp(
3934 op="insert", address="models.py::Order",
3935 content_id="c" * 64, content_summary="class Order", position=None,
3936 )],
3937 child_domain="code", child_summary="Order added",
3938 ),
3939 ],
3940 summary="co-change",
3941 )
3942 feature_id = compute_commit_id(
3943 [head.commit_id], head.snapshot_id,
3944 "co-change on feature branch", feature_at.isoformat(),
3945 author="test",
3946 )
3947 merge_id = compute_commit_id(
3948 [head.commit_id, feature_id], head.snapshot_id,
3949 "Merge feature", merge_at.isoformat(),
3950 author="test",
3951 )
3952 feature_body: CommitDict = {
3953 "commit_id": feature_id,
3954 "repo_id": repo_id,
3955 "branch": "feat/test",
3956 "snapshot_id": head.snapshot_id,
3957 "message": "co-change on feature branch",
3958 "committed_at": feature_at.isoformat(),
3959 "parent_commit_id": head.commit_id,
3960 "parent2_commit_id": None,
3961 "author": "test",
3962 "metadata": {},
3963 "structured_delta": feature_delta,
3964 }
3965 merge_body: CommitDict = {
3966 "commit_id": merge_id,
3967 "repo_id": repo_id,
3968 "branch": branch,
3969 "snapshot_id": head.snapshot_id,
3970 "message": "Merge feature",
3971 "committed_at": merge_at.isoformat(),
3972 "parent_commit_id": head.commit_id,
3973 "parent2_commit_id": feature_id,
3974 "author": "test",
3975 "metadata": {},
3976 "structured_delta": None,
3977 }
3978 from muse.core.store import write_commit, CommitRecord
3979 write_commit(repo, CommitRecord.from_dict(feature_body))
3980 write_commit(repo, CommitRecord.from_dict(merge_body))
3981 (ref_path(repo, branch)).write_text(merge_id)
3982
3983 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3984 assert result.exit_code == 0, result.output
3985 data = json.loads(result.output)
3986 pairs_found = {
3987 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
3988 }
3989 billing_models = any(
3990 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
3991 for a, b in pairs_found
3992 )
3993 assert billing_models, "BFS must find the feature-branch co-change commit"
3994
3995
3996 # ---------------------------------------------------------------------------
3997 # muse code stable
3998 # ---------------------------------------------------------------------------
3999
4000
4001 class TestStable:
4002 """Tests for muse code stable."""
4003
4004 # ── basic correctness ────────────────────────────────────────────────────
4005
4006 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
4007 result = runner.invoke(cli, ["code", "stable"])
4008 assert result.exit_code == 0, result.output
4009
4010 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
4011 result = runner.invoke(cli, ["code", "stable"])
4012 assert result.exit_code == 0, result.output
4013 assert "Symbol stability" in result.output
4014 assert "Commits analysed" in result.output
4015 assert "bedrock" in result.output
4016
4017 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
4018 """Invoice.apply_discount was defined in the genesis commit and never modified."""
4019 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4020 assert result.exit_code == 0, result.output
4021 # apply_discount was never touched in any structured_delta → maximally stable.
4022 assert "apply_discount" in result.output
4023
4024 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
4025 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4026 assert result.exit_code == 0, result.output
4027 assert "since start of range" in result.output
4028
4029 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
4030 """Markdown / TOML / YAML symbols must be absent from default output."""
4031 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4032 assert result.exit_code == 0, result.output
4033 assert ".md::" not in result.output
4034 assert ".toml::" not in result.output
4035
4036 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
4037 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4038 assert result.exit_code == 0, result.output
4039 assert "::import::" not in result.output
4040
4041 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
4042 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
4043 assert result.exit_code == 0, result.output
4044
4045 # ── JSON output ───────────────────────────────────────────────────────────
4046
4047 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
4048 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4049 assert result.exit_code == 0, result.output
4050 data = json.loads(result.output)
4051 assert "from_ref" in data
4052 assert "to_ref" in data
4053 assert "commits_analysed" in data
4054 assert "truncated" in data
4055 assert "filters" in data
4056 assert "stable" in data
4057 assert isinstance(data["stable"], list)
4058
4059 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
4060 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4061 data = json.loads(result.output)
4062 assert len(data["stable"]) > 0
4063 entry = data["stable"][0]
4064 assert "address" in entry
4065 assert "unchanged_for" in entry
4066 assert "since_start_of_range" in entry
4067 assert isinstance(entry["unchanged_for"], int)
4068 assert isinstance(entry["since_start_of_range"], bool)
4069
4070 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
4071 result = runner.invoke(
4072 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
4073 )
4074 data = json.loads(result.output)
4075 assert data["filters"]["top"] == 3
4076 assert data["filters"]["kind"] == "function"
4077 assert data["filters"]["include_imports"] is False
4078 assert data["filters"]["include_docs"] is False
4079
4080 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
4081 result = runner.invoke(cli, ["code", "stable", "--json"])
4082 data = json.loads(result.output)
4083 assert data["truncated"] is False
4084
4085 # ── --language filter ─────────────────────────────────────────────────────
4086
4087 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
4088 """--language python and --language Python must behave identically."""
4089 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4090 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
4091 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
4092 d_lower = json.loads(r_lower.output)
4093 d_upper = json.loads(r_upper.output)
4094 addrs_lower = {e["address"] for e in d_lower["stable"]}
4095 addrs_upper = {e["address"] for e in d_upper["stable"]}
4096 assert addrs_lower == addrs_upper
4097
4098 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
4099 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4100 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4101 d_py = json.loads(r_py.output)
4102 d_all = json.loads(r_all.output)
4103 # Python-filtered results must be a subset of or equal to unfiltered results.
4104 py_addrs = {e["address"] for e in d_py["stable"]}
4105 all_addrs = {e["address"] for e in d_all["stable"]}
4106 assert py_addrs <= all_addrs
4107
4108 # ── --since REF ───────────────────────────────────────────────────────────
4109
4110 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
4111 """--since HEAD restricts the window to 0 commits (stop immediately)."""
4112 # Get the HEAD commit id to use as --since boundary
4113 import json as _json
4114 root = code_repo
4115 repo_id = _json.loads((repo_json_path(root)).read_text())["repo_id"]
4116 from muse.core.store import read_current_branch, resolve_commit_ref
4117 branch = read_current_branch(root)
4118 head = resolve_commit_ref(root, repo_id, branch, None)
4119 assert head is not None
4120
4121 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4122 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4123 assert r_all.exit_code == 0 and r_since.exit_code == 0
4124 d_all = json.loads(r_all.output)
4125 d_since = json.loads(r_since.output)
4126 # Window stops at HEAD itself → at most 1 commit analysed.
4127 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4128
4129 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4130 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4131 assert result.exit_code != 0
4132
4133 # ── --max-commits ─────────────────────────────────────────────────────────
4134
4135 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4136 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4137 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4138 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4139 d_cap = json.loads(r_cap.output)
4140 assert d_cap["commits_analysed"] <= 1
4141
4142 def test_stable_max_commits_one_shows_truncated_warning(
4143 self, code_repo: pathlib.Path
4144 ) -> None:
4145 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4146 assert result.exit_code == 0, result.output
4147 # With 2 commits and cap=1, truncated warning should appear.
4148 assert "capped" in result.output or "⚠️" in result.output
4149
4150 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4151 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4152 assert result.exit_code != 0
4153
4154 # ── --top validation ──────────────────────────────────────────────────────
4155
4156 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4157 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4158 assert result.exit_code != 0
4159
4160 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4161 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4162 data = json.loads(result.output)
4163 assert len(data["stable"]) <= 2
4164
4165 # ── BFS follows merge parents ─────────────────────────────────────────────
4166
4167 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4168 """Symbols touched only on a merged feature branch must be detected as unstable."""
4169 import datetime
4170
4171 # Create a symbol in commit 1 (main).
4172 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4173 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4174 assert r.exit_code == 0, r.output
4175
4176 repo_json = json.loads((repo_json_path(repo)).read_text())
4177 repo_id = repo_json["repo_id"]
4178 from muse.core.store import read_current_branch, resolve_commit_ref
4179 branch = read_current_branch(repo)
4180 head_commit = resolve_commit_ref(repo, repo_id, branch, None)
4181 assert head_commit is not None
4182 head_id = head_commit.commit_id
4183
4184 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4185 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4186
4187 # Feature-branch commit that touched "bedrock" via a structured_delta.
4188 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4189 from muse.core.snapshot import compute_commit_id
4190 bedrock_delta = StructuredDelta(
4191 domain="code",
4192 ops=[PatchOp(
4193 op="patch", address="core.py",
4194 child_ops=[ReplaceOp(
4195 op="replace", address="core.py::bedrock",
4196 old_content_id="a" * 64, new_content_id="b" * 64,
4197 old_summary="function bedrock",
4198 new_summary="function bedrock (modified)", position=None,
4199 )],
4200 child_domain="code", child_summary="bedrock modified",
4201 )],
4202 summary="bedrock modified",
4203 )
4204 feature_id = compute_commit_id(
4205 [head_id], head_commit.snapshot_id,
4206 "Feature: touch bedrock", feature_at.isoformat(),
4207 author="test",
4208 )
4209 merge_id = compute_commit_id(
4210 [head_id, feature_id], head_commit.snapshot_id,
4211 "Merge feat/touch-bedrock", merge_at.isoformat(),
4212 author="test",
4213 )
4214 feature_body: CommitDict = {
4215 "commit_id": feature_id,
4216 "repo_id": repo_id,
4217 "branch": "feat/touch-bedrock",
4218 "snapshot_id": head_commit.snapshot_id,
4219 "message": "Feature: touch bedrock",
4220 "committed_at": feature_at.isoformat(),
4221 "parent_commit_id": head_id,
4222 "parent2_commit_id": None,
4223 "author": "test",
4224 "metadata": {},
4225 "structured_delta": bedrock_delta,
4226 }
4227 # Merge commit whose parent2 is the feature commit.
4228 merge_body: CommitDict = {
4229 "commit_id": merge_id,
4230 "repo_id": repo_id,
4231 "branch": branch,
4232 "snapshot_id": head_commit.snapshot_id,
4233 "message": "Merge feat/touch-bedrock",
4234 "committed_at": merge_at.isoformat(),
4235 "parent_commit_id": head_id,
4236 "parent2_commit_id": feature_id,
4237 "author": "test",
4238 "metadata": {},
4239 "structured_delta": None,
4240 }
4241 from muse.core.store import write_commit, CommitRecord
4242 write_commit(repo, CommitRecord.from_dict(feature_body))
4243 write_commit(repo, CommitRecord.from_dict(merge_body))
4244 (ref_path(repo, branch)).write_text(merge_id)
4245
4246 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4247 assert result.exit_code == 0, result.output
4248 data = json.loads(result.output)
4249 # bedrock was touched in the feature-branch commit; BFS must find it.
4250 # It should have unchanged_for < total_commits (not maximally stable).
4251 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4252 if bedrock_entries:
4253 assert not bedrock_entries[0]["since_start_of_range"]
4254
4255
4256 # ---------------------------------------------------------------------------
4257 # muse code compare
4258 # ---------------------------------------------------------------------------
4259
4260
4261 @pytest.fixture
4262 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4263 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4264
4265 Commit A — billing.py with Invoice.compute_total + process_order.
4266 Commit B — compute_total renamed to compute_invoice_total; generate_pdf
4267 and send_email added. Multi-line message to test truncation.
4268 """
4269 (repo / "billing.py").write_text(textwrap.dedent("""\
4270 class Invoice:
4271 def compute_total(self, items):
4272 return sum(items)
4273
4274 def apply_discount(self, total, pct):
4275 return total * (1 - pct)
4276
4277 def process_order(invoice, items):
4278 return invoice.compute_total(items)
4279 """))
4280 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4281 assert r.exit_code == 0, r.output
4282 from muse.core.store import read_current_branch
4283 branch = read_current_branch(repo)
4284 commit_a = get_head_commit_id(repo, branch)
4285
4286 (repo / "billing.py").write_text(textwrap.dedent("""\
4287 class Invoice:
4288 def compute_invoice_total(self, items):
4289 return sum(items)
4290
4291 def apply_discount(self, total, pct):
4292 return total * (1 - pct)
4293
4294 def generate_pdf(self):
4295 return b"pdf"
4296
4297 def process_order(invoice, items):
4298 return invoice.compute_invoice_total(items)
4299
4300 def send_email(address):
4301 pass
4302 """))
4303 # Multi-line message to test first-line truncation.
4304 r = runner.invoke(cli, [
4305 "commit", "-m",
4306 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4307 ])
4308 assert r.exit_code == 0, r.output
4309 commit_b = get_head_commit_id(repo, branch)
4310
4311 assert commit_a is not None
4312 assert commit_b is not None
4313 return repo, commit_a, commit_b
4314
4315
4316 class TestCompare:
4317 """Tests for muse code compare."""
4318
4319 # ── basic correctness ────────────────────────────────────────────────────
4320
4321 def test_compare_exits_zero(
4322 self, compare_repo: tuple[pathlib.Path, str, str]
4323 ) -> None:
4324 _, ref_a, ref_b = compare_repo
4325 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4326 assert result.exit_code == 0, result.output
4327
4328 def test_compare_shows_header(
4329 self, compare_repo: tuple[pathlib.Path, str, str]
4330 ) -> None:
4331 _, ref_a, ref_b = compare_repo
4332 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4333 assert result.exit_code == 0, result.output
4334 assert "Semantic comparison" in result.output
4335 assert "From:" in result.output
4336 assert "To:" in result.output
4337
4338 def test_compare_commit_message_first_line_only(
4339 self, compare_repo: tuple[pathlib.Path, str, str]
4340 ) -> None:
4341 """Multi-line commit messages must be truncated to their first line."""
4342 _, ref_a, ref_b = compare_repo
4343 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4344 assert result.exit_code == 0, result.output
4345 # The body of the second commit must not appear in the header.
4346 assert "This is the extended body" not in result.output
4347
4348 def test_compare_same_ref_no_changes(
4349 self, compare_repo: tuple[pathlib.Path, str, str]
4350 ) -> None:
4351 _, ref_a, _ = compare_repo
4352 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4353 assert result.exit_code == 0, result.output
4354 assert "no semantic changes" in result.output
4355
4356 def test_compare_detects_added_symbols(
4357 self, compare_repo: tuple[pathlib.Path, str, str]
4358 ) -> None:
4359 _, ref_a, ref_b = compare_repo
4360 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4361 assert result.exit_code == 0, result.output
4362 # generate_pdf and send_email were added in commit B.
4363 assert "generate_pdf" in result.output or "send_email" in result.output
4364
4365 def test_compare_invalid_ref_exits_nonzero(
4366 self, compare_repo: tuple[pathlib.Path, str, str]
4367 ) -> None:
4368 _, ref_a, _ = compare_repo
4369 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4370 assert result.exit_code != 0
4371
4372 def test_compare_requires_repo(
4373 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4374 ) -> None:
4375 monkeypatch.chdir(tmp_path)
4376 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4377 assert result.exit_code != 0
4378
4379 # ── JSON schema ──────────────────────────────────────────────────────────
4380
4381 def test_compare_json_schema(
4382 self, compare_repo: tuple[pathlib.Path, str, str]
4383 ) -> None:
4384 _, ref_a, ref_b = compare_repo
4385 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4386 assert result.exit_code == 0, result.output
4387 data = json.loads(result.output)
4388 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4389
4390 def test_compare_json_from_to_schema(
4391 self, compare_repo: tuple[pathlib.Path, str, str]
4392 ) -> None:
4393 _, ref_a, ref_b = compare_repo
4394 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4395 assert result.exit_code == 0, result.output
4396 data = json.loads(result.output)
4397 assert "commit_id" in data["from"]
4398 assert "message" in data["from"]
4399 assert "commit_id" in data["to"]
4400 assert "message" in data["to"]
4401
4402 def test_compare_json_message_first_line_only(
4403 self, compare_repo: tuple[pathlib.Path, str, str]
4404 ) -> None:
4405 _, ref_a, ref_b = compare_repo
4406 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4407 assert result.exit_code == 0, result.output
4408 data = json.loads(result.output)
4409 assert "\n" not in data["to"]["message"]
4410 assert "This is the extended body" not in data["to"]["message"]
4411
4412 def test_compare_json_stat_schema(
4413 self, compare_repo: tuple[pathlib.Path, str, str]
4414 ) -> None:
4415 _, ref_a, ref_b = compare_repo
4416 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4417 assert result.exit_code == 0, result.output
4418 stat = json.loads(result.output)["stat"]
4419 assert set(stat.keys()) >= {
4420 "files_changed", "symbols_added", "symbols_removed",
4421 "symbols_modified", "semver_impact",
4422 }
4423 assert isinstance(stat["files_changed"], int)
4424 assert isinstance(stat["symbols_added"], int)
4425 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4426
4427 def test_compare_json_filters_schema(
4428 self, compare_repo: tuple[pathlib.Path, str, str]
4429 ) -> None:
4430 _, ref_a, ref_b = compare_repo
4431 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4432 assert result.exit_code == 0, result.output
4433 filters = json.loads(result.output)["filters"]
4434 assert set(filters.keys()) >= {"kind", "file", "language"}
4435 # No filters applied — all None.
4436 assert filters["kind"] is None
4437 assert filters["file"] is None
4438 assert filters["language"] is None
4439
4440 def test_compare_json_ops_schema(
4441 self, compare_repo: tuple[pathlib.Path, str, str]
4442 ) -> None:
4443 _, ref_a, ref_b = compare_repo
4444 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4445 assert result.exit_code == 0, result.output
4446 ops = json.loads(result.output)["ops"]
4447 assert isinstance(ops, list)
4448 assert len(ops) > 0
4449 for op in ops:
4450 assert "op" in op
4451 assert "address" in op
4452 assert "detail" in op
4453
4454 def test_compare_same_ref_json_empty_ops(
4455 self, compare_repo: tuple[pathlib.Path, str, str]
4456 ) -> None:
4457 _, ref_a, _ = compare_repo
4458 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--json"])
4459 assert result.exit_code == 0, result.output
4460 data = json.loads(result.output)
4461 assert data["ops"] == []
4462 assert data["stat"]["semver_impact"] == "NONE"
4463
4464 # ── --stat flag ──────────────────────────────────────────────────────────
4465
4466 def test_compare_stat_shows_counts(
4467 self, compare_repo: tuple[pathlib.Path, str, str]
4468 ) -> None:
4469 _, ref_a, ref_b = compare_repo
4470 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4471 assert result.exit_code == 0, result.output
4472 assert "Files changed:" in result.output
4473 assert "Symbols added:" in result.output
4474 assert "Symbols removed:" in result.output
4475 assert "Symbols modified:" in result.output
4476 assert "SemVer impact:" in result.output
4477
4478 def test_compare_stat_no_per_symbol_listing(
4479 self, compare_repo: tuple[pathlib.Path, str, str]
4480 ) -> None:
4481 _, ref_a, ref_b = compare_repo
4482 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4483 assert result.exit_code == 0, result.output
4484 # --stat should not include per-symbol listing lines ("added …", "removed …").
4485 assert " added " not in result.output
4486 assert " removed " not in result.output
4487 assert " modified " not in result.output
4488
4489 def test_compare_stat_same_ref_semver_none(
4490 self, compare_repo: tuple[pathlib.Path, str, str]
4491 ) -> None:
4492 _, ref_a, _ = compare_repo
4493 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--stat"])
4494 assert result.exit_code == 0, result.output
4495 assert "NONE" in result.output
4496
4497 # ── --semver flag ────────────────────────────────────────────────────────
4498
4499 def test_compare_semver_appended_to_full_output(
4500 self, compare_repo: tuple[pathlib.Path, str, str]
4501 ) -> None:
4502 _, ref_a, ref_b = compare_repo
4503 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--semver"])
4504 assert result.exit_code == 0, result.output
4505 assert "SemVer impact:" in result.output
4506
4507 # ── --file filter ────────────────────────────────────────────────────────
4508
4509 def test_compare_file_filter_restricts_output(
4510 self, compare_repo: tuple[pathlib.Path, str, str]
4511 ) -> None:
4512 _, ref_a, ref_b = compare_repo
4513 result = runner.invoke(
4514 cli, ["code", "compare", ref_a, ref_b, "--file", "billing.py"]
4515 )
4516 assert result.exit_code == 0, result.output
4517
4518 def test_compare_file_filter_nonexistent_no_ops(
4519 self, compare_repo: tuple[pathlib.Path, str, str]
4520 ) -> None:
4521 _, ref_a, ref_b = compare_repo
4522 result = runner.invoke(
4523 cli, ["code", "compare", ref_a, ref_b, "--file", "nonexistent.py"]
4524 )
4525 assert result.exit_code == 0, result.output
4526 assert "no semantic changes" in result.output
4527
4528 def test_compare_file_filter_in_json(
4529 self, compare_repo: tuple[pathlib.Path, str, str]
4530 ) -> None:
4531 _, ref_a, ref_b = compare_repo
4532 result = runner.invoke(
4533 cli,
4534 ["code", "compare", ref_a, ref_b, "--file", "billing.py", "--json"],
4535 )
4536 assert result.exit_code == 0, result.output
4537 data = json.loads(result.output)
4538 assert data["filters"]["file"] == "billing.py"
4539
4540 # ── --kind filter ────────────────────────────────────────────────────────
4541
4542 def test_compare_kind_filter_case_insensitive(
4543 self, compare_repo: tuple[pathlib.Path, str, str]
4544 ) -> None:
4545 _, ref_a, ref_b = compare_repo
4546 r_lower = runner.invoke(
4547 cli, ["code", "compare", ref_a, ref_b, "--kind", "function"]
4548 )
4549 r_upper = runner.invoke(
4550 cli, ["code", "compare", ref_a, ref_b, "--kind", "Function"]
4551 )
4552 assert r_lower.exit_code == 0
4553 assert r_upper.exit_code == 0
4554 # Both produce the same ops list.
4555 assert r_lower.output == r_upper.output
4556
4557 def test_compare_kind_filter_in_json(
4558 self, compare_repo: tuple[pathlib.Path, str, str]
4559 ) -> None:
4560 _, ref_a, ref_b = compare_repo
4561 result = runner.invoke(
4562 cli, ["code", "compare", ref_a, ref_b, "--kind", "function", "--json"]
4563 )
4564 assert result.exit_code == 0, result.output
4565 data = json.loads(result.output)
4566 assert data["filters"]["kind"] == "function"
4567
4568 # ── --language filter ────────────────────────────────────────────────────
4569
4570 def test_compare_language_filter_python(
4571 self, compare_repo: tuple[pathlib.Path, str, str]
4572 ) -> None:
4573 _, ref_a, ref_b = compare_repo
4574 result = runner.invoke(
4575 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4576 )
4577 assert result.exit_code == 0, result.output
4578
4579 def test_compare_language_filter_case_insensitive(
4580 self, compare_repo: tuple[pathlib.Path, str, str]
4581 ) -> None:
4582 _, ref_a, ref_b = compare_repo
4583 r_lower = runner.invoke(
4584 cli, ["code", "compare", ref_a, ref_b, "--language", "python"]
4585 )
4586 r_upper = runner.invoke(
4587 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4588 )
4589 assert r_lower.exit_code == 0
4590 assert r_upper.exit_code == 0
4591 assert r_lower.output == r_upper.output
4592
4593 def test_compare_language_filter_in_json(
4594 self, compare_repo: tuple[pathlib.Path, str, str]
4595 ) -> None:
4596 _, ref_a, ref_b = compare_repo
4597 result = runner.invoke(
4598 cli, ["code", "compare", ref_a, ref_b, "--language", "python", "--json"]
4599 )
4600 assert result.exit_code == 0, result.output
4601 data = json.loads(result.output)
4602 assert data["filters"]["language"] == "Python"
4603
4604
4605 # ---------------------------------------------------------------------------
4606 # muse code languages
4607 # ---------------------------------------------------------------------------
4608
4609
4610 @pytest.fixture
4611 def lang_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4612 """Two-commit repo; returns (path, commit_id_a, commit_id_b).
4613
4614 Commit A — billing.py (Python) only.
4615 Commit B — billing.py extended + README.md added.
4616 """
4617 (repo / "billing.py").write_text(textwrap.dedent("""\
4618 import os
4619 import json
4620
4621 class Invoice:
4622 def compute_total(self, items: list[float]) -> float:
4623 return sum(items)
4624
4625 def process_order(invoice: Invoice, items: list[float]) -> float:
4626 return invoice.compute_total(items)
4627 """))
4628 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4629 assert r.exit_code == 0, r.output
4630 from muse.core.store import read_current_branch
4631 branch = read_current_branch(repo)
4632 commit_a = get_head_commit_id(repo, branch)
4633
4634 (repo / "billing.py").write_text(textwrap.dedent("""\
4635 import os
4636 import json
4637
4638 class Invoice:
4639 def compute_total(self, items: list[float]) -> float:
4640 return sum(items)
4641
4642 def generate_pdf(self) -> bytes:
4643 return b"pdf"
4644
4645 def process_order(invoice: Invoice, items: list[float]) -> float:
4646 return invoice.compute_total(items)
4647
4648 def send_email(address: str) -> None:
4649 pass
4650 """))
4651 (repo / "README.md").write_text("# My Project\n\nA billing module.\n")
4652 r = runner.invoke(cli, ["commit", "-m", "Add generate_pdf, send_email, README"])
4653 assert r.exit_code == 0, r.output
4654 commit_b = get_head_commit_id(repo, branch)
4655
4656 assert commit_a is not None
4657 assert commit_b is not None
4658 return repo, commit_a, commit_b
4659
4660
4661 class TestLanguages:
4662 """Tests for muse code languages."""
4663
4664 # ── basic correctness ────────────────────────────────────────────────────
4665
4666 def test_languages_exits_zero(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4667 result = runner.invoke(cli, ["code", "languages"])
4668 assert result.exit_code == 0, result.output
4669
4670 def test_languages_shows_header(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4671 result = runner.invoke(cli, ["code", "languages"])
4672 assert result.exit_code == 0, result.output
4673 assert "Language breakdown" in result.output
4674 assert "Total" in result.output
4675
4676 def test_languages_shows_python(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4677 result = runner.invoke(cli, ["code", "languages"])
4678 assert result.exit_code == 0, result.output
4679 assert "Python" in result.output
4680
4681 def test_languages_shows_markdown(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4682 result = runner.invoke(cli, ["code", "languages"])
4683 assert result.exit_code == 0, result.output
4684 assert "Markdown" in result.output
4685
4686 def test_languages_excludes_imports_by_default(
4687 self, lang_repo: tuple[pathlib.Path, str, str]
4688 ) -> None:
4689 """Import pseudo-symbols must not inflate the count by default."""
4690 r_default = runner.invoke(cli, ["code", "languages", "--json"])
4691 assert r_default.exit_code == 0, r_default.output
4692 r_imports = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4693 assert r_imports.exit_code == 0, r_imports.output
4694
4695 class _LangEntry(TypedDict):
4696 language: str
4697 files: int
4698 symbols: int
4699 kinds: _KindsMap
4700
4701 class _LangsJson(TypedDict):
4702 languages: list[_LangEntry]
4703
4704 data_default: _LangsJson = json.loads(r_default.output)
4705 data_imports: _LangsJson = json.loads(r_imports.output)
4706
4707 def _py_syms(data: _LangsJson) -> int:
4708 for e in data["languages"]:
4709 if e["language"] == "Python":
4710 return e["symbols"]
4711 return 0
4712
4713 syms_default = _py_syms(data_default)
4714 syms_imports = _py_syms(data_imports)
4715 # With imports included the symbol count must be strictly higher.
4716 assert syms_imports > syms_default
4717
4718 def test_languages_requires_repo(
4719 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4720 ) -> None:
4721 monkeypatch.chdir(tmp_path)
4722 result = runner.invoke(cli, ["code", "languages"])
4723 assert result.exit_code != 0
4724
4725 def test_languages_invalid_commit_exits_nonzero(
4726 self, lang_repo: tuple[pathlib.Path, str, str]
4727 ) -> None:
4728 result = runner.invoke(cli, ["code", "languages", "--commit", "deadbeefdeadbeef"])
4729 assert result.exit_code != 0
4730
4731 # ── JSON schema ──────────────────────────────────────────────────────────
4732
4733 def test_languages_json_schema(
4734 self, lang_repo: tuple[pathlib.Path, str, str]
4735 ) -> None:
4736 result = runner.invoke(cli, ["code", "languages", "--json"])
4737 assert result.exit_code == 0, result.output
4738 data = json.loads(result.output)
4739 assert set(data.keys()) >= {"commit", "include_imports", "languages"}
4740
4741 def test_languages_json_commit_block(
4742 self, lang_repo: tuple[pathlib.Path, str, str]
4743 ) -> None:
4744 result = runner.invoke(cli, ["code", "languages", "--json"])
4745 assert result.exit_code == 0, result.output
4746 data = json.loads(result.output)
4747 commit = data["commit"]
4748 assert "commit_id" in commit
4749 assert "message" in commit
4750 # message is first line only — no newlines.
4751 assert "\n" not in commit["message"]
4752
4753 def test_languages_json_entry_schema(
4754 self, lang_repo: tuple[pathlib.Path, str, str]
4755 ) -> None:
4756 result = runner.invoke(cli, ["code", "languages", "--json"])
4757 assert result.exit_code == 0, result.output
4758 langs = json.loads(result.output)["languages"]
4759 assert isinstance(langs, list)
4760 assert len(langs) > 0
4761 for entry in langs:
4762 assert "language" in entry
4763 assert "files" in entry
4764 assert "symbols" in entry
4765 assert "kinds" in entry
4766 assert isinstance(entry["files"], int)
4767 assert isinstance(entry["symbols"], int)
4768 assert isinstance(entry["kinds"], dict)
4769
4770 def test_languages_json_include_imports_flag(
4771 self, lang_repo: tuple[pathlib.Path, str, str]
4772 ) -> None:
4773 result = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4774 assert result.exit_code == 0, result.output
4775 data = json.loads(result.output)
4776 assert data["include_imports"] is True
4777
4778 # ── --sort flag ──────────────────────────────────────────────────────────
4779
4780 def test_languages_sort_name(
4781 self, lang_repo: tuple[pathlib.Path, str, str]
4782 ) -> None:
4783 result = runner.invoke(cli, ["code", "languages", "--sort", "name"])
4784 assert result.exit_code == 0, result.output
4785
4786 def test_languages_sort_symbols(
4787 self, lang_repo: tuple[pathlib.Path, str, str]
4788 ) -> None:
4789 result = runner.invoke(cli, ["code", "languages", "--sort", "symbols"])
4790 assert result.exit_code == 0, result.output
4791 # Python should appear before Markdown when sorted by symbols desc.
4792 lines = result.output.splitlines()
4793 py_line = next((i for i, l in enumerate(lines) if "Python" in l), None)
4794 md_line = next((i for i, l in enumerate(lines) if "Markdown" in l), None)
4795 # Both might not exist if the repo only has Python; at least ensure no crash.
4796 assert py_line is not None
4797
4798 def test_languages_sort_files(
4799 self, lang_repo: tuple[pathlib.Path, str, str]
4800 ) -> None:
4801 result = runner.invoke(cli, ["code", "languages", "--sort", "files"])
4802 assert result.exit_code == 0, result.output
4803
4804 def test_languages_invalid_sort_exits_nonzero(
4805 self, lang_repo: tuple[pathlib.Path, str, str]
4806 ) -> None:
4807 result = runner.invoke(cli, ["code", "languages", "--sort", "bad"])
4808 assert result.exit_code != 0
4809
4810 # ── --diff flag ──────────────────────────────────────────────────────────
4811
4812 def test_languages_diff_exits_zero(
4813 self, lang_repo: tuple[pathlib.Path, str, str]
4814 ) -> None:
4815 _, commit_a, _ = lang_repo
4816 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4817 assert result.exit_code == 0, result.output
4818
4819 def test_languages_diff_shows_header(
4820 self, lang_repo: tuple[pathlib.Path, str, str]
4821 ) -> None:
4822 _, commit_a, _ = lang_repo
4823 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4824 assert result.exit_code == 0, result.output
4825 assert "Language change" in result.output
4826 assert "Net" in result.output
4827
4828 def test_languages_diff_detects_new_symbols(
4829 self, lang_repo: tuple[pathlib.Path, str, str]
4830 ) -> None:
4831 """Commit B added generate_pdf and send_email — Python symbol count must grow."""
4832 _, commit_a, _ = lang_repo
4833 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4834 assert result.exit_code == 0, result.output
4835 # Python line should show a positive delta.
4836 lines = result.output.splitlines()
4837 py_line = next((l for l in lines if "Python" in l), "")
4838 assert "+" in py_line
4839
4840 def test_languages_diff_unchanged_label(
4841 self, lang_repo: tuple[pathlib.Path, str, str]
4842 ) -> None:
4843 """Comparing a commit to itself must show all languages as unchanged."""
4844 _, _, commit_b = lang_repo
4845 result = runner.invoke(cli, ["code", "languages", "--diff", commit_b])
4846 assert result.exit_code == 0, result.output
4847 assert "unchanged" in result.output
4848
4849 def test_languages_diff_invalid_ref_exits_nonzero(
4850 self, lang_repo: tuple[pathlib.Path, str, str]
4851 ) -> None:
4852 result = runner.invoke(cli, ["code", "languages", "--diff", "deadbeefdeadbeef"])
4853 assert result.exit_code != 0
4854
4855 def test_languages_diff_json_schema(
4856 self, lang_repo: tuple[pathlib.Path, str, str]
4857 ) -> None:
4858 _, commit_a, _ = lang_repo
4859 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4860 assert result.exit_code == 0, result.output
4861 data = json.loads(result.output)
4862 assert set(data.keys()) >= {"from_commit", "to_commit", "include_imports", "diff"}
4863 assert "commit_id" in data["from_commit"]
4864 assert "message" in data["to_commit"]
4865
4866 def test_languages_diff_json_entry_schema(
4867 self, lang_repo: tuple[pathlib.Path, str, str]
4868 ) -> None:
4869 _, commit_a, _ = lang_repo
4870 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4871 assert result.exit_code == 0, result.output
4872 diff = json.loads(result.output)["diff"]
4873 assert isinstance(diff, list)
4874 assert len(diff) > 0
4875 for entry in diff:
4876 assert "language" in entry
4877 assert "delta_files" in entry
4878 assert "delta_symbols" in entry
4879 assert "files_before" in entry
4880 assert "files_after" in entry
4881 assert "symbols_before" in entry
4882 assert "symbols_after" in entry
4883 assert "status" in entry
4884 assert entry["status"] in ("added", "removed", "changed", "unchanged")
4885
4886 def test_languages_diff_json_python_delta_positive(
4887 self, lang_repo: tuple[pathlib.Path, str, str]
4888 ) -> None:
4889 _, commit_a, _ = lang_repo
4890 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4891 assert result.exit_code == 0, result.output
4892 diff = json.loads(result.output)["diff"]
4893 py = next((e for e in diff if e["language"] == "Python"), None)
4894 assert py is not None
4895 assert py["delta_symbols"] > 0
4896 assert py["status"] == "changed"
4897
4898 def test_languages_diff_json_markdown_added(
4899 self, lang_repo: tuple[pathlib.Path, str, str]
4900 ) -> None:
4901 """README.md was added in commit B — Markdown status should be 'added'."""
4902 _, commit_a, _ = lang_repo
4903 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4904 assert result.exit_code == 0, result.output
4905 diff = json.loads(result.output)["diff"]
4906 md = next((e for e in diff if e["language"] == "Markdown"), None)
4907 assert md is not None
4908 assert md["status"] == "added"
4909 assert md["files_before"] == 0
4910 assert md["files_after"] == 1
4911
4912
4913 # ---------------------------------------------------------------------------
4914 # muse code rename
4915 # ---------------------------------------------------------------------------
4916
4917
4918 @pytest.fixture
4919 def rename_repo(repo: pathlib.Path) -> pathlib.Path:
4920 """Repo with billing.py and a test file that imports and calls its symbols."""
4921 (repo / "billing.py").write_text(textwrap.dedent("""\
4922 import os
4923
4924 class Invoice:
4925 def compute_total(self, items):
4926 return sum(items)
4927
4928 def apply_discount(self, total, pct):
4929 return total * (1 - pct)
4930
4931 def process_order(invoice, items):
4932 total = compute_total(items)
4933 return total
4934 """))
4935 (repo / "test_billing.py").write_text(textwrap.dedent("""\
4936 from billing import compute_total, Invoice
4937
4938 def test_compute_total():
4939 inv = Invoice()
4940 result = inv.compute_total([1, 2, 3])
4941 assert compute_total([1, 2, 3]) == 6
4942 """))
4943 r = runner.invoke(cli, ["commit", "-m", "Initial billing + tests"])
4944 assert r.exit_code == 0, r.output
4945 return repo
4946
4947
4948 class TestRename:
4949 """Tests for muse code rename."""
4950
4951 # ── basic correctness ────────────────────────────────────────────────────
4952
4953 def test_rename_dry_run_exits_zero(self, rename_repo: pathlib.Path) -> None:
4954 result = runner.invoke(
4955 cli,
4956 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4957 )
4958 assert result.exit_code == 0, result.output
4959
4960 def test_rename_dry_run_shows_preview(self, rename_repo: pathlib.Path) -> None:
4961 result = runner.invoke(
4962 cli,
4963 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4964 )
4965 assert result.exit_code == 0, result.output
4966 assert "Renaming" in result.output
4967 assert "process_order" in result.output
4968 assert "handle_order" in result.output
4969
4970 def test_rename_dry_run_does_not_write(self, rename_repo: pathlib.Path) -> None:
4971 before = (rename_repo / "billing.py").read_text()
4972 runner.invoke(
4973 cli,
4974 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4975 )
4976 assert (rename_repo / "billing.py").read_text() == before
4977
4978 def test_rename_applies_definition(self, rename_repo: pathlib.Path) -> None:
4979 result = runner.invoke(
4980 cli,
4981 ["code", "rename", "billing.py::process_order", "handle_order",
4982 "--scope", "definition", "--yes"],
4983 )
4984 assert result.exit_code == 0, result.output
4985 content = (rename_repo / "billing.py").read_text()
4986 assert "def handle_order(" in content
4987 assert "def process_order(" not in content
4988
4989 def test_rename_only_def_token_not_string_literal(
4990 self, rename_repo: pathlib.Path
4991 ) -> None:
4992 """The rename must not touch string literals containing the old name."""
4993 # Add a docstring with the old name.
4994 billing = (rename_repo / "billing.py").read_text()
4995 billing += '\nDOC = "compute_total is a function"\n'
4996 (rename_repo / "billing.py").write_text(billing)
4997 runner.invoke(cli, ["commit", "-m", "add docstring"])
4998
4999 runner.invoke(
5000 cli,
5001 ["code", "rename", "billing.py::Invoice.compute_total",
5002 "compute_invoice_total", "--scope", "definition", "--yes"],
5003 )
5004 content = (rename_repo / "billing.py").read_text()
5005 # The string literal must be untouched.
5006 assert '"compute_total is a function"' in content
5007
5008 def test_rename_method_definition_scoped_to_class(
5009 self, rename_repo: pathlib.Path
5010 ) -> None:
5011 """billing.py::Invoice.compute_total must rename the method inside Invoice."""
5012 result = runner.invoke(
5013 cli,
5014 ["code", "rename", "billing.py::Invoice.compute_total",
5015 "compute_invoice_total", "--scope", "definition", "--yes"],
5016 )
5017 assert result.exit_code == 0, result.output
5018 content = (rename_repo / "billing.py").read_text()
5019 assert "def compute_invoice_total(self" in content
5020 # The module-level bare call in process_order stays unchanged.
5021 assert "compute_total(items)" in content
5022
5023 def test_rename_updates_import_sites(self, rename_repo: pathlib.Path) -> None:
5024 result = runner.invoke(
5025 cli,
5026 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5027 "--scope", "imports", "--yes"],
5028 )
5029 assert result.exit_code == 0, result.output
5030 content = (rename_repo / "test_billing.py").read_text()
5031 assert "compute_invoice_total" in content
5032 assert "from billing import" in content
5033
5034 def test_rename_requires_repo(
5035 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5036 ) -> None:
5037 monkeypatch.chdir(tmp_path)
5038 result = runner.invoke(
5039 cli, ["code", "rename", "billing.py::foo", "bar", "--yes"]
5040 )
5041 assert result.exit_code != 0
5042
5043 def test_rename_rejects_same_name(self, rename_repo: pathlib.Path) -> None:
5044 result = runner.invoke(
5045 cli,
5046 ["code", "rename", "billing.py::process_order", "process_order", "--yes"],
5047 )
5048 assert result.exit_code != 0
5049
5050 def test_rename_rejects_invalid_identifier(self, rename_repo: pathlib.Path) -> None:
5051 result = runner.invoke(
5052 cli,
5053 ["code", "rename", "billing.py::process_order", "123invalid", "--yes"],
5054 )
5055 assert result.exit_code != 0
5056
5057 def test_rename_rejects_address_without_double_colon(
5058 self, rename_repo: pathlib.Path
5059 ) -> None:
5060 result = runner.invoke(
5061 cli, ["code", "rename", "billing.py", "new_name", "--yes"]
5062 )
5063 assert result.exit_code != 0
5064
5065 def test_rename_rejects_nonexistent_symbol(self, rename_repo: pathlib.Path) -> None:
5066 result = runner.invoke(
5067 cli,
5068 ["code", "rename", "billing.py::nonexistent_func", "new_name",
5069 "--scope", "definition", "--yes"],
5070 )
5071 assert result.exit_code != 0
5072
5073 def test_rename_rejects_path_traversal(self, rename_repo: pathlib.Path) -> None:
5074 result = runner.invoke(
5075 cli,
5076 ["code", "rename", "../../etc/passwd::foo", "bar", "--yes"],
5077 )
5078 assert result.exit_code != 0
5079
5080 def test_rename_rejects_dunder_without_force(self, rename_repo: pathlib.Path) -> None:
5081 result = runner.invoke(
5082 cli,
5083 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__", "--yes"],
5084 )
5085 assert result.exit_code != 0
5086
5087 def test_rename_allows_dunder_with_force(self, rename_repo: pathlib.Path) -> None:
5088 result = runner.invoke(
5089 cli,
5090 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__",
5091 "--scope", "definition", "--yes", "--force"],
5092 )
5093 assert result.exit_code == 0, result.output
5094 content = (rename_repo / "billing.py").read_text()
5095 assert "def __compute__(self" in content
5096
5097 # ── JSON output ──────────────────────────────────────────────────────────
5098
5099 def test_rename_json_schema(self, rename_repo: pathlib.Path) -> None:
5100 result = runner.invoke(
5101 cli,
5102 ["code", "rename", "billing.py::process_order", "handle_order",
5103 "--dry-run", "--json"],
5104 )
5105 assert result.exit_code == 0, result.output
5106 data = json.loads(result.output)
5107 assert set(data.keys()) >= {
5108 "from_address", "to_address", "from_name", "to_name",
5109 "scope", "dry_run", "files_to_modify", "total_edit_sites", "edit_sites",
5110 }
5111
5112 def test_rename_json_dry_run_empty_files_to_modify(
5113 self, rename_repo: pathlib.Path
5114 ) -> None:
5115 result = runner.invoke(
5116 cli,
5117 ["code", "rename", "billing.py::process_order", "handle_order",
5118 "--dry-run", "--json"],
5119 )
5120 assert result.exit_code == 0, result.output
5121 data = json.loads(result.output)
5122 assert data["dry_run"] is True
5123 assert data["files_to_modify"] == []
5124
5125 def test_rename_json_edit_site_schema(self, rename_repo: pathlib.Path) -> None:
5126 result = runner.invoke(
5127 cli,
5128 ["code", "rename", "billing.py::process_order", "handle_order",
5129 "--dry-run", "--json"],
5130 )
5131 assert result.exit_code == 0, result.output
5132 sites = json.loads(result.output)["edit_sites"]
5133 assert isinstance(sites, list)
5134 assert len(sites) > 0
5135 for site in sites:
5136 assert "file" in site
5137 assert "line" in site
5138 assert "col_start" in site
5139 assert "col_end" in site
5140 assert "kind" in site
5141 assert "context" in site
5142 assert site["kind"] in ("definition", "import", "reference")
5143 assert site["col_start"] < site["col_end"]
5144
5145 def test_rename_json_definition_site_present(self, rename_repo: pathlib.Path) -> None:
5146 result = runner.invoke(
5147 cli,
5148 ["code", "rename", "billing.py::process_order", "handle_order",
5149 "--dry-run", "--json"],
5150 )
5151 assert result.exit_code == 0, result.output
5152 sites = json.loads(result.output)["edit_sites"]
5153 def_sites = [s for s in sites if s["kind"] == "definition"]
5154 assert len(def_sites) == 1
5155 assert def_sites[0]["file"] == "billing.py"
5156 assert "process_order" in def_sites[0]["context"]
5157
5158 def test_rename_json_apply_writes_files(self, rename_repo: pathlib.Path) -> None:
5159 result = runner.invoke(
5160 cli,
5161 ["code", "rename", "billing.py::process_order", "handle_order",
5162 "--yes", "--json", "--scope", "definition"],
5163 )
5164 assert result.exit_code == 0, result.output
5165 content = (rename_repo / "billing.py").read_text()
5166 assert "def handle_order(" in content
5167
5168 # ── --scope flag ─────────────────────────────────────────────────────────
5169
5170 def test_rename_scope_definition_only(self, rename_repo: pathlib.Path) -> None:
5171 """--scope definition should only touch the def token."""
5172 runner.invoke(
5173 cli,
5174 ["code", "rename", "billing.py::process_order", "handle_order",
5175 "--scope", "definition", "--yes"],
5176 )
5177 billing = (rename_repo / "billing.py").read_text()
5178 test = (rename_repo / "test_billing.py").read_text()
5179 assert "def handle_order(" in billing
5180 # The import in test_billing.py must be untouched.
5181 assert "process_order" not in test or "import" in test
5182
5183 def test_rename_scope_imports_only(self, rename_repo: pathlib.Path) -> None:
5184 runner.invoke(
5185 cli,
5186 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5187 "--scope", "imports", "--yes"],
5188 )
5189 billing = (rename_repo / "billing.py").read_text()
5190 # The definition in billing.py must be untouched.
5191 assert "def compute_total(" in billing
5192
5193 def test_rename_json_scope_reflected(self, rename_repo: pathlib.Path) -> None:
5194 result = runner.invoke(
5195 cli,
5196 ["code", "rename", "billing.py::process_order", "handle_order",
5197 "--scope", "definition", "--dry-run", "--json"],
5198 )
5199 assert result.exit_code == 0, result.output
5200 assert json.loads(result.output)["scope"] == "definition"
5201
5202 # ── --max-files guard ────────────────────────────────────────────────────
5203
5204 def test_rename_max_files_validation(self, rename_repo: pathlib.Path) -> None:
5205 result = runner.invoke(
5206 cli,
5207 ["code", "rename", "billing.py::process_order", "handle_order",
5208 "--max-files", "0", "--dry-run"],
5209 )
5210 assert result.exit_code != 0
5211
5212 # ── edit precision ───────────────────────────────────────────────────────
5213
5214 def test_rename_preserves_surrounding_code(self, rename_repo: pathlib.Path) -> None:
5215 """Renaming process_order must not touch apply_discount or compute_total."""
5216 runner.invoke(
5217 cli,
5218 ["code", "rename", "billing.py::process_order", "handle_order",
5219 "--scope", "definition", "--yes"],
5220 )
5221 content = (rename_repo / "billing.py").read_text()
5222 assert "def apply_discount(" in content
5223 assert "def compute_total(" in content
5224
5225 def test_rename_col_precision_correct(self, rename_repo: pathlib.Path) -> None:
5226 """The definition rename must produce syntactically valid Python."""
5227 runner.invoke(
5228 cli,
5229 ["code", "rename", "billing.py::process_order", "handle_order",
5230 "--scope", "definition", "--yes"],
5231 )
5232 import ast as _ast
5233 content = (rename_repo / "billing.py").read_text()
5234 # Must parse without SyntaxError.
5235 try:
5236 _ast.parse(content)
5237 except SyntaxError as e:
5238 pytest.fail(f"Renamed file has a syntax error: {e}")
5239
5240
5241 # ---------------------------------------------------------------------------
5242 # blast-risk
5243 # ---------------------------------------------------------------------------
5244
5245
5246 @pytest.fixture
5247 def blast_repo(repo: pathlib.Path) -> pathlib.Path:
5248 """Repo with two commits: a production module and a test file.
5249
5250 billing.py defines Invoice.compute_total and process_order.
5251 test_billing.py imports and calls both — so they have at least one
5252 test caller. A second commit modifies compute_total so churn > 0.
5253 """
5254 (repo / "billing.py").write_text(textwrap.dedent("""\
5255 class Invoice:
5256 def compute_total(self, items):
5257 return sum(items)
5258
5259 def apply_discount(self, total, pct):
5260 return total * (1 - pct)
5261
5262 def process_order(invoice, items):
5263 return invoice.compute_total(items)
5264 """))
5265 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5266 from billing import Invoice, process_order
5267
5268 def test_compute_total():
5269 inv = Invoice()
5270 assert inv.compute_total([1, 2, 3]) == 6
5271
5272 def test_process_order():
5273 inv = Invoice()
5274 assert process_order(inv, [10]) == 10
5275 """))
5276 r = runner.invoke(cli, ["commit", "-m", "Add billing module and tests"])
5277 assert r.exit_code == 0, r.output
5278
5279 # Second commit: modify compute_total so churn count > 0.
5280 (repo / "billing.py").write_text(textwrap.dedent("""\
5281 class Invoice:
5282 def compute_total(self, items):
5283 # round to two decimal places
5284 return round(sum(items), 2)
5285
5286 def apply_discount(self, total, pct):
5287 return total * (1 - pct)
5288
5289 def process_order(invoice, items):
5290 return invoice.compute_total(items)
5291 """))
5292 r2 = runner.invoke(cli, ["commit", "-m", "Round compute_total result"])
5293 assert r2.exit_code == 0, r2.output
5294
5295 return repo
5296
5297
5298 class TestBlastRisk:
5299 """Tests for muse code blast-risk."""
5300
5301 # ── basic correctness ────────────────────────────────────────────────────
5302
5303 def test_blast_risk_exits_zero(self, blast_repo: pathlib.Path) -> None:
5304 result = runner.invoke(cli, ["code", "blast-risk"])
5305 assert result.exit_code == 0, result.output
5306
5307 def test_blast_risk_shows_header(self, blast_repo: pathlib.Path) -> None:
5308 result = runner.invoke(cli, ["code", "blast-risk"])
5309 assert result.exit_code == 0
5310 assert "blast-risk" in result.output
5311 assert "commits" in result.output
5312
5313 def test_blast_risk_shows_scoring_line(self, blast_repo: pathlib.Path) -> None:
5314 result = runner.invoke(cli, ["code", "blast-risk"])
5315 assert result.exit_code == 0
5316 assert "Scoring:" in result.output
5317 assert "impact" in result.output
5318 assert "churn" in result.output
5319 assert "test-gap" in result.output
5320 assert "coupling" in result.output
5321
5322 def test_blast_risk_shows_table_columns(self, blast_repo: pathlib.Path) -> None:
5323 result = runner.invoke(cli, ["code", "blast-risk"])
5324 assert result.exit_code == 0
5325 assert "RISK" in result.output
5326 assert "IMPACT" in result.output
5327 assert "CHURN" in result.output
5328 assert "TEST-GAP" in result.output
5329
5330 def test_blast_risk_lists_symbols(self, blast_repo: pathlib.Path) -> None:
5331 result = runner.invoke(cli, ["code", "blast-risk"])
5332 assert result.exit_code == 0
5333 # At least one symbol from billing.py should appear.
5334 assert "billing.py" in result.output
5335
5336 def test_blast_risk_risk_scores_in_range(self, blast_repo: pathlib.Path) -> None:
5337 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5338 assert result.exit_code == 0, result.output
5339 data = json.loads(result.output)
5340 for sym in data["symbols"]:
5341 assert 0 <= sym["risk"] <= 100
5342 assert 0 <= sym["impact_score"] <= 100
5343 assert 0 <= sym["churn_score"] <= 100
5344 assert 0 <= sym["test_gap_score"] <= 100
5345 assert 0 <= sym["coupling_score"] <= 100
5346
5347 # ── JSON schema ──────────────────────────────────────────────────────────
5348
5349 def test_blast_risk_json_top_level_keys(self, blast_repo: pathlib.Path) -> None:
5350 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5351 assert result.exit_code == 0, result.output
5352 data = json.loads(result.output)
5353 assert "ref" in data
5354 assert "commits_analysed" in data
5355 assert "truncated" in data
5356 assert "filters" in data
5357 assert "weights" in data
5358 assert "symbols" in data
5359
5360 def test_blast_risk_json_weights_sum_to_one(self, blast_repo: pathlib.Path) -> None:
5361 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5362 data = json.loads(result.output)
5363 total = sum(data["weights"].values())
5364 assert abs(total - 1.0) < 1e-6
5365
5366 def test_blast_risk_json_symbol_schema(self, blast_repo: pathlib.Path) -> None:
5367 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5368 data = json.loads(result.output)
5369 assert len(data["symbols"]) > 0
5370 sym = data["symbols"][0]
5371 for key in ("address", "kind", "file", "risk",
5372 "impact_raw", "churn_raw", "test_gap_raw",
5373 "coupling_raw", "impact_score", "churn_score",
5374 "test_gap_score", "coupling_score"):
5375 assert key in sym, f"missing key: {key}"
5376
5377 def test_blast_risk_json_sorted_by_risk_desc(self, blast_repo: pathlib.Path) -> None:
5378 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5379 data = json.loads(result.output)
5380 risks = [s["risk"] for s in data["symbols"]]
5381 assert risks == sorted(risks, reverse=True)
5382
5383 def test_blast_risk_json_no_import_pseudosymbols(self, blast_repo: pathlib.Path) -> None:
5384 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5385 data = json.loads(result.output)
5386 for sym in data["symbols"]:
5387 assert "::import::" not in sym["address"]
5388
5389 def test_blast_risk_json_filters_reflected(self, blast_repo: pathlib.Path) -> None:
5390 result = runner.invoke(
5391 cli, ["code", "blast-risk", "--json", "--kind", "function", "--min-risk", "10"]
5392 )
5393 data = json.loads(result.output)
5394 assert data["filters"]["kind"] == "function"
5395 assert data["filters"]["min_risk"] == 10
5396
5397 # ── --top flag ───────────────────────────────────────────────────────────
5398
5399 def test_blast_risk_top_limits_output(self, blast_repo: pathlib.Path) -> None:
5400 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--top", "2"])
5401 data = json.loads(result.output)
5402 assert len(data["symbols"]) <= 2
5403
5404 def test_blast_risk_top_validation(self, blast_repo: pathlib.Path) -> None:
5405 result = runner.invoke(cli, ["code", "blast-risk", "--top", "0"])
5406 assert result.exit_code != 0
5407
5408 # ── --kind filter ────────────────────────────────────────────────────────
5409
5410 def test_blast_risk_kind_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5411 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "class"])
5412 data = json.loads(result.output)
5413 for sym in data["symbols"]:
5414 assert sym["kind"] == "class"
5415
5416 def test_blast_risk_kind_filter_function(self, blast_repo: pathlib.Path) -> None:
5417 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "function"])
5418 assert result.exit_code == 0
5419 data = json.loads(result.output)
5420 for sym in data["symbols"]:
5421 assert sym["kind"] in ("function", "method")
5422
5423 # ── --file filter ────────────────────────────────────────────────────────
5424
5425 def test_blast_risk_file_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5426 result = runner.invoke(
5427 cli, ["code", "blast-risk", "--json", "--file", "billing.py"]
5428 )
5429 data = json.loads(result.output)
5430 for sym in data["symbols"]:
5431 assert "billing.py" in sym["file"]
5432
5433 def test_blast_risk_file_filter_nonexistent_returns_empty(
5434 self, blast_repo: pathlib.Path
5435 ) -> None:
5436 result = runner.invoke(
5437 cli, ["code", "blast-risk", "--json", "--file", "no_such_file.py"]
5438 )
5439 assert result.exit_code == 0
5440 data = json.loads(result.output)
5441 assert data["symbols"] == []
5442
5443 # ── --min-risk filter ────────────────────────────────────────────────────
5444
5445 def test_blast_risk_min_risk_filters(self, blast_repo: pathlib.Path) -> None:
5446 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "80"])
5447 data = json.loads(result.output)
5448 for sym in data["symbols"]:
5449 assert sym["risk"] >= 80
5450
5451 def test_blast_risk_min_risk_100_all_excluded(self, blast_repo: pathlib.Path) -> None:
5452 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "100"])
5453 assert result.exit_code == 0
5454
5455 def test_blast_risk_min_risk_validation(self, blast_repo: pathlib.Path) -> None:
5456 result = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "101"])
5457 assert result.exit_code != 0
5458 result2 = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "-1"])
5459 assert result2.exit_code != 0
5460
5461 # ── --explain flag ───────────────────────────────────────────────────────
5462
5463 def test_blast_risk_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
5464 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5465 data = json.loads(result.output)
5466 if not data["symbols"]:
5467 pytest.skip("no symbols")
5468 addr = data["symbols"][0]["address"]
5469 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5470 assert result2.exit_code == 0, result2.output
5471
5472 def test_blast_risk_explain_shows_breakdown(self, blast_repo: pathlib.Path) -> None:
5473 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5474 data = json.loads(result.output)
5475 if not data["symbols"]:
5476 pytest.skip("no symbols")
5477 addr = data["symbols"][0]["address"]
5478 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5479 assert "Risk score:" in result2.output
5480 assert "Impact" in result2.output
5481 assert "Churn" in result2.output
5482 assert "Test gap" in result2.output
5483 assert "Coupling" in result2.output
5484
5485 def test_blast_risk_explain_nonexistent_errors(self, blast_repo: pathlib.Path) -> None:
5486 result = runner.invoke(
5487 cli, ["code", "blast-risk", "--explain", "no_file.py::no_symbol"]
5488 )
5489 assert result.exit_code != 0
5490
5491 def test_blast_risk_explain_json(self, blast_repo: pathlib.Path) -> None:
5492 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5493 data = json.loads(result.output)
5494 if not data["symbols"]:
5495 pytest.skip("no symbols")
5496 addr = data["symbols"][0]["address"]
5497 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr, "--json"])
5498 assert result2.exit_code == 0, result2.output
5499 detail = json.loads(result2.output)
5500 assert detail["address"] == addr
5501 assert "risk" in detail
5502
5503 # ── --max-commits ────────────────────────────────────────────────────────
5504
5505 def test_blast_risk_max_commits_validation(self, blast_repo: pathlib.Path) -> None:
5506 result = runner.invoke(cli, ["code", "blast-risk", "--max-commits", "0"])
5507 assert result.exit_code != 0
5508
5509 def test_blast_risk_max_commits_respected(self, blast_repo: pathlib.Path) -> None:
5510 # With max-commits=1, commits_analysed <= 1.
5511 result = runner.invoke(
5512 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5513 )
5514 assert result.exit_code == 0
5515 data = json.loads(result.output)
5516 assert data["commits_analysed"] <= 1
5517
5518 def test_blast_risk_max_commits_truncated_flag(self, blast_repo: pathlib.Path) -> None:
5519 result = runner.invoke(
5520 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5521 )
5522 data = json.loads(result.output)
5523 # Two commits exist so truncated should be True with cap=1.
5524 assert isinstance(data["truncated"], bool)
5525
5526 # ── --since ──────────────────────────────────────────────────────────────
5527
5528 def test_blast_risk_since_invalid_ref(self, blast_repo: pathlib.Path) -> None:
5529 result = runner.invoke(cli, ["code", "blast-risk", "--since", "nonexistent_ref"])
5530 assert result.exit_code != 0
5531
5532 # ── requires repo ────────────────────────────────────────────────────────
5533
5534 def test_blast_risk_requires_repo(self, tmp_path: pathlib.Path) -> None:
5535 import os
5536 old = os.getcwd()
5537 try:
5538 os.chdir(tmp_path)
5539 result = runner.invoke(cli, ["code", "blast-risk"])
5540 assert result.exit_code != 0
5541 finally:
5542 os.chdir(old)
5543
5544
5545 # ---------------------------------------------------------------------------
5546 # velocity
5547 # ---------------------------------------------------------------------------
5548
5549
5550 @pytest.fixture
5551 def velocity_repo(repo: pathlib.Path) -> pathlib.Path:
5552 """Repo with two modules across several commits to exercise velocity metrics.
5553
5554 Module layout:
5555 core/store.py — grows across commits (inserts)
5556 shrink/util.py — has a delete later (net negative at some point)
5557
5558 Commit structure (window=2):
5559 1: create core/store.py with 2 functions
5560 2: add a third function to core/store.py → current window: +3 added
5561 3: add shrink/util.py with one function → also in current window
5562 4: delete the function in shrink/util.py → shrink net = 0 (1 added, 1 deleted)
5563 """
5564 (repo / "core").mkdir(exist_ok=True)
5565 (repo / "shrink").mkdir(exist_ok=True)
5566
5567 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5568 def read_object(path):
5569 return path.read_bytes()
5570
5571 def write_object(path, data):
5572 path.write_bytes(data)
5573 """))
5574 r = runner.invoke(cli, ["commit", "-m", "core: initial store"])
5575 assert r.exit_code == 0, r.output
5576
5577 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5578 def read_object(path):
5579 return path.read_bytes()
5580
5581 def write_object(path, data):
5582 path.write_bytes(data)
5583
5584 def delete_object(path):
5585 path.unlink()
5586 """))
5587 r2 = runner.invoke(cli, ["commit", "-m", "core: add delete_object"])
5588 assert r2.exit_code == 0, r2.output
5589
5590 (repo / "shrink" / "util.py").write_text(textwrap.dedent("""\
5591 def helper():
5592 return True
5593 """))
5594 r3 = runner.invoke(cli, ["commit", "-m", "shrink: add helper"])
5595 assert r3.exit_code == 0, r3.output
5596
5597 return repo
5598
5599
5600 class TestVelocity:
5601 """Tests for muse code velocity."""
5602
5603 # ── basic correctness ────────────────────────────────────────────────────
5604
5605 def test_velocity_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5606 result = runner.invoke(cli, ["code", "velocity"])
5607 assert result.exit_code == 0, result.output
5608
5609 def test_velocity_shows_header(self, velocity_repo: pathlib.Path) -> None:
5610 result = runner.invoke(cli, ["code", "velocity"])
5611 assert "velocity" in result.output.lower()
5612
5613 def test_velocity_shows_column_headers(self, velocity_repo: pathlib.Path) -> None:
5614 result = runner.invoke(cli, ["code", "velocity"])
5615 assert "ADD" in result.output
5616 assert "NET" in result.output
5617
5618 def test_velocity_shows_modules(self, velocity_repo: pathlib.Path) -> None:
5619 result = runner.invoke(cli, ["code", "velocity"])
5620 # Both modules should appear.
5621 assert "core/" in result.output or "store" in result.output
5622
5623 # ── JSON schema ──────────────────────────────────────────────────────────
5624
5625 def test_velocity_json_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5626 result = runner.invoke(cli, ["code", "velocity", "--json"])
5627 assert result.exit_code == 0, result.output
5628 json.loads(result.output)
5629
5630 def test_velocity_json_top_level_keys(self, velocity_repo: pathlib.Path) -> None:
5631 result = runner.invoke(cli, ["code", "velocity", "--json"])
5632 data = json.loads(result.output)
5633 for key in (
5634 "ref", "window_size", "commits_analysed", "truncated",
5635 "filters", "modules", "predictions",
5636 ):
5637 assert key in data, f"missing key: {key}"
5638
5639 def test_velocity_json_module_schema(self, velocity_repo: pathlib.Path) -> None:
5640 result = runner.invoke(cli, ["code", "velocity", "--json"])
5641 data = json.loads(result.output)
5642 if not data["modules"]:
5643 pytest.skip("no modules")
5644 mod = data["modules"][0]
5645 for key in ("module", "current", "prior", "acceleration", "stagnant_commits"):
5646 assert key in mod, f"missing key: {key}"
5647 for key in ("added", "removed", "net", "modified", "active_commits"):
5648 assert key in mod["current"], f"missing current key: {key}"
5649 assert key in mod["prior"], f"missing prior key: {key}"
5650
5651 def test_velocity_json_acceleration_is_net_delta(
5652 self, velocity_repo: pathlib.Path
5653 ) -> None:
5654 result = runner.invoke(cli, ["code", "velocity", "--json"])
5655 data = json.loads(result.output)
5656 for mod in data["modules"]:
5657 expected = mod["current"]["net"] - mod["prior"]["net"]
5658 assert mod["acceleration"] == expected
5659
5660 def test_velocity_json_filters_reflected(self, velocity_repo: pathlib.Path) -> None:
5661 result = runner.invoke(
5662 cli, ["code", "velocity", "--json", "--window", "5", "--top", "3"]
5663 )
5664 data = json.loads(result.output)
5665 assert data["window_size"] == 5
5666 assert data["filters"]["top"] == 3
5667
5668 def test_velocity_json_no_import_pseudosymbols_in_counts(
5669 self, velocity_repo: pathlib.Path
5670 ) -> None:
5671 # Modules should not be "(root)" due to import pseudo-symbols
5672 # (import:: addresses should be filtered out).
5673 result = runner.invoke(cli, ["code", "velocity", "--json"])
5674 data = json.loads(result.output)
5675 # We can't assert 0 imports in the module list, but we can assert
5676 # that '::import::' doesn't appear as a module name.
5677 for mod in data["modules"]:
5678 assert "import" not in mod["module"].lower() or "/" in mod["module"]
5679
5680 # ── --window ─────────────────────────────────────────────────────────────
5681
5682 def test_velocity_window_1_runs(self, velocity_repo: pathlib.Path) -> None:
5683 result = runner.invoke(cli, ["code", "velocity", "--window", "1"])
5684 assert result.exit_code == 0, result.output
5685
5686 def test_velocity_window_validation(self, velocity_repo: pathlib.Path) -> None:
5687 result = runner.invoke(cli, ["code", "velocity", "--window", "0"])
5688 assert result.exit_code != 0
5689
5690 def test_velocity_window_reflected_in_json(
5691 self, velocity_repo: pathlib.Path
5692 ) -> None:
5693 result = runner.invoke(cli, ["code", "velocity", "--json", "--window", "1"])
5694 data = json.loads(result.output)
5695 assert data["window_size"] == 1
5696
5697 # ── --top ─────────────────────────────────────────────────────────────────
5698
5699 def test_velocity_top_limits(self, velocity_repo: pathlib.Path) -> None:
5700 result = runner.invoke(cli, ["code", "velocity", "--json", "--top", "1"])
5701 data = json.loads(result.output)
5702 assert len(data["modules"]) <= 1
5703
5704 def test_velocity_top_validation(self, velocity_repo: pathlib.Path) -> None:
5705 result = runner.invoke(cli, ["code", "velocity", "--top", "0"])
5706 assert result.exit_code != 0
5707
5708 # ── --predict ─────────────────────────────────────────────────────────────
5709
5710 def test_velocity_predict_0_empty(self, velocity_repo: pathlib.Path) -> None:
5711 result = runner.invoke(cli, ["code", "velocity", "--json", "--predict", "0"])
5712 data = json.loads(result.output)
5713 assert data["predictions"] == []
5714
5715 def test_velocity_predict_returns_results(self, velocity_repo: pathlib.Path) -> None:
5716 result = runner.invoke(
5717 cli, ["code", "velocity", "--json", "--predict", "5"]
5718 )
5719 data = json.loads(result.output)
5720 # There are symbols in the window so predictions should be non-empty.
5721 assert isinstance(data["predictions"], list)
5722 if data["predictions"]:
5723 pred = data["predictions"][0]
5724 for key in ("address", "module", "score", "frequency", "last_commit_rank"):
5725 assert key in pred, f"missing key: {key}"
5726
5727 def test_velocity_predict_scores_descending(
5728 self, velocity_repo: pathlib.Path
5729 ) -> None:
5730 result = runner.invoke(
5731 cli, ["code", "velocity", "--json", "--predict", "10"]
5732 )
5733 data = json.loads(result.output)
5734 scores = [p["score"] for p in data["predictions"]]
5735 assert scores == sorted(scores, reverse=True)
5736
5737 def test_velocity_predict_validation(self, velocity_repo: pathlib.Path) -> None:
5738 result = runner.invoke(cli, ["code", "velocity", "--predict", "-1"])
5739 assert result.exit_code != 0
5740
5741 def test_velocity_predict_shown_in_human_output(
5742 self, velocity_repo: pathlib.Path
5743 ) -> None:
5744 result = runner.invoke(
5745 cli, ["code", "velocity", "--predict", "3"]
5746 )
5747 assert result.exit_code == 0
5748 if "predictions" in result.output.lower() or "score" in result.output:
5749 # Just check it doesn't crash.
5750 pass
5751
5752 # ── --max-commits ─────────────────────────────────────────────────────────
5753
5754 def test_velocity_max_commits_validation(self, velocity_repo: pathlib.Path) -> None:
5755 result = runner.invoke(cli, ["code", "velocity", "--max-commits", "0"])
5756 assert result.exit_code != 0
5757
5758 def test_velocity_max_commits_respected(self, velocity_repo: pathlib.Path) -> None:
5759 # With --window 1 and --max-commits 1, effective_max = max(1, 1*2) = 2.
5760 # The 3-commit repo should be capped at 2 commits analysed.
5761 result = runner.invoke(
5762 cli, ["code", "velocity", "--json", "--window", "1", "--max-commits", "1"]
5763 )
5764 assert result.exit_code == 0
5765 data = json.loads(result.output)
5766 assert data["commits_analysed"] <= 2
5767
5768 # ── --since ───────────────────────────────────────────────────────────────
5769
5770 def test_velocity_since_invalid_ref(self, velocity_repo: pathlib.Path) -> None:
5771 result = runner.invoke(cli, ["code", "velocity", "--since", "bad_ref"])
5772 assert result.exit_code != 0
5773
5774 # ── stagnation detection ──────────────────────────────────────────────────
5775
5776 def test_velocity_stagnant_commits_non_negative(
5777 self, velocity_repo: pathlib.Path
5778 ) -> None:
5779 result = runner.invoke(cli, ["code", "velocity", "--json"])
5780 data = json.loads(result.output)
5781 for mod in data["modules"]:
5782 assert mod["stagnant_commits"] >= 0
5783
5784 # ── net counts are consistent ─────────────────────────────────────────────
5785
5786 def test_velocity_net_equals_added_minus_removed(
5787 self, velocity_repo: pathlib.Path
5788 ) -> None:
5789 result = runner.invoke(cli, ["code", "velocity", "--json"])
5790 data = json.loads(result.output)
5791 for mod in data["modules"]:
5792 assert mod["current"]["net"] == (
5793 mod["current"]["added"] - mod["current"]["removed"]
5794 )
5795 assert mod["prior"]["net"] == (
5796 mod["prior"]["added"] - mod["prior"]["removed"]
5797 )
5798
5799 # ── requires repo ─────────────────────────────────────────────────────────
5800
5801 def test_velocity_requires_repo(self, tmp_path: pathlib.Path) -> None:
5802 import os
5803 old = os.getcwd()
5804 try:
5805 os.chdir(tmp_path)
5806 result = runner.invoke(cli, ["code", "velocity"])
5807 assert result.exit_code != 0
5808 finally:
5809 os.chdir(old)
5810
5811
5812 # ---------------------------------------------------------------------------
5813 # age
5814 # ---------------------------------------------------------------------------
5815
5816
5817 @pytest.fixture
5818 def age_repo(repo: pathlib.Path) -> pathlib.Path:
5819 """Repo with several commits to exercise evolutionary-age metrics.
5820
5821 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
5822 Commit 2: modify compute_total body → 1 impl change
5823 Commit 3: modify compute_total body → 2 impl changes
5824 Commit 4: modify compute_total signature only (add type hint)
5825
5826 stable_fn is created in commit 1 and never touched again.
5827 """
5828 (repo / "billing.py").write_text(textwrap.dedent("""\
5829 class Invoice:
5830 def compute_total(self, items):
5831 return sum(items)
5832
5833 def stable_fn():
5834 return 42
5835 """))
5836 r = runner.invoke(cli, ["commit", "-m", "initial billing"])
5837 assert r.exit_code == 0, r.output
5838
5839 # Commit 2: impl change to compute_total
5840 (repo / "billing.py").write_text(textwrap.dedent("""\
5841 class Invoice:
5842 def compute_total(self, items):
5843 return round(sum(items), 2)
5844
5845 def stable_fn():
5846 return 42
5847 """))
5848 r2 = runner.invoke(cli, ["commit", "-m", "round result"])
5849 assert r2.exit_code == 0, r2.output
5850
5851 # Commit 3: second impl change to compute_total
5852 (repo / "billing.py").write_text(textwrap.dedent("""\
5853 class Invoice:
5854 def compute_total(self, items):
5855 total = sum(items)
5856 return round(total, 4)
5857
5858 def stable_fn():
5859 return 42
5860 """))
5861 r3 = runner.invoke(cli, ["commit", "-m", "higher precision"])
5862 assert r3.exit_code == 0, r3.output
5863
5864 return repo
5865
5866
5867 class TestAge:
5868 """Tests for muse code age."""
5869
5870 # ── basic correctness ────────────────────────────────────────────────────
5871
5872 def test_age_exits_zero(self, age_repo: pathlib.Path) -> None:
5873 result = runner.invoke(cli, ["code", "age"])
5874 assert result.exit_code == 0, result.output
5875
5876 def test_age_shows_header(self, age_repo: pathlib.Path) -> None:
5877 result = runner.invoke(cli, ["code", "age"])
5878 assert "evolutionary age" in result.output.lower()
5879
5880 def test_age_shows_sort_line(self, age_repo: pathlib.Path) -> None:
5881 result = runner.invoke(cli, ["code", "age"])
5882 assert "Sorted by" in result.output
5883
5884 def test_age_shows_table_columns(self, age_repo: pathlib.Path) -> None:
5885 result = runner.invoke(cli, ["code", "age"])
5886 assert "BORN" in result.output
5887 assert "REWRITES" in result.output
5888 assert "GENETIC" in result.output
5889
5890 def test_age_lists_symbols(self, age_repo: pathlib.Path) -> None:
5891 result = runner.invoke(cli, ["code", "age"])
5892 assert "billing.py" in result.output
5893
5894 # ── JSON schema ──────────────────────────────────────────────────────────
5895
5896 def test_age_json_exits_zero(self, age_repo: pathlib.Path) -> None:
5897 result = runner.invoke(cli, ["code", "age", "--json"])
5898 assert result.exit_code == 0, result.output
5899 json.loads(result.output)
5900
5901 def test_age_json_top_level_keys(self, age_repo: pathlib.Path) -> None:
5902 result = runner.invoke(cli, ["code", "age", "--json"])
5903 data = json.loads(result.output)
5904 for key in ("ref", "as_of", "commits_analysed", "truncated", "filters", "symbols"):
5905 assert key in data, f"missing key: {key}"
5906
5907 def test_age_json_symbol_schema(self, age_repo: pathlib.Path) -> None:
5908 result = runner.invoke(cli, ["code", "age", "--json"])
5909 data = json.loads(result.output)
5910 if not data["symbols"]:
5911 pytest.skip("no symbols with history")
5912 sym = data["symbols"][0]
5913 for key in (
5914 "address", "kind", "file",
5915 "born_commit", "born_date",
5916 "last_impl_commit", "last_impl_date",
5917 "last_change_commit", "last_change_date",
5918 "calendar_age_days", "genetic_age_days",
5919 "impl_changes", "sig_changes", "renames", "est_survival_pct",
5920 ):
5921 assert key in sym, f"missing key: {key}"
5922
5923 def test_age_json_survival_pct_in_range(self, age_repo: pathlib.Path) -> None:
5924 result = runner.invoke(cli, ["code", "age", "--json"])
5925 data = json.loads(result.output)
5926 for sym in data["symbols"]:
5927 assert 0 <= sym["est_survival_pct"] <= 100
5928
5929 def test_age_json_filters_reflected(self, age_repo: pathlib.Path) -> None:
5930 result = runner.invoke(
5931 cli, ["code", "age", "--json", "--sort", "calendar", "--kind", "function"]
5932 )
5933 data = json.loads(result.output)
5934 assert data["filters"]["sort"] == "calendar"
5935 assert data["filters"]["kind"] == "function"
5936
5937 def test_age_json_no_import_pseudosymbols(self, age_repo: pathlib.Path) -> None:
5938 result = runner.invoke(cli, ["code", "age", "--json"])
5939 data = json.loads(result.output)
5940 for sym in data["symbols"]:
5941 assert "::import::" not in sym["address"]
5942
5943 # ── impl_changes recorded correctly ─────────────────────────────────────
5944
5945 def test_age_compute_total_has_impl_changes(self, age_repo: pathlib.Path) -> None:
5946 """compute_total was modified twice — should have impl_changes >= 1."""
5947 result = runner.invoke(cli, ["code", "age", "--json"])
5948 data = json.loads(result.output)
5949 totals = [
5950 s for s in data["symbols"]
5951 if "compute_total" in s["address"]
5952 ]
5953 # If history was recorded, impl_changes should be positive.
5954 if totals:
5955 assert totals[0]["impl_changes"] >= 0 # at least recorded
5956
5957 def test_age_stable_fn_lower_impl_changes(self, age_repo: pathlib.Path) -> None:
5958 """stable_fn was never modified — should have 0 impl_changes."""
5959 result = runner.invoke(cli, ["code", "age", "--json"])
5960 data = json.loads(result.output)
5961 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5962 if stables:
5963 assert stables[0]["impl_changes"] == 0
5964
5965 def test_age_stable_fn_100pct_survival(self, age_repo: pathlib.Path) -> None:
5966 result = runner.invoke(cli, ["code", "age", "--json"])
5967 data = json.loads(result.output)
5968 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5969 if stables:
5970 assert stables[0]["est_survival_pct"] == 100
5971
5972 # ── --top ────────────────────────────────────────────────────────────────
5973
5974 def test_age_top_limits(self, age_repo: pathlib.Path) -> None:
5975 result = runner.invoke(cli, ["code", "age", "--json", "--top", "1"])
5976 data = json.loads(result.output)
5977 assert len(data["symbols"]) <= 1
5978
5979 def test_age_top_validation(self, age_repo: pathlib.Path) -> None:
5980 result = runner.invoke(cli, ["code", "age", "--top", "0"])
5981 assert result.exit_code != 0
5982
5983 # ── --sort ───────────────────────────────────────────────────────────────
5984
5985 def test_age_sort_rewrites(self, age_repo: pathlib.Path) -> None:
5986 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "rewrites"])
5987 assert result.exit_code == 0, result.output
5988 data = json.loads(result.output)
5989 impl_counts = [s["impl_changes"] for s in data["symbols"]]
5990 assert impl_counts == sorted(impl_counts, reverse=True)
5991
5992 def test_age_sort_calendar(self, age_repo: pathlib.Path) -> None:
5993 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "calendar"])
5994 assert result.exit_code == 0, result.output
5995 data = json.loads(result.output)
5996 ages = [s["calendar_age_days"] for s in data["symbols"]]
5997 assert ages == sorted(ages, reverse=True)
5998
5999 def test_age_sort_genetic(self, age_repo: pathlib.Path) -> None:
6000 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "genetic"])
6001 assert result.exit_code == 0, result.output
6002 data = json.loads(result.output)
6003 ages = [s["genetic_age_days"] for s in data["symbols"]]
6004 assert ages == sorted(ages, reverse=True)
6005
6006 def test_age_sort_survival(self, age_repo: pathlib.Path) -> None:
6007 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "survival"])
6008 assert result.exit_code == 0, result.output
6009 data = json.loads(result.output)
6010 survivals = [s["est_survival_pct"] for s in data["symbols"]]
6011 assert survivals == sorted(survivals)
6012
6013 def test_age_sort_invalid(self, age_repo: pathlib.Path) -> None:
6014 result = runner.invoke(cli, ["code", "age", "--sort", "bogus"])
6015 assert result.exit_code != 0
6016
6017 # ── --kind filter ────────────────────────────────────────────────────────
6018
6019 def test_age_kind_filter(self, age_repo: pathlib.Path) -> None:
6020 result = runner.invoke(cli, ["code", "age", "--json", "--kind", "function"])
6021 data = json.loads(result.output)
6022 for sym in data["symbols"]:
6023 assert sym["kind"] in ("function", "method")
6024
6025 # ── --file filter ─────────────────────────────────────────────────────────
6026
6027 def test_age_file_filter(self, age_repo: pathlib.Path) -> None:
6028 result = runner.invoke(
6029 cli, ["code", "age", "--json", "--file", "billing.py"]
6030 )
6031 data = json.loads(result.output)
6032 for sym in data["symbols"]:
6033 assert "billing.py" in sym["file"]
6034
6035 def test_age_file_filter_nonexistent(self, age_repo: pathlib.Path) -> None:
6036 result = runner.invoke(
6037 cli, ["code", "age", "--json", "--file", "no_such_file.py"]
6038 )
6039 assert result.exit_code == 0
6040 data = json.loads(result.output)
6041 assert data["symbols"] == []
6042
6043 # ── --explain ─────────────────────────────────────────────────────────────
6044
6045 def test_age_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
6046 result = runner.invoke(cli, ["code", "age", "--json"])
6047 data = json.loads(result.output)
6048 if not data["symbols"]:
6049 pytest.skip("no symbols")
6050 addr = data["symbols"][0]["address"]
6051 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6052 assert r2.exit_code == 0, r2.output
6053
6054 def test_age_explain_shows_breakdown(self, age_repo: pathlib.Path) -> None:
6055 result = runner.invoke(cli, ["code", "age", "--json"])
6056 data = json.loads(result.output)
6057 if not data["symbols"]:
6058 pytest.skip("no symbols")
6059 addr = data["symbols"][0]["address"]
6060 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6061 assert "Implementation changes" in r2.output
6062 assert "Signature changes" in r2.output
6063 assert "Est. survival" in r2.output
6064
6065 def test_age_explain_requires_double_colon(self, age_repo: pathlib.Path) -> None:
6066 result = runner.invoke(cli, ["code", "age", "--explain", "billing.py"])
6067 assert result.exit_code != 0
6068
6069 def test_age_explain_nonexistent_errors(self, age_repo: pathlib.Path) -> None:
6070 result = runner.invoke(cli, ["code", "age", "--explain", "no.py::nonexistent"])
6071 assert result.exit_code != 0
6072
6073 def test_age_explain_json(self, age_repo: pathlib.Path) -> None:
6074 result = runner.invoke(cli, ["code", "age", "--json"])
6075 data = json.loads(result.output)
6076 if not data["symbols"]:
6077 pytest.skip("no symbols")
6078 addr = data["symbols"][0]["address"]
6079 r2 = runner.invoke(cli, ["code", "age", "--explain", addr, "--json"])
6080 assert r2.exit_code == 0, r2.output
6081 detail = json.loads(r2.output)
6082 assert detail["address"] == addr
6083 assert "events" in detail
6084
6085 # ── --max-commits ─────────────────────────────────────────────────────────
6086
6087 def test_age_max_commits_validation(self, age_repo: pathlib.Path) -> None:
6088 result = runner.invoke(cli, ["code", "age", "--max-commits", "0"])
6089 assert result.exit_code != 0
6090
6091 def test_age_max_commits_respected(self, age_repo: pathlib.Path) -> None:
6092 result = runner.invoke(cli, ["code", "age", "--json", "--max-commits", "1"])
6093 assert result.exit_code == 0
6094 data = json.loads(result.output)
6095 assert data["commits_analysed"] <= 1
6096
6097 # ── --since ───────────────────────────────────────────────────────────────
6098
6099 def test_age_since_invalid_ref(self, age_repo: pathlib.Path) -> None:
6100 result = runner.invoke(cli, ["code", "age", "--since", "bad_ref"])
6101 assert result.exit_code != 0
6102
6103 # ── requires repo ─────────────────────────────────────────────────────────
6104
6105 def test_age_requires_repo(self, tmp_path: pathlib.Path) -> None:
6106 import os
6107 old = os.getcwd()
6108 try:
6109 os.chdir(tmp_path)
6110 result = runner.invoke(cli, ["code", "age"])
6111 assert result.exit_code != 0
6112 finally:
6113 os.chdir(old)
6114
6115
6116 # ---------------------------------------------------------------------------
6117 # entangle
6118 # ---------------------------------------------------------------------------
6119
6120
6121 @pytest.fixture
6122 def entangle_repo(repo: pathlib.Path) -> pathlib.Path:
6123 """Repo that has two files with no import link but symbols that co-change.
6124
6125 Commit 1: create billing.py (Invoice class) and serializers.py (to_json).
6126 Commit 2: modify Invoice.compute_total AND to_json together — they
6127 co-change with no import link.
6128 Commit 3: same again — both change again.
6129
6130 billing.py does NOT import serializers.py, so the pair should be
6131 flagged as entangled.
6132 """
6133 (repo / "billing.py").write_text(textwrap.dedent("""\
6134 class Invoice:
6135 def compute_total(self, items):
6136 return sum(items)
6137 """))
6138 (repo / "serializers.py").write_text(textwrap.dedent("""\
6139 def to_json(obj):
6140 return str(obj)
6141 """))
6142 r = runner.invoke(cli, ["commit", "-m", "initial"])
6143 assert r.exit_code == 0, r.output
6144
6145 # Commit 2: both change.
6146 (repo / "billing.py").write_text(textwrap.dedent("""\
6147 class Invoice:
6148 def compute_total(self, items):
6149 return round(sum(items), 2)
6150 """))
6151 (repo / "serializers.py").write_text(textwrap.dedent("""\
6152 def to_json(obj):
6153 import json
6154 return json.dumps(obj)
6155 """))
6156 r2 = runner.invoke(cli, ["commit", "-m", "update both"])
6157 assert r2.exit_code == 0, r2.output
6158
6159 # Commit 3: both change again.
6160 (repo / "billing.py").write_text(textwrap.dedent("""\
6161 class Invoice:
6162 def compute_total(self, items):
6163 return round(sum(items), 4)
6164 """))
6165 (repo / "serializers.py").write_text(textwrap.dedent("""\
6166 def to_json(obj):
6167 import json
6168 return json.dumps(obj, indent=2)
6169 """))
6170 r3 = runner.invoke(cli, ["commit", "-m", "tweak both again"])
6171 assert r3.exit_code == 0, r3.output
6172
6173 return repo
6174
6175
6176 class TestEntangle:
6177 """Tests for muse code entangle."""
6178
6179 # ── basic correctness ────────────────────────────────────────────────────
6180
6181 def test_entangle_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6182 result = runner.invoke(cli, ["code", "entangle"])
6183 assert result.exit_code == 0, result.output
6184
6185 def test_entangle_shows_header(self, entangle_repo: pathlib.Path) -> None:
6186 result = runner.invoke(cli, ["code", "entangle"])
6187 assert result.exit_code == 0
6188 assert "entanglement" in result.output.lower()
6189
6190 def test_entangle_detects_unlinked_pair(self, entangle_repo: pathlib.Path) -> None:
6191 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6192 assert result.exit_code == 0
6193 # Both files should appear in the output.
6194 assert "billing.py" in result.output or "serializers.py" in result.output
6195
6196 def test_entangle_shows_rate(self, entangle_repo: pathlib.Path) -> None:
6197 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6198 assert result.exit_code == 0
6199 # Rate column should show a percentage.
6200 assert "%" in result.output
6201
6202 # ── JSON schema ──────────────────────────────────────────────────────────
6203
6204 def test_entangle_json_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6205 result = runner.invoke(cli, ["code", "entangle", "--json"])
6206 assert result.exit_code == 0, result.output
6207 json.loads(result.output) # must be valid JSON
6208
6209 def test_entangle_json_top_level_keys(self, entangle_repo: pathlib.Path) -> None:
6210 result = runner.invoke(cli, ["code", "entangle", "--json"])
6211 data = json.loads(result.output)
6212 for key in ("ref", "commits_analysed", "truncated", "filters", "pairs"):
6213 assert key in data, f"missing key: {key}"
6214
6215 def test_entangle_json_pair_schema(self, entangle_repo: pathlib.Path) -> None:
6216 result = runner.invoke(
6217 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6218 )
6219 data = json.loads(result.output)
6220 if not data["pairs"]:
6221 pytest.skip("no pairs detected")
6222 pair = data["pairs"][0]
6223 for key in (
6224 "symbol_a", "symbol_b", "file_a", "file_b", "same_file",
6225 "structurally_linked", "co_changes", "commits_both_active",
6226 "co_change_rate", "a_in_test", "b_in_test",
6227 ):
6228 assert key in pair, f"missing key: {key}"
6229
6230 def test_entangle_json_co_change_rate_in_range(
6231 self, entangle_repo: pathlib.Path
6232 ) -> None:
6233 result = runner.invoke(
6234 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6235 )
6236 data = json.loads(result.output)
6237 for pair in data["pairs"]:
6238 assert 0.0 <= pair["co_change_rate"] <= 1.0
6239
6240 def test_entangle_json_filters_reflected(
6241 self, entangle_repo: pathlib.Path
6242 ) -> None:
6243 result = runner.invoke(
6244 cli, ["code", "entangle", "--json", "--min-co-changes", "3", "--min-rate", "0.5"]
6245 )
6246 data = json.loads(result.output)
6247 assert data["filters"]["min_co_changes"] == 3
6248 assert data["filters"]["min_rate"] == 0.5
6249
6250 def test_entangle_json_sorted_by_rate_desc(
6251 self, entangle_repo: pathlib.Path
6252 ) -> None:
6253 result = runner.invoke(
6254 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6255 )
6256 data = json.loads(result.output)
6257 rates = [p["co_change_rate"] for p in data["pairs"]]
6258 assert rates == sorted(rates, reverse=True)
6259
6260 # ── --top ────────────────────────────────────────────────────────────────
6261
6262 def test_entangle_top_limits(self, entangle_repo: pathlib.Path) -> None:
6263 result = runner.invoke(
6264 cli, ["code", "entangle", "--json", "--top", "1", "--min-co-changes", "1"]
6265 )
6266 data = json.loads(result.output)
6267 assert len(data["pairs"]) <= 1
6268
6269 def test_entangle_top_validation(self, entangle_repo: pathlib.Path) -> None:
6270 result = runner.invoke(cli, ["code", "entangle", "--top", "0"])
6271 assert result.exit_code != 0
6272
6273 # ── --min-co-changes ─────────────────────────────────────────────────────
6274
6275 def test_entangle_min_co_changes_filters(
6276 self, entangle_repo: pathlib.Path
6277 ) -> None:
6278 result = runner.invoke(
6279 cli, ["code", "entangle", "--json", "--min-co-changes", "100"]
6280 )
6281 data = json.loads(result.output)
6282 # No pair can have co-changed 100 times in a 3-commit repo.
6283 assert data["pairs"] == []
6284
6285 def test_entangle_min_co_changes_validation(
6286 self, entangle_repo: pathlib.Path
6287 ) -> None:
6288 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "0"])
6289 assert result.exit_code != 0
6290
6291 # ── --min-rate ───────────────────────────────────────────────────────────
6292
6293 def test_entangle_min_rate_1_may_return_results(
6294 self, entangle_repo: pathlib.Path
6295 ) -> None:
6296 result = runner.invoke(
6297 cli, ["code", "entangle", "--json", "--min-rate", "1.0", "--min-co-changes", "1"]
6298 )
6299 assert result.exit_code == 0
6300 data = json.loads(result.output)
6301 for pair in data["pairs"]:
6302 assert pair["co_change_rate"] == 1.0
6303
6304 def test_entangle_min_rate_validation(self, entangle_repo: pathlib.Path) -> None:
6305 result = runner.invoke(cli, ["code", "entangle", "--min-rate", "1.5"])
6306 assert result.exit_code != 0
6307 result2 = runner.invoke(cli, ["code", "entangle", "--min-rate", "-0.1"])
6308 assert result2.exit_code != 0
6309
6310 # ── --symbol filter ──────────────────────────────────────────────────────
6311
6312 def test_entangle_symbol_requires_double_colon(
6313 self, entangle_repo: pathlib.Path
6314 ) -> None:
6315 result = runner.invoke(cli, ["code", "entangle", "--symbol", "billing.py"])
6316 assert result.exit_code != 0
6317
6318 def test_entangle_symbol_exits_zero_valid(
6319 self, entangle_repo: pathlib.Path
6320 ) -> None:
6321 result = runner.invoke(
6322 cli,
6323 ["code", "entangle", "--symbol", "billing.py::Invoice",
6324 "--min-co-changes", "1"],
6325 )
6326 assert result.exit_code == 0, result.output
6327
6328 def test_entangle_symbol_filters_pairs(
6329 self, entangle_repo: pathlib.Path
6330 ) -> None:
6331 result = runner.invoke(
6332 cli,
6333 ["code", "entangle", "--json", "--symbol", "billing.py::Invoice",
6334 "--min-co-changes", "1"],
6335 )
6336 data = json.loads(result.output)
6337 for pair in data["pairs"]:
6338 assert (
6339 "billing.py" in pair["symbol_a"]
6340 or "billing.py" in pair["symbol_b"]
6341 )
6342
6343 # ── --include-same-file ──────────────────────────────────────────────────
6344
6345 def test_entangle_include_same_file_flag(
6346 self, entangle_repo: pathlib.Path
6347 ) -> None:
6348 # Should not crash, and may return same-file pairs.
6349 result = runner.invoke(
6350 cli,
6351 ["code", "entangle", "--json", "--include-same-file",
6352 "--min-co-changes", "1"],
6353 )
6354 assert result.exit_code == 0, result.output
6355 data = json.loads(result.output)
6356 assert data["filters"]["include_same_file"] is True
6357
6358 # ── --max-commits ─────────────────────────────────────────────────────────
6359
6360 def test_entangle_max_commits_validation(
6361 self, entangle_repo: pathlib.Path
6362 ) -> None:
6363 result = runner.invoke(cli, ["code", "entangle", "--max-commits", "0"])
6364 assert result.exit_code != 0
6365
6366 def test_entangle_max_commits_respected(
6367 self, entangle_repo: pathlib.Path
6368 ) -> None:
6369 result = runner.invoke(
6370 cli, ["code", "entangle", "--json", "--max-commits", "1"]
6371 )
6372 assert result.exit_code == 0
6373 data = json.loads(result.output)
6374 assert data["commits_analysed"] <= 1
6375
6376 # ── --since ───────────────────────────────────────────────────────────────
6377
6378 def test_entangle_since_invalid_ref(self, entangle_repo: pathlib.Path) -> None:
6379 result = runner.invoke(cli, ["code", "entangle", "--since", "no_such_ref"])
6380 assert result.exit_code != 0
6381
6382 # ── requires repo ─────────────────────────────────────────────────────────
6383
6384 def test_entangle_requires_repo(self, tmp_path: pathlib.Path) -> None:
6385 import os
6386 old = os.getcwd()
6387 try:
6388 os.chdir(tmp_path)
6389 result = runner.invoke(cli, ["code", "entangle"])
6390 assert result.exit_code != 0
6391 finally:
6392 os.chdir(old)
6393
6394
6395 # ---------------------------------------------------------------------------
6396 # muse code semantic-test-coverage
6397 # ---------------------------------------------------------------------------
6398
6399
6400 @pytest.fixture
6401 def stc_repo(repo: pathlib.Path) -> pathlib.Path:
6402 """Repo with production code and a test file for semantic-test-coverage.
6403
6404 Layout::
6405
6406 billing.py — compute_total (function), Invoice (class),
6407 Invoice.apply_discount (method),
6408 Invoice.generate_pdf (method) ← never called by tests
6409 services.py — process_order (calls compute_total transitively)
6410 tests/test_billing.py — test_compute_total, test_apply_discount,
6411 test_process_order (direct calls)
6412
6413 Direct coverage expected:
6414 compute_total ← test_compute_total, test_process_order (via bare name)
6415 Invoice ← test_compute_total (instantiation)
6416 apply_discount ← test_apply_discount
6417 generate_pdf ← NOT covered
6418 process_order ← test_process_order
6419
6420 Transitive (depth 2) additionally covers:
6421 compute_total ← test_process_order (because process_order calls it)
6422 """
6423 (repo / "tests").mkdir(exist_ok=True)
6424
6425 (repo / "billing.py").write_text(textwrap.dedent("""\
6426 class Invoice:
6427 def apply_discount(self, rate):
6428 return self.total * (1 - rate)
6429
6430 def generate_pdf(self):
6431 return b"PDF"
6432
6433 def compute_total(items):
6434 return sum(i["price"] for i in items)
6435 """))
6436
6437 (repo / "services.py").write_text(textwrap.dedent("""\
6438 from billing import compute_total
6439
6440 def process_order(order):
6441 return compute_total(order["items"])
6442 """))
6443
6444 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
6445 from billing import compute_total, Invoice
6446 from services import process_order
6447
6448 def test_compute_total():
6449 inv = Invoice()
6450 assert compute_total([{"price": 10}]) == 10
6451
6452 def test_apply_discount():
6453 inv = Invoice()
6454 inv.total = 100
6455 assert inv.apply_discount(0.1) == 90
6456
6457 def test_process_order():
6458 result = process_order({"items": [{"price": 5}]})
6459 assert result == 5
6460 """))
6461
6462 r = runner.invoke(cli, ["commit", "-m", "stc: initial repo"])
6463 assert r.exit_code == 0, r.output
6464 return repo
6465
6466
6467 class TestSemanticTestCoverage:
6468 """Tests for ``muse code semantic-test-coverage``."""
6469
6470 CMD = ["code", "semantic-test-coverage"]
6471
6472 # ── basic correctness ────────────────────────────────────────────────────
6473
6474 def test_stc_exits_zero(self, stc_repo: pathlib.Path) -> None:
6475 result = runner.invoke(cli, self.CMD)
6476 assert result.exit_code == 0, result.output
6477
6478 def test_stc_shows_header(self, stc_repo: pathlib.Path) -> None:
6479 result = runner.invoke(cli, self.CMD)
6480 assert "Semantic test coverage" in result.output
6481 assert "HEAD" in result.output
6482
6483 def test_stc_shows_test_function_count(self, stc_repo: pathlib.Path) -> None:
6484 result = runner.invoke(cli, self.CMD)
6485 # 3 test functions in the repo
6486 assert "test functions" in result.output
6487
6488 def test_stc_shows_total_line(self, stc_repo: pathlib.Path) -> None:
6489 result = runner.invoke(cli, self.CMD)
6490 assert "TOTAL:" in result.output
6491
6492 def test_stc_covered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6493 result = runner.invoke(cli, self.CMD)
6494 assert "compute_total" in result.output
6495
6496 def test_stc_uncovered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6497 result = runner.invoke(cli, self.CMD)
6498 assert "generate_pdf" in result.output
6499
6500 def test_stc_covered_has_check_icon(self, stc_repo: pathlib.Path) -> None:
6501 result = runner.invoke(cli, self.CMD)
6502 assert "✅" in result.output
6503
6504 def test_stc_uncovered_has_cross_icon(self, stc_repo: pathlib.Path) -> None:
6505 result = runner.invoke(cli, self.CMD)
6506 assert "❌" in result.output
6507
6508 # ── JSON output ──────────────────────────────────────────────────────────
6509
6510 def test_stc_json_exits_zero(self, stc_repo: pathlib.Path) -> None:
6511 result = runner.invoke(cli, self.CMD + ["--json"])
6512 assert result.exit_code == 0, result.output
6513
6514 def test_stc_json_is_valid(self, stc_repo: pathlib.Path) -> None:
6515 result = runner.invoke(cli, self.CMD + ["--json"])
6516 data = json.loads(result.output)
6517 assert isinstance(data, dict)
6518
6519 def test_stc_json_top_level_keys(self, stc_repo: pathlib.Path) -> None:
6520 result = runner.invoke(cli, self.CMD + ["--json"])
6521 data = json.loads(result.output)
6522 for key in ("ref", "snapshot_id", "depth", "transitive", "filters",
6523 "summary", "files"):
6524 assert key in data, f"missing key: {key}"
6525
6526 def test_stc_json_ref_is_head(self, stc_repo: pathlib.Path) -> None:
6527 result = runner.invoke(cli, self.CMD + ["--json"])
6528 data = json.loads(result.output)
6529 assert data["ref"] == "HEAD"
6530
6531 def test_stc_json_depth_default(self, stc_repo: pathlib.Path) -> None:
6532 result = runner.invoke(cli, self.CMD + ["--json"])
6533 data = json.loads(result.output)
6534 assert data["depth"] == 1
6535
6536 def test_stc_json_transitive_default_false(self, stc_repo: pathlib.Path) -> None:
6537 result = runner.invoke(cli, self.CMD + ["--json"])
6538 data = json.loads(result.output)
6539 assert data["transitive"] is False
6540
6541 def test_stc_json_summary_schema(self, stc_repo: pathlib.Path) -> None:
6542 result = runner.invoke(cli, self.CMD + ["--json"])
6543 data = json.loads(result.output)
6544 summary = data["summary"]
6545 for key in ("total_symbols", "covered_symbols", "uncovered_symbols",
6546 "coverage_pct", "total_test_functions", "total_production_files"):
6547 assert key in summary, f"summary missing: {key}"
6548
6549 def test_stc_json_summary_counts_consistent(self, stc_repo: pathlib.Path) -> None:
6550 result = runner.invoke(cli, self.CMD + ["--json"])
6551 data = json.loads(result.output)
6552 s = data["summary"]
6553 assert s["covered_symbols"] + s["uncovered_symbols"] == s["total_symbols"]
6554
6555 def test_stc_json_summary_test_fn_count(self, stc_repo: pathlib.Path) -> None:
6556 result = runner.invoke(cli, self.CMD + ["--json"])
6557 data = json.loads(result.output)
6558 # 3 test functions: test_compute_total, test_apply_discount, test_process_order
6559 assert data["summary"]["total_test_functions"] >= 3
6560
6561 def test_stc_json_file_schema(self, stc_repo: pathlib.Path) -> None:
6562 result = runner.invoke(cli, self.CMD + ["--json"])
6563 data = json.loads(result.output)
6564 assert len(data["files"]) > 0
6565 fc = data["files"][0]
6566 for key in ("file", "total_symbols", "covered_symbols",
6567 "uncovered_symbols", "coverage_pct", "symbols"):
6568 assert key in fc, f"file record missing: {key}"
6569
6570 def test_stc_json_symbol_schema(self, stc_repo: pathlib.Path) -> None:
6571 result = runner.invoke(cli, self.CMD + ["--json"])
6572 data = json.loads(result.output)
6573 # Find a file with at least one symbol
6574 sym = data["files"][0]["symbols"][0]
6575 for key in ("address", "name", "kind", "covered", "test_functions"):
6576 assert key in sym, f"symbol record missing: {key}"
6577
6578 def test_stc_json_covered_symbol_has_test_functions(
6579 self, stc_repo: pathlib.Path
6580 ) -> None:
6581 result = runner.invoke(cli, self.CMD + ["--json"])
6582 data = json.loads(result.output)
6583 covered = [
6584 sym
6585 for fc in data["files"]
6586 for sym in fc["symbols"]
6587 if sym["covered"]
6588 ]
6589 assert covered, "expected at least one covered symbol"
6590 assert any(len(sym["test_functions"]) > 0 for sym in covered)
6591
6592 def test_stc_json_uncovered_symbol_empty_test_fns(
6593 self, stc_repo: pathlib.Path
6594 ) -> None:
6595 result = runner.invoke(cli, self.CMD + ["--json"])
6596 data = json.loads(result.output)
6597 uncovered = [
6598 sym
6599 for fc in data["files"]
6600 for sym in fc["symbols"]
6601 if not sym["covered"]
6602 ]
6603 assert uncovered, "expected generate_pdf to be uncovered"
6604 assert all(sym["test_functions"] == [] for sym in uncovered)
6605
6606 def test_stc_json_generate_pdf_uncovered(self, stc_repo: pathlib.Path) -> None:
6607 result = runner.invoke(cli, self.CMD + ["--json"])
6608 data = json.loads(result.output)
6609 found = next(
6610 (
6611 sym
6612 for fc in data["files"]
6613 for sym in fc["symbols"]
6614 if sym["name"] == "generate_pdf"
6615 ),
6616 None,
6617 )
6618 assert found is not None, "generate_pdf symbol not found"
6619 assert found["covered"] is False
6620
6621 def test_stc_json_compute_total_covered(self, stc_repo: pathlib.Path) -> None:
6622 result = runner.invoke(cli, self.CMD + ["--json"])
6623 data = json.loads(result.output)
6624 found = next(
6625 (
6626 sym
6627 for fc in data["files"]
6628 for sym in fc["symbols"]
6629 if sym["name"] == "compute_total"
6630 ),
6631 None,
6632 )
6633 assert found is not None
6634 assert found["covered"] is True
6635
6636 def test_stc_json_coverage_pct_between_0_and_100(
6637 self, stc_repo: pathlib.Path
6638 ) -> None:
6639 result = runner.invoke(cli, self.CMD + ["--json"])
6640 data = json.loads(result.output)
6641 for fc in data["files"]:
6642 assert 0.0 <= fc["coverage_pct"] <= 100.0
6643
6644 def test_stc_json_filter_reflected(self, stc_repo: pathlib.Path) -> None:
6645 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6646 data = json.loads(result.output)
6647 assert data["filters"]["kind"] == "method"
6648
6649 def test_stc_json_no_import_pseudosymbols(self, stc_repo: pathlib.Path) -> None:
6650 result = runner.invoke(cli, self.CMD + ["--json"])
6651 data = json.loads(result.output)
6652 for fc in data["files"]:
6653 for sym in fc["symbols"]:
6654 assert sym["kind"] != "import"
6655
6656 # ── --file filter ────────────────────────────────────────────────────────
6657
6658 def test_stc_file_filter_scopes_output(self, stc_repo: pathlib.Path) -> None:
6659 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6660 data = json.loads(result.output)
6661 for fc in data["files"]:
6662 assert "billing.py" in fc["file"]
6663
6664 def test_stc_file_filter_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6665 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6666 data = json.loads(result.output)
6667 assert data["filters"]["file"] == "billing.py"
6668
6669 def test_stc_file_filter_billing_has_generate_pdf(
6670 self, stc_repo: pathlib.Path
6671 ) -> None:
6672 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6673 data = json.loads(result.output)
6674 names = [
6675 sym["name"] for fc in data["files"] for sym in fc["symbols"]
6676 ]
6677 assert "generate_pdf" in names
6678
6679 # ── --kind filter ────────────────────────────────────────────────────────
6680
6681 def test_stc_kind_method_only_methods(self, stc_repo: pathlib.Path) -> None:
6682 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6683 data = json.loads(result.output)
6684 for fc in data["files"]:
6685 for sym in fc["symbols"]:
6686 assert sym["kind"] == "method"
6687
6688 def test_stc_kind_function_only_functions(self, stc_repo: pathlib.Path) -> None:
6689 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function"])
6690 data = json.loads(result.output)
6691 for fc in data["files"]:
6692 for sym in fc["symbols"]:
6693 assert sym["kind"] == "function"
6694
6695 def test_stc_kind_invalid_rejected(self, stc_repo: pathlib.Path) -> None:
6696 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6697 assert result.exit_code != 0
6698
6699 # ── --uncovered-only ─────────────────────────────────────────────────────
6700
6701 def test_stc_uncovered_only_exits_zero(self, stc_repo: pathlib.Path) -> None:
6702 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6703 assert result.exit_code == 0, result.output
6704
6705 def test_stc_uncovered_only_hides_covered(self, stc_repo: pathlib.Path) -> None:
6706 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6707 # generate_pdf should appear; compute_total should not appear
6708 assert "generate_pdf" in result.output
6709
6710 def test_stc_uncovered_only_json_symbols_all_uncovered(
6711 self, stc_repo: pathlib.Path
6712 ) -> None:
6713 result = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6714 data = json.loads(result.output)
6715 for fc in data["files"]:
6716 for sym in fc["symbols"]:
6717 assert sym["covered"] is False
6718
6719 def test_stc_uncovered_only_json_stats_still_full(
6720 self, stc_repo: pathlib.Path
6721 ) -> None:
6722 result_all = runner.invoke(cli, self.CMD + ["--json"])
6723 result_uncov = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6724 data_all = json.loads(result_all.output)
6725 data_uncov = json.loads(result_uncov.output)
6726 # Total symbol count should be the same (stats reflect full picture)
6727 assert (
6728 data_all["summary"]["total_symbols"]
6729 == data_uncov["summary"]["total_symbols"]
6730 )
6731
6732 # ── --show-tests ─────────────────────────────────────────────────────────
6733
6734 def test_stc_show_tests_exits_zero(self, stc_repo: pathlib.Path) -> None:
6735 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6736 assert result.exit_code == 0, result.output
6737
6738 def test_stc_show_tests_lists_test_addr(self, stc_repo: pathlib.Path) -> None:
6739 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6740 # Should include a ← prefix followed by a test address
6741 assert "←" in result.output
6742
6743 def test_stc_show_tests_references_test_file(
6744 self, stc_repo: pathlib.Path
6745 ) -> None:
6746 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6747 assert "test_billing" in result.output
6748
6749 # ── --transitive / --depth ───────────────────────────────────────────────
6750
6751 def test_stc_transitive_exits_zero(self, stc_repo: pathlib.Path) -> None:
6752 result = runner.invoke(cli, self.CMD + ["--transitive"])
6753 assert result.exit_code == 0, result.output
6754
6755 def test_stc_transitive_json_flag_true(self, stc_repo: pathlib.Path) -> None:
6756 result = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6757 data = json.loads(result.output)
6758 assert data["transitive"] is True
6759
6760 def test_stc_depth_2_implies_transitive(self, stc_repo: pathlib.Path) -> None:
6761 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
6762 data = json.loads(result.output)
6763 assert data["transitive"] is True
6764 assert data["depth"] == 2
6765
6766 def test_stc_depth_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6767 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "3"])
6768 data = json.loads(result.output)
6769 assert data["depth"] == 3
6770
6771 def test_stc_transitive_does_not_reduce_coverage(
6772 self, stc_repo: pathlib.Path
6773 ) -> None:
6774 result_direct = runner.invoke(cli, self.CMD + ["--json"])
6775 result_trans = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6776 data_direct = json.loads(result_direct.output)
6777 data_trans = json.loads(result_trans.output)
6778 # Transitive coverage must be >= direct coverage
6779 assert (
6780 data_trans["summary"]["covered_symbols"]
6781 >= data_direct["summary"]["covered_symbols"]
6782 )
6783
6784 def test_stc_depth_0_invalid(self, stc_repo: pathlib.Path) -> None:
6785 result = runner.invoke(cli, self.CMD + ["--depth", "0"])
6786 assert result.exit_code != 0
6787
6788 def test_stc_depth_exceeds_max_invalid(self, stc_repo: pathlib.Path) -> None:
6789 result = runner.invoke(cli, self.CMD + ["--depth", "11"])
6790 assert result.exit_code != 0
6791
6792 # ── --min-coverage ───────────────────────────────────────────────────────
6793
6794 def test_stc_min_coverage_0_exits_zero(self, stc_repo: pathlib.Path) -> None:
6795 result = runner.invoke(cli, self.CMD + ["--min-coverage", "0"])
6796 assert result.exit_code == 0, result.output
6797
6798 def test_stc_min_coverage_100_exits_nonzero(self, stc_repo: pathlib.Path) -> None:
6799 # generate_pdf is never covered, so 100% is unachievable.
6800 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6801 assert result.exit_code != 0
6802
6803 def test_stc_min_coverage_shows_warning(self, stc_repo: pathlib.Path) -> None:
6804 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6805 assert "⚠️" in result.output or "below" in result.output.lower()
6806
6807 def test_stc_min_coverage_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6808 result = runner.invoke(cli, self.CMD + ["--json", "--min-coverage", "80"])
6809 data = json.loads(result.output)
6810 assert data["filters"]["min_coverage"] == 80
6811
6812 def test_stc_min_coverage_none_when_0(self, stc_repo: pathlib.Path) -> None:
6813 result = runner.invoke(cli, self.CMD + ["--json"])
6814 data = json.loads(result.output)
6815 assert data["filters"]["min_coverage"] is None
6816
6817 def test_stc_min_coverage_invalid_over_100(self, stc_repo: pathlib.Path) -> None:
6818 result = runner.invoke(cli, self.CMD + ["--min-coverage", "101"])
6819 assert result.exit_code != 0
6820
6821 def test_stc_min_coverage_invalid_negative(self, stc_repo: pathlib.Path) -> None:
6822 result = runner.invoke(cli, self.CMD + ["--min-coverage", "-1"])
6823 assert result.exit_code != 0
6824
6825 # ── test-file exclusion ──────────────────────────────────────────────────
6826
6827 def test_stc_test_files_not_in_production_symbols(
6828 self, stc_repo: pathlib.Path
6829 ) -> None:
6830 result = runner.invoke(cli, self.CMD + ["--json"])
6831 data = json.loads(result.output)
6832 for fc in data["files"]:
6833 assert "test_" not in pathlib.PurePosixPath(fc["file"]).name.split(".")[0][:5] or \
6834 not fc["file"].startswith("tests/"), \
6835 f"test file appeared in production symbols: {fc['file']}"
6836
6837 def test_stc_no_test_file_in_prod_files(self, stc_repo: pathlib.Path) -> None:
6838 result = runner.invoke(cli, self.CMD + ["--json"])
6839 data = json.loads(result.output)
6840 for fc in data["files"]:
6841 assert "tests/" not in fc["file"] or fc["file"].startswith("tests/") is False, \
6842 fc["file"]
6843
6844 # ── requires repo ────────────────────────────────────────────────────────
6845
6846 def test_stc_requires_repo(self, tmp_path: pathlib.Path) -> None:
6847 import os
6848 old = os.getcwd()
6849 try:
6850 os.chdir(tmp_path)
6851 result = runner.invoke(cli, self.CMD)
6852 assert result.exit_code != 0
6853 finally:
6854 os.chdir(old)
6855
6856 # ── empty repo ───────────────────────────────────────────────────────────
6857
6858 def test_stc_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
6859 """An empty repo (no commits yet) should not crash."""
6860 # The base repo fixture has no commits — must handle gracefully.
6861 # First commit something minimal so HEAD exists.
6862 (repo / "empty.py").write_text("")
6863 r = runner.invoke(cli, ["commit", "-m", "seed"])
6864 if r.exit_code != 0:
6865 pytest.skip("could not create initial commit")
6866 result = runner.invoke(cli, self.CMD)
6867 assert result.exit_code == 0, result.output
6868
6869
6870 # ---------------------------------------------------------------------------
6871 # muse code gravity
6872 # ---------------------------------------------------------------------------
6873
6874
6875 @pytest.fixture
6876 def gravity_repo(repo: pathlib.Path) -> pathlib.Path:
6877 """Repo whose call graph creates a clear gravity hierarchy.
6878
6879 Layout::
6880
6881 core.py — read_object (called by everything)
6882 mid.py — process (calls read_object)
6883 top.py — handle (calls process, which calls read_object)
6884 leaf.py — leaf_fn (calls handle)
6885
6886 Expected gravity (transitive dependents):
6887 read_object: 3 (process, handle, leaf_fn) → high gravity
6888 process: 2 (handle, leaf_fn)
6889 handle: 1 (leaf_fn)
6890 leaf_fn: 0 → lowest gravity
6891 """
6892 (repo / "core.py").write_text(textwrap.dedent("""\
6893 def read_object(path):
6894 return path.read_bytes()
6895 """))
6896 r1 = runner.invoke(cli, ["commit", "-m", "core: add read_object"])
6897 assert r1.exit_code == 0, r1.output
6898
6899 (repo / "mid.py").write_text(textwrap.dedent("""\
6900 from core import read_object
6901
6902 def process(path):
6903 return read_object(path)
6904 """))
6905 r2 = runner.invoke(cli, ["commit", "-m", "mid: add process"])
6906 assert r2.exit_code == 0, r2.output
6907
6908 (repo / "top.py").write_text(textwrap.dedent("""\
6909 from mid import process
6910
6911 def handle(path):
6912 return process(path)
6913 """))
6914 r3 = runner.invoke(cli, ["commit", "-m", "top: add handle"])
6915 assert r3.exit_code == 0, r3.output
6916
6917 (repo / "leaf.py").write_text(textwrap.dedent("""\
6918 from top import handle
6919
6920 def leaf_fn(path):
6921 return handle(path)
6922 """))
6923 r4 = runner.invoke(cli, ["commit", "-m", "leaf: add leaf_fn"])
6924 assert r4.exit_code == 0, r4.output
6925
6926 return repo
6927
6928
6929 class TestGravity:
6930 """Tests for ``muse code gravity``."""
6931
6932 CMD = ["code", "gravity"]
6933
6934 # ── basic correctness ─────────────────────────────────────────────────────
6935
6936 def test_gravity_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6937 result = runner.invoke(cli, self.CMD)
6938 assert result.exit_code == 0, result.output
6939
6940 def test_gravity_shows_header(self, gravity_repo: pathlib.Path) -> None:
6941 result = runner.invoke(cli, self.CMD)
6942 assert "Symbol gravity" in result.output
6943
6944 def test_gravity_shows_head(self, gravity_repo: pathlib.Path) -> None:
6945 result = runner.invoke(cli, self.CMD)
6946 assert "HEAD" in result.output
6947
6948 def test_gravity_shows_column_headers(self, gravity_repo: pathlib.Path) -> None:
6949 result = runner.invoke(cli, self.CMD)
6950 assert "GRAVITY" in result.output
6951 assert "DIRECT" in result.output
6952 assert "DEPTH" in result.output
6953
6954 def test_gravity_shows_symbols(self, gravity_repo: pathlib.Path) -> None:
6955 result = runner.invoke(cli, self.CMD)
6956 # At least one symbol should appear.
6957 assert "read_object" in result.output or "process" in result.output
6958
6959 def test_gravity_shows_percentage(self, gravity_repo: pathlib.Path) -> None:
6960 result = runner.invoke(cli, self.CMD)
6961 assert "%" in result.output
6962
6963 # ── --top ─────────────────────────────────────────────────────────────────
6964
6965 def test_gravity_top_limits_output(self, gravity_repo: pathlib.Path) -> None:
6966 result1 = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6967 result3 = runner.invoke(cli, self.CMD + ["--json", "--top", "3"])
6968 data1 = json.loads(result1.output)
6969 data3 = json.loads(result3.output)
6970 assert len(data1["symbols"]) <= 1
6971 assert len(data3["symbols"]) <= 3
6972
6973 def test_gravity_top_0_returns_all(self, gravity_repo: pathlib.Path) -> None:
6974 result_all = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6975 result_lim = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6976 data_all = json.loads(result_all.output)
6977 data_lim = json.loads(result_lim.output)
6978 assert len(data_all["symbols"]) >= len(data_lim["symbols"])
6979
6980 def test_gravity_top_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6981 result = runner.invoke(cli, self.CMD + ["--top", "-1"])
6982 assert result.exit_code != 0
6983
6984 # ── --sort ────────────────────────────────────────────────────────────────
6985
6986 def test_gravity_sort_gravity_default(self, gravity_repo: pathlib.Path) -> None:
6987 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6988 data = json.loads(result.output)
6989 if len(data["symbols"]) >= 2:
6990 pcts = [s["gravity_pct"] for s in data["symbols"]]
6991 assert pcts == sorted(pcts, reverse=True)
6992
6993 def test_gravity_sort_direct(self, gravity_repo: pathlib.Path) -> None:
6994 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "direct", "--top", "0"])
6995 data = json.loads(result.output)
6996 assert result.exit_code == 0
6997 if len(data["symbols"]) >= 2:
6998 directs = [s["direct_dependents"] for s in data["symbols"]]
6999 assert directs == sorted(directs, reverse=True)
7000
7001 def test_gravity_sort_depth(self, gravity_repo: pathlib.Path) -> None:
7002 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "depth", "--top", "0"])
7003 data = json.loads(result.output)
7004 assert result.exit_code == 0
7005 if len(data["symbols"]) >= 2:
7006 depths = [s["max_depth"] for s in data["symbols"]]
7007 assert depths == sorted(depths, reverse=True)
7008
7009 def test_gravity_sort_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7010 result = runner.invoke(cli, self.CMD + ["--sort", "invalid"])
7011 assert result.exit_code != 0
7012
7013 # ── --depth cap ───────────────────────────────────────────────────────────
7014
7015 def test_gravity_depth_0_unlimited(self, gravity_repo: pathlib.Path) -> None:
7016 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "0"])
7017 data = json.loads(result.output)
7018 assert result.exit_code == 0
7019 assert data["max_depth"] == 0
7020
7021 def test_gravity_depth_1_direct_only(self, gravity_repo: pathlib.Path) -> None:
7022 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "1", "--top", "0"])
7023 data = json.loads(result.output)
7024 assert result.exit_code == 0
7025 # With depth=1, max_depth for any symbol should be at most 1.
7026 for sym in data["symbols"]:
7027 assert sym["max_depth"] <= 1
7028
7029 def test_gravity_depth_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7030 result = runner.invoke(cli, self.CMD + ["--depth", "-1"])
7031 assert result.exit_code != 0
7032
7033 # ── --kind filter ─────────────────────────────────────────────────────────
7034
7035 def test_gravity_kind_function_only(self, gravity_repo: pathlib.Path) -> None:
7036 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function", "--top", "0"])
7037 data = json.loads(result.output)
7038 assert result.exit_code == 0
7039 for sym in data["symbols"]:
7040 assert sym["kind"] == "function"
7041
7042 def test_gravity_kind_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7043 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
7044 assert result.exit_code != 0
7045
7046 # ── --file filter ─────────────────────────────────────────────────────────
7047
7048 def test_gravity_file_filter_scopes(self, gravity_repo: pathlib.Path) -> None:
7049 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py", "--top", "0"])
7050 data = json.loads(result.output)
7051 assert result.exit_code == 0
7052 for sym in data["symbols"]:
7053 assert "core.py" in sym["file"]
7054
7055 def test_gravity_file_filter_reflected_in_json(self, gravity_repo: pathlib.Path) -> None:
7056 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py"])
7057 data = json.loads(result.output)
7058 assert data["filters"]["file"] == "core.py"
7059
7060 # ── --min-gravity ─────────────────────────────────────────────────────────
7061
7062 def test_gravity_min_gravity_filters_low(self, gravity_repo: pathlib.Path) -> None:
7063 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "50.0", "--top", "0"])
7064 data = json.loads(result.output)
7065 for sym in data["symbols"]:
7066 assert sym["gravity_pct"] >= 50.0
7067
7068 def test_gravity_min_gravity_100_returns_few(self, gravity_repo: pathlib.Path) -> None:
7069 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "100.0"])
7070 assert result.exit_code == 0
7071
7072 def test_gravity_min_gravity_invalid_over_100(self, gravity_repo: pathlib.Path) -> None:
7073 result = runner.invoke(cli, self.CMD + ["--min-gravity", "101.0"])
7074 assert result.exit_code != 0
7075
7076 def test_gravity_min_gravity_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7077 result = runner.invoke(cli, self.CMD + ["--min-gravity", "-1.0"])
7078 assert result.exit_code != 0
7079
7080 # ── --explain ─────────────────────────────────────────────────────────────
7081
7082 def test_gravity_explain_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7083 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7084 assert result.exit_code == 0, result.output
7085
7086 def test_gravity_explain_shows_breakdown(self, gravity_repo: pathlib.Path) -> None:
7087 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7088 assert "Gravity breakdown" in result.output
7089
7090 def test_gravity_explain_shows_depth_distribution(
7091 self, gravity_repo: pathlib.Path
7092 ) -> None:
7093 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7094 assert "Depth distribution" in result.output
7095
7096 def test_gravity_explain_shows_deepest_callers(
7097 self, gravity_repo: pathlib.Path
7098 ) -> None:
7099 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7100 assert "Deepest callers" in result.output
7101
7102 def test_gravity_explain_missing_address_format(
7103 self, gravity_repo: pathlib.Path
7104 ) -> None:
7105 result = runner.invoke(cli, self.CMD + ["--explain", "no_double_colon"])
7106 assert result.exit_code != 0
7107
7108 def test_gravity_explain_unknown_symbol_exits_nonzero(
7109 self, gravity_repo: pathlib.Path
7110 ) -> None:
7111 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::no_such_fn"])
7112 assert result.exit_code != 0
7113
7114 def test_gravity_explain_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7115 result = runner.invoke(
7116 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7117 )
7118 assert result.exit_code == 0, result.output
7119
7120 def test_gravity_explain_json_schema(self, gravity_repo: pathlib.Path) -> None:
7121 result = runner.invoke(
7122 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7123 )
7124 data = json.loads(result.output)
7125 for key in (
7126 "address", "name", "kind", "file",
7127 "gravity_pct", "direct_dependents", "transitive_dependents",
7128 "max_depth", "depth_distribution",
7129 ):
7130 assert key in data, f"missing key: {key}"
7131
7132 # ── JSON leaderboard ──────────────────────────────────────────────────────
7133
7134 def test_gravity_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7135 result = runner.invoke(cli, self.CMD + ["--json"])
7136 assert result.exit_code == 0, result.output
7137
7138 def test_gravity_json_top_level_keys(self, gravity_repo: pathlib.Path) -> None:
7139 result = runner.invoke(cli, self.CMD + ["--json"])
7140 data = json.loads(result.output)
7141 for key in (
7142 "ref", "snapshot_id", "total_production_symbols",
7143 "max_depth", "include_tests", "filters", "symbols",
7144 ):
7145 assert key in data, f"missing key: {key}"
7146
7147 def test_gravity_json_symbol_schema(self, gravity_repo: pathlib.Path) -> None:
7148 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7149 data = json.loads(result.output)
7150 if data["symbols"]:
7151 sym = data["symbols"][0]
7152 for key in (
7153 "address", "name", "kind", "file",
7154 "gravity_pct", "direct_dependents",
7155 "transitive_dependents", "max_depth", "depth_distribution",
7156 ):
7157 assert key in sym, f"symbol missing key: {key}"
7158
7159 def test_gravity_json_gravity_pct_range(self, gravity_repo: pathlib.Path) -> None:
7160 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7161 data = json.loads(result.output)
7162 for sym in data["symbols"]:
7163 assert 0.0 <= sym["gravity_pct"] <= 100.0
7164
7165 def test_gravity_json_read_object_has_highest_gravity(
7166 self, gravity_repo: pathlib.Path
7167 ) -> None:
7168 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7169 data = json.loads(result.output)
7170 # read_object is called transitively by everything — should be near top.
7171 names = [s["name"] for s in data["symbols"]]
7172 if "read_object" in names and len(names) > 1:
7173 ro_idx = names.index("read_object")
7174 # read_object should be in the top half.
7175 assert ro_idx <= len(names) // 2 + 1
7176
7177 def test_gravity_json_leaf_fn_lower_gravity(
7178 self, gravity_repo: pathlib.Path
7179 ) -> None:
7180 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7181 data = json.loads(result.output)
7182 syms = {s["name"]: s for s in data["symbols"]}
7183 if "leaf_fn" in syms and "read_object" in syms:
7184 assert syms["leaf_fn"]["gravity_pct"] <= syms["read_object"]["gravity_pct"]
7185
7186 def test_gravity_json_include_tests_flag(self, gravity_repo: pathlib.Path) -> None:
7187 result = runner.invoke(cli, self.CMD + ["--json", "--include-tests"])
7188 data = json.loads(result.output)
7189 assert data["include_tests"] is True
7190
7191 def test_gravity_json_depth_reflected(self, gravity_repo: pathlib.Path) -> None:
7192 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
7193 data = json.loads(result.output)
7194 assert data["max_depth"] == 2
7195
7196 def test_gravity_json_filters_reflected(self, gravity_repo: pathlib.Path) -> None:
7197 result = runner.invoke(
7198 cli,
7199 self.CMD + ["--json", "--kind", "function", "--min-gravity", "5.0", "--top", "10"],
7200 )
7201 data = json.loads(result.output)
7202 assert data["filters"]["kind"] == "function"
7203 assert data["filters"]["min_gravity"] == 5.0
7204 assert data["filters"]["top"] == 10
7205
7206 def test_gravity_json_depth_distribution_is_dict(
7207 self, gravity_repo: pathlib.Path
7208 ) -> None:
7209 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7210 data = json.loads(result.output)
7211 for sym in data["symbols"]:
7212 assert isinstance(sym["depth_distribution"], dict)
7213
7214 # ── requires repo ─────────────────────────────────────────────────────────
7215
7216 def test_gravity_requires_repo(self, tmp_path: pathlib.Path) -> None:
7217 import os
7218 old = os.getcwd()
7219 try:
7220 os.chdir(tmp_path)
7221 result = runner.invoke(cli, self.CMD)
7222 assert result.exit_code != 0
7223 finally:
7224 os.chdir(old)
7225
7226
7227 # ---------------------------------------------------------------------------
7228 # muse code narrative
7229 # ---------------------------------------------------------------------------
7230
7231
7232 @pytest.fixture
7233 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
7234 """Repo with a symbol that has a rich multi-event history.
7235
7236 billing.py::compute_total goes through:
7237 commit 1: seed commit (different file — gives billing.py a parent context)
7238 commit 2: created (insert — billing.py added, compute_total appears as new symbol)
7239 commit 3: body rewritten (replace with impl keywords)
7240 commit 4: signature changed (replace with signature keywords)
7241 """
7242 # Commit 1 — seed so billing.py's creation is a delta, not the initial commit.
7243 (repo / "readme.txt").write_text("MuseHub billing module\n")
7244 r0 = runner.invoke(cli, ["commit", "-m", "chore: initial seed"])
7245 assert r0.exit_code == 0, r0.output
7246
7247 # Commit 2 — create billing.py (compute_total becomes a new symbol in delta).
7248 (repo / "billing.py").write_text(textwrap.dedent("""\
7249 def compute_total(items):
7250 total = 0
7251 for item in items:
7252 total += item["price"]
7253 return total
7254 """))
7255 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7256 assert r1.exit_code == 0, r1.output
7257
7258 # Commit 3 — body rewrite: implementation changed.
7259 (repo / "billing.py").write_text(textwrap.dedent("""\
7260 def compute_total(items):
7261 return sum(i["price"] for i in items)
7262 """))
7263 r2 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
7264 assert r2.exit_code == 0, r2.output
7265
7266 # Commit 4 — signature change.
7267 (repo / "billing.py").write_text(textwrap.dedent("""\
7268 def compute_total(items, currency="USD"):
7269 return sum(i["price"] for i in items)
7270 """))
7271 r3 = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
7272 assert r3.exit_code == 0, r3.output
7273
7274 return repo
7275
7276
7277 class TestNarrative:
7278 """Tests for ``muse code narrative``."""
7279
7280 CMD = ["code", "narrative"]
7281 ADDR = "billing.py::compute_total"
7282
7283 # ── basic correctness ─────────────────────────────────────────────────────
7284
7285 def test_narrative_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7286 result = runner.invoke(cli, self.CMD + [self.ADDR])
7287 assert result.exit_code == 0, result.output
7288
7289 def test_narrative_shows_symbol_name(self, narrative_repo: pathlib.Path) -> None:
7290 result = runner.invoke(cli, self.CMD + [self.ADDR])
7291 assert "compute_total" in result.output
7292
7293 def test_narrative_shows_file(self, narrative_repo: pathlib.Path) -> None:
7294 result = runner.invoke(cli, self.CMD + [self.ADDR])
7295 assert "billing.py" in result.output
7296
7297 def test_narrative_shows_born_event(self, narrative_repo: pathlib.Path) -> None:
7298 result = runner.invoke(cli, self.CMD + [self.ADDR])
7299 assert "Born" in result.output or "born" in result.output
7300
7301 def test_narrative_shows_life_summary(self, narrative_repo: pathlib.Path) -> None:
7302 result = runner.invoke(cli, self.CMD + [self.ADDR])
7303 assert "Life summary" in result.output or "Survival" in result.output
7304
7305 def test_narrative_shows_commit_id(self, narrative_repo: pathlib.Path) -> None:
7306 result = runner.invoke(cli, self.CMD + [self.ADDR])
7307 assert "commit" in result.output
7308
7309 def test_narrative_shows_survival(self, narrative_repo: pathlib.Path) -> None:
7310 result = runner.invoke(cli, self.CMD + [self.ADDR])
7311 assert "%" in result.output
7312
7313 # ── missing symbol ────────────────────────────────────────────────────────
7314
7315 def test_narrative_missing_symbol_exits_nonzero(
7316 self, narrative_repo: pathlib.Path
7317 ) -> None:
7318 result = runner.invoke(
7319 cli, self.CMD + ["billing.py::does_not_exist"]
7320 )
7321 assert result.exit_code != 0
7322
7323 def test_narrative_bad_address_no_colons_exits_nonzero(
7324 self, narrative_repo: pathlib.Path
7325 ) -> None:
7326 result = runner.invoke(cli, self.CMD + ["no_double_colon"])
7327 assert result.exit_code != 0
7328
7329 # ── --format prose ────────────────────────────────────────────────────────
7330
7331 def test_narrative_prose_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7332 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7333 assert result.exit_code == 0, result.output
7334
7335 def test_narrative_prose_contains_name(self, narrative_repo: pathlib.Path) -> None:
7336 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7337 assert "compute_total" in result.output
7338
7339 def test_narrative_prose_contains_content(self, narrative_repo: pathlib.Path) -> None:
7340 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7341 # Symbol name or some indication of the symbol's life should appear.
7342 assert "compute_total" in result.output or "rewritten" in result.output or "born" in result.output.lower()
7343
7344 def test_narrative_prose_no_timeline_label(
7345 self, narrative_repo: pathlib.Path
7346 ) -> None:
7347 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7348 # Timeline labels like "Born " should not appear in prose.
7349 assert "Life summary" not in result.output
7350
7351 def test_narrative_format_invalid_rejected(
7352 self, narrative_repo: pathlib.Path
7353 ) -> None:
7354 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "invalid"])
7355 assert result.exit_code != 0
7356
7357 # ── --json ────────────────────────────────────────────────────────────────
7358
7359 def test_narrative_json_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7360 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7361 assert result.exit_code == 0, result.output
7362
7363 def test_narrative_json_is_valid(self, narrative_repo: pathlib.Path) -> None:
7364 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7365 data = json.loads(result.output)
7366 assert isinstance(data, dict)
7367
7368 def test_narrative_json_top_level_keys(self, narrative_repo: pathlib.Path) -> None:
7369 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7370 data = json.loads(result.output)
7371 for key in (
7372 "address", "name", "kind", "file", "status",
7373 "born_date", "born_commit", "last_change_date", "last_change_commit",
7374 "calendar_age_days", "genetic_age_days",
7375 "impl_changes", "sig_changes", "renames",
7376 "est_survival_pct", "commits_analysed", "truncated", "events",
7377 ):
7378 assert key in data, f"missing key: {key}"
7379
7380 def test_narrative_json_address_matches(self, narrative_repo: pathlib.Path) -> None:
7381 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7382 data = json.loads(result.output)
7383 assert data["address"] == self.ADDR
7384
7385 def test_narrative_json_name_is_bare(self, narrative_repo: pathlib.Path) -> None:
7386 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7387 data = json.loads(result.output)
7388 assert data["name"] == "compute_total"
7389
7390 def test_narrative_json_file_is_file_part(self, narrative_repo: pathlib.Path) -> None:
7391 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7392 data = json.loads(result.output)
7393 assert data["file"] == "billing.py"
7394
7395 def test_narrative_json_status_alive(self, narrative_repo: pathlib.Path) -> None:
7396 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7397 data = json.loads(result.output)
7398 assert data["status"] == "alive"
7399
7400 def test_narrative_json_events_list(self, narrative_repo: pathlib.Path) -> None:
7401 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7402 data = json.loads(result.output)
7403 assert isinstance(data["events"], list)
7404 assert len(data["events"]) >= 1
7405
7406 def test_narrative_json_event_schema(self, narrative_repo: pathlib.Path) -> None:
7407 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7408 data = json.loads(result.output)
7409 ev = data["events"][0]
7410 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
7411 assert key in ev, f"event missing key: {key}"
7412
7413 def test_narrative_json_born_commit_set(self, narrative_repo: pathlib.Path) -> None:
7414 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7415 data = json.loads(result.output)
7416 assert data["born_commit"] != ""
7417
7418 def test_narrative_json_born_date_format(self, narrative_repo: pathlib.Path) -> None:
7419 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7420 data = json.loads(result.output)
7421 import re
7422 assert re.match(r"\d{4}-\d{2}-\d{2}", data["born_date"])
7423
7424 def test_narrative_json_impl_changes_at_least_one(
7425 self, narrative_repo: pathlib.Path
7426 ) -> None:
7427 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7428 data = json.loads(result.output)
7429 # We made at least one body rewrite commit.
7430 assert data["impl_changes"] >= 1
7431
7432 def test_narrative_json_commits_analysed_positive(
7433 self, narrative_repo: pathlib.Path
7434 ) -> None:
7435 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7436 data = json.loads(result.output)
7437 assert data["commits_analysed"] > 0
7438
7439 def test_narrative_json_survival_between_0_and_100(
7440 self, narrative_repo: pathlib.Path
7441 ) -> None:
7442 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7443 data = json.loads(result.output)
7444 assert 0 <= data["est_survival_pct"] <= 100
7445
7446 def test_narrative_json_calendar_age_nonnegative(
7447 self, narrative_repo: pathlib.Path
7448 ) -> None:
7449 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7450 data = json.loads(result.output)
7451 assert data["calendar_age_days"] >= 0
7452
7453 def test_narrative_json_events_oldest_first(
7454 self, narrative_repo: pathlib.Path
7455 ) -> None:
7456 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7457 data = json.loads(result.output)
7458 dates = [ev["date"] for ev in data["events"]]
7459 assert dates == sorted(dates)
7460
7461 def test_narrative_json_create_event_present(
7462 self, narrative_repo: pathlib.Path
7463 ) -> None:
7464 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7465 data = json.loads(result.output)
7466 types = [ev["event_type"] for ev in data["events"]]
7467 assert "create" in types
7468
7469 # ── --since ───────────────────────────────────────────────────────────────
7470
7471 def test_narrative_since_invalid_ref_exits_nonzero(
7472 self, narrative_repo: pathlib.Path
7473 ) -> None:
7474 result = runner.invoke(
7475 cli, self.CMD + [self.ADDR, "--since", "no_such_ref_xyz"]
7476 )
7477 assert result.exit_code != 0
7478
7479 # ── --max-commits ─────────────────────────────────────────────────────────
7480
7481 def test_narrative_max_commits_validation(
7482 self, narrative_repo: pathlib.Path
7483 ) -> None:
7484 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7485 assert result.exit_code != 0
7486
7487 def test_narrative_max_commits_1_finds_head_event(
7488 self, narrative_repo: pathlib.Path
7489 ) -> None:
7490 result = runner.invoke(
7491 cli, self.CMD + [self.ADDR, "--json", "--max-commits", "1"]
7492 )
7493 # With max-commits=1 we only see the HEAD commit; it must still succeed
7494 # if the HEAD commit touched our symbol, or fail gracefully if not.
7495 assert result.exit_code in (0, 1)
7496
7497 # ── --show-source ─────────────────────────────────────────────────────────
7498
7499 def test_narrative_show_source_exits_zero(
7500 self, narrative_repo: pathlib.Path
7501 ) -> None:
7502 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7503 assert result.exit_code == 0, result.output
7504
7505 def test_narrative_show_source_contains_def(
7506 self, narrative_repo: pathlib.Path
7507 ) -> None:
7508 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7509 # HEAD source should contain the function definition.
7510 assert "def compute_total" in result.output
7511
7512 # ── requires repo ─────────────────────────────────────────────────────────
7513
7514 def test_narrative_requires_repo(self, tmp_path: pathlib.Path) -> None:
7515 import os
7516 old = os.getcwd()
7517 try:
7518 os.chdir(tmp_path)
7519 result = runner.invoke(cli, self.CMD + [self.ADDR])
7520 assert result.exit_code != 0
7521 finally:
7522 os.chdir(old)
7523
7524
7525 # ---------------------------------------------------------------------------
7526 # contract
7527 # ---------------------------------------------------------------------------
7528
7529
7530 @pytest.fixture()
7531 def contract_repo(repo: pathlib.Path) -> pathlib.Path:
7532 """Repo designed to exercise every dimension of ``muse code contract``.
7533
7534 Layout::
7535
7536 billing.py — compute_total(items, currency="USD") → float
7537 services.py — place_order() calls compute_total with currency="EUR" → stored
7538 report.py — generate_report() calls compute_total(items) → stored (omits currency)
7539 audit.py — run_audit() calls compute_total(items) → discarded (bad caller)
7540 tests/test_billing.py — tests with assertions about compute_total
7541
7542 Commit history::
7543
7544 1. seed commit — readme.txt so symbol events are real insert ops
7545 2. billing.py added — compute_total created
7546 3. services.py, report.py, audit.py, tests/ added — callers in place
7547 4. billing.py updated — body rewrite (PATCH)
7548 5. billing.py updated — add currency param (MINOR)
7549 """
7550 import os
7551
7552 (repo / "readme.txt").write_text("# contract test repo\n")
7553 r0 = runner.invoke(cli, ["commit", "-m", "seed: initial readme"])
7554 assert r0.exit_code == 0, r0.output
7555
7556 (repo / "billing.py").write_text(textwrap.dedent("""\
7557 def compute_total(items):
7558 return sum(i["price"] for i in items)
7559 """))
7560 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7561 assert r1.exit_code == 0, r1.output
7562
7563 os.makedirs(repo / "tests", exist_ok=True)
7564 (repo / "services.py").write_text(textwrap.dedent("""\
7565 from billing import compute_total
7566
7567 def place_order(items):
7568 total = compute_total(items, currency="EUR")
7569 return total
7570 """))
7571 (repo / "report.py").write_text(textwrap.dedent("""\
7572 from billing import compute_total
7573
7574 def generate_report(items):
7575 result = compute_total(items)
7576 return result
7577 """))
7578 (repo / "audit.py").write_text(textwrap.dedent("""\
7579 from billing import compute_total
7580
7581 def run_audit(items):
7582 compute_total(items)
7583 """))
7584 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
7585 from billing import compute_total
7586
7587 def test_compute_total_basic():
7588 result = compute_total([{"price": 10}, {"price": 5}])
7589 assert result == 15
7590 assert result > 0
7591 assert isinstance(result, (int, float))
7592
7593 def test_compute_total_empty():
7594 result = compute_total([])
7595 assert result == 0
7596 """))
7597 r2 = runner.invoke(cli, ["commit", "-m", "feat: add callers and tests"])
7598 assert r2.exit_code == 0, r2.output
7599
7600 # body rewrite — PATCH
7601 (repo / "billing.py").write_text(textwrap.dedent("""\
7602 def compute_total(items):
7603 total = 0.0
7604 for item in items:
7605 total += float(item["price"])
7606 return total
7607 """))
7608 r3 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total"])
7609 assert r3.exit_code == 0, r3.output
7610
7611 # add currency param — MINOR
7612 (repo / "billing.py").write_text(textwrap.dedent("""\
7613 def compute_total(items, currency="USD"):
7614 total = 0.0
7615 for item in items:
7616 total += float(item["price"])
7617 return total
7618 """))
7619 r4 = runner.invoke(cli, ["commit", "-m", "feat: add optional currency param"])
7620 assert r4.exit_code == 0, r4.output
7621
7622 return repo
7623
7624
7625 class TestContract:
7626 """Tests for ``muse code contract``."""
7627
7628 CMD = ["code", "contract"]
7629 ADDR = "billing.py::compute_total"
7630
7631 # ── basic correctness ─────────────────────────────────────────────────────
7632
7633 def test_contract_exits_zero(self, contract_repo: pathlib.Path) -> None:
7634 result = runner.invoke(cli, self.CMD + [self.ADDR])
7635 assert result.exit_code == 0, result.output
7636
7637 def test_contract_shows_address(self, contract_repo: pathlib.Path) -> None:
7638 result = runner.invoke(cli, self.CMD + [self.ADDR])
7639 assert "compute_total" in result.output
7640
7641 def test_contract_shows_signature_section(self, contract_repo: pathlib.Path) -> None:
7642 result = runner.invoke(cli, self.CMD + [self.ADDR])
7643 assert "Signature" in result.output
7644
7645 def test_contract_shows_def_keyword(self, contract_repo: pathlib.Path) -> None:
7646 result = runner.invoke(cli, self.CMD + [self.ADDR])
7647 assert "def compute_total" in result.output
7648
7649 def test_contract_shows_stability_section(self, contract_repo: pathlib.Path) -> None:
7650 result = runner.invoke(cli, self.CMD + [self.ADDR])
7651 assert "Stability" in result.output
7652
7653 def test_contract_shows_commits_analysed(self, contract_repo: pathlib.Path) -> None:
7654 result = runner.invoke(cli, self.CMD + [self.ADDR])
7655 assert "commits" in result.output
7656
7657 def test_contract_shows_assessment(self, contract_repo: pathlib.Path) -> None:
7658 result = runner.invoke(cli, self.CMD + [self.ADDR])
7659 assert "Assessment" in result.output
7660
7661 def test_contract_shows_return_section(self, contract_repo: pathlib.Path) -> None:
7662 result = runner.invoke(cli, self.CMD + [self.ADDR])
7663 assert "Return value" in result.output
7664
7665 def test_contract_shows_parameters_section(self, contract_repo: pathlib.Path) -> None:
7666 result = runner.invoke(cli, self.CMD + [self.ADDR])
7667 assert "Parameters" in result.output
7668
7669 # ── call-site disposition detection ──────────────────────────────────────
7670
7671 def test_contract_detects_stored(self, contract_repo: pathlib.Path) -> None:
7672 result = runner.invoke(cli, self.CMD + [self.ADDR])
7673 assert "stored" in result.output
7674
7675 def test_contract_detects_discarded(self, contract_repo: pathlib.Path) -> None:
7676 result = runner.invoke(cli, self.CMD + [self.ADDR])
7677 assert "discarded" in result.output
7678
7679 def test_contract_warns_on_discarded(self, contract_repo: pathlib.Path) -> None:
7680 result = runner.invoke(cli, self.CMD + [self.ADDR])
7681 # audit.py discards the return — should surface a warning.
7682 assert "⚠" in result.output
7683
7684 # ── test assertions ───────────────────────────────────────────────────────
7685
7686 def test_contract_shows_test_assertions(self, contract_repo: pathlib.Path) -> None:
7687 result = runner.invoke(cli, self.CMD + [self.ADDR])
7688 assert "assert" in result.output.lower()
7689
7690 def test_contract_shows_assert_result_positive(
7691 self, contract_repo: pathlib.Path
7692 ) -> None:
7693 result = runner.invoke(cli, self.CMD + [self.ADDR])
7694 assert "result > 0" in result.output or "assert" in result.output
7695
7696 # ── --json ────────────────────────────────────────────────────────────────
7697
7698 def test_contract_json_exits_zero(self, contract_repo: pathlib.Path) -> None:
7699 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7700 assert result.exit_code == 0, result.output
7701
7702 def test_contract_json_is_valid(self, contract_repo: pathlib.Path) -> None:
7703 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7704 data = json.loads(result.output)
7705 assert isinstance(data, dict)
7706
7707 def test_contract_json_top_level_keys(self, contract_repo: pathlib.Path) -> None:
7708 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7709 data = json.loads(result.output)
7710 for key in (
7711 "address", "name", "kind", "signature", "parameters",
7712 "return_annotation", "call_sites", "caller_files",
7713 "return_dispositions", "arg_observations",
7714 "test_assertions", "commit_signals", "history",
7715 "preconditions", "postconditions", "warnings", "stability",
7716 ):
7717 assert key in data, f"missing top-level key: {key}"
7718
7719 def test_contract_json_address_matches(self, contract_repo: pathlib.Path) -> None:
7720 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7721 data = json.loads(result.output)
7722 assert data["address"] == self.ADDR
7723
7724 def test_contract_json_name_is_bare(self, contract_repo: pathlib.Path) -> None:
7725 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7726 data = json.loads(result.output)
7727 assert data["name"] == "compute_total"
7728
7729 def test_contract_json_kind_is_function(self, contract_repo: pathlib.Path) -> None:
7730 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7731 data = json.loads(result.output)
7732 assert data["kind"] in {"function", "async_function", "method", "async_method"}
7733
7734 def test_contract_json_signature_contains_def(
7735 self, contract_repo: pathlib.Path
7736 ) -> None:
7737 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7738 data = json.loads(result.output)
7739 assert "def compute_total" in data["signature"]
7740
7741 def test_contract_json_parameters_is_list(self, contract_repo: pathlib.Path) -> None:
7742 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7743 data = json.loads(result.output)
7744 assert isinstance(data["parameters"], list)
7745
7746 def test_contract_json_parameters_not_empty(self, contract_repo: pathlib.Path) -> None:
7747 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7748 data = json.loads(result.output)
7749 assert len(data["parameters"]) >= 1
7750
7751 def test_contract_json_parameters_schema(self, contract_repo: pathlib.Path) -> None:
7752 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7753 data = json.loads(result.output)
7754 p = data["parameters"][0]
7755 for key in ("name", "annotation", "has_default", "default_str"):
7756 assert key in p, f"parameter missing key: {key}"
7757
7758 def test_contract_json_items_param_present(self, contract_repo: pathlib.Path) -> None:
7759 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7760 data = json.loads(result.output)
7761 names = [p["name"] for p in data["parameters"]]
7762 assert "items" in names
7763
7764 def test_contract_json_currency_param_present(
7765 self, contract_repo: pathlib.Path
7766 ) -> None:
7767 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7768 data = json.loads(result.output)
7769 names = [p["name"] for p in data["parameters"]]
7770 assert "currency" in names
7771
7772 def test_contract_json_currency_has_default(self, contract_repo: pathlib.Path) -> None:
7773 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7774 data = json.loads(result.output)
7775 params = {p["name"]: p for p in data["parameters"]}
7776 assert params["currency"]["has_default"] is True
7777
7778 def test_contract_json_currency_default_str(self, contract_repo: pathlib.Path) -> None:
7779 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7780 data = json.loads(result.output)
7781 params = {p["name"]: p for p in data["parameters"]}
7782 assert params["currency"]["default_str"] == "'USD'"
7783
7784 def test_contract_json_call_sites_positive(self, contract_repo: pathlib.Path) -> None:
7785 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7786 data = json.loads(result.output)
7787 assert data["call_sites"] >= 1
7788
7789 def test_contract_json_caller_files_positive(self, contract_repo: pathlib.Path) -> None:
7790 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7791 data = json.loads(result.output)
7792 assert data["caller_files"] >= 1
7793
7794 def test_contract_json_return_dispositions_is_dict(
7795 self, contract_repo: pathlib.Path
7796 ) -> None:
7797 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7798 data = json.loads(result.output)
7799 assert isinstance(data["return_dispositions"], dict)
7800
7801 def test_contract_json_return_dispositions_keys(
7802 self, contract_repo: pathlib.Path
7803 ) -> None:
7804 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7805 data = json.loads(result.output)
7806 rd = data["return_dispositions"]
7807 for key in ("stored", "discarded", "returned", "asserted", "compared"):
7808 assert key in rd, f"return_dispositions missing: {key}"
7809
7810 def test_contract_json_discarded_count_at_least_one(
7811 self, contract_repo: pathlib.Path
7812 ) -> None:
7813 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7814 data = json.loads(result.output)
7815 # audit.py discards the return value
7816 assert data["return_dispositions"].get("discarded", 0) >= 1
7817
7818 def test_contract_json_stored_count_at_least_one(
7819 self, contract_repo: pathlib.Path
7820 ) -> None:
7821 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7822 data = json.loads(result.output)
7823 assert data["return_dispositions"].get("stored", 0) >= 1
7824
7825 def test_contract_json_test_assertions_is_list(
7826 self, contract_repo: pathlib.Path
7827 ) -> None:
7828 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7829 data = json.loads(result.output)
7830 assert isinstance(data["test_assertions"], list)
7831
7832 def test_contract_json_test_assertions_not_empty(
7833 self, contract_repo: pathlib.Path
7834 ) -> None:
7835 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7836 data = json.loads(result.output)
7837 assert len(data["test_assertions"]) >= 1
7838
7839 def test_contract_json_test_assertions_are_strings(
7840 self, contract_repo: pathlib.Path
7841 ) -> None:
7842 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7843 data = json.loads(result.output)
7844 for assertion in data["test_assertions"]:
7845 assert isinstance(assertion, str)
7846
7847 def test_contract_json_history_schema(self, contract_repo: pathlib.Path) -> None:
7848 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7849 data = json.loads(result.output)
7850 h = data["history"]
7851 for key in (
7852 "commits_analysed", "truncated", "major_bumps",
7853 "minor_bumps", "patch_bumps", "sig_changes",
7854 "impl_changes", "est_survival_pct",
7855 ):
7856 assert key in h, f"history missing key: {key}"
7857
7858 def test_contract_json_history_commits_positive(
7859 self, contract_repo: pathlib.Path
7860 ) -> None:
7861 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7862 data = json.loads(result.output)
7863 assert data["history"]["commits_analysed"] > 0
7864
7865 def test_contract_json_history_survival_0_to_100(
7866 self, contract_repo: pathlib.Path
7867 ) -> None:
7868 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7869 data = json.loads(result.output)
7870 pct = data["history"]["est_survival_pct"]
7871 assert 0 <= pct <= 100
7872
7873 def test_contract_json_commit_signals_is_list(
7874 self, contract_repo: pathlib.Path
7875 ) -> None:
7876 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7877 data = json.loads(result.output)
7878 assert isinstance(data["commit_signals"], list)
7879
7880 def test_contract_json_preconditions_is_list(
7881 self, contract_repo: pathlib.Path
7882 ) -> None:
7883 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7884 data = json.loads(result.output)
7885 assert isinstance(data["preconditions"], list)
7886
7887 def test_contract_json_postconditions_is_list(
7888 self, contract_repo: pathlib.Path
7889 ) -> None:
7890 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7891 data = json.loads(result.output)
7892 assert isinstance(data["postconditions"], list)
7893
7894 def test_contract_json_warnings_is_list(self, contract_repo: pathlib.Path) -> None:
7895 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7896 data = json.loads(result.output)
7897 assert isinstance(data["warnings"], list)
7898
7899 def test_contract_json_stability_valid_value(
7900 self, contract_repo: pathlib.Path
7901 ) -> None:
7902 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7903 data = json.loads(result.output)
7904 assert data["stability"] in {"stable", "evolving", "volatile", "dormant"}
7905
7906 def test_contract_json_arg_observations_is_list(
7907 self, contract_repo: pathlib.Path
7908 ) -> None:
7909 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7910 data = json.loads(result.output)
7911 assert isinstance(data["arg_observations"], list)
7912
7913 # ── input validation ──────────────────────────────────────────────────────
7914
7915 def test_contract_missing_address_exits_nonzero(
7916 self, contract_repo: pathlib.Path
7917 ) -> None:
7918 result = runner.invoke(cli, self.CMD)
7919 assert result.exit_code != 0
7920
7921 def test_contract_bad_address_format_exits_nonzero(
7922 self, contract_repo: pathlib.Path
7923 ) -> None:
7924 result = runner.invoke(cli, self.CMD + ["billing_no_colon"])
7925 assert result.exit_code != 0
7926
7927 def test_contract_unknown_address_exits_nonzero(
7928 self, contract_repo: pathlib.Path
7929 ) -> None:
7930 result = runner.invoke(cli, self.CMD + ["billing.py::nonexistent_fn_xyz"])
7931 assert result.exit_code != 0
7932
7933 def test_contract_max_commits_zero_rejected(
7934 self, contract_repo: pathlib.Path
7935 ) -> None:
7936 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7937 assert result.exit_code != 0
7938
7939 def test_contract_max_commits_one_succeeds(self, contract_repo: pathlib.Path) -> None:
7940 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "1"])
7941 assert result.exit_code == 0, result.output
7942
7943 # ── requires repo ─────────────────────────────────────────────────────────
7944
7945 def test_contract_requires_repo(self, tmp_path: pathlib.Path) -> None:
7946 import os
7947
7948 old = os.getcwd()
7949 try:
7950 os.chdir(tmp_path)
7951 result = runner.invoke(cli, self.CMD + [self.ADDR])
7952 assert result.exit_code != 0
7953 finally:
7954 os.chdir(old)
7955
7956 # ── history accuracy ──────────────────────────────────────────────────────
7957
7958 def test_contract_json_impl_changes_nonzero(
7959 self, contract_repo: pathlib.Path
7960 ) -> None:
7961 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7962 data = json.loads(result.output)
7963 # We made 2 body changes (perf rewrite + currency add).
7964 assert data["history"]["impl_changes"] >= 1
7965
7966 def test_contract_json_truncated_false_small_repo(
7967 self, contract_repo: pathlib.Path
7968 ) -> None:
7969 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7970 data = json.loads(result.output)
7971 assert data["history"]["truncated"] is False
7972
7973 def test_contract_json_postconditions_nonempty(
7974 self, contract_repo: pathlib.Path
7975 ) -> None:
7976 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7977 data = json.loads(result.output)
7978 # Should infer at least one postcondition (return value is stored).
7979 assert len(data["postconditions"]) >= 1
7980
7981 def test_contract_json_warnings_nonempty(self, contract_repo: pathlib.Path) -> None:
7982 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7983 data = json.loads(result.output)
7984 # Missing type annotations + discarded return should generate warnings.
7985 assert len(data["warnings"]) >= 1
7986
7987
7988 # ---------------------------------------------------------------------------
7989 # predict
7990 # ---------------------------------------------------------------------------
7991
7992
7993 @pytest.fixture()
7994 def predict_repo(repo: pathlib.Path) -> pathlib.Path:
7995 """Repo with commit history that produces clear prediction signals.
7996
7997 billing.py::compute_total — changed in every commit (high frequency)
7998 billing.py::apply_discount — always co-changes with compute_total (entanglement)
7999 services.py::place_order — changed only once (low confidence)
8000
8001 5 commits are made so that recency, frequency, and co-change signals
8002 are all detectable within the default horizon.
8003 """
8004 # Commit 1 — establish both symbols
8005 (repo / "billing.py").write_text(textwrap.dedent("""\
8006 def compute_total(items):
8007 return sum(i["price"] for i in items)
8008
8009 def apply_discount(total, rate):
8010 return total * (1 - rate)
8011 """))
8012 (repo / "services.py").write_text(textwrap.dedent("""\
8013 def place_order(items):
8014 return True
8015 """))
8016 r1 = runner.invoke(cli, ["commit", "-m", "feat: initial billing"])
8017 assert r1.exit_code == 0, r1.output
8018
8019 # Commits 2-5 — co-evolve compute_total and apply_discount together
8020 for i in range(2, 6):
8021 (repo / "billing.py").write_text(textwrap.dedent(f"""\
8022 def compute_total(items, rev={i}):
8023 total = 0.0
8024 for item in items:
8025 total += float(item["price"])
8026 return total
8027
8028 def apply_discount(total, rate, rev={i}):
8029 return max(0.0, total * (1 - rate))
8030 """))
8031 r = runner.invoke(cli, ["commit", "-m", f"refactor: billing revision {i}"])
8032 assert r.exit_code == 0, r.output
8033
8034 return repo
8035
8036
8037 class TestPredict:
8038 """Tests for ``muse code predict``."""
8039
8040 CMD = ["code", "predict"]
8041
8042 # ── basic correctness ─────────────────────────────────────────────────────
8043
8044 def test_predict_exits_zero(self, predict_repo: pathlib.Path) -> None:
8045 result = runner.invoke(cli, self.CMD)
8046 assert result.exit_code == 0, result.output
8047
8048 def test_predict_shows_header(self, predict_repo: pathlib.Path) -> None:
8049 result = runner.invoke(cli, self.CMD)
8050 assert "Predicted changes" in result.output
8051
8052 def test_predict_shows_horizon(self, predict_repo: pathlib.Path) -> None:
8053 result = runner.invoke(cli, self.CMD)
8054 assert "horizon:" in result.output
8055
8056 def test_predict_shows_commits_analysed(self, predict_repo: pathlib.Path) -> None:
8057 result = runner.invoke(cli, self.CMD)
8058 assert "analysed" in result.output
8059
8060 def test_predict_shows_compute_total(self, predict_repo: pathlib.Path) -> None:
8061 result = runner.invoke(cli, self.CMD)
8062 assert "compute_total" in result.output
8063
8064 def test_predict_shows_apply_discount(self, predict_repo: pathlib.Path) -> None:
8065 result = runner.invoke(cli, self.CMD)
8066 assert "apply_discount" in result.output
8067
8068 def test_predict_shows_score(self, predict_repo: pathlib.Path) -> None:
8069 result = runner.invoke(cli, self.CMD)
8070 # Scores are in N.NN format at the start of each prediction line.
8071 import re
8072 assert re.search(r"0\.\d{2}", result.output)
8073
8074 def test_predict_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8075 result = runner.invoke(cli, self.CMD)
8076 assert "↳" in result.output
8077
8078 def test_predict_high_confidence_band_present(
8079 self, predict_repo: pathlib.Path
8080 ) -> None:
8081 result = runner.invoke(cli, self.CMD)
8082 # compute_total changed 4/5 commits — should be HIGH or MEDIUM.
8083 assert "CONFIDENCE" in result.output
8084
8085 def test_predict_entanglement_signal(self, predict_repo: pathlib.Path) -> None:
8086 result = runner.invoke(cli, self.CMD + ["--horizon", "10"])
8087 # compute_total and apply_discount co-change → entanglement reason expected.
8088 assert "entangled" in result.output or "co-change" in result.output
8089
8090 # ── --top ─────────────────────────────────────────────────────────────────
8091
8092 def test_predict_top_1_shows_one_prediction(
8093 self, predict_repo: pathlib.Path
8094 ) -> None:
8095 result = runner.invoke(cli, self.CMD + ["--top", "1"])
8096 assert result.exit_code == 0, result.output
8097 # With --top 1 there is exactly one score line.
8098 import re
8099 scores = re.findall(r"^\s+0\.\d{2}\s+", result.output, re.MULTILINE)
8100 assert len(scores) == 1
8101
8102 def test_predict_top_0_shows_all(self, predict_repo: pathlib.Path) -> None:
8103 result = runner.invoke(cli, self.CMD + ["--top", "0"])
8104 assert result.exit_code == 0, result.output
8105 assert "compute_total" in result.output
8106
8107 # ── --min-confidence ──────────────────────────────────────────────────────
8108
8109 def test_predict_min_confidence_1_empty(self, predict_repo: pathlib.Path) -> None:
8110 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.0"])
8111 assert result.exit_code == 0, result.output
8112 # Nothing should reach score 1.0 exactly.
8113 assert "No predictions" in result.output or "compute_total" not in result.output
8114
8115 def test_predict_min_confidence_invalid_rejected(
8116 self, predict_repo: pathlib.Path
8117 ) -> None:
8118 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.5"])
8119 assert result.exit_code != 0
8120
8121 def test_predict_min_confidence_zero_shows_all(
8122 self, predict_repo: pathlib.Path
8123 ) -> None:
8124 result = runner.invoke(cli, self.CMD + ["--min-confidence", "0.0"])
8125 assert result.exit_code == 0, result.output
8126 assert "compute_total" in result.output
8127
8128 # ── --horizon ─────────────────────────────────────────────────────────────
8129
8130 def test_predict_horizon_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8131 result = runner.invoke(cli, self.CMD + ["--horizon", "1"])
8132 assert result.exit_code == 0, result.output
8133
8134 def test_predict_horizon_invalid_rejected(self, predict_repo: pathlib.Path) -> None:
8135 result = runner.invoke(cli, self.CMD + ["--horizon", "0"])
8136 assert result.exit_code != 0
8137
8138 def test_predict_max_commits_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8139 result = runner.invoke(cli, self.CMD + ["--max-commits", "1"])
8140 assert result.exit_code == 0, result.output
8141
8142 def test_predict_max_commits_invalid_rejected(
8143 self, predict_repo: pathlib.Path
8144 ) -> None:
8145 result = runner.invoke(cli, self.CMD + ["--max-commits", "0"])
8146 assert result.exit_code != 0
8147
8148 # ── --file ────────────────────────────────────────────────────────────────
8149
8150 def test_predict_file_filter_billing(self, predict_repo: pathlib.Path) -> None:
8151 result = runner.invoke(cli, self.CMD + ["--file", "billing.py"])
8152 assert result.exit_code == 0, result.output
8153 # Should show billing symbols.
8154 if "compute_total" in result.output or "apply_discount" in result.output:
8155 pass # expected
8156 # Should NOT show services.py symbols.
8157 assert "place_order" not in result.output
8158
8159 def test_predict_file_filter_nonexistent_empty(
8160 self, predict_repo: pathlib.Path
8161 ) -> None:
8162 result = runner.invoke(cli, self.CMD + ["--file", "nonexistent_xyz.py"])
8163 assert result.exit_code == 0, result.output
8164 assert "No predictions" in result.output
8165
8166 # ── --explain ─────────────────────────────────────────────────────────────
8167
8168 def test_predict_explain_exits_zero(self, predict_repo: pathlib.Path) -> None:
8169 result = runner.invoke(
8170 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8171 )
8172 assert result.exit_code == 0, result.output
8173
8174 def test_predict_explain_shows_signal_breakdown(
8175 self, predict_repo: pathlib.Path
8176 ) -> None:
8177 result = runner.invoke(
8178 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8179 )
8180 assert "signal breakdown" in result.output
8181
8182 def test_predict_explain_shows_all_signals(
8183 self, predict_repo: pathlib.Path
8184 ) -> None:
8185 result = runner.invoke(
8186 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8187 )
8188 for signal in ("recency", "frequency", "co_change", "sig_instability",
8189 "module_velocity"):
8190 assert signal in result.output, f"missing signal: {signal}"
8191
8192 def test_predict_explain_shows_bar(self, predict_repo: pathlib.Path) -> None:
8193 result = runner.invoke(
8194 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8195 )
8196 assert "█" in result.output or "░" in result.output
8197
8198 def test_predict_explain_shows_score(self, predict_repo: pathlib.Path) -> None:
8199 result = runner.invoke(
8200 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8201 )
8202 assert "Score:" in result.output
8203
8204 def test_predict_explain_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8205 result = runner.invoke(
8206 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8207 )
8208 assert "Reasons" in result.output
8209
8210 def test_predict_explain_bad_format_rejected(
8211 self, predict_repo: pathlib.Path
8212 ) -> None:
8213 result = runner.invoke(cli, self.CMD + ["--explain", "no_colon_here"])
8214 assert result.exit_code != 0
8215
8216 def test_predict_explain_unknown_addr_rejected(
8217 self, predict_repo: pathlib.Path
8218 ) -> None:
8219 result = runner.invoke(
8220 cli, self.CMD + ["--explain", "billing.py::nonexistent_fn_xyz"]
8221 )
8222 assert result.exit_code != 0
8223
8224 # ── --json ────────────────────────────────────────────────────────────────
8225
8226 def test_predict_json_exits_zero(self, predict_repo: pathlib.Path) -> None:
8227 result = runner.invoke(cli, self.CMD + ["--json"])
8228 assert result.exit_code == 0, result.output
8229
8230 def test_predict_json_is_valid(self, predict_repo: pathlib.Path) -> None:
8231 result = runner.invoke(cli, self.CMD + ["--json"])
8232 data = json.loads(result.output)
8233 assert isinstance(data, dict)
8234
8235 def test_predict_json_top_level_keys(self, predict_repo: pathlib.Path) -> None:
8236 result = runner.invoke(cli, self.CMD + ["--json"])
8237 data = json.loads(result.output)
8238 for key in (
8239 "generated_at", "horizon_commits", "max_commits",
8240 "commits_analysed", "truncated", "predictions",
8241 ):
8242 assert key in data, f"missing key: {key}"
8243
8244 def test_predict_json_predictions_is_list(self, predict_repo: pathlib.Path) -> None:
8245 result = runner.invoke(cli, self.CMD + ["--json"])
8246 data = json.loads(result.output)
8247 assert isinstance(data["predictions"], list)
8248
8249 def test_predict_json_predictions_not_empty(
8250 self, predict_repo: pathlib.Path
8251 ) -> None:
8252 result = runner.invoke(cli, self.CMD + ["--json"])
8253 data = json.loads(result.output)
8254 assert len(data["predictions"]) >= 1
8255
8256 def test_predict_json_prediction_schema(self, predict_repo: pathlib.Path) -> None:
8257 result = runner.invoke(cli, self.CMD + ["--json"])
8258 data = json.loads(result.output)
8259 pred = data["predictions"][0]
8260 for key in (
8261 "address", "name", "kind", "file", "score", "confidence",
8262 "reasons", "signals", "last_changed_commit", "last_changed_date",
8263 "top_partners",
8264 ):
8265 assert key in pred, f"prediction missing key: {key}"
8266
8267 def test_predict_json_signals_schema(self, predict_repo: pathlib.Path) -> None:
8268 result = runner.invoke(cli, self.CMD + ["--json"])
8269 data = json.loads(result.output)
8270 signals = data["predictions"][0]["signals"]
8271 for key in ("recency", "frequency", "co_change", "sig_instability",
8272 "module_velocity"):
8273 assert key in signals, f"signals missing key: {key}"
8274
8275 def test_predict_json_score_is_float(self, predict_repo: pathlib.Path) -> None:
8276 result = runner.invoke(cli, self.CMD + ["--json"])
8277 data = json.loads(result.output)
8278 assert isinstance(data["predictions"][0]["score"], float)
8279
8280 def test_predict_json_score_in_range(self, predict_repo: pathlib.Path) -> None:
8281 result = runner.invoke(cli, self.CMD + ["--json"])
8282 data = json.loads(result.output)
8283 for pred in data["predictions"]:
8284 assert 0.0 <= pred["score"] <= 1.0, (
8285 f"score out of range: {pred['score']}"
8286 )
8287
8288 def test_predict_json_confidence_valid(self, predict_repo: pathlib.Path) -> None:
8289 result = runner.invoke(cli, self.CMD + ["--json"])
8290 data = json.loads(result.output)
8291 for pred in data["predictions"]:
8292 assert pred["confidence"] in {"high", "medium", "low"}, (
8293 f"invalid confidence: {pred['confidence']}"
8294 )
8295
8296 def test_predict_json_sorted_by_score_desc(self, predict_repo: pathlib.Path) -> None:
8297 result = runner.invoke(cli, self.CMD + ["--json"])
8298 data = json.loads(result.output)
8299 scores = [p["score"] for p in data["predictions"]]
8300 assert scores == sorted(scores, reverse=True)
8301
8302 def test_predict_json_commits_analysed_positive(
8303 self, predict_repo: pathlib.Path
8304 ) -> None:
8305 result = runner.invoke(cli, self.CMD + ["--json"])
8306 data = json.loads(result.output)
8307 assert data["commits_analysed"] > 0
8308
8309 def test_predict_json_truncated_false_small_repo(
8310 self, predict_repo: pathlib.Path
8311 ) -> None:
8312 result = runner.invoke(cli, self.CMD + ["--json"])
8313 data = json.loads(result.output)
8314 assert data["truncated"] is False
8315
8316 def test_predict_json_top_partners_is_list(self, predict_repo: pathlib.Path) -> None:
8317 result = runner.invoke(cli, self.CMD + ["--json"])
8318 data = json.loads(result.output)
8319 for pred in data["predictions"]:
8320 assert isinstance(pred["top_partners"], list)
8321
8322 def test_predict_json_partner_schema(self, predict_repo: pathlib.Path) -> None:
8323 result = runner.invoke(cli, self.CMD + ["--json"])
8324 data = json.loads(result.output)
8325 # Find a prediction that has partners.
8326 for pred in data["predictions"]:
8327 if pred["top_partners"]:
8328 p = pred["top_partners"][0]
8329 for key in ("address", "co_change_rate", "co_change_commits"):
8330 assert key in p, f"partner missing key: {key}"
8331 break
8332
8333 def test_predict_json_co_change_rate_in_range(
8334 self, predict_repo: pathlib.Path
8335 ) -> None:
8336 result = runner.invoke(cli, self.CMD + ["--json"])
8337 data = json.loads(result.output)
8338 for pred in data["predictions"]:
8339 for p in pred["top_partners"]:
8340 assert 0.0 <= p["co_change_rate"] <= 1.0
8341
8342 def test_predict_json_top_1_returns_one(self, predict_repo: pathlib.Path) -> None:
8343 result = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
8344 data = json.loads(result.output)
8345 assert len(data["predictions"]) == 1
8346
8347 def test_predict_json_horizon_matches_arg(self, predict_repo: pathlib.Path) -> None:
8348 result = runner.invoke(cli, self.CMD + ["--json", "--horizon", "3"])
8349 data = json.loads(result.output)
8350 assert data["horizon_commits"] == 3
8351
8352 def test_predict_json_reasons_is_list(self, predict_repo: pathlib.Path) -> None:
8353 result = runner.invoke(cli, self.CMD + ["--json"])
8354 data = json.loads(result.output)
8355 for pred in data["predictions"]:
8356 assert isinstance(pred["reasons"], list)
8357 assert all(isinstance(r, str) for r in pred["reasons"])
8358
8359 # ── requires repo ─────────────────────────────────────────────────────────
8360
8361 def test_predict_requires_repo(self, tmp_path: pathlib.Path) -> None:
8362 import os
8363
8364 old = os.getcwd()
8365 try:
8366 os.chdir(tmp_path)
8367 result = runner.invoke(cli, self.CMD)
8368 assert result.exit_code != 0
8369 finally:
8370 os.chdir(old)
8371
8372
8373 # ---------------------------------------------------------------------------
8374 # Helpers
8375 # ---------------------------------------------------------------------------
8376
8377
8378 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
8379 """Return all commit IDs from the store, newest-first (by log order)."""
8380 from muse.core.store import get_all_commits
8381 commits = get_all_commits(repo)
8382 return [c.commit_id for c in commits]
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago