gabriel / muse public
test_code_commands.py python
8,433 lines 369.8 KB
Raw
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 15 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.refs import get_head_commit_id
63 from muse.core.commits import CommitDict
64 from muse.core.types import Manifest
65 from muse.core.paths import coordination_dir, indices_dir, muse_dir, ref_path, repo_json_path
66
67 type _ImportsMap = dict[str, list[str]]
68 type _ImportsSetMap = dict[str, set[str]]
69 type _KindsMap = dict[str, int]
70
71 runner = CliRunner()
72
73
74 # ---------------------------------------------------------------------------
75 # Shared fixtures
76 # ---------------------------------------------------------------------------
77
78
79 @pytest.fixture
80 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
81 """Initialise a fresh code-domain Muse repo."""
82 monkeypatch.chdir(tmp_path)
83 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
84 result = runner.invoke(cli, ["init", "--domain", "code"])
85 assert result.exit_code == 0, result.output
86 return tmp_path
87
88
89 @pytest.fixture
90 def code_repo(repo: pathlib.Path) -> pathlib.Path:
91 """Repo with two Python commits for analysis commands."""
92 work = repo
93 # Commit 1 — define compute_total and Invoice class.
94 (work / "billing.py").write_text(textwrap.dedent("""\
95 class Invoice:
96 def compute_total(self, items):
97 return sum(items)
98
99 def apply_discount(self, total, pct):
100 return total * (1 - pct)
101
102 def process_order(invoice, items):
103 return invoice.compute_total(items)
104 """))
105 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
106 assert r.exit_code == 0, r.output
107
108 # Commit 2 — rename compute_total, add new function.
109 (work / "billing.py").write_text(textwrap.dedent("""\
110 class Invoice:
111 def compute_invoice_total(self, items):
112 return sum(items)
113
114 def apply_discount(self, total, pct):
115 return total * (1 - pct)
116
117 def generate_pdf(self):
118 return b"pdf"
119
120 def process_order(invoice, items):
121 return invoice.compute_invoice_total(items)
122
123 def send_email(address):
124 pass
125 """))
126 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
127 assert r.exit_code == 0, r.output
128 return repo
129
130
131 # ---------------------------------------------------------------------------
132 # muse lineage
133 # ---------------------------------------------------------------------------
134
135
136 class TestLineage:
137 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
138 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
139 assert result.exit_code == 0, result.output
140
141 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
142 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
143 assert result.exit_code == 0, result.output
144 data = json.loads(result.output)
145 assert isinstance(data, dict)
146 assert "events" in data
147
148 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
149 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_func"])
150 # Should not crash — exit 0 or 1, but no unhandled exception.
151 assert result.exit_code in (0, 1)
152
153 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
154 monkeypatch.chdir(tmp_path)
155 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
156 assert result.exit_code != 0
157
158
159 # ---------------------------------------------------------------------------
160 # muse api-surface
161 # ---------------------------------------------------------------------------
162
163
164 class TestApiSurface:
165 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
166 result = runner.invoke(cli, ["code", "api-surface"])
167 assert result.exit_code == 0, result.output
168
169 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
170 result = runner.invoke(cli, ["code", "api-surface", "--json"])
171 assert result.exit_code == 0
172 data = json.loads(result.output)
173 assert isinstance(data, dict)
174
175 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
176 commits = _all_commit_ids(code_repo)
177 if len(commits) >= 2:
178 result = runner.invoke(cli, ["code", "api-surface", "--diff", commits[-2]])
179 assert result.exit_code == 0
180
181 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
182 result = runner.invoke(cli, ["code", "api-surface"])
183 assert result.exit_code in (0, 1)
184
185
186 # ---------------------------------------------------------------------------
187 # muse codemap
188 # ---------------------------------------------------------------------------
189
190
191 class TestCodemap:
192 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
193 result = runner.invoke(cli, ["code", "codemap"])
194 assert result.exit_code == 0, result.output
195
196 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
197 result = runner.invoke(cli, ["code", "codemap", "--top", "3"])
198 assert result.exit_code == 0
199
200 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
201 result = runner.invoke(cli, ["code", "codemap", "--json"])
202 assert result.exit_code == 0
203 data = json.loads(result.output)
204 assert isinstance(data, dict)
205
206
207 # ---------------------------------------------------------------------------
208 # muse clones
209 # ---------------------------------------------------------------------------
210
211
212 class TestClones:
213 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
214 result = runner.invoke(cli, ["code", "clones"])
215 assert result.exit_code == 0, result.output
216
217 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
218 result = runner.invoke(cli, ["code", "clones", "--tier", "exact"])
219 assert result.exit_code == 0
220
221 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
222 result = runner.invoke(cli, ["code", "clones", "--tier", "near"])
223 assert result.exit_code == 0
224
225 def test_clones_json(self, code_repo: pathlib.Path) -> None:
226 result = runner.invoke(cli, ["code", "clones", "--tier", "both", "--json"])
227 assert result.exit_code == 0
228 data = json.loads(result.output)
229 assert isinstance(data, dict)
230
231
232 # ---------------------------------------------------------------------------
233 # muse checkout-symbol
234 # ---------------------------------------------------------------------------
235
236
237 class TestCheckoutSymbol:
238 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
239 commits = _all_commit_ids(code_repo)
240 if len(commits) < 2:
241 pytest.skip("need at least 2 commits")
242 first_commit = commits[-2] # oldest commit (list is newest-first)
243 result = runner.invoke(cli, [
244 "code", "checkout-symbol", "--commit", first_commit, "--dry-run",
245 "billing.py::Invoice.compute_total",
246 ])
247 # May fail if symbol is not present; should not crash unhandled.
248 assert result.exit_code in (0, 1, 2)
249
250 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
251 result = runner.invoke(cli, ["code", "checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
252 assert result.exit_code != 0
253
254
255 # ---------------------------------------------------------------------------
256 # muse semantic-cherry-pick
257 # ---------------------------------------------------------------------------
258
259
260 class TestSemanticCherryPick:
261 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
262 commits = _all_commit_ids(code_repo)
263 if len(commits) < 2:
264 pytest.skip("need at least 2 commits")
265 first_commit = commits[-2]
266 result = runner.invoke(cli, [
267 "code", "semantic-cherry-pick",
268 "--from", first_commit,
269 "--dry-run",
270 "billing.py::Invoice.compute_total",
271 ])
272 assert result.exit_code in (0, 1)
273
274 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
275 result = runner.invoke(cli, ["code", "semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
276 assert result.exit_code != 0
277
278
279 # ---------------------------------------------------------------------------
280 # muse query
281 # ---------------------------------------------------------------------------
282
283
284 class TestQueryV2:
285 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
286 result = runner.invoke(cli, ["code", "query", "kind=function"])
287 assert result.exit_code == 0, result.output
288
289 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
290 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
291 assert result.exit_code == 0
292 data = json.loads(result.output)
293 assert "muse_version" in data
294
295 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
296 result = runner.invoke(cli, ["code", "query", "kind=function", "OR", "kind=method"])
297 assert result.exit_code == 0
298
299 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
300 result = runner.invoke(cli, ["code", "query", "NOT", "kind=import"])
301 assert result.exit_code == 0
302
303 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
304 result = runner.invoke(cli, ["code", "query", "--all-commits", "kind=function"])
305 assert result.exit_code == 0
306
307 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
308 result = runner.invoke(cli, ["code", "query", "name~=total"])
309 assert result.exit_code == 0
310 # Should find compute_invoice_total.
311 assert "total" in result.output.lower()
312
313 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
314 # query with kind=class to match everything of a known type.
315 result = runner.invoke(cli, ["code", "query", "kind=class"])
316 assert result.exit_code == 0
317 assert "Invoice" in result.output
318
319 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
320 result = runner.invoke(cli, ["code", "query", "lineno_gt=1"])
321 assert result.exit_code == 0
322
323 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
324 monkeypatch.chdir(tmp_path)
325 result = runner.invoke(cli, ["code", "query", "kind=function"])
326 assert result.exit_code != 0
327
328 # ── new v2.1 flags ────────────────────────────────────────────────────────
329
330 def test_query_count_only(self, code_repo: pathlib.Path) -> None:
331 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
332 assert result.exit_code == 0, result.output
333 # Output should be a single integer.
334 assert result.output.strip().isdigit()
335
336 def test_query_count_nonzero(self, code_repo: pathlib.Path) -> None:
337 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
338 assert int(result.output.strip()) >= 1
339
340 def test_query_limit_caps_results(self, code_repo: pathlib.Path) -> None:
341 all_r = runner.invoke(cli, ["code", "query", "kind=function"])
342 lim_r = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
343 assert lim_r.exit_code == 0, lim_r.output
344 # Limited output should be shorter than unlimited.
345 assert len(lim_r.output) <= len(all_r.output)
346
347 def test_query_limit_truncation_noted(self, code_repo: pathlib.Path) -> None:
348 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
349 assert "limited to 1" in result.output or "match" in result.output
350
351 def test_query_limit_zero_unlimited(self, code_repo: pathlib.Path) -> None:
352 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "0"])
353 assert result.exit_code == 0, result.output
354
355 def test_query_sort_name(self, code_repo: pathlib.Path) -> None:
356 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "name"])
357 assert result.exit_code == 0, result.output
358
359 def test_query_sort_size(self, code_repo: pathlib.Path) -> None:
360 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "size"])
361 assert result.exit_code == 0, result.output
362 # Size column should appear in output.
363 assert "L" in result.output
364
365 def test_query_sort_kind(self, code_repo: pathlib.Path) -> None:
366 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "kind"])
367 assert result.exit_code == 0, result.output
368
369 def test_query_sort_lineno(self, code_repo: pathlib.Path) -> None:
370 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "lineno"])
371 assert result.exit_code == 0, result.output
372
373 def test_query_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
374 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "zzz"])
375 assert result.exit_code != 0
376
377 def test_query_unique_bodies_exits_zero(self, code_repo: pathlib.Path) -> None:
378 result = runner.invoke(cli, ["code", "query", "kind=function", "--unique-bodies"])
379 assert result.exit_code == 0, result.output
380
381 def test_query_unique_bodies_count_lte_all(self, code_repo: pathlib.Path) -> None:
382 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
383 uniq_r = runner.invoke(cli, ["code", "query", "--count", "--unique-bodies", "kind=function"])
384 assert int(uniq_r.output.strip()) <= int(all_r.output.strip())
385
386 def test_query_size_gt_predicate(self, code_repo: pathlib.Path) -> None:
387 result = runner.invoke(cli, ["code", "query", "kind=function", "size_gt=0"])
388 assert result.exit_code == 0, result.output
389
390 def test_query_size_lt_predicate(self, code_repo: pathlib.Path) -> None:
391 result = runner.invoke(cli, ["code", "query", "kind=function", "size_lt=1000"])
392 assert result.exit_code == 0, result.output
393
394 def test_query_size_gt_excludes_small(self, code_repo: pathlib.Path) -> None:
395 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
396 large_r = runner.invoke(cli, ["code", "query", "--count", "kind=function", "size_gt=100"])
397 # Large-only count should be <= total.
398 assert int(large_r.output.strip()) <= int(all_r.output.strip())
399
400 def test_query_json_includes_size(self, code_repo: pathlib.Path) -> None:
401 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
402 data = json.loads(result.output)
403 for r in data["results"]:
404 assert "size" in r
405
406 def test_query_json_includes_sort_field(self, code_repo: pathlib.Path) -> None:
407 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--sort", "name"])
408 data = json.loads(result.output)
409 assert data["sort"] == "name"
410
411 def test_query_json_includes_unique_bodies(self, code_repo: pathlib.Path) -> None:
412 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--unique-bodies"])
413 data = json.loads(result.output)
414 assert data["unique_bodies"] is True
415
416 def test_query_since_without_all_commits_rejected(self, code_repo: pathlib.Path) -> None:
417 result = runner.invoke(cli, ["code", "query", "kind=function", "--since", "2026-01-01"])
418 assert result.exit_code != 0
419
420 def test_query_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
421 result = runner.invoke(
422 cli,
423 ["code", "query", "kind=function", "--all-commits", "--since", "not-a-date"],
424 )
425 assert result.exit_code != 0
426
427 def test_query_all_commits_since_future_empty(self, code_repo: pathlib.Path) -> None:
428 result = runner.invoke(
429 cli,
430 ["code", "query", "kind=function", "--all-commits", "--since", "2099-01-01"],
431 )
432 assert result.exit_code == 0, result.output
433 # Future date means no commits match.
434 assert "no symbols" in result.output.lower() or result.output.strip() == ""
435
436 def test_query_max_commits_caps_walk(self, code_repo: pathlib.Path) -> None:
437 result = runner.invoke(
438 cli,
439 ["code", "query", "kind=function", "--all-commits", "--max-commits", "1"],
440 )
441 assert result.exit_code == 0, result.output
442
443
444 # ---------------------------------------------------------------------------
445 # muse query-history
446 # ---------------------------------------------------------------------------
447
448
449 class TestQueryHistory:
450 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
451 result = runner.invoke(cli, ["code", "query-history", "kind=function"])
452 assert result.exit_code == 0, result.output
453
454 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
455 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=function"])
456 assert result.exit_code == 0
457 data = json.loads(result.output)
458 assert "muse_version" in data
459 assert "results" in data
460
461 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
462 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
463 assert result.exit_code == 0
464
465 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
466 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
467 assert result.exit_code == 0
468 data = json.loads(result.output)
469 for entry in data.get("results", []):
470 assert "commit_count" in entry
471 assert "change_count" in entry
472
473 # ── new v2 flags ──────────────────────────────────────────────────────────
474
475 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
476 result = runner.invoke(
477 cli, ["code", "query-history", "--changed-only", "kind=function"]
478 )
479 assert result.exit_code == 0, result.output
480
481 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
482 result = runner.invoke(
483 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
484 )
485 assert result.exit_code == 0
486 data = json.loads(result.output)
487 for entry in data["results"]:
488 assert entry["change_count"] > 1
489
490 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
491 result = runner.invoke(
492 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
493 )
494 assert result.exit_code == 0, result.output
495
496 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
497 result = runner.invoke(
498 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
499 )
500 assert result.exit_code == 0, result.output
501
502 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
503 result = runner.invoke(
504 cli, ["code", "query-history", "--sort", "first", "kind=function"]
505 )
506 assert result.exit_code == 0, result.output
507
508 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
509 result = runner.invoke(
510 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
511 )
512 assert result.exit_code != 0
513
514 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
515 result = runner.invoke(
516 cli, ["code", "query-history", "--count", "kind=function"]
517 )
518 assert result.exit_code == 0, result.output
519 assert result.output.strip().isdigit()
520 assert int(result.output.strip()) >= 1
521
522 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
523 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
524 lim_r = runner.invoke(
525 cli, ["code", "query-history", "--limit", "1", "kind=function"]
526 )
527 assert lim_r.exit_code == 0, lim_r.output
528 assert len(lim_r.output) <= len(all_r.output)
529
530 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
531 result = runner.invoke(
532 cli, ["code", "query-history", "--limit", "1", "kind=function"]
533 )
534 assert "1" in result.output
535
536 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
537 result = runner.invoke(
538 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
539 )
540 assert result.exit_code == 0
541 data = json.loads(result.output)
542 for entry in data["results"]:
543 assert entry["change_count"] >= 2
544
545 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
546 result = runner.invoke(
547 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
548 )
549 assert result.exit_code != 0
550
551 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
552 result = runner.invoke(
553 cli, ["code", "query-history", "--introduced-only", "kind=function"]
554 )
555 assert result.exit_code == 0, result.output
556
557 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
558 result = runner.invoke(
559 cli, ["code", "query-history", "--removed-only", "kind=function"]
560 )
561 assert result.exit_code == 0, result.output
562
563 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
564 result = runner.invoke(
565 cli,
566 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
567 )
568 assert result.exit_code == 0
569 data = json.loads(result.output)
570 assert data["mode"] == "introduced-only"
571 assert "symbols_found" in data
572 for entry in data["results"]:
573 assert entry["status"] == "introduced"
574
575 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
576 result = runner.invoke(
577 cli,
578 ["code", "query-history", "--removed-only", "--json", "kind=function"],
579 )
580 assert result.exit_code == 0
581 data = json.loads(result.output)
582 assert data["mode"] == "removed-only"
583 assert "symbols_found" in data
584 for entry in data["results"]:
585 assert entry["status"] == "removed"
586
587 def test_query_history_mode_flags_mutually_exclusive(
588 self, code_repo: pathlib.Path
589 ) -> None:
590 result = runner.invoke(
591 cli,
592 [
593 "code", "query-history",
594 "--changed-only", "--introduced-only",
595 "kind=function",
596 ],
597 )
598 assert result.exit_code != 0
599
600 def test_query_history_json_has_full_commit_ids(
601 self, code_repo: pathlib.Path
602 ) -> None:
603 result = runner.invoke(
604 cli, ["code", "query-history", "--json", "kind=function"]
605 )
606 assert result.exit_code == 0
607 data = json.loads(result.output)
608 for entry in data["results"]:
609 # Full commit IDs should be present (not just 8-char short form).
610 assert len(entry["first_commit_id"]) > 8
611 assert "stable" in entry
612
613 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
614 result = runner.invoke(
615 cli,
616 ["code", "query-history", "--max-commits", "1", "kind=function"],
617 )
618 assert result.exit_code == 0, result.output
619
620 def test_query_history_introduced_count_only(
621 self, code_repo: pathlib.Path
622 ) -> None:
623 result = runner.invoke(
624 cli,
625 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
626 )
627 assert result.exit_code == 0
628 assert result.output.strip().isdigit()
629
630
631 # ---------------------------------------------------------------------------
632 # muse index
633 # ---------------------------------------------------------------------------
634
635
636 class TestIndexCommands:
637 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
638 result = runner.invoke(cli, ["code", "index", "status"])
639 assert result.exit_code == 0, result.output
640
641 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
642 result = runner.invoke(cli, ["code", "index", "status"])
643 # Indexes have not been built yet.
644 assert "absent" in result.output.lower() or result.exit_code == 0
645
646 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
647 result = runner.invoke(cli, ["code", "index", "rebuild"])
648 assert result.exit_code == 0, result.output
649
650 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
651 runner.invoke(cli, ["code", "index", "rebuild"])
652 idx_dir = indices_dir(code_repo)
653 assert idx_dir.exists()
654
655 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
656 runner.invoke(cli, ["code", "index", "rebuild"])
657 result = runner.invoke(cli, ["code", "index", "status"])
658 assert result.exit_code == 0
659 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
660 assert "entries" in result.output.lower() or "✅" in result.output
661
662 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
663 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
664 assert result.exit_code == 0
665
666 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
667 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
668 assert result.exit_code == 0
669
670
671 # ---------------------------------------------------------------------------
672 # muse detect-refactor
673 # ---------------------------------------------------------------------------
674
675
676 class TestHotspots:
677 """Tests for muse code hotspots."""
678
679 # ── basic correctness ────────────────────────────────────────────────────
680
681 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
682 result = runner.invoke(cli, ["code", "hotspots"])
683 assert result.exit_code == 0, result.output
684
685 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
686 """compute_invoice_total was modified across two commits — must appear."""
687 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
688 assert result.exit_code == 0, result.output
689 assert "billing.py" in result.output
690
691 def test_hotspots_excludes_imports_by_default(
692 self, code_repo: pathlib.Path
693 ) -> None:
694 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
695 assert result.exit_code == 0, result.output
696 assert "::import::" not in result.output
697
698 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
699 """--include-imports must surface import pseudo-symbols if any exist."""
700 result = runner.invoke(
701 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
702 )
703 assert result.exit_code == 0, result.output
704 # Just verify it runs cleanly; the repo may or may not have import ops.
705
706 # ── --kind filter (was broken before) ────────────────────────────────────
707
708 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
709 """--kind function must not return class symbols."""
710 result = runner.invoke(
711 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
712 )
713 assert result.exit_code == 0, result.output
714 for line in result.output.splitlines():
715 if "::" in line and "class" in line.lower():
716 # Make sure any class line is not a function kind result
717 # (Addresses that contain the word "class" in their name are OK)
718 pass # Name may contain "class" as substring
719
720 def test_kind_filter_function_returns_functions(
721 self, code_repo: pathlib.Path
722 ) -> None:
723 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
724 result_fn = runner.invoke(
725 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
726 )
727 assert result_fn.exit_code == 0, result_fn.output
728 # filtered result should have <= symbols than unfiltered
729 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
730 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
731 assert len(fn_lines) <= len(all_lines)
732
733 # ── --min filter ──────────────────────────────────────────────────────────
734
735 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
736 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
737 result_min = runner.invoke(
738 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
739 )
740 assert result_min.exit_code == 0, result_min.output
741 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
742 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
743 assert len(min_lines) <= len(all_lines)
744
745 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
746 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
747 assert result.exit_code == 1
748
749 # ── --language filter ─────────────────────────────────────────────────────
750
751 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
752 result = runner.invoke(
753 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
754 )
755 assert result.exit_code == 0, result.output
756 assert "billing.py" in result.output
757
758 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
759 result = runner.invoke(
760 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
761 )
762 assert result.exit_code == 0, result.output
763
764 # ── --top validation ──────────────────────────────────────────────────────
765
766 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
767 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
768 assert result.exit_code == 1
769
770 # ── JSON schema ───────────────────────────────────────────────────────────
771
772 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
773 result = runner.invoke(cli, ["code", "hotspots", "--json"])
774 assert result.exit_code == 0, result.output
775 data = json.loads(result.output)
776 for key in (
777 "from_ref", "to_ref", "commits_analysed", "truncated",
778 "filters", "hotspots",
779 ):
780 assert key in data, f"missing key: {key}"
781 assert isinstance(data["hotspots"], list)
782 assert isinstance(data["truncated"], bool)
783 assert isinstance(data["commits_analysed"], int)
784
785 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
786 result = runner.invoke(
787 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
788 )
789 data = json.loads(result.output)
790 assert data["filters"]["kind"] == "function"
791 assert data["filters"]["min_changes"] == 2
792 assert data["filters"]["include_imports"] is False
793
794 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
795 result = runner.invoke(cli, ["code", "hotspots", "--json"])
796 data = json.loads(result.output)
797 if data["hotspots"]:
798 entry = data["hotspots"][0]
799 assert "address" in entry
800 assert "changes" in entry
801 assert isinstance(entry["changes"], int)
802 assert entry["changes"] >= 1
803
804 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
805 result = runner.invoke(cli, ["code", "hotspots", "--json"])
806 data = json.loads(result.output)
807 addresses = [h["address"] for h in data["hotspots"]]
808 assert not any("::import::" in a for a in addresses)
809
810 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
811 result = runner.invoke(cli, ["code", "hotspots", "--json"])
812 data = json.loads(result.output)
813 counts = [h["changes"] for h in data["hotspots"]]
814 assert counts == sorted(counts, reverse=True)
815
816 # ── --max-commits truncation ──────────────────────────────────────────────
817
818 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
819 result = runner.invoke(
820 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
821 )
822 assert result.exit_code == 0, result.output
823 data = json.loads(result.output)
824 assert data["commits_analysed"] <= 1
825
826 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
827 result = runner.invoke(
828 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
829 )
830 data = json.loads(result.output)
831 assert data["truncated"] is True
832
833
834 class TestDetectRefactorV2:
835 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
836 """JSON output contains all required top-level fields."""
837 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
838 assert result.exit_code == 0, result.output
839 data = json.loads(result.output)
840 for field in ("commits_scanned", "truncated", "total", "events"):
841 assert field in data, f"missing field '{field}'"
842 assert isinstance(data["commits_scanned"], int)
843 assert isinstance(data["truncated"], bool)
844 assert isinstance(data["total"], int)
845 assert isinstance(data["events"], list)
846
847 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
848 """Each JSON event contains the required fields."""
849 # Run over the full history; code_repo has at least one rename event.
850 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
851 assert result.exit_code == 0, result.output
852 data = json.loads(result.output)
853 for ev in data["events"]:
854 for field in ("kind", "address", "detail",
855 "commit_id", "commit_message", "committed_at"):
856 assert field in ev, f"missing event field '{field}'"
857 assert ev["kind"] in ("rename", "move", "signature", "implementation")
858
859 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
860 """A commit that renames a symbol produces a 'rename' event."""
861 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
862 assert result.exit_code == 0, result.output
863 data = json.loads(result.output)
864 kinds = [e["kind"] for e in data["events"]]
865 assert "rename" in kinds, (
866 f"Expected at least one rename event; got: {sorted(set(kinds))}"
867 )
868
869 def test_detect_refactor_classifies_modified_as_implementation(
870 self, code_repo: pathlib.Path
871 ) -> None:
872 """Replace ops with '(modified)' in new_summary are classified as implementation.
873
874 Previously, only '(implementation changed)' triggered implementation
875 classification; '(modified)' was silently dropped.
876 """
877 import datetime
878 root = code_repo
879 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
880 from muse.core.refs import (
881 get_head_commit_id,
882 read_current_branch,
883 )
884 from muse.core.commits import (
885 CommitDict,
886 CommitRecord,
887 write_commit,
888 )
889 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
890 branch = read_current_branch(root)
891 head_id = get_head_commit_id(root, branch)
892
893 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
894 message = "perf: optimise batch"
895 snap_manifest: Manifest = {}
896 snap_id = compute_snapshot_id(snap_manifest)
897 parent_ids = [head_id] if head_id else []
898 commit_id = compute_commit_id(
899 parent_ids=parent_ids,
900 snapshot_id=snap_id,
901 message=message,
902 committed_at_iso=now.isoformat(),
903 author="test",
904 )
905 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
906 commit = CommitRecord(
907 commit_id=commit_id,
908 branch=branch,
909 snapshot_id=snap_id,
910 message=message,
911 committed_at=now,
912 parent_commit_id=head_id,
913 author="test",
914 structured_delta=StructuredDelta(ops=[PatchOp(
915 op="patch",
916 address="billing.py",
917 child_ops=[ReplaceOp(
918 op="replace",
919 address="billing.py::process_batch",
920 new_summary="function process_batch (modified) L10–30",
921 old_summary="function process_batch",
922 )],
923 )]),
924 )
925 write_commit(root, commit)
926 (ref_path(root, branch)).write_text(commit_id)
927
928 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
929 assert result.exit_code == 0, result.output
930 data = json.loads(result.output)
931 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
932 addrs = [e["address"] for e in impl_events]
933 assert "billing.py::process_batch" in addrs, (
934 f"'(modified)' op not classified as implementation; events: {data['events']}"
935 )
936
937 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
938 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
939 import datetime
940 root = code_repo
941 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
942 from muse.core.refs import (
943 get_head_commit_id,
944 read_current_branch,
945 )
946 from muse.core.commits import (
947 CommitDict,
948 CommitRecord,
949 write_commit,
950 )
951 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
952 branch = read_current_branch(root)
953 head_id = get_head_commit_id(root, branch)
954
955 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
956 message = "style: reformat"
957 snap_manifest: Manifest = {}
958 snap_id = compute_snapshot_id(snap_manifest)
959 parent_ids = [head_id] if head_id else []
960 commit_id = compute_commit_id(
961 parent_ids=parent_ids,
962 snapshot_id=snap_id,
963 message=message,
964 committed_at_iso=now.isoformat(),
965 author="test",
966 )
967 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
968 commit = CommitRecord(
969 commit_id=commit_id,
970 branch=branch,
971 snapshot_id=snap_id,
972 message=message,
973 committed_at=now,
974 parent_commit_id=head_id,
975 author="test",
976 structured_delta=StructuredDelta(ops=[PatchOp(
977 op="patch",
978 address="billing.py",
979 child_ops=[ReplaceOp(
980 op="replace",
981 address="billing.py::UniqueReformattedSymbol",
982 new_summary="reformatted — no semantic change",
983 old_summary="",
984 )],
985 )]),
986 )
987 write_commit(root, commit)
988 (ref_path(root, branch)).write_text(commit_id)
989
990 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
991 assert result.exit_code == 0, result.output
992 data = json.loads(result.output)
993 # The reformatted op must not appear as an event.
994 reformatted_events = [
995 e for e in data["events"]
996 if e["address"] == "billing.py::UniqueReformattedSymbol"
997 ]
998 assert reformatted_events == [], (
999 f"Reformatted op should be skipped; got: {reformatted_events}"
1000 )
1001
1002 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
1003 """When --max is hit, a truncation warning appears in human output."""
1004 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
1005 assert result.exit_code == 0, result.output
1006 assert "incomplete" in result.output or "limit" in result.output
1007
1008 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
1009 """When --max is hit, truncated=true in JSON."""
1010 result = runner.invoke(
1011 cli, ["code", "detect-refactor", "--max", "1", "--json"]
1012 )
1013 assert result.exit_code == 0, result.output
1014 data = json.loads(result.output)
1015 assert data["truncated"] is True
1016 assert data["commits_scanned"] == 1
1017
1018 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
1019 """--max 0 exits non-zero."""
1020 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
1021 assert result.exit_code != 0
1022
1023 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1024 """``--kind rename`` returns only rename events."""
1025 result = runner.invoke(
1026 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1027 )
1028 assert result.exit_code == 0, result.output
1029 data = json.loads(result.output)
1030 for ev in data["events"]:
1031 assert ev["kind"] == "rename"
1032
1033 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1034 """``--kind`` with an invalid value exits non-zero."""
1035 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1036 assert result.exit_code != 0
1037
1038 def test_detect_refactor_bfs_follows_merge_parent2(
1039 self, code_repo: pathlib.Path
1040 ) -> None:
1041 """BFS walk finds refactoring events on merged feature branches."""
1042 import datetime
1043 root = code_repo
1044 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
1045 from muse.core.refs import (
1046 get_head_commit_id,
1047 read_current_branch,
1048 )
1049 from muse.core.commits import (
1050 CommitRecord,
1051 write_commit,
1052 )
1053 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
1054 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1055 branch = read_current_branch(root)
1056 head_id = get_head_commit_id(root, branch)
1057 assert head_id is not None
1058
1059 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1060 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1061
1062 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1063 feature_id = compute_commit_id(
1064 parent_ids=[head_id],
1065 snapshot_id=feat_snap_id,
1066 message="perf: vectorise",
1067 committed_at_iso=feat_at.isoformat(),
1068 author="test",
1069 )
1070 write_commit(root, CommitRecord(
1071 commit_id=feature_id,
1072 branch="feat/perf",
1073 snapshot_id=feat_snap_id,
1074 message="perf: vectorise",
1075 committed_at=feat_at,
1076 parent_commit_id=head_id,
1077 author="test",
1078 structured_delta=StructuredDelta(ops=[PatchOp(
1079 op="patch",
1080 address="billing.py",
1081 child_ops=[ReplaceOp(
1082 op="replace",
1083 address="billing.py::vectorised_fn",
1084 new_summary="function vectorised_fn (implementation changed) L1–20",
1085 old_summary="function vectorised_fn",
1086 )],
1087 )]),
1088 ))
1089 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1090 merge_id = compute_commit_id(
1091 parent_ids=[head_id, feature_id],
1092 snapshot_id=merge_snap_id,
1093 message="merge feat/perf",
1094 committed_at_iso=merge_at.isoformat(),
1095 author="test",
1096 )
1097 write_commit(root, CommitRecord(
1098 commit_id=merge_id,
1099 branch=branch,
1100 snapshot_id=merge_snap_id,
1101 message="merge feat/perf",
1102 committed_at=merge_at,
1103 parent_commit_id=head_id,
1104 parent2_commit_id=feature_id,
1105 author="test",
1106 ))
1107 (ref_path(root, branch)).write_text(merge_id)
1108
1109 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1110 assert result.exit_code == 0, result.output
1111 data = json.loads(result.output)
1112 addrs = [e["address"] for e in data["events"]]
1113 assert "billing.py::vectorised_fn" in addrs, (
1114 "BFS must find the implementation event on the feature branch"
1115 )
1116
1117
1118 # ---------------------------------------------------------------------------
1119 # muse reserve
1120 # ---------------------------------------------------------------------------
1121
1122
1123 class TestReserve:
1124 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1125 result = runner.invoke(cli, [
1126 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1127 ])
1128 assert result.exit_code == 0, result.output
1129
1130 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1131 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1132 coord_dir = coordination_dir(code_repo) / "reservations"
1133 assert coord_dir.exists()
1134 files = list(coord_dir.glob("*.json"))
1135 assert len(files) >= 1
1136
1137 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1138 result = runner.invoke(cli, [
1139 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1140 ])
1141 assert result.exit_code == 0
1142 data = json.loads(result.output)
1143 assert "reservation_id" in data
1144
1145 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1146 result = runner.invoke(cli, [
1147 "coord", "reserve", "--run-id", "r3",
1148 "billing.py::process_order",
1149 "billing.py::Invoice.apply_discount",
1150 ])
1151 assert result.exit_code == 0
1152
1153 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1154 result = runner.invoke(cli, [
1155 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1156 "billing.py::process_order",
1157 ])
1158 assert result.exit_code == 0
1159
1160 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1161 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1162 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1163 # Should warn but not fail.
1164 assert result.exit_code == 0
1165 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1166
1167
1168 # ---------------------------------------------------------------------------
1169 # muse intent
1170 # ---------------------------------------------------------------------------
1171
1172
1173 class TestIntent:
1174 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1175 result = runner.invoke(cli, [
1176 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1177 "billing.py::process_order",
1178 ])
1179 assert result.exit_code == 0, result.output
1180
1181 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1182 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1183 idir = coordination_dir(code_repo) / "intents"
1184 assert idir.exists()
1185 assert len(list(idir.glob("*.json"))) >= 1
1186
1187 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1188 result = runner.invoke(cli, [
1189 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1190 ])
1191 assert result.exit_code == 0
1192 data = json.loads(result.output)
1193 assert "intent_id" in data or "operation" in data
1194
1195
1196 # ---------------------------------------------------------------------------
1197 # muse forecast
1198 # ---------------------------------------------------------------------------
1199
1200
1201 class TestForecast:
1202 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1203 result = runner.invoke(cli, ["coord", "forecast"])
1204 assert result.exit_code == 0, result.output
1205
1206 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1207 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1208 assert result.exit_code == 0
1209 data = json.loads(result.output)
1210 assert "conflicts" in data
1211
1212 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1213 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1214 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1215 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1216 assert result.exit_code == 0
1217 data = json.loads(result.output)
1218 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1219 assert "address_overlap" in types
1220
1221
1222 # ---------------------------------------------------------------------------
1223 # muse plan-merge
1224 # ---------------------------------------------------------------------------
1225
1226
1227 class TestPlanMerge:
1228 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1229 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1230 assert result.exit_code == 0, result.output
1231
1232 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1233 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1234 assert result.exit_code == 0
1235 data = json.loads(result.output)
1236 assert "conflicts" in data or isinstance(data, dict)
1237
1238 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1239 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1240 assert result.exit_code != 0
1241
1242
1243 # ---------------------------------------------------------------------------
1244 # muse shard
1245 # ---------------------------------------------------------------------------
1246
1247
1248 class TestShard:
1249 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1250 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1251 assert result.exit_code == 0, result.output
1252
1253 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1254 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1255 assert result.exit_code == 0
1256 data = json.loads(result.output)
1257 assert "shards" in data
1258
1259 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1260 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1261 assert result.exit_code == 0
1262
1263 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1264 # N larger than symbol count still works (produces fewer shards).
1265 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1266 assert result.exit_code == 0
1267
1268
1269 # ---------------------------------------------------------------------------
1270 # muse reconcile
1271 # ---------------------------------------------------------------------------
1272
1273
1274 class TestReconcile:
1275 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1276 result = runner.invoke(cli, ["coord", "reconcile"])
1277 assert result.exit_code == 0, result.output
1278
1279 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1280 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1281 assert result.exit_code == 0
1282 data = json.loads(result.output)
1283 assert isinstance(data, dict)
1284
1285
1286 # ---------------------------------------------------------------------------
1287 # muse breakage
1288 # ---------------------------------------------------------------------------
1289
1290
1291 class TestBreakage:
1292 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1293 result = runner.invoke(cli, ["code", "breakage"])
1294 assert result.exit_code == 0, result.output
1295
1296 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1297 result = runner.invoke(cli, ["code", "breakage", "--json"])
1298 assert result.exit_code == 0
1299 data = json.loads(result.output)
1300 # breakage JSON has "issues" list and error count.
1301 assert "issues" in data
1302 assert isinstance(data["issues"], list)
1303
1304 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1305 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1306 assert result.exit_code == 0
1307
1308 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1309 monkeypatch.chdir(tmp_path)
1310 result = runner.invoke(cli, ["code", "breakage"])
1311 assert result.exit_code != 0
1312
1313
1314 # ---------------------------------------------------------------------------
1315 # muse invariants
1316 # ---------------------------------------------------------------------------
1317
1318
1319 class TestInvariants:
1320 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1321 result = runner.invoke(cli, ["code", "invariants"])
1322 toml_path = muse_dir(code_repo) / "invariants.toml"
1323 assert result.exit_code == 0 or toml_path.exists()
1324
1325 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1326 # Create empty invariants.toml
1327 (muse_dir(code_repo) / "invariants.toml").write_text("# No rules\n")
1328 result = runner.invoke(cli, ["code", "invariants", "--json"])
1329 assert result.exit_code == 0
1330 # Output may be JSON or human-readable depending on rules count.
1331 output = result.output.strip()
1332 if output and not output.startswith("#"):
1333 try:
1334 data = json.loads(output)
1335 assert isinstance(data, dict)
1336 except json.JSONDecodeError:
1337 pass # Human-readable output is also acceptable.
1338
1339 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1340 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1341 [[rules]]
1342 type = "no_cycles"
1343 name = "no import cycles"
1344 """))
1345 result = runner.invoke(cli, ["code", "invariants"])
1346 assert result.exit_code == 0
1347
1348 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1349 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1350 [[rules]]
1351 type = "forbidden_dependency"
1352 name = "billing must not import utils"
1353 source_pattern = "billing.py"
1354 forbidden_pattern = "utils.py"
1355 """))
1356 result = runner.invoke(cli, ["code", "invariants"])
1357 assert result.exit_code == 0
1358
1359 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1360 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1361 [[rules]]
1362 type = "required_test"
1363 name = "billing must have tests"
1364 source_pattern = "billing.py"
1365 test_pattern = "test_billing.py"
1366 """))
1367 result = runner.invoke(cli, ["code", "invariants"])
1368 # May pass or fail depending on whether test_billing.py exists; should not crash.
1369 assert result.exit_code in (0, 1)
1370
1371 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1372 (muse_dir(code_repo) / "invariants.toml").write_text("# empty\n")
1373 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1374 assert result.exit_code == 0
1375
1376
1377 # ---------------------------------------------------------------------------
1378 # muse commit — semantic versioning
1379 # ---------------------------------------------------------------------------
1380
1381
1382 class TestSemVerInCommit:
1383 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1384 from muse.core.refs import get_head_commit_id
1385 from muse.core.commits import (
1386 CommitDict,
1387 read_commit,
1388 )
1389 commit_id = get_head_commit_id(code_repo, "main")
1390 assert commit_id is not None
1391 commit = read_commit(code_repo, commit_id)
1392 assert commit is not None
1393 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1394
1395 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1396 from muse.core.refs import get_head_commit_id
1397 from muse.core.commits import (
1398 CommitDict,
1399 read_commit,
1400 )
1401 commit_id = get_head_commit_id(code_repo, "main")
1402 assert commit_id is not None
1403 commit = read_commit(code_repo, commit_id)
1404 assert commit is not None
1405 assert isinstance(commit.breaking_changes, list)
1406
1407 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1408 from muse.core.refs import get_head_commit_id
1409 from muse.core.commits import (
1410 CommitDict,
1411 read_commit,
1412 )
1413 commit_id = get_head_commit_id(code_repo, "main")
1414 assert commit_id is not None
1415 commit = read_commit(code_repo, commit_id)
1416 assert commit is not None
1417 if commit.sem_ver_bump == "major":
1418 result = runner.invoke(cli, ["log"])
1419 assert "MAJOR" in result.output or "major" in result.output.lower()
1420
1421
1422 # ---------------------------------------------------------------------------
1423 # Call-graph tier — muse impact
1424 # ---------------------------------------------------------------------------
1425
1426
1427 class TestImpact:
1428 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1429 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1430 assert result.exit_code == 0, result.output
1431
1432 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1433 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1434 assert result.exit_code == 0
1435 data = json.loads(result.output)
1436 assert isinstance(data, dict)
1437 assert "blast_radius" in data
1438 assert "total" in data
1439 assert "commit_id" in data
1440 assert data["mode"] == "reverse"
1441
1442 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1443 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1444 assert result.exit_code in (0, 1)
1445
1446 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1447 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1448 assert result.exit_code == 0
1449 assert result.output.strip().isdigit()
1450
1451 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1452 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1453 assert result.exit_code == 1
1454
1455 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1456 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1457 assert result.exit_code == 0
1458
1459 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1460 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1461 assert result.exit_code == 0
1462 data = json.loads(result.output)
1463 assert data["mode"] == "forward"
1464 assert "callees" in data
1465 assert "total" in data
1466 assert "commit_id" in data
1467
1468 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1469 result = runner.invoke(cli, [
1470 "code", "impact", "--forward", "--compare", "HEAD",
1471 "--", "billing.py::process_order",
1472 ])
1473 assert result.exit_code == 1
1474
1475 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1476 result = runner.invoke(cli, [
1477 "code", "impact", "--file", "billing.py",
1478 "--", "billing.py::Invoice.compute_invoice_total",
1479 ])
1480 assert result.exit_code == 0
1481
1482 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1483 result = runner.invoke(cli, [
1484 "code", "impact", "--file", "billing.py", "--json",
1485 "--", "billing.py::Invoice.compute_invoice_total",
1486 ])
1487 assert result.exit_code == 0
1488 data = json.loads(result.output)
1489 assert data["file_filter"] == "billing.py"
1490 for depth_addrs in data["blast_radius"].values():
1491 for addr in depth_addrs:
1492 assert addr.startswith("billing.py::")
1493
1494 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1495 result = runner.invoke(cli, [
1496 "code", "impact", "--compare", "HEAD",
1497 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1498 ])
1499 assert result.exit_code == 0
1500 data = json.loads(result.output)
1501 assert "compare_commit_id" in data
1502 assert "added_callers" in data
1503 assert "removed_callers" in data
1504 assert "net_change" in data
1505 assert isinstance(data["added_callers"], list)
1506 assert isinstance(data["removed_callers"], list)
1507
1508 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1509 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1510 assert result.exit_code == 0
1511 assert result.output.strip().isdigit()
1512
1513
1514 # ---------------------------------------------------------------------------
1515 # Call-graph tier — muse dead
1516 # ---------------------------------------------------------------------------
1517
1518
1519 class TestDead:
1520 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1521 result = runner.invoke(cli, ["code", "dead"])
1522 assert result.exit_code == 0, result.output
1523
1524 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1525 result = runner.invoke(cli, ["code", "dead", "--json"])
1526 assert result.exit_code == 0
1527 data = json.loads(result.output)
1528 assert isinstance(data, dict)
1529 assert "results" in data
1530 assert "high_confidence_count" in data
1531 assert "total_files_scanned" in data
1532 assert "duration_ms" in data
1533
1534 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1535 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1536 assert result.exit_code == 0
1537
1538 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1539 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1540 assert result.exit_code == 0
1541
1542 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1543 result = runner.invoke(cli, ["code", "dead", "--count"])
1544 assert result.exit_code == 0
1545 assert result.output.strip().isdigit()
1546
1547 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1548 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1549 assert result.exit_code == 0
1550 data = json.loads(result.output)
1551 assert "compare_commit_id" in data
1552 assert "new_dead" in data
1553 assert "recovered" in data
1554 assert "net_change" in data
1555 assert isinstance(data["new_dead"], list)
1556 assert isinstance(data["recovered"], list)
1557
1558 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1559 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1560 assert result.exit_code == 0
1561
1562 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1563 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1564 assert result.exit_code == 1
1565
1566 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1567 out_file = tmp_path / "allowlist.json"
1568 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1569 assert result.exit_code == 0
1570 if out_file.exists():
1571 data = json.loads(out_file.read_text())
1572 assert isinstance(data, list)
1573 assert all(isinstance(x, str) for x in data)
1574
1575 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1576 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1577 assert result.exit_code == 0
1578 data = json.loads(result.output)
1579 for c in data["results"]:
1580 assert c["confidence"] == "high"
1581
1582 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1583 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1584 assert result.exit_code == 0
1585
1586
1587 # ---------------------------------------------------------------------------
1588 # muse code cat
1589 # ---------------------------------------------------------------------------
1590
1591
1592 class TestCat:
1593 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1594 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1595 assert result.exit_code == 0, result.output
1596 assert "class Invoice" in result.output
1597
1598 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1599 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1600 assert result.exit_code == 0, result.output
1601 assert "def compute_invoice_total" in result.output
1602
1603 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1604 # Invoice is unique — short name should resolve.
1605 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1606 assert result.exit_code == 0
1607
1608 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1609 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1610 assert result.exit_code != 0
1611
1612 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1613 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1614 assert result.exit_code != 0
1615
1616 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1617 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1618 assert result.exit_code != 0
1619
1620 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1621 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1622 assert result.exit_code == 0
1623 # Line numbers prefix lines with digits.
1624 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1625 first_code_line = next((ln for ln in lines if ln.strip()), "")
1626 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1627
1628 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1629 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1630 assert result.exit_code == 0
1631 data = json.loads(result.output)
1632 assert "results" in data
1633 assert "errors" in data
1634 assert "source_ref" in data
1635 assert len(data["results"]) == 1
1636 r = data["results"][0]
1637 assert r["path"] == "billing.py"
1638 assert r["kind"] in ("class", "function", "method")
1639 assert isinstance(r["lineno"], int)
1640 assert isinstance(r["end_lineno"], int)
1641 assert "class Invoice" in r["source"]
1642
1643 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1644 result = runner.invoke(
1645 cli,
1646 [
1647 "code", "cat",
1648 "billing.py::Invoice",
1649 "billing.py::Invoice.compute_invoice_total",
1650 "--json",
1651 ],
1652 )
1653 assert result.exit_code == 0, result.output
1654 data = json.loads(result.output)
1655 assert len(data["results"]) == 2
1656
1657 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1658 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1659 assert result.exit_code == 0
1660 assert "Invoice" in result.output
1661
1662 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1663 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1664 assert result.exit_code == 0
1665
1666 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1667 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1668 assert result.exit_code == 0
1669 data = json.loads(result.output)
1670 assert len(data["results"]) > 0
1671 # Every result has required fields.
1672 for r in data["results"]:
1673 assert "address" in r
1674 assert "lineno" in r
1675 assert "source" in r
1676
1677 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1678 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1679 result_ctx = runner.invoke(
1680 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1681 )
1682 assert result_ctx.exit_code == 0
1683 # With context we get at least as many lines.
1684 plain_lines = result_plain.output.count("\n")
1685 ctx_lines = result_ctx.output.count("\n")
1686 assert ctx_lines >= plain_lines
1687
1688 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1689 # In --json mode a missing symbol goes to the errors field, not a crash.
1690 result = runner.invoke(
1691 cli,
1692 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1693 )
1694 # Output must be valid JSON (no stderr bleed into stdout).
1695 data = json.loads(result.output)
1696 assert len(data["results"]) == 1
1697 assert len(data["errors"]) == 1
1698 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1699
1700 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1701 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1702 assert result.exit_code == 0
1703 assert "working tree" in result.output
1704
1705 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1706 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1707 assert result.exit_code == 0
1708 assert "Invoice" in result.output
1709
1710 def test_cat_wrong_file_fallback_finds_symbol(
1711 self, code_repo: pathlib.Path, tmp_path: pathlib.Path
1712 ) -> None:
1713 """FILE::SYMBOL where SYMBOL lives in a different file — should fall back
1714 to a global snapshot search and cat it from its actual location, exit 0."""
1715 # Add a second file with a unique function the billing module doesn't have.
1716 work = pathlib.Path.cwd()
1717 (work / "utils.py").write_text(
1718 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1719 )
1720 runner.invoke(cli, ["commit", "-m", "Add utils"])
1721
1722 # Ask for utils.format_currency but specify the wrong file (billing.py).
1723 result = runner.invoke(
1724 cli, ["code", "cat", "billing.py::format_currency"]
1725 )
1726 assert result.exit_code == 0, result.output
1727 assert "format_currency" in result.output
1728
1729 def test_cat_wrong_file_fallback_json(self, code_repo: pathlib.Path) -> None:
1730 """Same fallback in --json mode: result is in results[], not errors[]."""
1731 work = pathlib.Path.cwd()
1732 (work / "utils.py").write_text(
1733 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1734 )
1735 runner.invoke(cli, ["commit", "-m", "Add utils"])
1736
1737 result = runner.invoke(
1738 cli, ["code", "cat", "billing.py::format_currency", "--json"]
1739 )
1740 assert result.exit_code == 0, result.output
1741 data = json.loads(result.output)
1742 assert len(data["results"]) == 1
1743 assert data["results"][0]["symbol"] == "format_currency"
1744 assert data["results"][0]["path"] == "utils.py"
1745
1746 def test_cat_wrong_file_fallback_ambiguous_exits_nonzero(
1747 self, code_repo: pathlib.Path
1748 ) -> None:
1749 """If the symbol exists in multiple files, fallback reports ambiguity and exits 1."""
1750 work = pathlib.Path.cwd()
1751 (work / "utils.py").write_text("def send_email(to): pass\n")
1752 runner.invoke(cli, ["commit", "-m", "Duplicate send_email in utils"])
1753
1754 # billing.py already has send_email; utils.py now also has it.
1755 result = runner.invoke(
1756 cli, ["code", "cat", "nope.py::send_email"]
1757 )
1758 assert result.exit_code != 0
1759
1760 def test_cat_truly_missing_symbol_still_errors(self, code_repo: pathlib.Path) -> None:
1761 """A symbol that doesn't exist anywhere in the snapshot still exits 1."""
1762 result = runner.invoke(cli, ["code", "cat", "billing.py::AbsolutelyNowhere"])
1763 assert result.exit_code != 0
1764
1765
1766 # ---------------------------------------------------------------------------
1767 # Call-graph tier — muse coverage
1768 # ---------------------------------------------------------------------------
1769
1770
1771 class TestCoverage:
1772 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1773 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1774 assert result.exit_code == 0, result.output
1775
1776 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1777 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1778 assert result.exit_code == 0
1779 data = json.loads(result.output)
1780 assert isinstance(data, dict)
1781 assert "methods" in data
1782 assert "total_methods" in data
1783 assert "covered" in data
1784 assert "percent" in data
1785 assert "commit_id" in data
1786 assert "filters" in data
1787 for m in data["methods"]:
1788 assert "address" in m
1789 assert "called" in m
1790 assert "callers" in m
1791
1792 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1793 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1794 assert result.exit_code in (0, 1)
1795
1796 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1797 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1798 assert result.exit_code == 0
1799 # Output should be "n/total" format
1800 assert "/" in result.output.strip()
1801
1802 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1803 result = runner.invoke(cli, [
1804 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1805 ])
1806 assert result.exit_code == 0
1807 data = json.loads(result.output)
1808 assert data["filters"]["exclude_dunder"] is True
1809 for m in data["methods"]:
1810 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1811
1812 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1813 result = runner.invoke(cli, [
1814 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1815 ])
1816 assert result.exit_code == 0
1817 data = json.loads(result.output)
1818 assert data["filters"]["exclude_private"] is True
1819
1820 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1821 result = runner.invoke(cli, [
1822 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1823 ])
1824 assert result.exit_code == 0
1825 data = json.loads(result.output)
1826 assert data["filters"]["min_callers"] == 2
1827
1828 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1829 result = runner.invoke(cli, [
1830 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1831 ])
1832 assert result.exit_code == 0
1833 data = json.loads(result.output)
1834 assert data["filters"]["exclude_self"] is True
1835 # All reported callers should be from a different file
1836 for m in data["methods"]:
1837 for caller in m["callers"]:
1838 assert not caller.startswith("billing.py::")
1839
1840 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1841 result = runner.invoke(cli, [
1842 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1843 ])
1844 assert result.exit_code == 0
1845 data = json.loads(result.output)
1846 assert "compare_commit_id" in data
1847 assert "newly_covered" in data
1848 assert "newly_uncovered" in data
1849 assert "percent_change" in data
1850
1851 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1852 result = runner.invoke(cli, [
1853 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1854 ])
1855 assert result.exit_code == 0
1856
1857 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1858 result = runner.invoke(cli, [
1859 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1860 ])
1861 assert result.exit_code == 0
1862
1863
1864 # ---------------------------------------------------------------------------
1865 # Call-graph tier — muse deps
1866 # ---------------------------------------------------------------------------
1867
1868
1869 class TestDeps:
1870 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1871 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1872 assert result.exit_code == 0, result.output
1873
1874 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1875 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1876 assert result.exit_code == 0
1877
1878 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1879 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1880 assert result.exit_code == 0
1881 data = json.loads(result.output)
1882 assert isinstance(data, dict)
1883
1884 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1885 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1886 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1887
1888 # ── new flags ──────────────────────────────────────────────────────────────
1889
1890 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1891 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1892 assert result.exit_code == 0, result.output
1893 assert result.output.strip().isdigit()
1894
1895 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1896 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1897 assert result.exit_code == 0, result.output
1898 assert result.output.strip().isdigit()
1899
1900 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1901 result = runner.invoke(
1902 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1903 )
1904 assert result.exit_code == 0, result.output
1905
1906 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1907 # --depth > 1 in file mode is fine (just filters imports as before).
1908 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1909 assert result.exit_code == 0, result.output
1910
1911 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1912 result = runner.invoke(
1913 cli,
1914 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1915 )
1916 assert result.exit_code != 0
1917
1918 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1919 result = runner.invoke(
1920 cli,
1921 ["code", "deps", "--reverse", "--depth", "2",
1922 "billing.py::Invoice.compute_invoice_total"],
1923 )
1924 assert result.exit_code == 0, result.output
1925
1926 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1927 result = runner.invoke(
1928 cli,
1929 ["code", "deps", "--transitive",
1930 "billing.py::Invoice.compute_invoice_total"],
1931 )
1932 assert result.exit_code == 0, result.output
1933
1934 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1935 result = runner.invoke(
1936 cli,
1937 ["code", "deps", "--transitive", "--count",
1938 "billing.py::Invoice.compute_invoice_total"],
1939 )
1940 assert result.exit_code == 0
1941 assert result.output.strip().isdigit()
1942
1943 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1944 result = runner.invoke(
1945 cli,
1946 ["code", "deps", "--transitive", "--json",
1947 "billing.py::Invoice.compute_invoice_total"],
1948 )
1949 assert result.exit_code == 0
1950 data = json.loads(result.output)
1951 assert "by_depth" in data
1952 assert data["transitive"] is True
1953
1954 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1955 result = runner.invoke(
1956 cli,
1957 ["code", "deps", "--reverse", "--depth", "2", "--json",
1958 "billing.py::Invoice.compute_invoice_total"],
1959 )
1960 assert result.exit_code == 0
1961 data = json.loads(result.output)
1962 assert "by_depth" in data
1963 assert data["depth"] == 2
1964
1965 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1966 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1967 assert result.exit_code != 0
1968
1969 def test_deps_empty_file_rel_in_symbol_rejected(
1970 self, code_repo: pathlib.Path
1971 ) -> None:
1972 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1973 assert result.exit_code != 0
1974
1975 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1976 result = runner.invoke(
1977 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1978 )
1979 assert result.exit_code == 0
1980 data = json.loads(result.output)
1981 assert "imported_by" in data
1982 assert isinstance(data["imported_by"], list)
1983
1984
1985 # ---------------------------------------------------------------------------
1986 # Call-graph tier — muse find-symbol
1987 # ---------------------------------------------------------------------------
1988
1989
1990 class TestFindSymbol:
1991 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1992 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1993 assert result.exit_code == 0, result.output
1994
1995 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
1996 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
1997 assert result.exit_code == 0
1998 data = json.loads(result.output)
1999 assert isinstance(data, dict)
2000 assert "results" in data
2001 assert "query" in data
2002 assert "total" in data
2003
2004 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
2005 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
2006 assert result.exit_code == 0
2007 assert result.output is not None
2008
2009 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
2010 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
2011 assert result.exit_code == 0
2012 assert "no matching" in result.output
2013
2014 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
2015 result = runner.invoke(cli, ["code", "find-symbol"])
2016 assert result.exit_code == 1
2017
2018 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
2019 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2020 assert result.exit_code == 0
2021 assert result.output.strip().isdigit()
2022
2023 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
2024 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
2025 assert result.exit_code == 1
2026
2027 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
2028 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
2029 assert result.exit_code == 1
2030
2031 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
2032 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
2033 assert result.exit_code == 1
2034
2035 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
2036 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
2037 assert result.exit_code == 1
2038
2039 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
2040 result = runner.invoke(cli, [
2041 "code", "find-symbol", "--name", "process_order",
2042 "--since", "2099-01-01",
2043 ])
2044 assert result.exit_code == 0
2045 assert "no matching" in result.output
2046
2047 def test_find_limit(self, code_repo: pathlib.Path) -> None:
2048 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
2049 assert result.exit_code == 0
2050
2051 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
2052 result = runner.invoke(cli, [
2053 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
2054 ])
2055 assert result.exit_code == 0
2056
2057 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
2058 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
2059 assert result.exit_code == 0
2060 data = json.loads(result.output)
2061 for ap in data["results"]:
2062 assert ap["name"].lower().startswith("process")
2063
2064 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
2065 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2066 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
2067 assert result_all.exit_code == 0
2068 assert result_first.exit_code == 0
2069 count_all = int(result_all.output.strip())
2070 count_first = int(result_first.output.strip())
2071 assert count_first <= count_all
2072
2073 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
2074 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
2075 assert result.exit_code == 0
2076 data = json.loads(result.output)
2077 assert "query" in data
2078 assert "results" in data
2079 assert "total" in data
2080 assert data["total"] == len(data["results"])
2081 if data["results"]:
2082 ap = data["results"][0]
2083 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
2084 assert key in ap
2085
2086
2087 # ---------------------------------------------------------------------------
2088 # Call-graph tier — muse patch
2089 # ---------------------------------------------------------------------------
2090
2091
2092 class TestPatch:
2093 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
2094 new_impl = textwrap.dedent("""\
2095 def send_email(address):
2096 return f"Sending to {address}"
2097 """)
2098 impl_file = code_repo / "send_email_impl.py"
2099 impl_file.write_text(new_impl)
2100 # patch takes ADDRESS SOURCE — put options before address.
2101 result = runner.invoke(cli, [
2102 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
2103 ])
2104 assert result.exit_code in (0, 1, 2)
2105
2106 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
2107 bad_impl = "def broken(\n not valid python at all{"
2108 bad_file = code_repo / "bad.py"
2109 bad_file.write_text(bad_impl)
2110 result = runner.invoke(cli, [
2111 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2112 ])
2113 # Invalid syntax must be rejected or command handles gracefully.
2114 assert result.exit_code in (0, 1, 2)
2115
2116
2117 # ---------------------------------------------------------------------------
2118 # Security — path traversal guards
2119 # ---------------------------------------------------------------------------
2120
2121
2122 class TestPatchPathTraversal:
2123 """patch must reject addresses whose file component escapes the repo root."""
2124
2125 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2126 body = code_repo / "body.py"
2127 body.write_text("def foo(): pass\n")
2128 result = runner.invoke(cli, [
2129 "code", "patch",
2130 "--body", str(body),
2131 "../../etc/passwd::foo",
2132 ])
2133 assert result.exit_code == 1
2134
2135 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2136 body = code_repo / "body.py"
2137 body.write_text("def foo(): pass\n")
2138 result = runner.invoke(cli, [
2139 "code", "patch",
2140 "--body", str(body),
2141 "../../../tmp/malicious::foo",
2142 ])
2143 assert result.exit_code == 1
2144
2145 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2146 """--json flag returns parseable JSON on a dry-run."""
2147 body = code_repo / "body.py"
2148 body.write_text("def send_email(address):\n return address\n")
2149 result = runner.invoke(cli, [
2150 "code", "patch",
2151 "--body", str(body),
2152 "--dry-run",
2153 "--json",
2154 "billing.py::send_email",
2155 ])
2156 # Address may or may not exist; if it exits 0 the output must be JSON.
2157 if result.exit_code == 0:
2158 data = json.loads(result.output)
2159 assert data["address"] == "billing.py::send_email"
2160 assert data["dry_run"] is True
2161
2162
2163 class TestCheckoutSymbolPathTraversal:
2164 """checkout-symbol must reject addresses whose file component escapes root."""
2165
2166 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2167 result = runner.invoke(cli, [
2168 "code", "checkout-symbol",
2169 "--commit", "HEAD",
2170 "../../etc/passwd::foo",
2171 ])
2172 assert result.exit_code == 1
2173
2174 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2175 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2176 result = runner.invoke(cli, [
2177 "code", "checkout-symbol",
2178 "--commit", "HEAD",
2179 "--json",
2180 "billing.py::nonexistent_func_xyz",
2181 ])
2182 # Either exits 1 (symbol not found) — but must not crash.
2183 assert result.exit_code in (0, 1)
2184
2185
2186 class TestSemanticCherryPickPathTraversal:
2187 """semantic-cherry-pick must reject addresses that escape the repo root."""
2188
2189 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2190 result = runner.invoke(cli, [
2191 "code", "semantic-cherry-pick",
2192 "--from", "HEAD",
2193 "../../etc/passwd::foo",
2194 ])
2195 # The traversal-rejected symbol is recorded as not_found but the
2196 # command exits 0 (failed symbols don't abort the batch).
2197 # The key invariant is that no file outside the repo is written.
2198 # We assert exit_code is 0 (graceful) and the output does NOT write.
2199 assert result.exit_code in (0, 1)
2200 # No file was created outside the repo.
2201 assert not pathlib.Path("/etc/passwd_copy").exists()
2202
2203 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2204 result = runner.invoke(cli, [
2205 "code", "semantic-cherry-pick",
2206 "--from", "HEAD",
2207 "--json",
2208 "../../etc/passwd::foo",
2209 ])
2210 assert result.exit_code in (0, 1)
2211 if result.exit_code == 0:
2212 data = json.loads(result.output)
2213 assert data["applied"] == 0
2214 # The traversal-escaped address should be marked as not_found
2215 results = data.get("results", [])
2216 assert any(r["status"] == "not_found" for r in results)
2217
2218
2219 # ---------------------------------------------------------------------------
2220 # muse code blame
2221 # ---------------------------------------------------------------------------
2222
2223
2224 @pytest.fixture
2225 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2226 """Repo with four commits: seed → creation → modification → rename.
2227
2228 A seed commit is required so that the billing.py creation commit has
2229 a parent (and therefore a structured_delta with insert ops).
2230
2231 Timeline (oldest → newest):
2232 commit 0: README.md only (seed — gives billing.py commit a parent)
2233 commit 1: billing.py created — defines compute_total + process_order
2234 commit 2: compute_total implementation modified (same name)
2235 commit 3: compute_total renamed to compute_invoice_total
2236 """
2237 work = repo
2238
2239 # Seed commit so billing.py introduction has a parent and structured_delta.
2240 (work / "README.md").write_text("# Billing module\n")
2241 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2242 assert r.exit_code == 0, r.output
2243
2244 (work / "billing.py").write_text(textwrap.dedent("""\
2245 def compute_total(items):
2246 return sum(items)
2247
2248 def process_order(items):
2249 return compute_total(items)
2250 """))
2251 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2252 assert r.exit_code == 0, r.output
2253
2254 (work / "billing.py").write_text(textwrap.dedent("""\
2255 def compute_total(items):
2256 # faster implementation
2257 return sum(x for x in items)
2258
2259 def process_order(items):
2260 return compute_total(items)
2261 """))
2262 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2263 assert r.exit_code == 0, r.output
2264
2265 (work / "billing.py").write_text(textwrap.dedent("""\
2266 def compute_invoice_total(items):
2267 # faster implementation
2268 return sum(x for x in items)
2269
2270 def process_order(items):
2271 return compute_invoice_total(items)
2272 """))
2273 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2274 assert r.exit_code == 0, r.output
2275
2276 return repo
2277
2278
2279 class TestBlame:
2280 """Tests for muse code blame."""
2281
2282 # ── address validation ───────────────────────────────────────────────────
2283
2284 def test_invalid_address_no_separator_exits_error(
2285 self, blame_repo: pathlib.Path
2286 ) -> None:
2287 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2288 assert result.exit_code == 1
2289 assert "Invalid address" in result.stderr or "::" in result.stderr
2290
2291 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2292 result = runner.invoke(
2293 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2294 )
2295 assert result.exit_code == 1
2296
2297 # ── basic correctness (no rename involved) ───────────────────────────────
2298
2299 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2300 """A symbol that was never renamed should have created + modified events."""
2301 result = runner.invoke(
2302 cli, ["code", "blame", "billing.py::process_order", "--json"]
2303 )
2304 assert result.exit_code == 0, result.output
2305 data = json.loads(result.output)
2306 kinds = [ev["event"] for ev in data["events"]]
2307 assert "created" in kinds
2308
2309 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2310 result = runner.invoke(
2311 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2312 )
2313 assert result.exit_code == 0
2314 assert "no events found" in result.output
2315
2316 # ── rename tracking — new name (the critical regression) ─────────────────
2317
2318 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2319 """Blaming the POST-rename name must find the rename event."""
2320 result = runner.invoke(
2321 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2322 )
2323 assert result.exit_code == 0, result.output
2324 data = json.loads(result.output)
2325 kinds = [ev["event"] for ev in data["events"]]
2326 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2327
2328 def test_blame_new_name_follows_into_old_history(
2329 self, blame_repo: pathlib.Path
2330 ) -> None:
2331 """After finding the rename, blame must continue tracking the old name.
2332
2333 The symbol was created as compute_total → modified → renamed.
2334 Blaming compute_invoice_total should find ALL three events.
2335 """
2336 result = runner.invoke(
2337 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2338 )
2339 assert result.exit_code == 0, result.output
2340 data = json.loads(result.output)
2341 kinds = [ev["event"] for ev in data["events"]]
2342 assert "created" in kinds, f"Expected created event, got: {kinds}"
2343 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2344
2345 # ── rename tracking — old name ────────────────────────────────────────────
2346
2347 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2348 """Blaming the PRE-rename name must find the creation event."""
2349 result = runner.invoke(
2350 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2351 )
2352 assert result.exit_code == 0, result.output
2353 data = json.loads(result.output)
2354 kinds = [ev["event"] for ev in data["events"]]
2355 assert "created" in kinds, f"Expected created event, got: {kinds}"
2356
2357 def test_blame_old_name_finds_rename_not_lost(
2358 self, blame_repo: pathlib.Path
2359 ) -> None:
2360 """Blaming the old name should also surface the rename event."""
2361 result = runner.invoke(
2362 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2363 )
2364 assert result.exit_code == 0, result.output
2365 data = json.loads(result.output)
2366 kinds = [ev["event"] for ev in data["events"]]
2367 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2368
2369 # ── JSON schema ───────────────────────────────────────────────────────────
2370
2371 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2372 result = runner.invoke(
2373 cli, ["code", "blame", "billing.py::process_order", "--json"]
2374 )
2375 assert result.exit_code == 0, result.output
2376 data = json.loads(result.output)
2377 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2378 assert key in data, f"missing key: {key}"
2379 assert isinstance(data["events"], list)
2380 assert isinstance(data["truncated"], bool)
2381 assert isinstance(data["total_commits_scanned"], int)
2382
2383 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2384 result = runner.invoke(
2385 cli,
2386 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2387 )
2388 assert result.exit_code == 0, result.output
2389 data = json.loads(result.output)
2390 assert data["events"], "expected at least one event"
2391 ev = data["events"][0]
2392 for field in (
2393 "event", "commit_id", "author", "message",
2394 "committed_at", "address", "detail",
2395 ):
2396 assert field in ev, f"missing event field: {field}"
2397
2398 def test_blame_json_address_field_matches_input(
2399 self, blame_repo: pathlib.Path
2400 ) -> None:
2401 addr = "billing.py::process_order"
2402 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2403 data = json.loads(result.output)
2404 assert data["address"] == addr
2405
2406 # ── --max truncation ──────────────────────────────────────────────────────
2407
2408 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2409 result = runner.invoke(
2410 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2411 )
2412 assert result.exit_code == 0, result.output
2413 data = json.loads(result.output)
2414 assert data["total_commits_scanned"] <= 1
2415
2416 def test_blame_truncation_flag_set_when_capped(
2417 self, blame_repo: pathlib.Path
2418 ) -> None:
2419 result = runner.invoke(
2420 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2421 )
2422 data = json.loads(result.output)
2423 assert data["truncated"] is True
2424
2425 def test_blame_truncation_warning_in_human_output(
2426 self, blame_repo: pathlib.Path
2427 ) -> None:
2428 result = runner.invoke(
2429 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2430 )
2431 assert result.exit_code == 0, result.output
2432 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2433
2434 # ── human output ─────────────────────────────────────────────────────────
2435
2436 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2437 result = runner.invoke(
2438 cli, ["code", "blame", "billing.py::process_order"]
2439 )
2440 assert result.exit_code == 0, result.output
2441 assert "last touched:" in result.output
2442
2443 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2444 result_default = runner.invoke(
2445 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2446 )
2447 result_all = runner.invoke(
2448 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2449 )
2450 assert result_all.exit_code == 0, result_all.output
2451 # --all shows at least as many lines as default
2452 assert len(result_all.output) >= len(result_default.output)
2453
2454 # ── BFS follows merge parents ─────────────────────────────────────────────
2455
2456 def test_blame_bfs_follows_merge_parent2(
2457 self, repo: pathlib.Path
2458 ) -> None:
2459 """A symbol introduced on a feature branch is visible after merging."""
2460 # Main: empty billing.py
2461 (repo / "billing.py").write_text("def main_fn(): pass\n")
2462 runner.invoke(cli, ["commit", "-m", "main commit"])
2463
2464 # Feature branch: add feature_fn
2465 runner.invoke(cli, ["branch", "feat/feature"])
2466 runner.invoke(cli, ["checkout", "feat/feature"])
2467 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2468 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2469
2470 # Merge back to main
2471 runner.invoke(cli, ["checkout", "main"])
2472 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2473
2474 # Blame feature_fn — should find 'created' event on the feature branch
2475 result = runner.invoke(
2476 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2477 )
2478 assert result.exit_code == 0, result.output
2479 data = json.loads(result.output)
2480 kinds = [ev["event"] for ev in data["events"]]
2481 assert "created" in kinds, (
2482 f"Expected created event for feature_fn after merge; got: {kinds}"
2483 )
2484
2485
2486 # ---------------------------------------------------------------------------
2487 # Security — ReDoS guard in grep
2488 # ---------------------------------------------------------------------------
2489
2490
2491 class TestGrepReDoS:
2492 """grep must reject patterns longer than 512 characters."""
2493
2494 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2495 long_pattern = "a" * 513
2496 result = runner.invoke(cli, ["code", "grep", long_pattern])
2497 assert result.exit_code == 1
2498 assert "too long" in result.stderr.lower() or "512" in result.stderr
2499
2500 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2501 pattern = "a" * 512
2502 result = runner.invoke(cli, ["code", "grep", pattern])
2503 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2504 assert result.exit_code != 1 or "too long" not in result.output.lower()
2505
2506 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2507 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2508 assert result.exit_code == 1
2509
2510
2511 # ---------------------------------------------------------------------------
2512 # JSON output — index status and rebuild
2513 # ---------------------------------------------------------------------------
2514
2515
2516 class TestIndexJsonOutput:
2517 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2518 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2519 assert result.exit_code == 0, result.output
2520 raw = json.loads(result.output)
2521 data = raw["indexes"] if isinstance(raw, dict) else raw
2522 assert isinstance(data, list)
2523 names = [entry["name"] for entry in data]
2524 assert "symbol_history" in names
2525 assert "hash_occurrence" in names
2526 for entry in data:
2527 assert "status" in entry
2528 assert "entries" in entry
2529
2530 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2531 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2532 assert result.exit_code == 0, result.output
2533 data = json.loads(result.output)
2534 assert isinstance(data, dict)
2535 assert "rebuilt" in data
2536 assert isinstance(data["rebuilt"], list)
2537 assert "symbol_history" in data["rebuilt"]
2538 assert "hash_occurrence" in data["rebuilt"]
2539
2540 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2541 result = runner.invoke(cli, [
2542 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2543 ])
2544 assert result.exit_code == 0, result.output
2545 data = json.loads(result.output)
2546 assert "symbol_history" in data.get("rebuilt", [])
2547 assert "symbol_history_addresses" in data
2548
2549
2550 # ---------------------------------------------------------------------------
2551 # Extended — muse code index status
2552 # ---------------------------------------------------------------------------
2553
2554
2555 class TestIndexStatusExtended:
2556 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2557 """-j is equivalent to --json."""
2558 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2559 assert result.exit_code == 0, result.output
2560 _raw = json.loads(result.output.strip())
2561 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2562 assert isinstance(data, list)
2563
2564 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2565 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2566 assert result.exit_code == 0
2567
2568 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2569 """JSON output is compact — single line, no indent=2."""
2570 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2571 assert result.exit_code == 0
2572 lines = [l for l in result.output.splitlines() if l.strip()]
2573 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2574
2575 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
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 assert isinstance(data, list)
2580
2581 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2582 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2583 _raw = json.loads(result.output.strip())
2584 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2585 names = [e["name"] for e in data]
2586 assert "symbol_history" in names
2587
2588 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2589 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2590 _raw = json.loads(result.output.strip())
2591 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2592 names = [e["name"] for e in data]
2593 assert "hash_occurrence" in names
2594
2595 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2596 """Every entry has name, status, entries, updated_at."""
2597 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2598 _raw = json.loads(result.output.strip())
2599 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2600 for entry in data:
2601 assert "name" in entry
2602 assert "status" in entry
2603 assert "entries" in entry
2604 assert "updated_at" in entry
2605
2606 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2607 """Freshly initialised repo: both indexes are absent."""
2608 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2609 _raw = json.loads(result.output.strip())
2610 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2611 statuses = {e["name"]: e["status"] for e in data}
2612 assert statuses["symbol_history"] == "absent"
2613 assert statuses["hash_occurrence"] == "absent"
2614
2615 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2616 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2617 _raw = json.loads(result.output.strip())
2618 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2619 for entry in data:
2620 if entry["status"] == "absent":
2621 assert entry["entries"] == 0
2622
2623 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2624 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2625 _raw = json.loads(result.output.strip())
2626 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2627 for entry in data:
2628 if entry["status"] == "absent":
2629 assert entry["updated_at"] is None
2630
2631 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2632 """After rebuild all indexes report present."""
2633 runner.invoke(cli, ["code", "index", "rebuild"])
2634 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2635 _raw = json.loads(result.output.strip())
2636 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2637 for entry in data:
2638 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2639
2640 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2641 """symbol_history should have entries after two commits."""
2642 runner.invoke(cli, ["code", "index", "rebuild"])
2643 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2644 _raw = json.loads(result.output.strip())
2645 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2646 sh = next(e for e in data if e["name"] == "symbol_history")
2647 assert sh["entries"] > 0
2648
2649 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2650 runner.invoke(cli, ["code", "index", "rebuild"])
2651 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2652 _raw = json.loads(result.output.strip())
2653 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2654 for entry in data:
2655 assert entry["updated_at"] is not None
2656
2657 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2658 """A file with bad content is reported as corrupt, not absent."""
2659 idx_dir = indices_dir(code_repo)
2660 idx_dir.mkdir(parents=True, exist_ok=True)
2661 (idx_dir / "symbol_history.json").write_bytes(b"\xff\xfe")
2662 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2663 assert result.exit_code == 0
2664 _raw = json.loads(result.output.strip())
2665 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2666 sh = next(e for e in data if e["name"] == "symbol_history")
2667 assert sh["status"] == "corrupt"
2668
2669 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2670 idx_dir = indices_dir(code_repo)
2671 idx_dir.mkdir(parents=True, exist_ok=True)
2672 (idx_dir / "hash_occurrence.json").write_bytes(b"notmsgpack")
2673 result = runner.invoke(cli, ["code", "index", "status"])
2674 assert result.exit_code == 0
2675
2676 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2677 """Text mode suggests rebuild command when index is absent."""
2678 result = runner.invoke(cli, ["code", "index", "status"])
2679 assert "rebuild" in result.output.lower()
2680
2681 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2682 runner.invoke(cli, ["code", "index", "rebuild"])
2683 result = runner.invoke(cli, ["code", "index", "status"])
2684 assert "✅" in result.output
2685
2686 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2687 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2688 assert "Agent quickstart" in result.output
2689
2690 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2691 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2692 assert "JSON output schema" in result.output
2693
2694 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2695 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2696 assert "Exit codes" in result.output
2697
2698
2699 # ---------------------------------------------------------------------------
2700 # Security — muse code index status
2701 # ---------------------------------------------------------------------------
2702
2703
2704 class TestIndexStatusSecurity:
2705 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2706 """A corrupt index file must not surface a traceback."""
2707 idx_dir = indices_dir(code_repo)
2708 idx_dir.mkdir(parents=True, exist_ok=True)
2709 (idx_dir / "symbol_history.json").write_bytes(b"\x00" * 16)
2710 result = runner.invoke(cli, ["code", "index", "status"])
2711 assert "Traceback" not in result.output
2712
2713 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2714 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2715 from muse.core.indices import KNOWN_INDEX_NAMES
2716 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2717 _raw = json.loads(result.output.strip())
2718 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2719 for entry in data:
2720 assert entry["name"] in KNOWN_INDEX_NAMES
2721
2722 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2723 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2724 assert "\x1b" not in result.output
2725
2726 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2727 """status field is always one of the three allowed values."""
2728 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2729 _raw = json.loads(result.output.strip())
2730 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2731 for entry in data:
2732 assert entry["status"] in ("present", "absent", "corrupt")
2733
2734 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2735 monkeypatch.chdir(tmp_path)
2736 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2737 result = runner.invoke(cli, ["code", "index", "status"])
2738 assert "Traceback" not in result.output
2739 assert result.exit_code != 0
2740
2741 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2742 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2743 _raw = json.loads(result.output.strip())
2744 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2745 for entry in data:
2746 assert isinstance(entry["entries"], int)
2747
2748
2749 # ---------------------------------------------------------------------------
2750 # Stress — muse code index status
2751 # ---------------------------------------------------------------------------
2752
2753
2754 class TestIndexStatusStress:
2755 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2756 """50 sequential status calls all exit 0."""
2757 for i in range(50):
2758 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2759 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2760
2761 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2762 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2763 for i in range(100):
2764 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2765 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2766 data = json.loads(result.output.strip())
2767 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2768 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2769 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2770 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2771 data = json.loads(result.output.strip())
2772 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2773 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2774
2775 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2776 """8 threads reading index status concurrently — all must succeed."""
2777 import argparse
2778 import threading
2779
2780 from muse.cli.commands.index_rebuild import run_status
2781
2782 errors: list[str] = []
2783
2784 def worker(idx: int) -> None:
2785 args = argparse.Namespace(json_out=True)
2786 try:
2787 run_status(args)
2788 except SystemExit as exc:
2789 if exc.code != 0:
2790 errors.append(f"Thread {idx}: exit {exc.code}")
2791 except Exception as exc:
2792 errors.append(f"Thread {idx}: {exc}")
2793
2794 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2795 for t in threads:
2796 t.start()
2797 for t in threads:
2798 t.join()
2799 assert not errors, f"Concurrent failures: {errors}"
2800
2801
2802 # ---------------------------------------------------------------------------
2803 # Extended — muse code index rebuild
2804 # ---------------------------------------------------------------------------
2805
2806
2807 class TestIndexRebuildExtended:
2808 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2809 """-j is equivalent to --json."""
2810 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2811 assert result.exit_code == 0, result.output
2812 data = json.loads(result.output.strip())
2813 assert "rebuilt" in data
2814
2815 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2816 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2817 assert result.exit_code == 0
2818
2819 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2820 """JSON output is a single compact line — no indent=2."""
2821 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2822 assert result.exit_code == 0
2823 lines = [l for l in result.output.splitlines() if l.strip()]
2824 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2825
2826 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2827 """JSON output always has dry_run, rebuilt."""
2828 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2829 data = json.loads(result.output.strip())
2830 assert "dry_run" in data
2831 assert "rebuilt" in data
2832
2833 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2834 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2835 data = json.loads(result.output.strip())
2836 assert "symbol_history" in data["rebuilt"]
2837 assert "hash_occurrence" in data["rebuilt"]
2838
2839 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2840 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2841 data = json.loads(result.output.strip())
2842 assert data["dry_run"] is False
2843
2844 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2845 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2846 assert result.exit_code == 0
2847 data = json.loads(result.output.strip())
2848 assert data["dry_run"] is True
2849
2850 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2851 """--dry-run must not create index files."""
2852 idx_dir = indices_dir(code_repo)
2853 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2854 assert not (idx_dir / "symbol_history.json").exists()
2855 assert not (idx_dir / "hash_occurrence.json").exists()
2856
2857 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2858 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2859 assert result.exit_code == 0
2860 data = json.loads(result.output.strip())
2861 assert data["rebuilt"] == ["symbol_history"]
2862 assert "symbol_history_addresses" in data
2863 assert "hash_occurrence_clusters" not in data
2864
2865 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2866 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2867 assert result.exit_code == 0
2868 data = json.loads(result.output.strip())
2869 assert data["rebuilt"] == ["hash_occurrence"]
2870 assert "hash_occurrence_clusters" in data
2871 assert "symbol_history_addresses" not in data
2872
2873 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2874 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2875 data = json.loads(result.output.strip())
2876 assert isinstance(data["symbol_history_addresses"], int)
2877 assert isinstance(data["symbol_history_events"], int)
2878
2879 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2880 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2881 data = json.loads(result.output.strip())
2882 assert isinstance(data["hash_occurrence_clusters"], int)
2883 assert isinstance(data["hash_occurrence_addresses"], int)
2884
2885 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2886 runner.invoke(cli, ["code", "index", "rebuild"])
2887 idx_dir = indices_dir(code_repo)
2888 assert (idx_dir / "symbol_history.json").exists()
2889 assert (idx_dir / "hash_occurrence.json").exists()
2890
2891 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2892 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2893 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2894 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2895 assert r1.exit_code == 0 and r2.exit_code == 0
2896 d1 = json.loads(r1.output.strip())
2897 d2 = json.loads(r2.output.strip())
2898 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2899
2900 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2901 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2902 assert result.exit_code == 0
2903 assert "Building" in result.output
2904
2905 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2906 result = runner.invoke(cli, ["code", "index", "rebuild"])
2907 assert "Rebuilt" in result.output or "index" in result.output.lower()
2908
2909 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2910 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2911 assert "Agent quickstart" in result.output
2912
2913 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2914 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2915 assert "JSON output schema" in result.output
2916
2917 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2918 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2919 assert "Exit codes" in result.output
2920
2921
2922 # ---------------------------------------------------------------------------
2923 # Security — muse code index rebuild
2924 # ---------------------------------------------------------------------------
2925
2926
2927 class TestIndexRebuildSecurity:
2928 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2929 """An unknown --index value must be rejected before run_rebuild is called."""
2930 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "malicious_index"])
2931 assert result.exit_code != 0
2932
2933 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2934 idx_dir = indices_dir(code_repo)
2935 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2936 assert not (idx_dir / "symbol_history.json").exists()
2937 assert not (idx_dir / "hash_occurrence.json").exists()
2938
2939 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2940 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2941 assert "\x1b" not in result.output
2942
2943 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2944 monkeypatch.chdir(tmp_path)
2945 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2946 result = runner.invoke(cli, ["code", "index", "rebuild"])
2947 assert "Traceback" not in result.output
2948 assert result.exit_code != 0
2949
2950 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2951 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2952 from muse.core.indices import KNOWN_INDEX_NAMES
2953 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2954 data = json.loads(result.output.strip())
2955 for name in data["rebuilt"]:
2956 assert name in KNOWN_INDEX_NAMES
2957
2958 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
2959 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2960 data = json.loads(result.output.strip())
2961 assert isinstance(data["muse_version"], str)
2962 assert len(data["muse_version"]) > 0
2963
2964
2965 # ---------------------------------------------------------------------------
2966 # Stress — muse code index rebuild
2967 # ---------------------------------------------------------------------------
2968
2969
2970 class TestIndexRebuildStress:
2971 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2972 """50 sequential rebuilds all exit 0."""
2973 for i in range(50):
2974 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2975 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2976
2977 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2978 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2979 indexes = ["symbol_history", "hash_occurrence"]
2980 for i in range(100):
2981 target = indexes[i % 2]
2982 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2983 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2984 data = json.loads(result.output.strip())
2985 assert target in data["rebuilt"]
2986
2987 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2988 """8 threads rebuilding hash_occurrence concurrently via core function."""
2989 import argparse
2990 import threading
2991
2992 from muse.cli.commands.index_rebuild import run_rebuild
2993
2994 errors: list[str] = []
2995
2996 def worker(idx: int) -> None:
2997 args = argparse.Namespace(
2998 index_name="hash_occurrence",
2999 dry_run=True, # dry_run avoids concurrent write races
3000 verbose=False,
3001 json_out=True,
3002 )
3003 try:
3004 run_rebuild(args)
3005 except SystemExit as exc:
3006 if exc.code != 0:
3007 errors.append(f"Thread {idx}: exit {exc.code}")
3008 except Exception as exc:
3009 errors.append(f"Thread {idx}: {exc}")
3010
3011 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3012 for t in threads:
3013 t.start()
3014 for t in threads:
3015 t.join()
3016 assert not errors, f"Concurrent failures: {errors}"
3017
3018
3019 # ---------------------------------------------------------------------------
3020 # Extended — muse code index purge
3021 # ---------------------------------------------------------------------------
3022
3023
3024 class TestIndexPurgeExtended:
3025 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
3026 """-j is equivalent to --json."""
3027 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3028 assert result.exit_code == 0, result.output
3029 data = json.loads(result.output.strip())
3030 assert "purged" in data
3031
3032 def test_help_flag(self, code_repo: pathlib.Path) -> None:
3033 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3034 assert result.exit_code == 0
3035
3036 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
3037 """JSON output is compact — single line, no indent=2."""
3038 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3039 assert result.exit_code == 0
3040 lines = [l for l in result.output.splitlines() if l.strip()]
3041 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
3042
3043 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
3044 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3045 data = json.loads(result.output.strip())
3046 assert "purged" in data
3047 assert "skipped" in data
3048
3049 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
3050 """Purging when indexes are absent — both in skipped, none in purged."""
3051 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3052 data = json.loads(result.output.strip())
3053 assert data["purged"] == []
3054 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
3055
3056 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
3057 """After rebuild, purge reports both as purged."""
3058 runner.invoke(cli, ["code", "index", "rebuild"])
3059 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3060 data = json.loads(result.output.strip())
3061 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
3062 assert data["skipped"] == []
3063
3064 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
3065 runner.invoke(cli, ["code", "index", "rebuild"])
3066 runner.invoke(cli, ["code", "index", "purge"])
3067 idx_dir = indices_dir(code_repo)
3068 assert not (idx_dir / "symbol_history.json").exists()
3069 assert not (idx_dir / "hash_occurrence.json").exists()
3070
3071 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
3072 runner.invoke(cli, ["code", "index", "rebuild"])
3073 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
3074 assert result.exit_code == 0
3075 data = json.loads(result.output.strip())
3076 assert data["purged"] == ["symbol_history"]
3077 assert data["skipped"] == []
3078 idx_dir = indices_dir(code_repo)
3079 assert not (idx_dir / "symbol_history.json").exists()
3080 assert (idx_dir / "hash_occurrence.json").exists()
3081
3082 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
3083 runner.invoke(cli, ["code", "index", "rebuild"])
3084 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3085 assert result.exit_code == 0
3086 data = json.loads(result.output.strip())
3087 assert data["purged"] == ["hash_occurrence"]
3088 idx_dir = indices_dir(code_repo)
3089 assert not (idx_dir / "hash_occurrence.json").exists()
3090 assert (idx_dir / "symbol_history.json").exists()
3091
3092 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
3093 """Purging when nothing is present still exits 0."""
3094 result = runner.invoke(cli, ["code", "index", "purge"])
3095 assert result.exit_code == 0
3096
3097 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
3098 """Purging twice in a row both exit 0."""
3099 runner.invoke(cli, ["code", "index", "rebuild"])
3100 r1 = runner.invoke(cli, ["code", "index", "purge"])
3101 r2 = runner.invoke(cli, ["code", "index", "purge"])
3102 assert r1.exit_code == 0
3103 assert r2.exit_code == 0
3104
3105 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
3106 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3107 data = json.loads(result.output.strip())
3108 assert isinstance(data["muse_version"], str)
3109 assert len(data["muse_version"]) > 0
3110
3111 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
3112 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3113 data = json.loads(result.output.strip())
3114 assert isinstance(data["purged"], list)
3115 assert isinstance(data["skipped"], list)
3116
3117 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
3118 runner.invoke(cli, ["code", "index", "rebuild"])
3119 result = runner.invoke(cli, ["code", "index", "purge"])
3120 assert "deleted" in result.output.lower() or "🗑" in result.output
3121
3122 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3123 result = runner.invoke(cli, ["code", "index", "purge"])
3124 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3125
3126 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3127 runner.invoke(cli, ["code", "index", "rebuild"])
3128 runner.invoke(cli, ["code", "index", "purge"])
3129 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3130 _raw = json.loads(result.output.strip())
3131 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
3132 for entry in data:
3133 assert entry["status"] == "absent"
3134
3135 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3136 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3137 assert "Agent quickstart" in result.output
3138
3139 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3140 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3141 assert "JSON output schema" in result.output
3142
3143 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3144 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3145 assert "Exit codes" in result.output
3146
3147
3148 # ---------------------------------------------------------------------------
3149 # Security — muse code index purge
3150 # ---------------------------------------------------------------------------
3151
3152
3153 class TestIndexPurgeSecurity:
3154 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3155 """Unknown --index value rejected by argparse before run_purge runs."""
3156 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3157 assert result.exit_code != 0
3158
3159 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3160 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3161 assert "\x1b" not in result.output
3162
3163 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3164 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3165 from muse.core.indices import KNOWN_INDEX_NAMES
3166 runner.invoke(cli, ["code", "index", "rebuild"])
3167 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3168 data = json.loads(result.output.strip())
3169 for name in data["purged"] + data["skipped"]:
3170 assert name in KNOWN_INDEX_NAMES
3171
3172 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3173 monkeypatch.chdir(tmp_path)
3174 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3175 result = runner.invoke(cli, ["code", "index", "purge"])
3176 assert "Traceback" not in result.output
3177 assert result.exit_code != 0
3178
3179 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3180 """Purge must not remove anything outside .muse/indices/."""
3181 runner.invoke(cli, ["code", "index", "rebuild"])
3182 repo_json = repo_json_path(code_repo)
3183 assert repo_json.exists()
3184 runner.invoke(cli, ["code", "index", "purge"])
3185 assert repo_json.exists(), "repo.json must not be deleted by purge"
3186
3187 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3188 runner.invoke(cli, ["code", "index", "rebuild"])
3189 runner.invoke(cli, ["code", "index", "purge"])
3190 result = runner.invoke(cli, ["code", "index", "purge"])
3191 assert "Traceback" not in result.output
3192
3193
3194 # ---------------------------------------------------------------------------
3195 # Stress — muse code index purge
3196 # ---------------------------------------------------------------------------
3197
3198
3199 class TestIndexPurgeStress:
3200 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3201 """50 sequential purge calls all exit 0 (idempotent)."""
3202 for i in range(50):
3203 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3204 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3205
3206 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3207 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3208 for i in range(100):
3209 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3210 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3211 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3212 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3213 d = json.loads(r2.output.strip())
3214 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3215
3216 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3217 """8 threads purging concurrently via core function — all must exit 0."""
3218 import argparse
3219 import threading
3220
3221 from muse.cli.commands.index_rebuild import run_purge
3222
3223 runner.invoke(cli, ["code", "index", "rebuild"])
3224 errors: list[str] = []
3225
3226 def worker(idx: int) -> None:
3227 args = argparse.Namespace(index_name=None, json_out=True)
3228 try:
3229 run_purge(args)
3230 except SystemExit as exc:
3231 if exc.code != 0:
3232 errors.append(f"Thread {idx}: exit {exc.code}")
3233 except Exception as exc:
3234 errors.append(f"Thread {idx}: {exc}")
3235
3236 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3237 for t in threads:
3238 t.start()
3239 for t in threads:
3240 t.join()
3241 assert not errors, f"Concurrent failures: {errors}"
3242
3243
3244 # ---------------------------------------------------------------------------
3245 # Performance — iterative DFS regression (no RecursionError)
3246 # ---------------------------------------------------------------------------
3247
3248
3249 class TestIterativeDFS:
3250 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3251
3252 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3253 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3254
3255 # Build a linear chain A→B→C→…→Z (depth 600, beyond Python's 1000 default).
3256 depth = 600
3257 nodes = [f"mod_{i}" for i in range(depth)]
3258 imports_out: _ImportsMap = {
3259 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3260 }
3261 imports_out[nodes[-1]] = []
3262
3263 # Must not raise RecursionError.
3264 cycles = codemap_find_cycles(imports_out)
3265 assert isinstance(cycles, list)
3266 assert len(cycles) == 0 # linear chain has no cycles
3267
3268 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3269 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3270
3271 # A→B→C→A is a cycle.
3272 imports_out: _ImportsMap = {
3273 "A": ["B"],
3274 "B": ["C"],
3275 "C": ["A"],
3276 }
3277 cycles = codemap_find_cycles(imports_out)
3278 assert len(cycles) >= 1
3279
3280 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3281 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3282
3283 depth = 600
3284 nodes = [f"file_{i}.py" for i in range(depth)]
3285 imports: _ImportsSetMap = {
3286 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3287 }
3288 imports[nodes[-1]] = set()
3289
3290 cycles = invariants_find_cycles(imports)
3291 assert isinstance(cycles, list)
3292 assert len(cycles) == 0
3293
3294 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3295 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3296
3297 # A module that imports itself.
3298 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3299 cycles = invariants_find_cycles(imports)
3300 assert len(cycles) >= 1
3301
3302
3303 # ---------------------------------------------------------------------------
3304 # muse code symbols
3305 # ---------------------------------------------------------------------------
3306
3307
3308 class TestSymbols:
3309 """Tests for ``muse code symbols``."""
3310
3311 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3312 """Basic invocation lists functions and classes from HEAD snapshot."""
3313 result = runner.invoke(cli, ["code", "symbols"])
3314 assert result.exit_code == 0, result.output
3315 # billing.py contains Invoice class and process_order / send_email functions.
3316 assert "Invoice" in result.output
3317 assert "process_order" in result.output
3318 assert "symbols across" in result.output
3319
3320 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3321 """``--count`` prints a total count and language breakdown, no symbol table."""
3322 result = runner.invoke(cli, ["code", "symbols", "--count"])
3323 assert result.exit_code == 0, result.output
3324 assert "symbols" in result.output
3325 assert "Python" in result.output
3326 # Should NOT print individual symbol lines.
3327 assert "Invoice" not in result.output
3328
3329 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3330 """``--json`` emits a structured envelope with a flat 'results' list."""
3331 result = runner.invoke(cli, ["code", "symbols", "--json"])
3332 assert result.exit_code == 0, result.output
3333 data = json.loads(result.output)
3334 assert isinstance(data, dict)
3335 assert "results" in data
3336 assert "files" not in data
3337 assert isinstance(data["results"], list)
3338 assert any(e.get("address", "").startswith("billing.py") for e in data["results"])
3339 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3340
3341 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3342 """``--kind class`` shows only class-kind symbols."""
3343 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3344 assert result.exit_code == 0, result.output
3345 assert "Invoice" in result.output
3346 assert "process_order" not in result.output
3347
3348 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3349 """``--kind function`` shows only top-level functions, not methods."""
3350 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3351 assert result.exit_code == 0, result.output
3352 assert "process_order" in result.output
3353 assert "send_email" in result.output
3354 assert "Invoice" not in result.output
3355
3356 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3357 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3358 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3359 assert result.exit_code != 0
3360 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3361
3362 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3363 """``--file`` restricts output to a single file."""
3364 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3365 assert result.exit_code == 0, result.output
3366 assert "symbols across" in result.output
3367
3368 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3369 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3370 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3371 assert result.exit_code == 0, result.output
3372 assert "no semantic symbols found" in result.output
3373
3374 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3375 """``--language Python`` includes Python symbols; other languages excluded."""
3376 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3377 assert result.exit_code == 0, result.output
3378 assert "Invoice" in result.output
3379
3380 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3381 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3382 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3383 assert result.exit_code == 0, result.output
3384 assert "no semantic symbols found" in result.output
3385
3386 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3387 """``--hashes`` appends content hash abbreviations to each symbol row."""
3388 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3389 assert result.exit_code == 0, result.output
3390 # Hash suffix is 8 hex chars followed by ".."
3391 assert ".." in result.output
3392
3393 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3394 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3395 default = runner.invoke(cli, ["code", "symbols"])
3396 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3397 assert default.exit_code == 0
3398 assert head.exit_code == 0
3399 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3400 assert "Invoice" in default.output
3401 assert "Invoice" in head.output
3402 assert "symbols across" in default.output
3403 assert "symbols across" in head.output
3404
3405 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3406 """``--count`` and ``--json`` cannot be combined."""
3407 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3408 assert result.exit_code != 0
3409
3410 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3411 """JSON output uses the structured envelope with source_ref and results."""
3412 result = runner.invoke(cli, ["code", "symbols", "--json"])
3413 assert result.exit_code == 0, result.output
3414 data = json.loads(result.output)
3415 assert "source_ref" in data
3416 assert "working_tree" in data
3417 assert "total_symbols" in data
3418 assert "results" in data
3419 assert "files" not in data
3420 assert isinstance(data["working_tree"], bool)
3421 assert isinstance(data["total_symbols"], int)
3422 for entry in data["results"]:
3423 for field in ("address", "kind", "name", "qualified_name",
3424 "lineno", "content_id", "body_hash", "signature_id"):
3425 assert field in entry, f"missing field '{field}' in JSON entry"
3426
3427 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3428 """``--json`` without ``--commit`` reports working_tree=true."""
3429 result = runner.invoke(cli, ["code", "symbols", "--json"])
3430 assert result.exit_code == 0, result.output
3431 data = json.loads(result.output)
3432 assert data["working_tree"] is True
3433 assert data["source_ref"] == "working-tree"
3434
3435 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3436 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3437 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3438 assert result.exit_code == 0, result.output
3439 data = json.loads(result.output)
3440 assert data["working_tree"] is False
3441 assert data["source_ref"] != "working-tree"
3442 # source_ref is a prefixed short commit id (e.g. "sha256:<12hex>")
3443 assert data["source_ref"].startswith("sha256:")
3444
3445 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3446 """Working-tree mode picks up edits made to files after the last commit."""
3447 # Find the billing.py path on disk.
3448 billing = code_repo / "billing.py"
3449 assert billing.exists()
3450 # Append a new function — not yet committed.
3451 billing.write_text(
3452 f"{billing.read_text()}\ndef newly_added_function():\n pass\n"
3453 )
3454 result = runner.invoke(cli, ["code", "symbols"])
3455 assert result.exit_code == 0, result.output
3456 assert "newly_added_function" in result.output
3457
3458 # Committed snapshot should NOT contain it.
3459 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3460 assert committed.exit_code == 0
3461 assert "newly_added_function" not in committed.output
3462
3463 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3464 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3465 for variant in ("python", "Python", "PYTHON"):
3466 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3467 assert result.exit_code == 0, f"failed for --language {variant!r}"
3468 assert "Invoice" in result.output
3469
3470 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3471 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3472 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3473 assert result.exit_code == 0, result.output
3474 assert "Invoice" in result.output
3475
3476 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3477 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3478 # Write a second file with the same basename in a sub-directory.
3479 sub = code_repo / "sub"
3480 sub.mkdir(exist_ok=True)
3481 (sub / "billing.py").write_text("def sub_func(): pass\n")
3482 # Stage and commit both so the manifest has two paths ending in billing.py.
3483 import subprocess
3484 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3485 subprocess.run(
3486 ["muse", "commit", "-m", "add sub/billing.py"],
3487 cwd=code_repo, check=True,
3488 )
3489 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3490 assert result.exit_code != 0
3491 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3492
3493 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3494 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3495 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3496 assert result.exit_code != 0
3497 assert "not found" in result.stderr
3498
3499
3500 # ---------------------------------------------------------------------------
3501 # TestSymbolLog
3502 # ---------------------------------------------------------------------------
3503
3504
3505 class TestSymbolLog:
3506 """Tests for ``muse code symbol-log``."""
3507
3508 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3509 """An address not found in any commit produces 'no events found'."""
3510 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3511 assert result.exit_code == 0, result.output
3512 assert "no events found" in result.output
3513
3514 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3515 """An address without '::' exits non-zero with a descriptive error."""
3516 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3517 assert result.exit_code != 0
3518 assert "::" in (result.output + (result.stderr or ""))
3519
3520 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3521 """An empty string as address exits non-zero."""
3522 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3523 # "::" is technically valid syntax; should at least not crash.
3524 assert result.exit_code == 0
3525
3526 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3527 """``--json`` emits the structured envelope with all top-level fields."""
3528 result = runner.invoke(
3529 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3530 )
3531 assert result.exit_code == 0, result.output
3532 data = json.loads(result.output)
3533 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3534 assert field in data, f"missing top-level field '{field}'"
3535 assert data["address"] == "billing.py::Invoice"
3536 assert data["start_ref"] == "HEAD"
3537 assert isinstance(data["total_commits_scanned"], int)
3538 assert isinstance(data["truncated"], bool)
3539 assert isinstance(data["events"], list)
3540
3541 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3542 """Each JSON event has the required fields."""
3543 result = runner.invoke(
3544 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3545 )
3546 assert result.exit_code == 0, result.output
3547 data = json.loads(result.output)
3548 for ev in data["events"]:
3549 for field in ("event", "commit_id", "message", "committed_at",
3550 "address", "detail", "new_address"):
3551 assert field in ev, f"missing event field '{field}'"
3552
3553 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3554 """When --max is hit, a truncation warning appears in human output."""
3555 result = runner.invoke(
3556 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3557 )
3558 assert result.exit_code == 0, result.output
3559 assert "incomplete" in result.output or "limit" in result.output
3560
3561 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3562 """When --max is hit, truncated=true appears in JSON output."""
3563 result = runner.invoke(
3564 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3565 )
3566 assert result.exit_code == 0, result.output
3567 data = json.loads(result.output)
3568 assert data["truncated"] is True
3569 assert data["total_commits_scanned"] == 1
3570
3571 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3572 """--max 0 exits non-zero with a clear error."""
3573 result = runner.invoke(
3574 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3575 )
3576 assert result.exit_code != 0
3577
3578 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3579 """``--from`` with a non-existent ref exits non-zero."""
3580 result = runner.invoke(
3581 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3582 )
3583 assert result.exit_code != 0
3584 assert "not found" in result.stderr
3585
3586 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3587 """BFS walk finds events on feature branches that were merged in via parent2.
3588
3589 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3590 The feature branch commit has a structured_delta inserting a symbol.
3591 The linear (parent1-only) walk would miss this; BFS must find it.
3592 """
3593 import datetime
3594
3595 root = code_repo
3596 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
3597 from muse.core.refs import (
3598 get_head_commit_id,
3599 read_current_branch,
3600 )
3601 from muse.core.commits import (
3602 CommitRecord,
3603 write_commit,
3604 )
3605 from muse.core.ids import hash_commit as compute_commit_id
3606 from muse.domain import InsertOp, PatchOp, StructuredDelta
3607 branch = read_current_branch(root)
3608 head_id = get_head_commit_id(root, branch)
3609 assert head_id is not None
3610
3611 feature_snap = "aa" * 32
3612 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3613 feature_id = compute_commit_id(
3614 parent_ids=[head_id],
3615 snapshot_id=feature_snap,
3616 message="feat: add merged_fn",
3617 committed_at_iso=feature_at.isoformat(),
3618 author="test",
3619 )
3620 write_commit(root, CommitRecord(
3621 commit_id=feature_id,
3622 branch="feat/branch",
3623 snapshot_id=feature_snap,
3624 message="feat: add merged_fn",
3625 committed_at=feature_at,
3626 parent_commit_id=head_id,
3627 author="test",
3628 structured_delta=StructuredDelta(ops=[PatchOp(
3629 op="patch",
3630 address="billing.py",
3631 child_ops=[InsertOp(
3632 op="insert",
3633 address="billing.py::merged_fn",
3634 content_summary="function merged_fn",
3635 )],
3636 )]),
3637 ))
3638
3639 merge_snap = "bb" * 32
3640 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3641 merge_id = compute_commit_id(
3642 parent_ids=[head_id, feature_id],
3643 snapshot_id=merge_snap,
3644 message="merge feat/branch",
3645 committed_at_iso=merge_at.isoformat(),
3646 author="test",
3647 )
3648 write_commit(root, CommitRecord(
3649 commit_id=merge_id,
3650 branch=branch,
3651 snapshot_id=merge_snap,
3652 message="merge feat/branch",
3653 committed_at=merge_at,
3654 parent_commit_id=head_id,
3655 parent2_commit_id=feature_id,
3656 author="test",
3657 ))
3658
3659 branch_ref = ref_path(root, branch)
3660 branch_ref.write_text(merge_id)
3661
3662 result = runner.invoke(
3663 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3664 )
3665 assert result.exit_code == 0, result.output
3666 # BFS must find the creation event on the feature branch.
3667 assert "merged_fn" in result.output
3668 assert "created" in result.output
3669
3670 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3671 """Regression guard: verify the BFS result differs from a parent1-only scan.
3672
3673 Directly calls _walk_commits_dag and checks it returns commits from
3674 both parent chains, not just parent1.
3675 """
3676 import datetime
3677
3678 root = code_repo
3679 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
3680 from muse.core.refs import (
3681 get_head_commit_id,
3682 read_current_branch,
3683 )
3684 from muse.core.commits import (
3685 CommitRecord,
3686 write_commit,
3687 )
3688 from muse.core.ids import hash_commit as compute_commit_id
3689 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3690 branch = read_current_branch(root)
3691 head_id = get_head_commit_id(root, branch)
3692 assert head_id is not None
3693
3694 feature_snap = "dd" * 32
3695 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3696 feature_id = compute_commit_id(
3697 parent_ids=[],
3698 snapshot_id=feature_snap,
3699 message="feat on second parent",
3700 committed_at_iso=feature_at.isoformat(),
3701 author="test",
3702 )
3703 write_commit(root, CommitRecord(
3704 commit_id=feature_id,
3705 branch="feat/x",
3706 snapshot_id=feature_snap,
3707 message="feat on second parent",
3708 committed_at=feature_at,
3709 author="test",
3710 ))
3711 merge_snap = "ee" * 32
3712 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3713 merge_id = compute_commit_id(
3714 parent_ids=[head_id, feature_id],
3715 snapshot_id=merge_snap,
3716 message="merge",
3717 committed_at_iso=merge_at.isoformat(),
3718 author="test",
3719 )
3720 write_commit(root, CommitRecord(
3721 commit_id=merge_id,
3722 branch=branch,
3723 snapshot_id=merge_snap,
3724 message="merge",
3725 committed_at=merge_at,
3726 parent_commit_id=head_id,
3727 parent2_commit_id=feature_id,
3728 author="test",
3729 ))
3730
3731 branch_ref = ref_path(root, branch)
3732 branch_ref.write_text(merge_id)
3733
3734 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3735 commit_ids = {c.commit_id for c in commits}
3736 assert feature_id in commit_ids
3737
3738
3739 # ---------------------------------------------------------------------------
3740 # muse code coupling
3741 # ---------------------------------------------------------------------------
3742
3743
3744 @pytest.fixture
3745 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3746 """Repo with 3 commits where billing.py + models.py co-change twice."""
3747 work = repo
3748
3749 # Commit 1: seed — only billing.py
3750 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3751 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3752 assert r.exit_code == 0, r.output
3753
3754 # Commit 2: billing.py + models.py change together
3755 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3756 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3757 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3758 assert r.exit_code == 0, r.output
3759
3760 # Commit 3: billing.py + models.py change together again
3761 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3762 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3763 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3764 assert r.exit_code == 0, r.output
3765
3766 return repo
3767
3768
3769 class TestCoupling:
3770 """Tests for muse code coupling."""
3771
3772 # ── basic correctness ────────────────────────────────────────────────────
3773
3774 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3775 result = runner.invoke(cli, ["code", "coupling"])
3776 assert result.exit_code == 0, result.output
3777
3778 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3779 """billing.py and models.py co-changed twice — must appear in output."""
3780 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3781 assert result.exit_code == 0, result.output
3782 assert "billing.py" in result.output
3783 assert "models.py" in result.output
3784
3785 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3786 result = runner.invoke(cli, ["code", "coupling"])
3787 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3788 assert "Commits analysed" in result.output
3789
3790 def test_coupling_min_filter_excludes_low_count(
3791 self, coupling_repo: pathlib.Path
3792 ) -> None:
3793 """--min 3 must exclude our pair that co-changed only twice."""
3794 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3795 assert result.exit_code == 0, result.output
3796 assert "billing.py" not in result.output or "no file pairs" in result.output
3797
3798 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3799 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3800 data = json.loads(result.output)
3801 assert len(data["pairs"]) <= 1
3802
3803 # ── --file filter ─────────────────────────────────────────────────────────
3804
3805 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3806 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3807 assert result.exit_code == 0, result.output
3808
3809 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3810 """--file billing.py must surface models.py as its partner."""
3811 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3812 assert result.exit_code == 0, result.output
3813 assert "models.py" in result.output
3814
3815 def test_coupling_file_filter_header_names_file(
3816 self, coupling_repo: pathlib.Path
3817 ) -> None:
3818 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3819 assert "billing.py" in result.output
3820
3821 def test_coupling_file_filter_nonexistent_returns_cleanly(
3822 self, coupling_repo: pathlib.Path
3823 ) -> None:
3824 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3825 assert result.exit_code == 0, result.output
3826
3827 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3828 """Suffix billing.py should match the file even without the full path."""
3829 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3830 assert result.exit_code == 0, result.output
3831 assert "models.py" in result.output
3832
3833 # ── JSON output ───────────────────────────────────────────────────────────
3834
3835 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3836 result = runner.invoke(cli, ["code", "coupling", "--json"])
3837 assert result.exit_code == 0, result.output
3838 data = json.loads(result.output)
3839 assert "from_ref" in data
3840 assert "to_ref" in data
3841 assert "commits_analysed" in data
3842 assert "truncated" in data
3843 assert "filters" in data
3844 assert "pairs" in data
3845 assert isinstance(data["pairs"], list)
3846
3847 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3848 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3849 data = json.loads(result.output)
3850 if data["pairs"]:
3851 pair = data["pairs"][0]
3852 assert "file_a" in pair or "file" in pair
3853 assert "co_changes" in pair
3854 assert isinstance(pair["co_changes"], int)
3855
3856 def test_coupling_json_file_filter_uses_partner_schema(
3857 self, coupling_repo: pathlib.Path
3858 ) -> None:
3859 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3860 result = runner.invoke(
3861 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3862 )
3863 data = json.loads(result.output)
3864 assert data["filters"]["file"] == "billing.py"
3865 if data["pairs"]:
3866 pair = data["pairs"][0]
3867 assert "file" in pair
3868 assert "partner" in pair
3869 assert "co_changes" in pair
3870 assert "file_a" not in pair # partner schema, not pair schema
3871
3872 def test_coupling_json_not_truncated_small_repo(
3873 self, coupling_repo: pathlib.Path
3874 ) -> None:
3875 result = runner.invoke(cli, ["code", "coupling", "--json"])
3876 data = json.loads(result.output)
3877 assert data["truncated"] is False
3878
3879 def test_coupling_json_filters_reflect_args(
3880 self, coupling_repo: pathlib.Path
3881 ) -> None:
3882 result = runner.invoke(
3883 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3884 )
3885 data = json.loads(result.output)
3886 assert data["filters"]["top"] == 5
3887 assert data["filters"]["min_count"] == 2
3888
3889 # ── --max-commits ─────────────────────────────────────────────────────────
3890
3891 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3892 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3893 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3894 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3895 d_cap = json.loads(r_cap.output)
3896 assert d_cap["commits_analysed"] <= 1
3897
3898 def test_coupling_max_commits_truncated_flag(
3899 self, coupling_repo: pathlib.Path
3900 ) -> None:
3901 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3902 data = json.loads(result.output)
3903 # With 3 commits and cap=1, truncated must be True.
3904 assert data["truncated"] is True
3905
3906 def test_coupling_max_commits_one_shows_warning(
3907 self, coupling_repo: pathlib.Path
3908 ) -> None:
3909 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3910 assert result.exit_code == 0, result.output
3911 assert "⚠️" in result.output or "capped" in result.output
3912
3913 # ── validation ────────────────────────────────────────────────────────────
3914
3915 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3916 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3917 assert result.exit_code != 0
3918
3919 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3920 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3921 assert result.exit_code != 0
3922
3923 def test_coupling_max_commits_zero_exits_error(
3924 self, coupling_repo: pathlib.Path
3925 ) -> None:
3926 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3927 assert result.exit_code != 0
3928
3929 def test_coupling_invalid_from_ref_exits_error(
3930 self, coupling_repo: pathlib.Path
3931 ) -> None:
3932 result = runner.invoke(
3933 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3934 )
3935 assert result.exit_code != 0
3936
3937 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3938 """Coupling must count co-changes on feature-branch commits (parent2)."""
3939 import datetime
3940
3941 # Genesis commit
3942 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3943 r = runner.invoke(cli, ["commit", "-m", "seed"])
3944 assert r.exit_code == 0, r.output
3945
3946 repo_json = json.loads((repo_json_path(repo)).read_text())
3947 repo_id = repo_json["repo_id"]
3948 from muse.core.refs import read_current_branch
3949 from muse.core.commits import resolve_commit_ref
3950 branch = read_current_branch(repo)
3951 head = resolve_commit_ref(repo, branch, None)
3952 assert head is not None
3953
3954 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3955 feature_at = now
3956 merge_at = now + datetime.timedelta(hours=1)
3957
3958 # Feature commit touching billing.py + models.py together.
3959 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3960 from muse.core.ids import hash_commit as compute_commit_id
3961 feature_delta = StructuredDelta(
3962 domain="code",
3963 ops=[
3964 PatchOp(
3965 op="patch", address="billing.py",
3966 child_ops=[ReplaceOp(
3967 op="replace", address="billing.py::compute",
3968 old_content_id="a" * 64, new_content_id="b" * 64,
3969 old_summary="function compute",
3970 new_summary="function compute (modified)", position=None,
3971 )],
3972 child_domain="code", child_summary="compute modified",
3973 ),
3974 PatchOp(
3975 op="patch", address="models.py",
3976 child_ops=[InsertOp(
3977 op="insert", address="models.py::Order",
3978 content_id="c" * 64, content_summary="class Order", position=None,
3979 )],
3980 child_domain="code", child_summary="Order added",
3981 ),
3982 ],
3983 summary="co-change",
3984 )
3985 feature_id = compute_commit_id(
3986 [head.commit_id], head.snapshot_id,
3987 "co-change on feature branch", feature_at.isoformat(),
3988 author="test",
3989 )
3990 merge_id = compute_commit_id(
3991 [head.commit_id, feature_id], head.snapshot_id,
3992 "Merge feature", merge_at.isoformat(),
3993 author="test",
3994 )
3995 feature_body: CommitDict = {
3996 "commit_id": feature_id,
3997 "repo_id": repo_id,
3998 "branch": "feat/test",
3999 "snapshot_id": head.snapshot_id,
4000 "message": "co-change on feature branch",
4001 "committed_at": feature_at.isoformat(),
4002 "parent_commit_id": head.commit_id,
4003 "parent2_commit_id": None,
4004 "author": "test",
4005 "metadata": {},
4006 "structured_delta": feature_delta,
4007 }
4008 merge_body: CommitDict = {
4009 "commit_id": merge_id,
4010 "repo_id": repo_id,
4011 "branch": branch,
4012 "snapshot_id": head.snapshot_id,
4013 "message": "Merge feature",
4014 "committed_at": merge_at.isoformat(),
4015 "parent_commit_id": head.commit_id,
4016 "parent2_commit_id": feature_id,
4017 "author": "test",
4018 "metadata": {},
4019 "structured_delta": None,
4020 }
4021 from muse.core.commits import (
4022 CommitRecord,
4023 write_commit,
4024 )
4025 write_commit(repo, CommitRecord.from_dict(feature_body))
4026 write_commit(repo, CommitRecord.from_dict(merge_body))
4027 (ref_path(repo, branch)).write_text(merge_id)
4028
4029 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
4030 assert result.exit_code == 0, result.output
4031 data = json.loads(result.output)
4032 pairs_found = {
4033 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
4034 }
4035 billing_models = any(
4036 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
4037 for a, b in pairs_found
4038 )
4039 assert billing_models, "BFS must find the feature-branch co-change commit"
4040
4041
4042 # ---------------------------------------------------------------------------
4043 # muse code stable
4044 # ---------------------------------------------------------------------------
4045
4046
4047 class TestStable:
4048 """Tests for muse code stable."""
4049
4050 # ── basic correctness ────────────────────────────────────────────────────
4051
4052 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
4053 result = runner.invoke(cli, ["code", "stable"])
4054 assert result.exit_code == 0, result.output
4055
4056 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
4057 result = runner.invoke(cli, ["code", "stable"])
4058 assert result.exit_code == 0, result.output
4059 assert "Symbol stability" in result.output
4060 assert "Commits analysed" in result.output
4061 assert "bedrock" in result.output
4062
4063 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
4064 """Invoice.apply_discount was defined in the genesis commit and never modified."""
4065 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4066 assert result.exit_code == 0, result.output
4067 # apply_discount was never touched in any structured_delta → maximally stable.
4068 assert "apply_discount" in result.output
4069
4070 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
4071 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4072 assert result.exit_code == 0, result.output
4073 assert "since start of range" in result.output
4074
4075 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
4076 """Markdown / TOML / YAML symbols must be absent from default output."""
4077 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4078 assert result.exit_code == 0, result.output
4079 assert ".md::" not in result.output
4080 assert ".toml::" not in result.output
4081
4082 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
4083 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4084 assert result.exit_code == 0, result.output
4085 assert "::import::" not in result.output
4086
4087 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
4088 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
4089 assert result.exit_code == 0, result.output
4090
4091 # ── JSON output ───────────────────────────────────────────────────────────
4092
4093 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
4094 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4095 assert result.exit_code == 0, result.output
4096 data = json.loads(result.output)
4097 assert "from_ref" in data
4098 assert "to_ref" in data
4099 assert "commits_analysed" in data
4100 assert "truncated" in data
4101 assert "filters" in data
4102 assert "stable" in data
4103 assert isinstance(data["stable"], list)
4104
4105 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
4106 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4107 data = json.loads(result.output)
4108 assert len(data["stable"]) > 0
4109 entry = data["stable"][0]
4110 assert "address" in entry
4111 assert "unchanged_for" in entry
4112 assert "since_start_of_range" in entry
4113 assert isinstance(entry["unchanged_for"], int)
4114 assert isinstance(entry["since_start_of_range"], bool)
4115
4116 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
4117 result = runner.invoke(
4118 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
4119 )
4120 data = json.loads(result.output)
4121 assert data["filters"]["top"] == 3
4122 assert data["filters"]["kind"] == "function"
4123 assert data["filters"]["include_imports"] is False
4124 assert data["filters"]["include_docs"] is False
4125
4126 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
4127 result = runner.invoke(cli, ["code", "stable", "--json"])
4128 data = json.loads(result.output)
4129 assert data["truncated"] is False
4130
4131 # ── --language filter ─────────────────────────────────────────────────────
4132
4133 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
4134 """--language python and --language Python must behave identically."""
4135 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4136 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
4137 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
4138 d_lower = json.loads(r_lower.output)
4139 d_upper = json.loads(r_upper.output)
4140 addrs_lower = {e["address"] for e in d_lower["stable"]}
4141 addrs_upper = {e["address"] for e in d_upper["stable"]}
4142 assert addrs_lower == addrs_upper
4143
4144 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
4145 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4146 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4147 d_py = json.loads(r_py.output)
4148 d_all = json.loads(r_all.output)
4149 # Python-filtered results must be a subset of or equal to unfiltered results.
4150 py_addrs = {e["address"] for e in d_py["stable"]}
4151 all_addrs = {e["address"] for e in d_all["stable"]}
4152 assert py_addrs <= all_addrs
4153
4154 # ── --since REF ───────────────────────────────────────────────────────────
4155
4156 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
4157 """--since HEAD restricts the window to 0 commits (stop immediately)."""
4158 # Get the HEAD commit id to use as --since boundary
4159 import json as _json
4160 root = code_repo
4161 repo_id = _json.loads((repo_json_path(root)).read_text())["repo_id"]
4162 from muse.core.refs import read_current_branch
4163 from muse.core.commits import resolve_commit_ref
4164 branch = read_current_branch(root)
4165 head = resolve_commit_ref(root, branch, None)
4166 assert head is not None
4167
4168 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4169 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4170 assert r_all.exit_code == 0 and r_since.exit_code == 0
4171 d_all = json.loads(r_all.output)
4172 d_since = json.loads(r_since.output)
4173 # Window stops at HEAD itself → at most 1 commit analysed.
4174 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4175
4176 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4177 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4178 assert result.exit_code != 0
4179
4180 # ── --max-commits ─────────────────────────────────────────────────────────
4181
4182 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4183 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4184 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4185 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4186 d_cap = json.loads(r_cap.output)
4187 assert d_cap["commits_analysed"] <= 1
4188
4189 def test_stable_max_commits_one_shows_truncated_warning(
4190 self, code_repo: pathlib.Path
4191 ) -> None:
4192 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4193 assert result.exit_code == 0, result.output
4194 # With 2 commits and cap=1, truncated warning should appear.
4195 assert "capped" in result.output or "⚠️" in result.output
4196
4197 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4198 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4199 assert result.exit_code != 0
4200
4201 # ── --top validation ──────────────────────────────────────────────────────
4202
4203 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4204 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4205 assert result.exit_code != 0
4206
4207 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4208 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4209 data = json.loads(result.output)
4210 assert len(data["stable"]) <= 2
4211
4212 # ── BFS follows merge parents ─────────────────────────────────────────────
4213
4214 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4215 """Symbols touched only on a merged feature branch must be detected as unstable."""
4216 import datetime
4217
4218 # Create a symbol in commit 1 (main).
4219 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4220 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4221 assert r.exit_code == 0, r.output
4222
4223 repo_json = json.loads((repo_json_path(repo)).read_text())
4224 repo_id = repo_json["repo_id"]
4225 from muse.core.refs import read_current_branch
4226 from muse.core.commits import resolve_commit_ref
4227 branch = read_current_branch(repo)
4228 head_commit = resolve_commit_ref(repo, branch, None)
4229 assert head_commit is not None
4230 head_id = head_commit.commit_id
4231
4232 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4233 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4234
4235 # Feature-branch commit that touched "bedrock" via a structured_delta.
4236 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4237 from muse.core.ids import hash_commit as compute_commit_id
4238 bedrock_delta = StructuredDelta(
4239 domain="code",
4240 ops=[PatchOp(
4241 op="patch", address="core.py",
4242 child_ops=[ReplaceOp(
4243 op="replace", address="core.py::bedrock",
4244 old_content_id="a" * 64, new_content_id="b" * 64,
4245 old_summary="function bedrock",
4246 new_summary="function bedrock (modified)", position=None,
4247 )],
4248 child_domain="code", child_summary="bedrock modified",
4249 )],
4250 summary="bedrock modified",
4251 )
4252 feature_id = compute_commit_id(
4253 [head_id], head_commit.snapshot_id,
4254 "Feature: touch bedrock", feature_at.isoformat(),
4255 author="test",
4256 )
4257 merge_id = compute_commit_id(
4258 [head_id, feature_id], head_commit.snapshot_id,
4259 "Merge feat/touch-bedrock", merge_at.isoformat(),
4260 author="test",
4261 )
4262 feature_body: CommitDict = {
4263 "commit_id": feature_id,
4264 "repo_id": repo_id,
4265 "branch": "feat/touch-bedrock",
4266 "snapshot_id": head_commit.snapshot_id,
4267 "message": "Feature: touch bedrock",
4268 "committed_at": feature_at.isoformat(),
4269 "parent_commit_id": head_id,
4270 "parent2_commit_id": None,
4271 "author": "test",
4272 "metadata": {},
4273 "structured_delta": bedrock_delta,
4274 }
4275 # Merge commit whose parent2 is the feature commit.
4276 merge_body: CommitDict = {
4277 "commit_id": merge_id,
4278 "repo_id": repo_id,
4279 "branch": branch,
4280 "snapshot_id": head_commit.snapshot_id,
4281 "message": "Merge feat/touch-bedrock",
4282 "committed_at": merge_at.isoformat(),
4283 "parent_commit_id": head_id,
4284 "parent2_commit_id": feature_id,
4285 "author": "test",
4286 "metadata": {},
4287 "structured_delta": None,
4288 }
4289 from muse.core.commits import (
4290 CommitRecord,
4291 write_commit,
4292 )
4293 write_commit(repo, CommitRecord.from_dict(feature_body))
4294 write_commit(repo, CommitRecord.from_dict(merge_body))
4295 (ref_path(repo, branch)).write_text(merge_id)
4296
4297 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4298 assert result.exit_code == 0, result.output
4299 data = json.loads(result.output)
4300 # bedrock was touched in the feature-branch commit; BFS must find it.
4301 # It should have unchanged_for < total_commits (not maximally stable).
4302 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4303 if bedrock_entries:
4304 assert not bedrock_entries[0]["since_start_of_range"]
4305
4306
4307 # ---------------------------------------------------------------------------
4308 # muse code compare
4309 # ---------------------------------------------------------------------------
4310
4311
4312 @pytest.fixture
4313 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4314 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4315
4316 Commit A — billing.py with Invoice.compute_total + process_order.
4317 Commit B — compute_total renamed to compute_invoice_total; generate_pdf
4318 and send_email added. Multi-line message to test truncation.
4319 """
4320 (repo / "billing.py").write_text(textwrap.dedent("""\
4321 class Invoice:
4322 def compute_total(self, items):
4323 return sum(items)
4324
4325 def apply_discount(self, total, pct):
4326 return total * (1 - pct)
4327
4328 def process_order(invoice, items):
4329 return invoice.compute_total(items)
4330 """))
4331 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4332 assert r.exit_code == 0, r.output
4333 from muse.core.refs import read_current_branch
4334 branch = read_current_branch(repo)
4335 commit_a = get_head_commit_id(repo, branch)
4336
4337 (repo / "billing.py").write_text(textwrap.dedent("""\
4338 class Invoice:
4339 def compute_invoice_total(self, items):
4340 return sum(items)
4341
4342 def apply_discount(self, total, pct):
4343 return total * (1 - pct)
4344
4345 def generate_pdf(self):
4346 return b"pdf"
4347
4348 def process_order(invoice, items):
4349 return invoice.compute_invoice_total(items)
4350
4351 def send_email(address):
4352 pass
4353 """))
4354 # Multi-line message to test first-line truncation.
4355 r = runner.invoke(cli, [
4356 "commit", "-m",
4357 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4358 ])
4359 assert r.exit_code == 0, r.output
4360 commit_b = get_head_commit_id(repo, branch)
4361
4362 assert commit_a is not None
4363 assert commit_b is not None
4364 return repo, commit_a, commit_b
4365
4366
4367 class TestCompare:
4368 """Tests for muse code compare."""
4369
4370 # ── basic correctness ────────────────────────────────────────────────────
4371
4372 def test_compare_exits_zero(
4373 self, compare_repo: tuple[pathlib.Path, str, str]
4374 ) -> None:
4375 _, ref_a, ref_b = compare_repo
4376 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4377 assert result.exit_code == 0, result.output
4378
4379 def test_compare_shows_header(
4380 self, compare_repo: tuple[pathlib.Path, str, str]
4381 ) -> None:
4382 _, ref_a, ref_b = compare_repo
4383 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4384 assert result.exit_code == 0, result.output
4385 assert "Semantic comparison" in result.output
4386 assert "From:" in result.output
4387 assert "To:" in result.output
4388
4389 def test_compare_commit_message_first_line_only(
4390 self, compare_repo: tuple[pathlib.Path, str, str]
4391 ) -> None:
4392 """Multi-line commit messages must be truncated to their first line."""
4393 _, ref_a, ref_b = compare_repo
4394 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4395 assert result.exit_code == 0, result.output
4396 # The body of the second commit must not appear in the header.
4397 assert "This is the extended body" not in result.output
4398
4399 def test_compare_same_ref_no_changes(
4400 self, compare_repo: tuple[pathlib.Path, str, str]
4401 ) -> None:
4402 _, ref_a, _ = compare_repo
4403 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4404 assert result.exit_code == 0, result.output
4405 assert "no semantic changes" in result.output
4406
4407 def test_compare_detects_added_symbols(
4408 self, compare_repo: tuple[pathlib.Path, str, str]
4409 ) -> None:
4410 _, ref_a, ref_b = compare_repo
4411 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4412 assert result.exit_code == 0, result.output
4413 # generate_pdf and send_email were added in commit B.
4414 assert "generate_pdf" in result.output or "send_email" in result.output
4415
4416 def test_compare_invalid_ref_exits_nonzero(
4417 self, compare_repo: tuple[pathlib.Path, str, str]
4418 ) -> None:
4419 _, ref_a, _ = compare_repo
4420 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4421 assert result.exit_code != 0
4422
4423 def test_compare_requires_repo(
4424 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4425 ) -> None:
4426 monkeypatch.chdir(tmp_path)
4427 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4428 assert result.exit_code != 0
4429
4430 # ── JSON schema ──────────────────────────────────────────────────────────
4431
4432 def test_compare_json_schema(
4433 self, compare_repo: tuple[pathlib.Path, str, str]
4434 ) -> None:
4435 _, ref_a, ref_b = compare_repo
4436 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4437 assert result.exit_code == 0, result.output
4438 data = json.loads(result.output)
4439 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4440
4441 def test_compare_json_from_to_schema(
4442 self, compare_repo: tuple[pathlib.Path, str, str]
4443 ) -> None:
4444 _, ref_a, ref_b = compare_repo
4445 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4446 assert result.exit_code == 0, result.output
4447 data = json.loads(result.output)
4448 assert "commit_id" in data["from"]
4449 assert "message" in data["from"]
4450 assert "commit_id" in data["to"]
4451 assert "message" in data["to"]
4452
4453 def test_compare_json_message_first_line_only(
4454 self, compare_repo: tuple[pathlib.Path, str, str]
4455 ) -> None:
4456 _, ref_a, ref_b = compare_repo
4457 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4458 assert result.exit_code == 0, result.output
4459 data = json.loads(result.output)
4460 assert "\n" not in data["to"]["message"]
4461 assert "This is the extended body" not in data["to"]["message"]
4462
4463 def test_compare_json_stat_schema(
4464 self, compare_repo: tuple[pathlib.Path, str, str]
4465 ) -> None:
4466 _, ref_a, ref_b = compare_repo
4467 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4468 assert result.exit_code == 0, result.output
4469 stat = json.loads(result.output)["stat"]
4470 assert set(stat.keys()) >= {
4471 "files_changed", "symbols_added", "symbols_removed",
4472 "symbols_modified", "semver_impact",
4473 }
4474 assert isinstance(stat["files_changed"], int)
4475 assert isinstance(stat["symbols_added"], int)
4476 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4477
4478 def test_compare_json_filters_schema(
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, "--json"])
4483 assert result.exit_code == 0, result.output
4484 filters = json.loads(result.output)["filters"]
4485 assert set(filters.keys()) >= {"kind", "file", "language"}
4486 # No filters applied — all None.
4487 assert filters["kind"] is None
4488 assert filters["file"] is None
4489 assert filters["language"] is None
4490
4491 def test_compare_json_ops_schema(
4492 self, compare_repo: tuple[pathlib.Path, str, str]
4493 ) -> None:
4494 _, ref_a, ref_b = compare_repo
4495 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4496 assert result.exit_code == 0, result.output
4497 ops = json.loads(result.output)["ops"]
4498 assert isinstance(ops, list)
4499 assert len(ops) > 0
4500 for op in ops:
4501 assert "op" in op
4502 assert "address" in op
4503 assert "detail" in op
4504
4505 def test_compare_same_ref_json_empty_ops(
4506 self, compare_repo: tuple[pathlib.Path, str, str]
4507 ) -> None:
4508 _, ref_a, _ = compare_repo
4509 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--json"])
4510 assert result.exit_code == 0, result.output
4511 data = json.loads(result.output)
4512 assert data["ops"] == []
4513 assert data["stat"]["semver_impact"] == "NONE"
4514
4515 # ── --stat flag ──────────────────────────────────────────────────────────
4516
4517 def test_compare_stat_shows_counts(
4518 self, compare_repo: tuple[pathlib.Path, str, str]
4519 ) -> None:
4520 _, ref_a, ref_b = compare_repo
4521 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4522 assert result.exit_code == 0, result.output
4523 assert "Files changed:" in result.output
4524 assert "Symbols added:" in result.output
4525 assert "Symbols removed:" in result.output
4526 assert "Symbols modified:" in result.output
4527 assert "SemVer impact:" in result.output
4528
4529 def test_compare_stat_no_per_symbol_listing(
4530 self, compare_repo: tuple[pathlib.Path, str, str]
4531 ) -> None:
4532 _, ref_a, ref_b = compare_repo
4533 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4534 assert result.exit_code == 0, result.output
4535 # --stat should not include per-symbol listing lines ("added …", "removed …").
4536 assert " added " not in result.output
4537 assert " removed " not in result.output
4538 assert " modified " not in result.output
4539
4540 def test_compare_stat_same_ref_semver_none(
4541 self, compare_repo: tuple[pathlib.Path, str, str]
4542 ) -> None:
4543 _, ref_a, _ = compare_repo
4544 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--stat"])
4545 assert result.exit_code == 0, result.output
4546 assert "NONE" in result.output
4547
4548 # ── --semver flag ────────────────────────────────────────────────────────
4549
4550 def test_compare_semver_appended_to_full_output(
4551 self, compare_repo: tuple[pathlib.Path, str, str]
4552 ) -> None:
4553 _, ref_a, ref_b = compare_repo
4554 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--semver"])
4555 assert result.exit_code == 0, result.output
4556 assert "SemVer impact:" in result.output
4557
4558 # ── --file filter ────────────────────────────────────────────────────────
4559
4560 def test_compare_file_filter_restricts_output(
4561 self, compare_repo: tuple[pathlib.Path, str, str]
4562 ) -> None:
4563 _, ref_a, ref_b = compare_repo
4564 result = runner.invoke(
4565 cli, ["code", "compare", ref_a, ref_b, "--file", "billing.py"]
4566 )
4567 assert result.exit_code == 0, result.output
4568
4569 def test_compare_file_filter_nonexistent_no_ops(
4570 self, compare_repo: tuple[pathlib.Path, str, str]
4571 ) -> None:
4572 _, ref_a, ref_b = compare_repo
4573 result = runner.invoke(
4574 cli, ["code", "compare", ref_a, ref_b, "--file", "nonexistent.py"]
4575 )
4576 assert result.exit_code == 0, result.output
4577 assert "no semantic changes" in result.output
4578
4579 def test_compare_file_filter_in_json(
4580 self, compare_repo: tuple[pathlib.Path, str, str]
4581 ) -> None:
4582 _, ref_a, ref_b = compare_repo
4583 result = runner.invoke(
4584 cli,
4585 ["code", "compare", ref_a, ref_b, "--file", "billing.py", "--json"],
4586 )
4587 assert result.exit_code == 0, result.output
4588 data = json.loads(result.output)
4589 assert data["filters"]["file"] == "billing.py"
4590
4591 # ── --kind filter ────────────────────────────────────────────────────────
4592
4593 def test_compare_kind_filter_case_insensitive(
4594 self, compare_repo: tuple[pathlib.Path, str, str]
4595 ) -> None:
4596 _, ref_a, ref_b = compare_repo
4597 r_lower = runner.invoke(
4598 cli, ["code", "compare", ref_a, ref_b, "--kind", "function"]
4599 )
4600 r_upper = runner.invoke(
4601 cli, ["code", "compare", ref_a, ref_b, "--kind", "Function"]
4602 )
4603 assert r_lower.exit_code == 0
4604 assert r_upper.exit_code == 0
4605 # Both produce the same ops list.
4606 assert r_lower.output == r_upper.output
4607
4608 def test_compare_kind_filter_in_json(
4609 self, compare_repo: tuple[pathlib.Path, str, str]
4610 ) -> None:
4611 _, ref_a, ref_b = compare_repo
4612 result = runner.invoke(
4613 cli, ["code", "compare", ref_a, ref_b, "--kind", "function", "--json"]
4614 )
4615 assert result.exit_code == 0, result.output
4616 data = json.loads(result.output)
4617 assert data["filters"]["kind"] == "function"
4618
4619 # ── --language filter ────────────────────────────────────────────────────
4620
4621 def test_compare_language_filter_python(
4622 self, compare_repo: tuple[pathlib.Path, str, str]
4623 ) -> None:
4624 _, ref_a, ref_b = compare_repo
4625 result = runner.invoke(
4626 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4627 )
4628 assert result.exit_code == 0, result.output
4629
4630 def test_compare_language_filter_case_insensitive(
4631 self, compare_repo: tuple[pathlib.Path, str, str]
4632 ) -> None:
4633 _, ref_a, ref_b = compare_repo
4634 r_lower = runner.invoke(
4635 cli, ["code", "compare", ref_a, ref_b, "--language", "python"]
4636 )
4637 r_upper = runner.invoke(
4638 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4639 )
4640 assert r_lower.exit_code == 0
4641 assert r_upper.exit_code == 0
4642 assert r_lower.output == r_upper.output
4643
4644 def test_compare_language_filter_in_json(
4645 self, compare_repo: tuple[pathlib.Path, str, str]
4646 ) -> None:
4647 _, ref_a, ref_b = compare_repo
4648 result = runner.invoke(
4649 cli, ["code", "compare", ref_a, ref_b, "--language", "python", "--json"]
4650 )
4651 assert result.exit_code == 0, result.output
4652 data = json.loads(result.output)
4653 assert data["filters"]["language"] == "Python"
4654
4655
4656 # ---------------------------------------------------------------------------
4657 # muse code languages
4658 # ---------------------------------------------------------------------------
4659
4660
4661 @pytest.fixture
4662 def lang_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4663 """Two-commit repo; returns (path, commit_id_a, commit_id_b).
4664
4665 Commit A — billing.py (Python) only.
4666 Commit B — billing.py extended + README.md added.
4667 """
4668 (repo / "billing.py").write_text(textwrap.dedent("""\
4669 import os
4670 import json
4671
4672 class Invoice:
4673 def compute_total(self, items: list[float]) -> float:
4674 return sum(items)
4675
4676 def process_order(invoice: Invoice, items: list[float]) -> float:
4677 return invoice.compute_total(items)
4678 """))
4679 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4680 assert r.exit_code == 0, r.output
4681 from muse.core.refs import read_current_branch
4682 branch = read_current_branch(repo)
4683 commit_a = get_head_commit_id(repo, branch)
4684
4685 (repo / "billing.py").write_text(textwrap.dedent("""\
4686 import os
4687 import json
4688
4689 class Invoice:
4690 def compute_total(self, items: list[float]) -> float:
4691 return sum(items)
4692
4693 def generate_pdf(self) -> bytes:
4694 return b"pdf"
4695
4696 def process_order(invoice: Invoice, items: list[float]) -> float:
4697 return invoice.compute_total(items)
4698
4699 def send_email(address: str) -> None:
4700 pass
4701 """))
4702 (repo / "README.md").write_text("# My Project\n\nA billing module.\n")
4703 r = runner.invoke(cli, ["commit", "-m", "Add generate_pdf, send_email, README"])
4704 assert r.exit_code == 0, r.output
4705 commit_b = get_head_commit_id(repo, branch)
4706
4707 assert commit_a is not None
4708 assert commit_b is not None
4709 return repo, commit_a, commit_b
4710
4711
4712 class TestLanguages:
4713 """Tests for muse code languages."""
4714
4715 # ── basic correctness ────────────────────────────────────────────────────
4716
4717 def test_languages_exits_zero(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4718 result = runner.invoke(cli, ["code", "languages"])
4719 assert result.exit_code == 0, result.output
4720
4721 def test_languages_shows_header(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4722 result = runner.invoke(cli, ["code", "languages"])
4723 assert result.exit_code == 0, result.output
4724 assert "Language breakdown" in result.output
4725 assert "Total" in result.output
4726
4727 def test_languages_shows_python(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4728 result = runner.invoke(cli, ["code", "languages"])
4729 assert result.exit_code == 0, result.output
4730 assert "Python" in result.output
4731
4732 def test_languages_shows_markdown(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4733 result = runner.invoke(cli, ["code", "languages"])
4734 assert result.exit_code == 0, result.output
4735 assert "Markdown" in result.output
4736
4737 def test_languages_excludes_imports_by_default(
4738 self, lang_repo: tuple[pathlib.Path, str, str]
4739 ) -> None:
4740 """Import pseudo-symbols must not inflate the count by default."""
4741 r_default = runner.invoke(cli, ["code", "languages", "--json"])
4742 assert r_default.exit_code == 0, r_default.output
4743 r_imports = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4744 assert r_imports.exit_code == 0, r_imports.output
4745
4746 class _LangEntry(TypedDict):
4747 language: str
4748 files: int
4749 symbols: int
4750 kinds: _KindsMap
4751
4752 class _LangsJson(TypedDict):
4753 languages: list[_LangEntry]
4754
4755 data_default: _LangsJson = json.loads(r_default.output)
4756 data_imports: _LangsJson = json.loads(r_imports.output)
4757
4758 def _py_syms(data: _LangsJson) -> int:
4759 for e in data["languages"]:
4760 if e["language"] == "Python":
4761 return e["symbols"]
4762 return 0
4763
4764 syms_default = _py_syms(data_default)
4765 syms_imports = _py_syms(data_imports)
4766 # With imports included the symbol count must be strictly higher.
4767 assert syms_imports > syms_default
4768
4769 def test_languages_requires_repo(
4770 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4771 ) -> None:
4772 monkeypatch.chdir(tmp_path)
4773 result = runner.invoke(cli, ["code", "languages"])
4774 assert result.exit_code != 0
4775
4776 def test_languages_invalid_commit_exits_nonzero(
4777 self, lang_repo: tuple[pathlib.Path, str, str]
4778 ) -> None:
4779 result = runner.invoke(cli, ["code", "languages", "--commit", "deadbeefdeadbeef"])
4780 assert result.exit_code != 0
4781
4782 # ── JSON schema ──────────────────────────────────────────────────────────
4783
4784 def test_languages_json_schema(
4785 self, lang_repo: tuple[pathlib.Path, str, str]
4786 ) -> None:
4787 result = runner.invoke(cli, ["code", "languages", "--json"])
4788 assert result.exit_code == 0, result.output
4789 data = json.loads(result.output)
4790 assert set(data.keys()) >= {"commit", "include_imports", "languages"}
4791
4792 def test_languages_json_commit_block(
4793 self, lang_repo: tuple[pathlib.Path, str, str]
4794 ) -> None:
4795 result = runner.invoke(cli, ["code", "languages", "--json"])
4796 assert result.exit_code == 0, result.output
4797 data = json.loads(result.output)
4798 commit = data["commit"]
4799 assert "commit_id" in commit
4800 assert "message" in commit
4801 # message is first line only — no newlines.
4802 assert "\n" not in commit["message"]
4803
4804 def test_languages_json_entry_schema(
4805 self, lang_repo: tuple[pathlib.Path, str, str]
4806 ) -> None:
4807 result = runner.invoke(cli, ["code", "languages", "--json"])
4808 assert result.exit_code == 0, result.output
4809 langs = json.loads(result.output)["languages"]
4810 assert isinstance(langs, list)
4811 assert len(langs) > 0
4812 for entry in langs:
4813 assert "language" in entry
4814 assert "files" in entry
4815 assert "symbols" in entry
4816 assert "kinds" in entry
4817 assert isinstance(entry["files"], int)
4818 assert isinstance(entry["symbols"], int)
4819 assert isinstance(entry["kinds"], dict)
4820
4821 def test_languages_json_include_imports_flag(
4822 self, lang_repo: tuple[pathlib.Path, str, str]
4823 ) -> None:
4824 result = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4825 assert result.exit_code == 0, result.output
4826 data = json.loads(result.output)
4827 assert data["include_imports"] is True
4828
4829 # ── --sort flag ──────────────────────────────────────────────────────────
4830
4831 def test_languages_sort_name(
4832 self, lang_repo: tuple[pathlib.Path, str, str]
4833 ) -> None:
4834 result = runner.invoke(cli, ["code", "languages", "--sort", "name"])
4835 assert result.exit_code == 0, result.output
4836
4837 def test_languages_sort_symbols(
4838 self, lang_repo: tuple[pathlib.Path, str, str]
4839 ) -> None:
4840 result = runner.invoke(cli, ["code", "languages", "--sort", "symbols"])
4841 assert result.exit_code == 0, result.output
4842 # Python should appear before Markdown when sorted by symbols desc.
4843 lines = result.output.splitlines()
4844 py_line = next((i for i, l in enumerate(lines) if "Python" in l), None)
4845 md_line = next((i for i, l in enumerate(lines) if "Markdown" in l), None)
4846 # Both might not exist if the repo only has Python; at least ensure no crash.
4847 assert py_line is not None
4848
4849 def test_languages_sort_files(
4850 self, lang_repo: tuple[pathlib.Path, str, str]
4851 ) -> None:
4852 result = runner.invoke(cli, ["code", "languages", "--sort", "files"])
4853 assert result.exit_code == 0, result.output
4854
4855 def test_languages_invalid_sort_exits_nonzero(
4856 self, lang_repo: tuple[pathlib.Path, str, str]
4857 ) -> None:
4858 result = runner.invoke(cli, ["code", "languages", "--sort", "bad"])
4859 assert result.exit_code != 0
4860
4861 # ── --diff flag ──────────────────────────────────────────────────────────
4862
4863 def test_languages_diff_exits_zero(
4864 self, lang_repo: tuple[pathlib.Path, str, str]
4865 ) -> None:
4866 _, commit_a, _ = lang_repo
4867 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4868 assert result.exit_code == 0, result.output
4869
4870 def test_languages_diff_shows_header(
4871 self, lang_repo: tuple[pathlib.Path, str, str]
4872 ) -> None:
4873 _, commit_a, _ = lang_repo
4874 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4875 assert result.exit_code == 0, result.output
4876 assert "Language change" in result.output
4877 assert "Net" in result.output
4878
4879 def test_languages_diff_detects_new_symbols(
4880 self, lang_repo: tuple[pathlib.Path, str, str]
4881 ) -> None:
4882 """Commit B added generate_pdf and send_email — Python symbol count must grow."""
4883 _, commit_a, _ = lang_repo
4884 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4885 assert result.exit_code == 0, result.output
4886 # Python line should show a positive delta.
4887 lines = result.output.splitlines()
4888 py_line = next((l for l in lines if "Python" in l), "")
4889 assert "+" in py_line
4890
4891 def test_languages_diff_unchanged_label(
4892 self, lang_repo: tuple[pathlib.Path, str, str]
4893 ) -> None:
4894 """Comparing a commit to itself must show all languages as unchanged."""
4895 _, _, commit_b = lang_repo
4896 result = runner.invoke(cli, ["code", "languages", "--diff", commit_b])
4897 assert result.exit_code == 0, result.output
4898 assert "unchanged" in result.output
4899
4900 def test_languages_diff_invalid_ref_exits_nonzero(
4901 self, lang_repo: tuple[pathlib.Path, str, str]
4902 ) -> None:
4903 result = runner.invoke(cli, ["code", "languages", "--diff", "deadbeefdeadbeef"])
4904 assert result.exit_code != 0
4905
4906 def test_languages_diff_json_schema(
4907 self, lang_repo: tuple[pathlib.Path, str, str]
4908 ) -> None:
4909 _, commit_a, _ = lang_repo
4910 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4911 assert result.exit_code == 0, result.output
4912 data = json.loads(result.output)
4913 assert set(data.keys()) >= {"from_commit", "to_commit", "include_imports", "diff"}
4914 assert "commit_id" in data["from_commit"]
4915 assert "message" in data["to_commit"]
4916
4917 def test_languages_diff_json_entry_schema(
4918 self, lang_repo: tuple[pathlib.Path, str, str]
4919 ) -> None:
4920 _, commit_a, _ = lang_repo
4921 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4922 assert result.exit_code == 0, result.output
4923 diff = json.loads(result.output)["diff"]
4924 assert isinstance(diff, list)
4925 assert len(diff) > 0
4926 for entry in diff:
4927 assert "language" in entry
4928 assert "delta_files" in entry
4929 assert "delta_symbols" in entry
4930 assert "files_before" in entry
4931 assert "files_after" in entry
4932 assert "symbols_before" in entry
4933 assert "symbols_after" in entry
4934 assert "status" in entry
4935 assert entry["status"] in ("added", "removed", "changed", "unchanged")
4936
4937 def test_languages_diff_json_python_delta_positive(
4938 self, lang_repo: tuple[pathlib.Path, str, str]
4939 ) -> None:
4940 _, commit_a, _ = lang_repo
4941 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4942 assert result.exit_code == 0, result.output
4943 diff = json.loads(result.output)["diff"]
4944 py = next((e for e in diff if e["language"] == "Python"), None)
4945 assert py is not None
4946 assert py["delta_symbols"] > 0
4947 assert py["status"] == "changed"
4948
4949 def test_languages_diff_json_markdown_added(
4950 self, lang_repo: tuple[pathlib.Path, str, str]
4951 ) -> None:
4952 """README.md was added in commit B — Markdown status should be 'added'."""
4953 _, commit_a, _ = lang_repo
4954 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4955 assert result.exit_code == 0, result.output
4956 diff = json.loads(result.output)["diff"]
4957 md = next((e for e in diff if e["language"] == "Markdown"), None)
4958 assert md is not None
4959 assert md["status"] == "added"
4960 assert md["files_before"] == 0
4961 assert md["files_after"] == 1
4962
4963
4964 # ---------------------------------------------------------------------------
4965 # muse code rename
4966 # ---------------------------------------------------------------------------
4967
4968
4969 @pytest.fixture
4970 def rename_repo(repo: pathlib.Path) -> pathlib.Path:
4971 """Repo with billing.py and a test file that imports and calls its symbols."""
4972 (repo / "billing.py").write_text(textwrap.dedent("""\
4973 import os
4974
4975 class Invoice:
4976 def compute_total(self, items):
4977 return sum(items)
4978
4979 def apply_discount(self, total, pct):
4980 return total * (1 - pct)
4981
4982 def process_order(invoice, items):
4983 total = compute_total(items)
4984 return total
4985 """))
4986 (repo / "test_billing.py").write_text(textwrap.dedent("""\
4987 from billing import compute_total, Invoice
4988
4989 def test_compute_total():
4990 inv = Invoice()
4991 result = inv.compute_total([1, 2, 3])
4992 assert compute_total([1, 2, 3]) == 6
4993 """))
4994 r = runner.invoke(cli, ["commit", "-m", "Initial billing + tests"])
4995 assert r.exit_code == 0, r.output
4996 return repo
4997
4998
4999 class TestRename:
5000 """Tests for muse code rename."""
5001
5002 # ── basic correctness ────────────────────────────────────────────────────
5003
5004 def test_rename_dry_run_exits_zero(self, rename_repo: pathlib.Path) -> None:
5005 result = runner.invoke(
5006 cli,
5007 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
5008 )
5009 assert result.exit_code == 0, result.output
5010
5011 def test_rename_dry_run_shows_preview(self, rename_repo: pathlib.Path) -> None:
5012 result = runner.invoke(
5013 cli,
5014 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
5015 )
5016 assert result.exit_code == 0, result.output
5017 assert "Renaming" in result.output
5018 assert "process_order" in result.output
5019 assert "handle_order" in result.output
5020
5021 def test_rename_dry_run_does_not_write(self, rename_repo: pathlib.Path) -> None:
5022 before = (rename_repo / "billing.py").read_text()
5023 runner.invoke(
5024 cli,
5025 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
5026 )
5027 assert (rename_repo / "billing.py").read_text() == before
5028
5029 def test_rename_applies_definition(self, rename_repo: pathlib.Path) -> None:
5030 result = runner.invoke(
5031 cli,
5032 ["code", "rename", "billing.py::process_order", "handle_order",
5033 "--scope", "definition", "--yes"],
5034 )
5035 assert result.exit_code == 0, result.output
5036 content = (rename_repo / "billing.py").read_text()
5037 assert "def handle_order(" in content
5038 assert "def process_order(" not in content
5039
5040 def test_rename_only_def_token_not_string_literal(
5041 self, rename_repo: pathlib.Path
5042 ) -> None:
5043 """The rename must not touch string literals containing the old name."""
5044 # Add a docstring with the old name.
5045 billing = (rename_repo / "billing.py").read_text()
5046 billing += '\nDOC = "compute_total is a function"\n'
5047 (rename_repo / "billing.py").write_text(billing)
5048 runner.invoke(cli, ["commit", "-m", "add docstring"])
5049
5050 runner.invoke(
5051 cli,
5052 ["code", "rename", "billing.py::Invoice.compute_total",
5053 "compute_invoice_total", "--scope", "definition", "--yes"],
5054 )
5055 content = (rename_repo / "billing.py").read_text()
5056 # The string literal must be untouched.
5057 assert '"compute_total is a function"' in content
5058
5059 def test_rename_method_definition_scoped_to_class(
5060 self, rename_repo: pathlib.Path
5061 ) -> None:
5062 """billing.py::Invoice.compute_total must rename the method inside Invoice."""
5063 result = runner.invoke(
5064 cli,
5065 ["code", "rename", "billing.py::Invoice.compute_total",
5066 "compute_invoice_total", "--scope", "definition", "--yes"],
5067 )
5068 assert result.exit_code == 0, result.output
5069 content = (rename_repo / "billing.py").read_text()
5070 assert "def compute_invoice_total(self" in content
5071 # The module-level bare call in process_order stays unchanged.
5072 assert "compute_total(items)" in content
5073
5074 def test_rename_updates_import_sites(self, rename_repo: pathlib.Path) -> None:
5075 result = runner.invoke(
5076 cli,
5077 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5078 "--scope", "imports", "--yes"],
5079 )
5080 assert result.exit_code == 0, result.output
5081 content = (rename_repo / "test_billing.py").read_text()
5082 assert "compute_invoice_total" in content
5083 assert "from billing import" in content
5084
5085 def test_rename_requires_repo(
5086 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5087 ) -> None:
5088 monkeypatch.chdir(tmp_path)
5089 result = runner.invoke(
5090 cli, ["code", "rename", "billing.py::foo", "bar", "--yes"]
5091 )
5092 assert result.exit_code != 0
5093
5094 def test_rename_rejects_same_name(self, rename_repo: pathlib.Path) -> None:
5095 result = runner.invoke(
5096 cli,
5097 ["code", "rename", "billing.py::process_order", "process_order", "--yes"],
5098 )
5099 assert result.exit_code != 0
5100
5101 def test_rename_rejects_invalid_identifier(self, rename_repo: pathlib.Path) -> None:
5102 result = runner.invoke(
5103 cli,
5104 ["code", "rename", "billing.py::process_order", "123invalid", "--yes"],
5105 )
5106 assert result.exit_code != 0
5107
5108 def test_rename_rejects_address_without_double_colon(
5109 self, rename_repo: pathlib.Path
5110 ) -> None:
5111 result = runner.invoke(
5112 cli, ["code", "rename", "billing.py", "new_name", "--yes"]
5113 )
5114 assert result.exit_code != 0
5115
5116 def test_rename_rejects_nonexistent_symbol(self, rename_repo: pathlib.Path) -> None:
5117 result = runner.invoke(
5118 cli,
5119 ["code", "rename", "billing.py::nonexistent_func", "new_name",
5120 "--scope", "definition", "--yes"],
5121 )
5122 assert result.exit_code != 0
5123
5124 def test_rename_rejects_path_traversal(self, rename_repo: pathlib.Path) -> None:
5125 result = runner.invoke(
5126 cli,
5127 ["code", "rename", "../../etc/passwd::foo", "bar", "--yes"],
5128 )
5129 assert result.exit_code != 0
5130
5131 def test_rename_rejects_dunder_without_force(self, rename_repo: pathlib.Path) -> None:
5132 result = runner.invoke(
5133 cli,
5134 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__", "--yes"],
5135 )
5136 assert result.exit_code != 0
5137
5138 def test_rename_allows_dunder_with_force(self, rename_repo: pathlib.Path) -> None:
5139 result = runner.invoke(
5140 cli,
5141 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__",
5142 "--scope", "definition", "--yes", "--force"],
5143 )
5144 assert result.exit_code == 0, result.output
5145 content = (rename_repo / "billing.py").read_text()
5146 assert "def __compute__(self" in content
5147
5148 # ── JSON output ──────────────────────────────────────────────────────────
5149
5150 def test_rename_json_schema(self, rename_repo: pathlib.Path) -> None:
5151 result = runner.invoke(
5152 cli,
5153 ["code", "rename", "billing.py::process_order", "handle_order",
5154 "--dry-run", "--json"],
5155 )
5156 assert result.exit_code == 0, result.output
5157 data = json.loads(result.output)
5158 assert set(data.keys()) >= {
5159 "from_address", "to_address", "from_name", "to_name",
5160 "scope", "dry_run", "files_to_modify", "total_edit_sites", "edit_sites",
5161 }
5162
5163 def test_rename_json_dry_run_empty_files_to_modify(
5164 self, rename_repo: pathlib.Path
5165 ) -> None:
5166 result = runner.invoke(
5167 cli,
5168 ["code", "rename", "billing.py::process_order", "handle_order",
5169 "--dry-run", "--json"],
5170 )
5171 assert result.exit_code == 0, result.output
5172 data = json.loads(result.output)
5173 assert data["dry_run"] is True
5174 assert data["files_to_modify"] == []
5175
5176 def test_rename_json_edit_site_schema(self, rename_repo: pathlib.Path) -> None:
5177 result = runner.invoke(
5178 cli,
5179 ["code", "rename", "billing.py::process_order", "handle_order",
5180 "--dry-run", "--json"],
5181 )
5182 assert result.exit_code == 0, result.output
5183 sites = json.loads(result.output)["edit_sites"]
5184 assert isinstance(sites, list)
5185 assert len(sites) > 0
5186 for site in sites:
5187 assert "file" in site
5188 assert "line" in site
5189 assert "col_start" in site
5190 assert "col_end" in site
5191 assert "kind" in site
5192 assert "context" in site
5193 assert site["kind"] in ("definition", "import", "reference")
5194 assert site["col_start"] < site["col_end"]
5195
5196 def test_rename_json_definition_site_present(self, rename_repo: pathlib.Path) -> None:
5197 result = runner.invoke(
5198 cli,
5199 ["code", "rename", "billing.py::process_order", "handle_order",
5200 "--dry-run", "--json"],
5201 )
5202 assert result.exit_code == 0, result.output
5203 sites = json.loads(result.output)["edit_sites"]
5204 def_sites = [s for s in sites if s["kind"] == "definition"]
5205 assert len(def_sites) == 1
5206 assert def_sites[0]["file"] == "billing.py"
5207 assert "process_order" in def_sites[0]["context"]
5208
5209 def test_rename_json_apply_writes_files(self, rename_repo: pathlib.Path) -> None:
5210 result = runner.invoke(
5211 cli,
5212 ["code", "rename", "billing.py::process_order", "handle_order",
5213 "--yes", "--json", "--scope", "definition"],
5214 )
5215 assert result.exit_code == 0, result.output
5216 content = (rename_repo / "billing.py").read_text()
5217 assert "def handle_order(" in content
5218
5219 # ── --scope flag ─────────────────────────────────────────────────────────
5220
5221 def test_rename_scope_definition_only(self, rename_repo: pathlib.Path) -> None:
5222 """--scope definition should only touch the def token."""
5223 runner.invoke(
5224 cli,
5225 ["code", "rename", "billing.py::process_order", "handle_order",
5226 "--scope", "definition", "--yes"],
5227 )
5228 billing = (rename_repo / "billing.py").read_text()
5229 test = (rename_repo / "test_billing.py").read_text()
5230 assert "def handle_order(" in billing
5231 # The import in test_billing.py must be untouched.
5232 assert "process_order" not in test or "import" in test
5233
5234 def test_rename_scope_imports_only(self, rename_repo: pathlib.Path) -> None:
5235 runner.invoke(
5236 cli,
5237 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5238 "--scope", "imports", "--yes"],
5239 )
5240 billing = (rename_repo / "billing.py").read_text()
5241 # The definition in billing.py must be untouched.
5242 assert "def compute_total(" in billing
5243
5244 def test_rename_json_scope_reflected(self, rename_repo: pathlib.Path) -> None:
5245 result = runner.invoke(
5246 cli,
5247 ["code", "rename", "billing.py::process_order", "handle_order",
5248 "--scope", "definition", "--dry-run", "--json"],
5249 )
5250 assert result.exit_code == 0, result.output
5251 assert json.loads(result.output)["scope"] == "definition"
5252
5253 # ── --max-files guard ────────────────────────────────────────────────────
5254
5255 def test_rename_max_files_validation(self, rename_repo: pathlib.Path) -> None:
5256 result = runner.invoke(
5257 cli,
5258 ["code", "rename", "billing.py::process_order", "handle_order",
5259 "--max-files", "0", "--dry-run"],
5260 )
5261 assert result.exit_code != 0
5262
5263 # ── edit precision ───────────────────────────────────────────────────────
5264
5265 def test_rename_preserves_surrounding_code(self, rename_repo: pathlib.Path) -> None:
5266 """Renaming process_order must not touch apply_discount or compute_total."""
5267 runner.invoke(
5268 cli,
5269 ["code", "rename", "billing.py::process_order", "handle_order",
5270 "--scope", "definition", "--yes"],
5271 )
5272 content = (rename_repo / "billing.py").read_text()
5273 assert "def apply_discount(" in content
5274 assert "def compute_total(" in content
5275
5276 def test_rename_col_precision_correct(self, rename_repo: pathlib.Path) -> None:
5277 """The definition rename must produce syntactically valid Python."""
5278 runner.invoke(
5279 cli,
5280 ["code", "rename", "billing.py::process_order", "handle_order",
5281 "--scope", "definition", "--yes"],
5282 )
5283 import ast as _ast
5284 content = (rename_repo / "billing.py").read_text()
5285 # Must parse without SyntaxError.
5286 try:
5287 _ast.parse(content)
5288 except SyntaxError as e:
5289 pytest.fail(f"Renamed file has a syntax error: {e}")
5290
5291
5292 # ---------------------------------------------------------------------------
5293 # blast-risk
5294 # ---------------------------------------------------------------------------
5295
5296
5297 @pytest.fixture
5298 def blast_repo(repo: pathlib.Path) -> pathlib.Path:
5299 """Repo with two commits: a production module and a test file.
5300
5301 billing.py defines Invoice.compute_total and process_order.
5302 test_billing.py imports and calls both — so they have at least one
5303 test caller. A second commit modifies compute_total so churn > 0.
5304 """
5305 (repo / "billing.py").write_text(textwrap.dedent("""\
5306 class Invoice:
5307 def compute_total(self, items):
5308 return sum(items)
5309
5310 def apply_discount(self, total, pct):
5311 return total * (1 - pct)
5312
5313 def process_order(invoice, items):
5314 return invoice.compute_total(items)
5315 """))
5316 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5317 from billing import Invoice, process_order
5318
5319 def test_compute_total():
5320 inv = Invoice()
5321 assert inv.compute_total([1, 2, 3]) == 6
5322
5323 def test_process_order():
5324 inv = Invoice()
5325 assert process_order(inv, [10]) == 10
5326 """))
5327 r = runner.invoke(cli, ["commit", "-m", "Add billing module and tests"])
5328 assert r.exit_code == 0, r.output
5329
5330 # Second commit: modify compute_total so churn count > 0.
5331 (repo / "billing.py").write_text(textwrap.dedent("""\
5332 class Invoice:
5333 def compute_total(self, items):
5334 # round to two decimal places
5335 return round(sum(items), 2)
5336
5337 def apply_discount(self, total, pct):
5338 return total * (1 - pct)
5339
5340 def process_order(invoice, items):
5341 return invoice.compute_total(items)
5342 """))
5343 r2 = runner.invoke(cli, ["commit", "-m", "Round compute_total result"])
5344 assert r2.exit_code == 0, r2.output
5345
5346 return repo
5347
5348
5349 class TestBlastRisk:
5350 """Tests for muse code blast-risk."""
5351
5352 # ── basic correctness ────────────────────────────────────────────────────
5353
5354 def test_blast_risk_exits_zero(self, blast_repo: pathlib.Path) -> None:
5355 result = runner.invoke(cli, ["code", "blast-risk"])
5356 assert result.exit_code == 0, result.output
5357
5358 def test_blast_risk_shows_header(self, blast_repo: pathlib.Path) -> None:
5359 result = runner.invoke(cli, ["code", "blast-risk"])
5360 assert result.exit_code == 0
5361 assert "blast-risk" in result.output
5362 assert "commits" in result.output
5363
5364 def test_blast_risk_shows_scoring_line(self, blast_repo: pathlib.Path) -> None:
5365 result = runner.invoke(cli, ["code", "blast-risk"])
5366 assert result.exit_code == 0
5367 assert "Scoring:" in result.output
5368 assert "impact" in result.output
5369 assert "churn" in result.output
5370 assert "test-gap" in result.output
5371 assert "coupling" in result.output
5372
5373 def test_blast_risk_shows_table_columns(self, blast_repo: pathlib.Path) -> None:
5374 result = runner.invoke(cli, ["code", "blast-risk"])
5375 assert result.exit_code == 0
5376 assert "RISK" in result.output
5377 assert "IMPACT" in result.output
5378 assert "CHURN" in result.output
5379 assert "TEST-GAP" in result.output
5380
5381 def test_blast_risk_lists_symbols(self, blast_repo: pathlib.Path) -> None:
5382 result = runner.invoke(cli, ["code", "blast-risk"])
5383 assert result.exit_code == 0
5384 # At least one symbol from billing.py should appear.
5385 assert "billing.py" in result.output
5386
5387 def test_blast_risk_risk_scores_in_range(self, blast_repo: pathlib.Path) -> None:
5388 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5389 assert result.exit_code == 0, result.output
5390 data = json.loads(result.output)
5391 for sym in data["symbols"]:
5392 assert 0 <= sym["risk"] <= 100
5393 assert 0 <= sym["impact_score"] <= 100
5394 assert 0 <= sym["churn_score"] <= 100
5395 assert 0 <= sym["test_gap_score"] <= 100
5396 assert 0 <= sym["coupling_score"] <= 100
5397
5398 # ── JSON schema ──────────────────────────────────────────────────────────
5399
5400 def test_blast_risk_json_top_level_keys(self, blast_repo: pathlib.Path) -> None:
5401 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5402 assert result.exit_code == 0, result.output
5403 data = json.loads(result.output)
5404 assert "ref" in data
5405 assert "commits_analysed" in data
5406 assert "truncated" in data
5407 assert "filters" in data
5408 assert "weights" in data
5409 assert "symbols" in data
5410
5411 def test_blast_risk_json_weights_sum_to_one(self, blast_repo: pathlib.Path) -> None:
5412 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5413 data = json.loads(result.output)
5414 total = sum(data["weights"].values())
5415 assert abs(total - 1.0) < 1e-6
5416
5417 def test_blast_risk_json_symbol_schema(self, blast_repo: pathlib.Path) -> None:
5418 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5419 data = json.loads(result.output)
5420 assert len(data["symbols"]) > 0
5421 sym = data["symbols"][0]
5422 for key in ("address", "kind", "file", "risk",
5423 "impact_raw", "churn_raw", "test_gap_raw",
5424 "coupling_raw", "impact_score", "churn_score",
5425 "test_gap_score", "coupling_score"):
5426 assert key in sym, f"missing key: {key}"
5427
5428 def test_blast_risk_json_sorted_by_risk_desc(self, blast_repo: pathlib.Path) -> None:
5429 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5430 data = json.loads(result.output)
5431 risks = [s["risk"] for s in data["symbols"]]
5432 assert risks == sorted(risks, reverse=True)
5433
5434 def test_blast_risk_json_no_import_pseudosymbols(self, blast_repo: pathlib.Path) -> None:
5435 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5436 data = json.loads(result.output)
5437 for sym in data["symbols"]:
5438 assert "::import::" not in sym["address"]
5439
5440 def test_blast_risk_json_filters_reflected(self, blast_repo: pathlib.Path) -> None:
5441 result = runner.invoke(
5442 cli, ["code", "blast-risk", "--json", "--kind", "function", "--min-risk", "10"]
5443 )
5444 data = json.loads(result.output)
5445 assert data["filters"]["kind"] == "function"
5446 assert data["filters"]["min_risk"] == 10
5447
5448 # ── --top flag ───────────────────────────────────────────────────────────
5449
5450 def test_blast_risk_top_limits_output(self, blast_repo: pathlib.Path) -> None:
5451 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--top", "2"])
5452 data = json.loads(result.output)
5453 assert len(data["symbols"]) <= 2
5454
5455 def test_blast_risk_top_validation(self, blast_repo: pathlib.Path) -> None:
5456 result = runner.invoke(cli, ["code", "blast-risk", "--top", "0"])
5457 assert result.exit_code != 0
5458
5459 # ── --kind filter ────────────────────────────────────────────────────────
5460
5461 def test_blast_risk_kind_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5462 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "class"])
5463 data = json.loads(result.output)
5464 for sym in data["symbols"]:
5465 assert sym["kind"] == "class"
5466
5467 def test_blast_risk_kind_filter_function(self, blast_repo: pathlib.Path) -> None:
5468 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "function"])
5469 assert result.exit_code == 0
5470 data = json.loads(result.output)
5471 for sym in data["symbols"]:
5472 assert sym["kind"] in ("function", "method")
5473
5474 # ── --file filter ────────────────────────────────────────────────────────
5475
5476 def test_blast_risk_file_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5477 result = runner.invoke(
5478 cli, ["code", "blast-risk", "--json", "--file", "billing.py"]
5479 )
5480 data = json.loads(result.output)
5481 for sym in data["symbols"]:
5482 assert "billing.py" in sym["file"]
5483
5484 def test_blast_risk_file_filter_nonexistent_returns_empty(
5485 self, blast_repo: pathlib.Path
5486 ) -> None:
5487 result = runner.invoke(
5488 cli, ["code", "blast-risk", "--json", "--file", "no_such_file.py"]
5489 )
5490 assert result.exit_code == 0
5491 data = json.loads(result.output)
5492 assert data["symbols"] == []
5493
5494 # ── --min-risk filter ────────────────────────────────────────────────────
5495
5496 def test_blast_risk_min_risk_filters(self, blast_repo: pathlib.Path) -> None:
5497 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "80"])
5498 data = json.loads(result.output)
5499 for sym in data["symbols"]:
5500 assert sym["risk"] >= 80
5501
5502 def test_blast_risk_min_risk_100_all_excluded(self, blast_repo: pathlib.Path) -> None:
5503 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "100"])
5504 assert result.exit_code == 0
5505
5506 def test_blast_risk_min_risk_validation(self, blast_repo: pathlib.Path) -> None:
5507 result = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "101"])
5508 assert result.exit_code != 0
5509 result2 = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "-1"])
5510 assert result2.exit_code != 0
5511
5512 # ── --explain flag ───────────────────────────────────────────────────────
5513
5514 def test_blast_risk_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
5515 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5516 data = json.loads(result.output)
5517 if not data["symbols"]:
5518 pytest.skip("no symbols")
5519 addr = data["symbols"][0]["address"]
5520 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5521 assert result2.exit_code == 0, result2.output
5522
5523 def test_blast_risk_explain_shows_breakdown(self, blast_repo: pathlib.Path) -> None:
5524 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5525 data = json.loads(result.output)
5526 if not data["symbols"]:
5527 pytest.skip("no symbols")
5528 addr = data["symbols"][0]["address"]
5529 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5530 assert "Risk score:" in result2.output
5531 assert "Impact" in result2.output
5532 assert "Churn" in result2.output
5533 assert "Test gap" in result2.output
5534 assert "Coupling" in result2.output
5535
5536 def test_blast_risk_explain_nonexistent_errors(self, blast_repo: pathlib.Path) -> None:
5537 result = runner.invoke(
5538 cli, ["code", "blast-risk", "--explain", "no_file.py::no_symbol"]
5539 )
5540 assert result.exit_code != 0
5541
5542 def test_blast_risk_explain_json(self, blast_repo: pathlib.Path) -> None:
5543 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5544 data = json.loads(result.output)
5545 if not data["symbols"]:
5546 pytest.skip("no symbols")
5547 addr = data["symbols"][0]["address"]
5548 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr, "--json"])
5549 assert result2.exit_code == 0, result2.output
5550 detail = json.loads(result2.output)
5551 assert detail["address"] == addr
5552 assert "risk" in detail
5553
5554 # ── --max-commits ────────────────────────────────────────────────────────
5555
5556 def test_blast_risk_max_commits_validation(self, blast_repo: pathlib.Path) -> None:
5557 result = runner.invoke(cli, ["code", "blast-risk", "--max-commits", "0"])
5558 assert result.exit_code != 0
5559
5560 def test_blast_risk_max_commits_respected(self, blast_repo: pathlib.Path) -> None:
5561 # With max-commits=1, commits_analysed <= 1.
5562 result = runner.invoke(
5563 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5564 )
5565 assert result.exit_code == 0
5566 data = json.loads(result.output)
5567 assert data["commits_analysed"] <= 1
5568
5569 def test_blast_risk_max_commits_truncated_flag(self, blast_repo: pathlib.Path) -> None:
5570 result = runner.invoke(
5571 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5572 )
5573 data = json.loads(result.output)
5574 # Two commits exist so truncated should be True with cap=1.
5575 assert isinstance(data["truncated"], bool)
5576
5577 # ── --since ──────────────────────────────────────────────────────────────
5578
5579 def test_blast_risk_since_invalid_ref(self, blast_repo: pathlib.Path) -> None:
5580 result = runner.invoke(cli, ["code", "blast-risk", "--since", "nonexistent_ref"])
5581 assert result.exit_code != 0
5582
5583 # ── requires repo ────────────────────────────────────────────────────────
5584
5585 def test_blast_risk_requires_repo(self, tmp_path: pathlib.Path) -> None:
5586 import os
5587 old = os.getcwd()
5588 try:
5589 os.chdir(tmp_path)
5590 result = runner.invoke(cli, ["code", "blast-risk"])
5591 assert result.exit_code != 0
5592 finally:
5593 os.chdir(old)
5594
5595
5596 # ---------------------------------------------------------------------------
5597 # velocity
5598 # ---------------------------------------------------------------------------
5599
5600
5601 @pytest.fixture
5602 def velocity_repo(repo: pathlib.Path) -> pathlib.Path:
5603 """Repo with two modules across several commits to exercise velocity metrics.
5604
5605 Module layout:
5606 core/store.py — grows across commits (inserts)
5607 shrink/util.py — has a delete later (net negative at some point)
5608
5609 Commit structure (window=2):
5610 1: create core/store.py with 2 functions
5611 2: add a third function to core/store.py → current window: +3 added
5612 3: add shrink/util.py with one function → also in current window
5613 4: delete the function in shrink/util.py → shrink net = 0 (1 added, 1 deleted)
5614 """
5615 (repo / "core").mkdir(exist_ok=True)
5616 (repo / "shrink").mkdir(exist_ok=True)
5617
5618 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5619 def read_object(path):
5620 return path.read_bytes()
5621
5622 def write_object(path, data):
5623 path.write_bytes(data)
5624 """))
5625 r = runner.invoke(cli, ["commit", "-m", "core: initial store"])
5626 assert r.exit_code == 0, r.output
5627
5628 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5629 def read_object(path):
5630 return path.read_bytes()
5631
5632 def write_object(path, data):
5633 path.write_bytes(data)
5634
5635 def delete_object(path):
5636 path.unlink()
5637 """))
5638 r2 = runner.invoke(cli, ["commit", "-m", "core: add delete_object"])
5639 assert r2.exit_code == 0, r2.output
5640
5641 (repo / "shrink" / "util.py").write_text(textwrap.dedent("""\
5642 def helper():
5643 return True
5644 """))
5645 r3 = runner.invoke(cli, ["commit", "-m", "shrink: add helper"])
5646 assert r3.exit_code == 0, r3.output
5647
5648 return repo
5649
5650
5651 class TestVelocity:
5652 """Tests for muse code velocity."""
5653
5654 # ── basic correctness ────────────────────────────────────────────────────
5655
5656 def test_velocity_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5657 result = runner.invoke(cli, ["code", "velocity"])
5658 assert result.exit_code == 0, result.output
5659
5660 def test_velocity_shows_header(self, velocity_repo: pathlib.Path) -> None:
5661 result = runner.invoke(cli, ["code", "velocity"])
5662 assert "velocity" in result.output.lower()
5663
5664 def test_velocity_shows_column_headers(self, velocity_repo: pathlib.Path) -> None:
5665 result = runner.invoke(cli, ["code", "velocity"])
5666 assert "ADD" in result.output
5667 assert "NET" in result.output
5668
5669 def test_velocity_shows_modules(self, velocity_repo: pathlib.Path) -> None:
5670 result = runner.invoke(cli, ["code", "velocity"])
5671 # Both modules should appear.
5672 assert "core/" in result.output or "store" in result.output
5673
5674 # ── JSON schema ──────────────────────────────────────────────────────────
5675
5676 def test_velocity_json_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5677 result = runner.invoke(cli, ["code", "velocity", "--json"])
5678 assert result.exit_code == 0, result.output
5679 json.loads(result.output)
5680
5681 def test_velocity_json_top_level_keys(self, velocity_repo: pathlib.Path) -> None:
5682 result = runner.invoke(cli, ["code", "velocity", "--json"])
5683 data = json.loads(result.output)
5684 for key in (
5685 "ref", "window_size", "commits_analysed", "truncated",
5686 "filters", "modules", "predictions",
5687 ):
5688 assert key in data, f"missing key: {key}"
5689
5690 def test_velocity_json_module_schema(self, velocity_repo: pathlib.Path) -> None:
5691 result = runner.invoke(cli, ["code", "velocity", "--json"])
5692 data = json.loads(result.output)
5693 if not data["modules"]:
5694 pytest.skip("no modules")
5695 mod = data["modules"][0]
5696 for key in ("module", "current", "prior", "acceleration", "stagnant_commits"):
5697 assert key in mod, f"missing key: {key}"
5698 for key in ("added", "removed", "net", "modified", "active_commits"):
5699 assert key in mod["current"], f"missing current key: {key}"
5700 assert key in mod["prior"], f"missing prior key: {key}"
5701
5702 def test_velocity_json_acceleration_is_net_delta(
5703 self, velocity_repo: pathlib.Path
5704 ) -> None:
5705 result = runner.invoke(cli, ["code", "velocity", "--json"])
5706 data = json.loads(result.output)
5707 for mod in data["modules"]:
5708 expected = mod["current"]["net"] - mod["prior"]["net"]
5709 assert mod["acceleration"] == expected
5710
5711 def test_velocity_json_filters_reflected(self, velocity_repo: pathlib.Path) -> None:
5712 result = runner.invoke(
5713 cli, ["code", "velocity", "--json", "--window", "5", "--top", "3"]
5714 )
5715 data = json.loads(result.output)
5716 assert data["window_size"] == 5
5717 assert data["filters"]["top"] == 3
5718
5719 def test_velocity_json_no_import_pseudosymbols_in_counts(
5720 self, velocity_repo: pathlib.Path
5721 ) -> None:
5722 # Modules should not be "(root)" due to import pseudo-symbols
5723 # (import:: addresses should be filtered out).
5724 result = runner.invoke(cli, ["code", "velocity", "--json"])
5725 data = json.loads(result.output)
5726 # We can't assert 0 imports in the module list, but we can assert
5727 # that '::import::' doesn't appear as a module name.
5728 for mod in data["modules"]:
5729 assert "import" not in mod["module"].lower() or "/" in mod["module"]
5730
5731 # ── --window ─────────────────────────────────────────────────────────────
5732
5733 def test_velocity_window_1_runs(self, velocity_repo: pathlib.Path) -> None:
5734 result = runner.invoke(cli, ["code", "velocity", "--window", "1"])
5735 assert result.exit_code == 0, result.output
5736
5737 def test_velocity_window_validation(self, velocity_repo: pathlib.Path) -> None:
5738 result = runner.invoke(cli, ["code", "velocity", "--window", "0"])
5739 assert result.exit_code != 0
5740
5741 def test_velocity_window_reflected_in_json(
5742 self, velocity_repo: pathlib.Path
5743 ) -> None:
5744 result = runner.invoke(cli, ["code", "velocity", "--json", "--window", "1"])
5745 data = json.loads(result.output)
5746 assert data["window_size"] == 1
5747
5748 # ── --top ─────────────────────────────────────────────────────────────────
5749
5750 def test_velocity_top_limits(self, velocity_repo: pathlib.Path) -> None:
5751 result = runner.invoke(cli, ["code", "velocity", "--json", "--top", "1"])
5752 data = json.loads(result.output)
5753 assert len(data["modules"]) <= 1
5754
5755 def test_velocity_top_validation(self, velocity_repo: pathlib.Path) -> None:
5756 result = runner.invoke(cli, ["code", "velocity", "--top", "0"])
5757 assert result.exit_code != 0
5758
5759 # ── --predict ─────────────────────────────────────────────────────────────
5760
5761 def test_velocity_predict_0_empty(self, velocity_repo: pathlib.Path) -> None:
5762 result = runner.invoke(cli, ["code", "velocity", "--json", "--predict", "0"])
5763 data = json.loads(result.output)
5764 assert data["predictions"] == []
5765
5766 def test_velocity_predict_returns_results(self, velocity_repo: pathlib.Path) -> None:
5767 result = runner.invoke(
5768 cli, ["code", "velocity", "--json", "--predict", "5"]
5769 )
5770 data = json.loads(result.output)
5771 # There are symbols in the window so predictions should be non-empty.
5772 assert isinstance(data["predictions"], list)
5773 if data["predictions"]:
5774 pred = data["predictions"][0]
5775 for key in ("address", "module", "score", "frequency", "last_commit_rank"):
5776 assert key in pred, f"missing key: {key}"
5777
5778 def test_velocity_predict_scores_descending(
5779 self, velocity_repo: pathlib.Path
5780 ) -> None:
5781 result = runner.invoke(
5782 cli, ["code", "velocity", "--json", "--predict", "10"]
5783 )
5784 data = json.loads(result.output)
5785 scores = [p["score"] for p in data["predictions"]]
5786 assert scores == sorted(scores, reverse=True)
5787
5788 def test_velocity_predict_validation(self, velocity_repo: pathlib.Path) -> None:
5789 result = runner.invoke(cli, ["code", "velocity", "--predict", "-1"])
5790 assert result.exit_code != 0
5791
5792 def test_velocity_predict_shown_in_human_output(
5793 self, velocity_repo: pathlib.Path
5794 ) -> None:
5795 result = runner.invoke(
5796 cli, ["code", "velocity", "--predict", "3"]
5797 )
5798 assert result.exit_code == 0
5799 if "predictions" in result.output.lower() or "score" in result.output:
5800 # Just check it doesn't crash.
5801 pass
5802
5803 # ── --max-commits ─────────────────────────────────────────────────────────
5804
5805 def test_velocity_max_commits_validation(self, velocity_repo: pathlib.Path) -> None:
5806 result = runner.invoke(cli, ["code", "velocity", "--max-commits", "0"])
5807 assert result.exit_code != 0
5808
5809 def test_velocity_max_commits_respected(self, velocity_repo: pathlib.Path) -> None:
5810 # With --window 1 and --max-commits 1, effective_max = max(1, 1*2) = 2.
5811 # The 3-commit repo should be capped at 2 commits analysed.
5812 result = runner.invoke(
5813 cli, ["code", "velocity", "--json", "--window", "1", "--max-commits", "1"]
5814 )
5815 assert result.exit_code == 0
5816 data = json.loads(result.output)
5817 assert data["commits_analysed"] <= 2
5818
5819 # ── --since ───────────────────────────────────────────────────────────────
5820
5821 def test_velocity_since_invalid_ref(self, velocity_repo: pathlib.Path) -> None:
5822 result = runner.invoke(cli, ["code", "velocity", "--since", "bad_ref"])
5823 assert result.exit_code != 0
5824
5825 # ── stagnation detection ──────────────────────────────────────────────────
5826
5827 def test_velocity_stagnant_commits_non_negative(
5828 self, velocity_repo: pathlib.Path
5829 ) -> None:
5830 result = runner.invoke(cli, ["code", "velocity", "--json"])
5831 data = json.loads(result.output)
5832 for mod in data["modules"]:
5833 assert mod["stagnant_commits"] >= 0
5834
5835 # ── net counts are consistent ─────────────────────────────────────────────
5836
5837 def test_velocity_net_equals_added_minus_removed(
5838 self, velocity_repo: pathlib.Path
5839 ) -> None:
5840 result = runner.invoke(cli, ["code", "velocity", "--json"])
5841 data = json.loads(result.output)
5842 for mod in data["modules"]:
5843 assert mod["current"]["net"] == (
5844 mod["current"]["added"] - mod["current"]["removed"]
5845 )
5846 assert mod["prior"]["net"] == (
5847 mod["prior"]["added"] - mod["prior"]["removed"]
5848 )
5849
5850 # ── requires repo ─────────────────────────────────────────────────────────
5851
5852 def test_velocity_requires_repo(self, tmp_path: pathlib.Path) -> None:
5853 import os
5854 old = os.getcwd()
5855 try:
5856 os.chdir(tmp_path)
5857 result = runner.invoke(cli, ["code", "velocity"])
5858 assert result.exit_code != 0
5859 finally:
5860 os.chdir(old)
5861
5862
5863 # ---------------------------------------------------------------------------
5864 # age
5865 # ---------------------------------------------------------------------------
5866
5867
5868 @pytest.fixture
5869 def age_repo(repo: pathlib.Path) -> pathlib.Path:
5870 """Repo with several commits to exercise evolutionary-age metrics.
5871
5872 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
5873 Commit 2: modify compute_total body → 1 impl change
5874 Commit 3: modify compute_total body → 2 impl changes
5875 Commit 4: modify compute_total signature only (add type hint)
5876
5877 stable_fn is created in commit 1 and never touched again.
5878 """
5879 (repo / "billing.py").write_text(textwrap.dedent("""\
5880 class Invoice:
5881 def compute_total(self, items):
5882 return sum(items)
5883
5884 def stable_fn():
5885 return 42
5886 """))
5887 r = runner.invoke(cli, ["commit", "-m", "initial billing"])
5888 assert r.exit_code == 0, r.output
5889
5890 # Commit 2: impl change to compute_total
5891 (repo / "billing.py").write_text(textwrap.dedent("""\
5892 class Invoice:
5893 def compute_total(self, items):
5894 return round(sum(items), 2)
5895
5896 def stable_fn():
5897 return 42
5898 """))
5899 r2 = runner.invoke(cli, ["commit", "-m", "round result"])
5900 assert r2.exit_code == 0, r2.output
5901
5902 # Commit 3: second impl change to compute_total
5903 (repo / "billing.py").write_text(textwrap.dedent("""\
5904 class Invoice:
5905 def compute_total(self, items):
5906 total = sum(items)
5907 return round(total, 4)
5908
5909 def stable_fn():
5910 return 42
5911 """))
5912 r3 = runner.invoke(cli, ["commit", "-m", "higher precision"])
5913 assert r3.exit_code == 0, r3.output
5914
5915 return repo
5916
5917
5918 class TestAge:
5919 """Tests for muse code age."""
5920
5921 # ── basic correctness ────────────────────────────────────────────────────
5922
5923 def test_age_exits_zero(self, age_repo: pathlib.Path) -> None:
5924 result = runner.invoke(cli, ["code", "age"])
5925 assert result.exit_code == 0, result.output
5926
5927 def test_age_shows_header(self, age_repo: pathlib.Path) -> None:
5928 result = runner.invoke(cli, ["code", "age"])
5929 assert "evolutionary age" in result.output.lower()
5930
5931 def test_age_shows_sort_line(self, age_repo: pathlib.Path) -> None:
5932 result = runner.invoke(cli, ["code", "age"])
5933 assert "Sorted by" in result.output
5934
5935 def test_age_shows_table_columns(self, age_repo: pathlib.Path) -> None:
5936 result = runner.invoke(cli, ["code", "age"])
5937 assert "BORN" in result.output
5938 assert "REWRITES" in result.output
5939 assert "GENETIC" in result.output
5940
5941 def test_age_lists_symbols(self, age_repo: pathlib.Path) -> None:
5942 result = runner.invoke(cli, ["code", "age"])
5943 assert "billing.py" in result.output
5944
5945 # ── JSON schema ──────────────────────────────────────────────────────────
5946
5947 def test_age_json_exits_zero(self, age_repo: pathlib.Path) -> None:
5948 result = runner.invoke(cli, ["code", "age", "--json"])
5949 assert result.exit_code == 0, result.output
5950 json.loads(result.output)
5951
5952 def test_age_json_top_level_keys(self, age_repo: pathlib.Path) -> None:
5953 result = runner.invoke(cli, ["code", "age", "--json"])
5954 data = json.loads(result.output)
5955 for key in ("ref", "as_of", "commits_analysed", "truncated", "filters", "symbols"):
5956 assert key in data, f"missing key: {key}"
5957
5958 def test_age_json_symbol_schema(self, age_repo: pathlib.Path) -> None:
5959 result = runner.invoke(cli, ["code", "age", "--json"])
5960 data = json.loads(result.output)
5961 if not data["symbols"]:
5962 pytest.skip("no symbols with history")
5963 sym = data["symbols"][0]
5964 for key in (
5965 "address", "kind", "file",
5966 "born_commit", "born_date",
5967 "last_impl_commit", "last_impl_date",
5968 "last_change_commit", "last_change_date",
5969 "calendar_age_days", "genetic_age_days",
5970 "impl_changes", "sig_changes", "renames", "est_survival_pct",
5971 ):
5972 assert key in sym, f"missing key: {key}"
5973
5974 def test_age_json_survival_pct_in_range(self, age_repo: pathlib.Path) -> None:
5975 result = runner.invoke(cli, ["code", "age", "--json"])
5976 data = json.loads(result.output)
5977 for sym in data["symbols"]:
5978 assert 0 <= sym["est_survival_pct"] <= 100
5979
5980 def test_age_json_filters_reflected(self, age_repo: pathlib.Path) -> None:
5981 result = runner.invoke(
5982 cli, ["code", "age", "--json", "--sort", "calendar", "--kind", "function"]
5983 )
5984 data = json.loads(result.output)
5985 assert data["filters"]["sort"] == "calendar"
5986 assert data["filters"]["kind"] == "function"
5987
5988 def test_age_json_no_import_pseudosymbols(self, age_repo: pathlib.Path) -> None:
5989 result = runner.invoke(cli, ["code", "age", "--json"])
5990 data = json.loads(result.output)
5991 for sym in data["symbols"]:
5992 assert "::import::" not in sym["address"]
5993
5994 # ── impl_changes recorded correctly ─────────────────────────────────────
5995
5996 def test_age_compute_total_has_impl_changes(self, age_repo: pathlib.Path) -> None:
5997 """compute_total was modified twice — should have impl_changes >= 1."""
5998 result = runner.invoke(cli, ["code", "age", "--json"])
5999 data = json.loads(result.output)
6000 totals = [
6001 s for s in data["symbols"]
6002 if "compute_total" in s["address"]
6003 ]
6004 # If history was recorded, impl_changes should be positive.
6005 if totals:
6006 assert totals[0]["impl_changes"] >= 0 # at least recorded
6007
6008 def test_age_stable_fn_lower_impl_changes(self, age_repo: pathlib.Path) -> None:
6009 """stable_fn was never modified — should have 0 impl_changes."""
6010 result = runner.invoke(cli, ["code", "age", "--json"])
6011 data = json.loads(result.output)
6012 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
6013 if stables:
6014 assert stables[0]["impl_changes"] == 0
6015
6016 def test_age_stable_fn_100pct_survival(self, age_repo: pathlib.Path) -> None:
6017 result = runner.invoke(cli, ["code", "age", "--json"])
6018 data = json.loads(result.output)
6019 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
6020 if stables:
6021 assert stables[0]["est_survival_pct"] == 100
6022
6023 # ── --top ────────────────────────────────────────────────────────────────
6024
6025 def test_age_top_limits(self, age_repo: pathlib.Path) -> None:
6026 result = runner.invoke(cli, ["code", "age", "--json", "--top", "1"])
6027 data = json.loads(result.output)
6028 assert len(data["symbols"]) <= 1
6029
6030 def test_age_top_validation(self, age_repo: pathlib.Path) -> None:
6031 result = runner.invoke(cli, ["code", "age", "--top", "0"])
6032 assert result.exit_code != 0
6033
6034 # ── --sort ───────────────────────────────────────────────────────────────
6035
6036 def test_age_sort_rewrites(self, age_repo: pathlib.Path) -> None:
6037 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "rewrites"])
6038 assert result.exit_code == 0, result.output
6039 data = json.loads(result.output)
6040 impl_counts = [s["impl_changes"] for s in data["symbols"]]
6041 assert impl_counts == sorted(impl_counts, reverse=True)
6042
6043 def test_age_sort_calendar(self, age_repo: pathlib.Path) -> None:
6044 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "calendar"])
6045 assert result.exit_code == 0, result.output
6046 data = json.loads(result.output)
6047 ages = [s["calendar_age_days"] for s in data["symbols"]]
6048 assert ages == sorted(ages, reverse=True)
6049
6050 def test_age_sort_genetic(self, age_repo: pathlib.Path) -> None:
6051 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "genetic"])
6052 assert result.exit_code == 0, result.output
6053 data = json.loads(result.output)
6054 ages = [s["genetic_age_days"] for s in data["symbols"]]
6055 assert ages == sorted(ages, reverse=True)
6056
6057 def test_age_sort_survival(self, age_repo: pathlib.Path) -> None:
6058 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "survival"])
6059 assert result.exit_code == 0, result.output
6060 data = json.loads(result.output)
6061 survivals = [s["est_survival_pct"] for s in data["symbols"]]
6062 assert survivals == sorted(survivals)
6063
6064 def test_age_sort_invalid(self, age_repo: pathlib.Path) -> None:
6065 result = runner.invoke(cli, ["code", "age", "--sort", "bogus"])
6066 assert result.exit_code != 0
6067
6068 # ── --kind filter ────────────────────────────────────────────────────────
6069
6070 def test_age_kind_filter(self, age_repo: pathlib.Path) -> None:
6071 result = runner.invoke(cli, ["code", "age", "--json", "--kind", "function"])
6072 data = json.loads(result.output)
6073 for sym in data["symbols"]:
6074 assert sym["kind"] in ("function", "method")
6075
6076 # ── --file filter ─────────────────────────────────────────────────────────
6077
6078 def test_age_file_filter(self, age_repo: pathlib.Path) -> None:
6079 result = runner.invoke(
6080 cli, ["code", "age", "--json", "--file", "billing.py"]
6081 )
6082 data = json.loads(result.output)
6083 for sym in data["symbols"]:
6084 assert "billing.py" in sym["file"]
6085
6086 def test_age_file_filter_nonexistent(self, age_repo: pathlib.Path) -> None:
6087 result = runner.invoke(
6088 cli, ["code", "age", "--json", "--file", "no_such_file.py"]
6089 )
6090 assert result.exit_code == 0
6091 data = json.loads(result.output)
6092 assert data["symbols"] == []
6093
6094 # ── --explain ─────────────────────────────────────────────────────────────
6095
6096 def test_age_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
6097 result = runner.invoke(cli, ["code", "age", "--json"])
6098 data = json.loads(result.output)
6099 if not data["symbols"]:
6100 pytest.skip("no symbols")
6101 addr = data["symbols"][0]["address"]
6102 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6103 assert r2.exit_code == 0, r2.output
6104
6105 def test_age_explain_shows_breakdown(self, age_repo: pathlib.Path) -> None:
6106 result = runner.invoke(cli, ["code", "age", "--json"])
6107 data = json.loads(result.output)
6108 if not data["symbols"]:
6109 pytest.skip("no symbols")
6110 addr = data["symbols"][0]["address"]
6111 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6112 assert "Implementation changes" in r2.output
6113 assert "Signature changes" in r2.output
6114 assert "Est. survival" in r2.output
6115
6116 def test_age_explain_requires_double_colon(self, age_repo: pathlib.Path) -> None:
6117 result = runner.invoke(cli, ["code", "age", "--explain", "billing.py"])
6118 assert result.exit_code != 0
6119
6120 def test_age_explain_nonexistent_errors(self, age_repo: pathlib.Path) -> None:
6121 result = runner.invoke(cli, ["code", "age", "--explain", "no.py::nonexistent"])
6122 assert result.exit_code != 0
6123
6124 def test_age_explain_json(self, age_repo: pathlib.Path) -> None:
6125 result = runner.invoke(cli, ["code", "age", "--json"])
6126 data = json.loads(result.output)
6127 if not data["symbols"]:
6128 pytest.skip("no symbols")
6129 addr = data["symbols"][0]["address"]
6130 r2 = runner.invoke(cli, ["code", "age", "--explain", addr, "--json"])
6131 assert r2.exit_code == 0, r2.output
6132 detail = json.loads(r2.output)
6133 assert detail["address"] == addr
6134 assert "events" in detail
6135
6136 # ── --max-commits ─────────────────────────────────────────────────────────
6137
6138 def test_age_max_commits_validation(self, age_repo: pathlib.Path) -> None:
6139 result = runner.invoke(cli, ["code", "age", "--max-commits", "0"])
6140 assert result.exit_code != 0
6141
6142 def test_age_max_commits_respected(self, age_repo: pathlib.Path) -> None:
6143 result = runner.invoke(cli, ["code", "age", "--json", "--max-commits", "1"])
6144 assert result.exit_code == 0
6145 data = json.loads(result.output)
6146 assert data["commits_analysed"] <= 1
6147
6148 # ── --since ───────────────────────────────────────────────────────────────
6149
6150 def test_age_since_invalid_ref(self, age_repo: pathlib.Path) -> None:
6151 result = runner.invoke(cli, ["code", "age", "--since", "bad_ref"])
6152 assert result.exit_code != 0
6153
6154 # ── requires repo ─────────────────────────────────────────────────────────
6155
6156 def test_age_requires_repo(self, tmp_path: pathlib.Path) -> None:
6157 import os
6158 old = os.getcwd()
6159 try:
6160 os.chdir(tmp_path)
6161 result = runner.invoke(cli, ["code", "age"])
6162 assert result.exit_code != 0
6163 finally:
6164 os.chdir(old)
6165
6166
6167 # ---------------------------------------------------------------------------
6168 # entangle
6169 # ---------------------------------------------------------------------------
6170
6171
6172 @pytest.fixture
6173 def entangle_repo(repo: pathlib.Path) -> pathlib.Path:
6174 """Repo that has two files with no import link but symbols that co-change.
6175
6176 Commit 1: create billing.py (Invoice class) and serializers.py (to_json).
6177 Commit 2: modify Invoice.compute_total AND to_json together — they
6178 co-change with no import link.
6179 Commit 3: same again — both change again.
6180
6181 billing.py does NOT import serializers.py, so the pair should be
6182 flagged as entangled.
6183 """
6184 (repo / "billing.py").write_text(textwrap.dedent("""\
6185 class Invoice:
6186 def compute_total(self, items):
6187 return sum(items)
6188 """))
6189 (repo / "serializers.py").write_text(textwrap.dedent("""\
6190 def to_json(obj):
6191 return str(obj)
6192 """))
6193 r = runner.invoke(cli, ["commit", "-m", "initial"])
6194 assert r.exit_code == 0, r.output
6195
6196 # Commit 2: both change.
6197 (repo / "billing.py").write_text(textwrap.dedent("""\
6198 class Invoice:
6199 def compute_total(self, items):
6200 return round(sum(items), 2)
6201 """))
6202 (repo / "serializers.py").write_text(textwrap.dedent("""\
6203 def to_json(obj):
6204 import json
6205 return json.dumps(obj)
6206 """))
6207 r2 = runner.invoke(cli, ["commit", "-m", "update both"])
6208 assert r2.exit_code == 0, r2.output
6209
6210 # Commit 3: both change again.
6211 (repo / "billing.py").write_text(textwrap.dedent("""\
6212 class Invoice:
6213 def compute_total(self, items):
6214 return round(sum(items), 4)
6215 """))
6216 (repo / "serializers.py").write_text(textwrap.dedent("""\
6217 def to_json(obj):
6218 import json
6219 return json.dumps(obj, indent=2)
6220 """))
6221 r3 = runner.invoke(cli, ["commit", "-m", "tweak both again"])
6222 assert r3.exit_code == 0, r3.output
6223
6224 return repo
6225
6226
6227 class TestEntangle:
6228 """Tests for muse code entangle."""
6229
6230 # ── basic correctness ────────────────────────────────────────────────────
6231
6232 def test_entangle_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6233 result = runner.invoke(cli, ["code", "entangle"])
6234 assert result.exit_code == 0, result.output
6235
6236 def test_entangle_shows_header(self, entangle_repo: pathlib.Path) -> None:
6237 result = runner.invoke(cli, ["code", "entangle"])
6238 assert result.exit_code == 0
6239 assert "entanglement" in result.output.lower()
6240
6241 def test_entangle_detects_unlinked_pair(self, entangle_repo: pathlib.Path) -> None:
6242 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6243 assert result.exit_code == 0
6244 # Both files should appear in the output.
6245 assert "billing.py" in result.output or "serializers.py" in result.output
6246
6247 def test_entangle_shows_rate(self, entangle_repo: pathlib.Path) -> None:
6248 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6249 assert result.exit_code == 0
6250 # Rate column should show a percentage.
6251 assert "%" in result.output
6252
6253 # ── JSON schema ──────────────────────────────────────────────────────────
6254
6255 def test_entangle_json_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6256 result = runner.invoke(cli, ["code", "entangle", "--json"])
6257 assert result.exit_code == 0, result.output
6258 json.loads(result.output) # must be valid JSON
6259
6260 def test_entangle_json_top_level_keys(self, entangle_repo: pathlib.Path) -> None:
6261 result = runner.invoke(cli, ["code", "entangle", "--json"])
6262 data = json.loads(result.output)
6263 for key in ("ref", "commits_analysed", "truncated", "filters", "pairs"):
6264 assert key in data, f"missing key: {key}"
6265
6266 def test_entangle_json_pair_schema(self, entangle_repo: pathlib.Path) -> None:
6267 result = runner.invoke(
6268 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6269 )
6270 data = json.loads(result.output)
6271 if not data["pairs"]:
6272 pytest.skip("no pairs detected")
6273 pair = data["pairs"][0]
6274 for key in (
6275 "symbol_a", "symbol_b", "file_a", "file_b", "same_file",
6276 "structurally_linked", "co_changes", "commits_both_active",
6277 "co_change_rate", "a_in_test", "b_in_test",
6278 ):
6279 assert key in pair, f"missing key: {key}"
6280
6281 def test_entangle_json_co_change_rate_in_range(
6282 self, entangle_repo: pathlib.Path
6283 ) -> None:
6284 result = runner.invoke(
6285 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6286 )
6287 data = json.loads(result.output)
6288 for pair in data["pairs"]:
6289 assert 0.0 <= pair["co_change_rate"] <= 1.0
6290
6291 def test_entangle_json_filters_reflected(
6292 self, entangle_repo: pathlib.Path
6293 ) -> None:
6294 result = runner.invoke(
6295 cli, ["code", "entangle", "--json", "--min-co-changes", "3", "--min-rate", "0.5"]
6296 )
6297 data = json.loads(result.output)
6298 assert data["filters"]["min_co_changes"] == 3
6299 assert data["filters"]["min_rate"] == 0.5
6300
6301 def test_entangle_json_sorted_by_rate_desc(
6302 self, entangle_repo: pathlib.Path
6303 ) -> None:
6304 result = runner.invoke(
6305 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6306 )
6307 data = json.loads(result.output)
6308 rates = [p["co_change_rate"] for p in data["pairs"]]
6309 assert rates == sorted(rates, reverse=True)
6310
6311 # ── --top ────────────────────────────────────────────────────────────────
6312
6313 def test_entangle_top_limits(self, entangle_repo: pathlib.Path) -> None:
6314 result = runner.invoke(
6315 cli, ["code", "entangle", "--json", "--top", "1", "--min-co-changes", "1"]
6316 )
6317 data = json.loads(result.output)
6318 assert len(data["pairs"]) <= 1
6319
6320 def test_entangle_top_validation(self, entangle_repo: pathlib.Path) -> None:
6321 result = runner.invoke(cli, ["code", "entangle", "--top", "0"])
6322 assert result.exit_code != 0
6323
6324 # ── --min-co-changes ─────────────────────────────────────────────────────
6325
6326 def test_entangle_min_co_changes_filters(
6327 self, entangle_repo: pathlib.Path
6328 ) -> None:
6329 result = runner.invoke(
6330 cli, ["code", "entangle", "--json", "--min-co-changes", "100"]
6331 )
6332 data = json.loads(result.output)
6333 # No pair can have co-changed 100 times in a 3-commit repo.
6334 assert data["pairs"] == []
6335
6336 def test_entangle_min_co_changes_validation(
6337 self, entangle_repo: pathlib.Path
6338 ) -> None:
6339 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "0"])
6340 assert result.exit_code != 0
6341
6342 # ── --min-rate ───────────────────────────────────────────────────────────
6343
6344 def test_entangle_min_rate_1_may_return_results(
6345 self, entangle_repo: pathlib.Path
6346 ) -> None:
6347 result = runner.invoke(
6348 cli, ["code", "entangle", "--json", "--min-rate", "1.0", "--min-co-changes", "1"]
6349 )
6350 assert result.exit_code == 0
6351 data = json.loads(result.output)
6352 for pair in data["pairs"]:
6353 assert pair["co_change_rate"] == 1.0
6354
6355 def test_entangle_min_rate_validation(self, entangle_repo: pathlib.Path) -> None:
6356 result = runner.invoke(cli, ["code", "entangle", "--min-rate", "1.5"])
6357 assert result.exit_code != 0
6358 result2 = runner.invoke(cli, ["code", "entangle", "--min-rate", "-0.1"])
6359 assert result2.exit_code != 0
6360
6361 # ── --symbol filter ──────────────────────────────────────────────────────
6362
6363 def test_entangle_symbol_requires_double_colon(
6364 self, entangle_repo: pathlib.Path
6365 ) -> None:
6366 result = runner.invoke(cli, ["code", "entangle", "--symbol", "billing.py"])
6367 assert result.exit_code != 0
6368
6369 def test_entangle_symbol_exits_zero_valid(
6370 self, entangle_repo: pathlib.Path
6371 ) -> None:
6372 result = runner.invoke(
6373 cli,
6374 ["code", "entangle", "--symbol", "billing.py::Invoice",
6375 "--min-co-changes", "1"],
6376 )
6377 assert result.exit_code == 0, result.output
6378
6379 def test_entangle_symbol_filters_pairs(
6380 self, entangle_repo: pathlib.Path
6381 ) -> None:
6382 result = runner.invoke(
6383 cli,
6384 ["code", "entangle", "--json", "--symbol", "billing.py::Invoice",
6385 "--min-co-changes", "1"],
6386 )
6387 data = json.loads(result.output)
6388 for pair in data["pairs"]:
6389 assert (
6390 "billing.py" in pair["symbol_a"]
6391 or "billing.py" in pair["symbol_b"]
6392 )
6393
6394 # ── --include-same-file ──────────────────────────────────────────────────
6395
6396 def test_entangle_include_same_file_flag(
6397 self, entangle_repo: pathlib.Path
6398 ) -> None:
6399 # Should not crash, and may return same-file pairs.
6400 result = runner.invoke(
6401 cli,
6402 ["code", "entangle", "--json", "--include-same-file",
6403 "--min-co-changes", "1"],
6404 )
6405 assert result.exit_code == 0, result.output
6406 data = json.loads(result.output)
6407 assert data["filters"]["include_same_file"] is True
6408
6409 # ── --max-commits ─────────────────────────────────────────────────────────
6410
6411 def test_entangle_max_commits_validation(
6412 self, entangle_repo: pathlib.Path
6413 ) -> None:
6414 result = runner.invoke(cli, ["code", "entangle", "--max-commits", "0"])
6415 assert result.exit_code != 0
6416
6417 def test_entangle_max_commits_respected(
6418 self, entangle_repo: pathlib.Path
6419 ) -> None:
6420 result = runner.invoke(
6421 cli, ["code", "entangle", "--json", "--max-commits", "1"]
6422 )
6423 assert result.exit_code == 0
6424 data = json.loads(result.output)
6425 assert data["commits_analysed"] <= 1
6426
6427 # ── --since ───────────────────────────────────────────────────────────────
6428
6429 def test_entangle_since_invalid_ref(self, entangle_repo: pathlib.Path) -> None:
6430 result = runner.invoke(cli, ["code", "entangle", "--since", "no_such_ref"])
6431 assert result.exit_code != 0
6432
6433 # ── requires repo ─────────────────────────────────────────────────────────
6434
6435 def test_entangle_requires_repo(self, tmp_path: pathlib.Path) -> None:
6436 import os
6437 old = os.getcwd()
6438 try:
6439 os.chdir(tmp_path)
6440 result = runner.invoke(cli, ["code", "entangle"])
6441 assert result.exit_code != 0
6442 finally:
6443 os.chdir(old)
6444
6445
6446 # ---------------------------------------------------------------------------
6447 # muse code semantic-test-coverage
6448 # ---------------------------------------------------------------------------
6449
6450
6451 @pytest.fixture
6452 def stc_repo(repo: pathlib.Path) -> pathlib.Path:
6453 """Repo with production code and a test file for semantic-test-coverage.
6454
6455 Layout::
6456
6457 billing.py — compute_total (function), Invoice (class),
6458 Invoice.apply_discount (method),
6459 Invoice.generate_pdf (method) ← never called by tests
6460 services.py — process_order (calls compute_total transitively)
6461 tests/test_billing.py — test_compute_total, test_apply_discount,
6462 test_process_order (direct calls)
6463
6464 Direct coverage expected:
6465 compute_total ← test_compute_total, test_process_order (via bare name)
6466 Invoice ← test_compute_total (instantiation)
6467 apply_discount ← test_apply_discount
6468 generate_pdf ← NOT covered
6469 process_order ← test_process_order
6470
6471 Transitive (depth 2) additionally covers:
6472 compute_total ← test_process_order (because process_order calls it)
6473 """
6474 (repo / "tests").mkdir(exist_ok=True)
6475
6476 (repo / "billing.py").write_text(textwrap.dedent("""\
6477 class Invoice:
6478 def apply_discount(self, rate):
6479 return self.total * (1 - rate)
6480
6481 def generate_pdf(self):
6482 return b"PDF"
6483
6484 def compute_total(items):
6485 return sum(i["price"] for i in items)
6486 """))
6487
6488 (repo / "services.py").write_text(textwrap.dedent("""\
6489 from billing import compute_total
6490
6491 def process_order(order):
6492 return compute_total(order["items"])
6493 """))
6494
6495 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
6496 from billing import compute_total, Invoice
6497 from services import process_order
6498
6499 def test_compute_total():
6500 inv = Invoice()
6501 assert compute_total([{"price": 10}]) == 10
6502
6503 def test_apply_discount():
6504 inv = Invoice()
6505 inv.total = 100
6506 assert inv.apply_discount(0.1) == 90
6507
6508 def test_process_order():
6509 result = process_order({"items": [{"price": 5}]})
6510 assert result == 5
6511 """))
6512
6513 r = runner.invoke(cli, ["commit", "-m", "stc: initial repo"])
6514 assert r.exit_code == 0, r.output
6515 return repo
6516
6517
6518 class TestSemanticTestCoverage:
6519 """Tests for ``muse code semantic-test-coverage``."""
6520
6521 CMD = ["code", "semantic-test-coverage"]
6522
6523 # ── basic correctness ────────────────────────────────────────────────────
6524
6525 def test_stc_exits_zero(self, stc_repo: pathlib.Path) -> None:
6526 result = runner.invoke(cli, self.CMD)
6527 assert result.exit_code == 0, result.output
6528
6529 def test_stc_shows_header(self, stc_repo: pathlib.Path) -> None:
6530 result = runner.invoke(cli, self.CMD)
6531 assert "Semantic test coverage" in result.output
6532 assert "HEAD" in result.output
6533
6534 def test_stc_shows_test_function_count(self, stc_repo: pathlib.Path) -> None:
6535 result = runner.invoke(cli, self.CMD)
6536 # 3 test functions in the repo
6537 assert "test functions" in result.output
6538
6539 def test_stc_shows_total_line(self, stc_repo: pathlib.Path) -> None:
6540 result = runner.invoke(cli, self.CMD)
6541 assert "TOTAL:" in result.output
6542
6543 def test_stc_covered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6544 result = runner.invoke(cli, self.CMD)
6545 assert "compute_total" in result.output
6546
6547 def test_stc_uncovered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6548 result = runner.invoke(cli, self.CMD)
6549 assert "generate_pdf" in result.output
6550
6551 def test_stc_covered_has_check_icon(self, stc_repo: pathlib.Path) -> None:
6552 result = runner.invoke(cli, self.CMD)
6553 assert "✅" in result.output
6554
6555 def test_stc_uncovered_has_cross_icon(self, stc_repo: pathlib.Path) -> None:
6556 result = runner.invoke(cli, self.CMD)
6557 assert "❌" in result.output
6558
6559 # ── JSON output ──────────────────────────────────────────────────────────
6560
6561 def test_stc_json_exits_zero(self, stc_repo: pathlib.Path) -> None:
6562 result = runner.invoke(cli, self.CMD + ["--json"])
6563 assert result.exit_code == 0, result.output
6564
6565 def test_stc_json_is_valid(self, stc_repo: pathlib.Path) -> None:
6566 result = runner.invoke(cli, self.CMD + ["--json"])
6567 data = json.loads(result.output)
6568 assert isinstance(data, dict)
6569
6570 def test_stc_json_top_level_keys(self, stc_repo: pathlib.Path) -> None:
6571 result = runner.invoke(cli, self.CMD + ["--json"])
6572 data = json.loads(result.output)
6573 for key in ("ref", "snapshot_id", "depth", "transitive", "filters",
6574 "summary", "files"):
6575 assert key in data, f"missing key: {key}"
6576
6577 def test_stc_json_ref_is_head(self, stc_repo: pathlib.Path) -> None:
6578 result = runner.invoke(cli, self.CMD + ["--json"])
6579 data = json.loads(result.output)
6580 assert data["ref"] == "HEAD"
6581
6582 def test_stc_json_depth_default(self, stc_repo: pathlib.Path) -> None:
6583 result = runner.invoke(cli, self.CMD + ["--json"])
6584 data = json.loads(result.output)
6585 assert data["depth"] == 1
6586
6587 def test_stc_json_transitive_default_false(self, stc_repo: pathlib.Path) -> None:
6588 result = runner.invoke(cli, self.CMD + ["--json"])
6589 data = json.loads(result.output)
6590 assert data["transitive"] is False
6591
6592 def test_stc_json_summary_schema(self, stc_repo: pathlib.Path) -> None:
6593 result = runner.invoke(cli, self.CMD + ["--json"])
6594 data = json.loads(result.output)
6595 summary = data["summary"]
6596 for key in ("total_symbols", "covered_symbols", "uncovered_symbols",
6597 "coverage_pct", "total_test_functions", "total_production_files"):
6598 assert key in summary, f"summary missing: {key}"
6599
6600 def test_stc_json_summary_counts_consistent(self, stc_repo: pathlib.Path) -> None:
6601 result = runner.invoke(cli, self.CMD + ["--json"])
6602 data = json.loads(result.output)
6603 s = data["summary"]
6604 assert s["covered_symbols"] + s["uncovered_symbols"] == s["total_symbols"]
6605
6606 def test_stc_json_summary_test_fn_count(self, stc_repo: pathlib.Path) -> None:
6607 result = runner.invoke(cli, self.CMD + ["--json"])
6608 data = json.loads(result.output)
6609 # 3 test functions: test_compute_total, test_apply_discount, test_process_order
6610 assert data["summary"]["total_test_functions"] >= 3
6611
6612 def test_stc_json_file_schema(self, stc_repo: pathlib.Path) -> None:
6613 result = runner.invoke(cli, self.CMD + ["--json"])
6614 data = json.loads(result.output)
6615 assert len(data["files"]) > 0
6616 fc = data["files"][0]
6617 for key in ("file", "total_symbols", "covered_symbols",
6618 "uncovered_symbols", "coverage_pct", "symbols"):
6619 assert key in fc, f"file record missing: {key}"
6620
6621 def test_stc_json_symbol_schema(self, stc_repo: pathlib.Path) -> None:
6622 result = runner.invoke(cli, self.CMD + ["--json"])
6623 data = json.loads(result.output)
6624 # Find a file with at least one symbol
6625 sym = data["files"][0]["symbols"][0]
6626 for key in ("address", "name", "kind", "covered", "test_functions"):
6627 assert key in sym, f"symbol record missing: {key}"
6628
6629 def test_stc_json_covered_symbol_has_test_functions(
6630 self, stc_repo: pathlib.Path
6631 ) -> None:
6632 result = runner.invoke(cli, self.CMD + ["--json"])
6633 data = json.loads(result.output)
6634 covered = [
6635 sym
6636 for fc in data["files"]
6637 for sym in fc["symbols"]
6638 if sym["covered"]
6639 ]
6640 assert covered, "expected at least one covered symbol"
6641 assert any(len(sym["test_functions"]) > 0 for sym in covered)
6642
6643 def test_stc_json_uncovered_symbol_empty_test_fns(
6644 self, stc_repo: pathlib.Path
6645 ) -> None:
6646 result = runner.invoke(cli, self.CMD + ["--json"])
6647 data = json.loads(result.output)
6648 uncovered = [
6649 sym
6650 for fc in data["files"]
6651 for sym in fc["symbols"]
6652 if not sym["covered"]
6653 ]
6654 assert uncovered, "expected generate_pdf to be uncovered"
6655 assert all(sym["test_functions"] == [] for sym in uncovered)
6656
6657 def test_stc_json_generate_pdf_uncovered(self, stc_repo: pathlib.Path) -> None:
6658 result = runner.invoke(cli, self.CMD + ["--json"])
6659 data = json.loads(result.output)
6660 found = next(
6661 (
6662 sym
6663 for fc in data["files"]
6664 for sym in fc["symbols"]
6665 if sym["name"] == "generate_pdf"
6666 ),
6667 None,
6668 )
6669 assert found is not None, "generate_pdf symbol not found"
6670 assert found["covered"] is False
6671
6672 def test_stc_json_compute_total_covered(self, stc_repo: pathlib.Path) -> None:
6673 result = runner.invoke(cli, self.CMD + ["--json"])
6674 data = json.loads(result.output)
6675 found = next(
6676 (
6677 sym
6678 for fc in data["files"]
6679 for sym in fc["symbols"]
6680 if sym["name"] == "compute_total"
6681 ),
6682 None,
6683 )
6684 assert found is not None
6685 assert found["covered"] is True
6686
6687 def test_stc_json_coverage_pct_between_0_and_100(
6688 self, stc_repo: pathlib.Path
6689 ) -> None:
6690 result = runner.invoke(cli, self.CMD + ["--json"])
6691 data = json.loads(result.output)
6692 for fc in data["files"]:
6693 assert 0.0 <= fc["coverage_pct"] <= 100.0
6694
6695 def test_stc_json_filter_reflected(self, stc_repo: pathlib.Path) -> None:
6696 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6697 data = json.loads(result.output)
6698 assert data["filters"]["kind"] == "method"
6699
6700 def test_stc_json_no_import_pseudosymbols(self, stc_repo: pathlib.Path) -> None:
6701 result = runner.invoke(cli, self.CMD + ["--json"])
6702 data = json.loads(result.output)
6703 for fc in data["files"]:
6704 for sym in fc["symbols"]:
6705 assert sym["kind"] != "import"
6706
6707 # ── --file filter ────────────────────────────────────────────────────────
6708
6709 def test_stc_file_filter_scopes_output(self, stc_repo: pathlib.Path) -> None:
6710 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6711 data = json.loads(result.output)
6712 for fc in data["files"]:
6713 assert "billing.py" in fc["file"]
6714
6715 def test_stc_file_filter_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6716 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6717 data = json.loads(result.output)
6718 assert data["filters"]["file"] == "billing.py"
6719
6720 def test_stc_file_filter_billing_has_generate_pdf(
6721 self, stc_repo: pathlib.Path
6722 ) -> None:
6723 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6724 data = json.loads(result.output)
6725 names = [
6726 sym["name"] for fc in data["files"] for sym in fc["symbols"]
6727 ]
6728 assert "generate_pdf" in names
6729
6730 # ── --kind filter ────────────────────────────────────────────────────────
6731
6732 def test_stc_kind_method_only_methods(self, stc_repo: pathlib.Path) -> None:
6733 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6734 data = json.loads(result.output)
6735 for fc in data["files"]:
6736 for sym in fc["symbols"]:
6737 assert sym["kind"] == "method"
6738
6739 def test_stc_kind_function_only_functions(self, stc_repo: pathlib.Path) -> None:
6740 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function"])
6741 data = json.loads(result.output)
6742 for fc in data["files"]:
6743 for sym in fc["symbols"]:
6744 assert sym["kind"] == "function"
6745
6746 def test_stc_kind_invalid_rejected(self, stc_repo: pathlib.Path) -> None:
6747 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6748 assert result.exit_code != 0
6749
6750 # ── --uncovered-only ─────────────────────────────────────────────────────
6751
6752 def test_stc_uncovered_only_exits_zero(self, stc_repo: pathlib.Path) -> None:
6753 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6754 assert result.exit_code == 0, result.output
6755
6756 def test_stc_uncovered_only_hides_covered(self, stc_repo: pathlib.Path) -> None:
6757 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6758 # generate_pdf should appear; compute_total should not appear
6759 assert "generate_pdf" in result.output
6760
6761 def test_stc_uncovered_only_json_symbols_all_uncovered(
6762 self, stc_repo: pathlib.Path
6763 ) -> None:
6764 result = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6765 data = json.loads(result.output)
6766 for fc in data["files"]:
6767 for sym in fc["symbols"]:
6768 assert sym["covered"] is False
6769
6770 def test_stc_uncovered_only_json_stats_still_full(
6771 self, stc_repo: pathlib.Path
6772 ) -> None:
6773 result_all = runner.invoke(cli, self.CMD + ["--json"])
6774 result_uncov = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6775 data_all = json.loads(result_all.output)
6776 data_uncov = json.loads(result_uncov.output)
6777 # Total symbol count should be the same (stats reflect full picture)
6778 assert (
6779 data_all["summary"]["total_symbols"]
6780 == data_uncov["summary"]["total_symbols"]
6781 )
6782
6783 # ── --show-tests ─────────────────────────────────────────────────────────
6784
6785 def test_stc_show_tests_exits_zero(self, stc_repo: pathlib.Path) -> None:
6786 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6787 assert result.exit_code == 0, result.output
6788
6789 def test_stc_show_tests_lists_test_addr(self, stc_repo: pathlib.Path) -> None:
6790 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6791 # Should include a ← prefix followed by a test address
6792 assert "←" in result.output
6793
6794 def test_stc_show_tests_references_test_file(
6795 self, stc_repo: pathlib.Path
6796 ) -> None:
6797 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6798 assert "test_billing" in result.output
6799
6800 # ── --transitive / --depth ───────────────────────────────────────────────
6801
6802 def test_stc_transitive_exits_zero(self, stc_repo: pathlib.Path) -> None:
6803 result = runner.invoke(cli, self.CMD + ["--transitive"])
6804 assert result.exit_code == 0, result.output
6805
6806 def test_stc_transitive_json_flag_true(self, stc_repo: pathlib.Path) -> None:
6807 result = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6808 data = json.loads(result.output)
6809 assert data["transitive"] is True
6810
6811 def test_stc_depth_2_implies_transitive(self, stc_repo: pathlib.Path) -> None:
6812 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
6813 data = json.loads(result.output)
6814 assert data["transitive"] is True
6815 assert data["depth"] == 2
6816
6817 def test_stc_depth_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6818 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "3"])
6819 data = json.loads(result.output)
6820 assert data["depth"] == 3
6821
6822 def test_stc_transitive_does_not_reduce_coverage(
6823 self, stc_repo: pathlib.Path
6824 ) -> None:
6825 result_direct = runner.invoke(cli, self.CMD + ["--json"])
6826 result_trans = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6827 data_direct = json.loads(result_direct.output)
6828 data_trans = json.loads(result_trans.output)
6829 # Transitive coverage must be >= direct coverage
6830 assert (
6831 data_trans["summary"]["covered_symbols"]
6832 >= data_direct["summary"]["covered_symbols"]
6833 )
6834
6835 def test_stc_depth_0_invalid(self, stc_repo: pathlib.Path) -> None:
6836 result = runner.invoke(cli, self.CMD + ["--depth", "0"])
6837 assert result.exit_code != 0
6838
6839 def test_stc_depth_exceeds_max_invalid(self, stc_repo: pathlib.Path) -> None:
6840 result = runner.invoke(cli, self.CMD + ["--depth", "11"])
6841 assert result.exit_code != 0
6842
6843 # ── --min-coverage ───────────────────────────────────────────────────────
6844
6845 def test_stc_min_coverage_0_exits_zero(self, stc_repo: pathlib.Path) -> None:
6846 result = runner.invoke(cli, self.CMD + ["--min-coverage", "0"])
6847 assert result.exit_code == 0, result.output
6848
6849 def test_stc_min_coverage_100_exits_nonzero(self, stc_repo: pathlib.Path) -> None:
6850 # generate_pdf is never covered, so 100% is unachievable.
6851 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6852 assert result.exit_code != 0
6853
6854 def test_stc_min_coverage_shows_warning(self, stc_repo: pathlib.Path) -> None:
6855 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6856 assert "⚠️" in result.output or "below" in result.output.lower()
6857
6858 def test_stc_min_coverage_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6859 result = runner.invoke(cli, self.CMD + ["--json", "--min-coverage", "80"])
6860 data = json.loads(result.output)
6861 assert data["filters"]["min_coverage"] == 80
6862
6863 def test_stc_min_coverage_none_when_0(self, stc_repo: pathlib.Path) -> None:
6864 result = runner.invoke(cli, self.CMD + ["--json"])
6865 data = json.loads(result.output)
6866 assert data["filters"]["min_coverage"] is None
6867
6868 def test_stc_min_coverage_invalid_over_100(self, stc_repo: pathlib.Path) -> None:
6869 result = runner.invoke(cli, self.CMD + ["--min-coverage", "101"])
6870 assert result.exit_code != 0
6871
6872 def test_stc_min_coverage_invalid_negative(self, stc_repo: pathlib.Path) -> None:
6873 result = runner.invoke(cli, self.CMD + ["--min-coverage", "-1"])
6874 assert result.exit_code != 0
6875
6876 # ── test-file exclusion ──────────────────────────────────────────────────
6877
6878 def test_stc_test_files_not_in_production_symbols(
6879 self, stc_repo: pathlib.Path
6880 ) -> None:
6881 result = runner.invoke(cli, self.CMD + ["--json"])
6882 data = json.loads(result.output)
6883 for fc in data["files"]:
6884 assert "test_" not in pathlib.PurePosixPath(fc["file"]).name.split(".")[0][:5] or \
6885 not fc["file"].startswith("tests/"), \
6886 f"test file appeared in production symbols: {fc['file']}"
6887
6888 def test_stc_no_test_file_in_prod_files(self, stc_repo: pathlib.Path) -> None:
6889 result = runner.invoke(cli, self.CMD + ["--json"])
6890 data = json.loads(result.output)
6891 for fc in data["files"]:
6892 assert "tests/" not in fc["file"] or fc["file"].startswith("tests/") is False, \
6893 fc["file"]
6894
6895 # ── requires repo ────────────────────────────────────────────────────────
6896
6897 def test_stc_requires_repo(self, tmp_path: pathlib.Path) -> None:
6898 import os
6899 old = os.getcwd()
6900 try:
6901 os.chdir(tmp_path)
6902 result = runner.invoke(cli, self.CMD)
6903 assert result.exit_code != 0
6904 finally:
6905 os.chdir(old)
6906
6907 # ── empty repo ───────────────────────────────────────────────────────────
6908
6909 def test_stc_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
6910 """An empty repo (no commits yet) should not crash."""
6911 # The base repo fixture has no commits — must handle gracefully.
6912 # First commit something minimal so HEAD exists.
6913 (repo / "empty.py").write_text("")
6914 r = runner.invoke(cli, ["commit", "-m", "seed"])
6915 if r.exit_code != 0:
6916 pytest.skip("could not create initial commit")
6917 result = runner.invoke(cli, self.CMD)
6918 assert result.exit_code == 0, result.output
6919
6920
6921 # ---------------------------------------------------------------------------
6922 # muse code gravity
6923 # ---------------------------------------------------------------------------
6924
6925
6926 @pytest.fixture
6927 def gravity_repo(repo: pathlib.Path) -> pathlib.Path:
6928 """Repo whose call graph creates a clear gravity hierarchy.
6929
6930 Layout::
6931
6932 core.py — read_object (called by everything)
6933 mid.py — process (calls read_object)
6934 top.py — handle (calls process, which calls read_object)
6935 leaf.py — leaf_fn (calls handle)
6936
6937 Expected gravity (transitive dependents):
6938 read_object: 3 (process, handle, leaf_fn) → high gravity
6939 process: 2 (handle, leaf_fn)
6940 handle: 1 (leaf_fn)
6941 leaf_fn: 0 → lowest gravity
6942 """
6943 (repo / "core.py").write_text(textwrap.dedent("""\
6944 def read_object(path):
6945 return path.read_bytes()
6946 """))
6947 r1 = runner.invoke(cli, ["commit", "-m", "core: add read_object"])
6948 assert r1.exit_code == 0, r1.output
6949
6950 (repo / "mid.py").write_text(textwrap.dedent("""\
6951 from core import read_object
6952
6953 def process(path):
6954 return read_object(path)
6955 """))
6956 r2 = runner.invoke(cli, ["commit", "-m", "mid: add process"])
6957 assert r2.exit_code == 0, r2.output
6958
6959 (repo / "top.py").write_text(textwrap.dedent("""\
6960 from mid import process
6961
6962 def handle(path):
6963 return process(path)
6964 """))
6965 r3 = runner.invoke(cli, ["commit", "-m", "top: add handle"])
6966 assert r3.exit_code == 0, r3.output
6967
6968 (repo / "leaf.py").write_text(textwrap.dedent("""\
6969 from top import handle
6970
6971 def leaf_fn(path):
6972 return handle(path)
6973 """))
6974 r4 = runner.invoke(cli, ["commit", "-m", "leaf: add leaf_fn"])
6975 assert r4.exit_code == 0, r4.output
6976
6977 return repo
6978
6979
6980 class TestGravity:
6981 """Tests for ``muse code gravity``."""
6982
6983 CMD = ["code", "gravity"]
6984
6985 # ── basic correctness ─────────────────────────────────────────────────────
6986
6987 def test_gravity_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6988 result = runner.invoke(cli, self.CMD)
6989 assert result.exit_code == 0, result.output
6990
6991 def test_gravity_shows_header(self, gravity_repo: pathlib.Path) -> None:
6992 result = runner.invoke(cli, self.CMD)
6993 assert "Symbol gravity" in result.output
6994
6995 def test_gravity_shows_head(self, gravity_repo: pathlib.Path) -> None:
6996 result = runner.invoke(cli, self.CMD)
6997 assert "HEAD" in result.output
6998
6999 def test_gravity_shows_column_headers(self, gravity_repo: pathlib.Path) -> None:
7000 result = runner.invoke(cli, self.CMD)
7001 assert "GRAVITY" in result.output
7002 assert "DIRECT" in result.output
7003 assert "DEPTH" in result.output
7004
7005 def test_gravity_shows_symbols(self, gravity_repo: pathlib.Path) -> None:
7006 result = runner.invoke(cli, self.CMD)
7007 # At least one symbol should appear.
7008 assert "read_object" in result.output or "process" in result.output
7009
7010 def test_gravity_shows_percentage(self, gravity_repo: pathlib.Path) -> None:
7011 result = runner.invoke(cli, self.CMD)
7012 assert "%" in result.output
7013
7014 # ── --top ─────────────────────────────────────────────────────────────────
7015
7016 def test_gravity_top_limits_output(self, gravity_repo: pathlib.Path) -> None:
7017 result1 = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
7018 result3 = runner.invoke(cli, self.CMD + ["--json", "--top", "3"])
7019 data1 = json.loads(result1.output)
7020 data3 = json.loads(result3.output)
7021 assert len(data1["symbols"]) <= 1
7022 assert len(data3["symbols"]) <= 3
7023
7024 def test_gravity_top_0_returns_all(self, gravity_repo: pathlib.Path) -> None:
7025 result_all = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7026 result_lim = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
7027 data_all = json.loads(result_all.output)
7028 data_lim = json.loads(result_lim.output)
7029 assert len(data_all["symbols"]) >= len(data_lim["symbols"])
7030
7031 def test_gravity_top_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7032 result = runner.invoke(cli, self.CMD + ["--top", "-1"])
7033 assert result.exit_code != 0
7034
7035 # ── --sort ────────────────────────────────────────────────────────────────
7036
7037 def test_gravity_sort_gravity_default(self, gravity_repo: pathlib.Path) -> None:
7038 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7039 data = json.loads(result.output)
7040 if len(data["symbols"]) >= 2:
7041 pcts = [s["gravity_pct"] for s in data["symbols"]]
7042 assert pcts == sorted(pcts, reverse=True)
7043
7044 def test_gravity_sort_direct(self, gravity_repo: pathlib.Path) -> None:
7045 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "direct", "--top", "0"])
7046 data = json.loads(result.output)
7047 assert result.exit_code == 0
7048 if len(data["symbols"]) >= 2:
7049 directs = [s["direct_dependents"] for s in data["symbols"]]
7050 assert directs == sorted(directs, reverse=True)
7051
7052 def test_gravity_sort_depth(self, gravity_repo: pathlib.Path) -> None:
7053 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "depth", "--top", "0"])
7054 data = json.loads(result.output)
7055 assert result.exit_code == 0
7056 if len(data["symbols"]) >= 2:
7057 depths = [s["max_depth"] for s in data["symbols"]]
7058 assert depths == sorted(depths, reverse=True)
7059
7060 def test_gravity_sort_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7061 result = runner.invoke(cli, self.CMD + ["--sort", "invalid"])
7062 assert result.exit_code != 0
7063
7064 # ── --depth cap ───────────────────────────────────────────────────────────
7065
7066 def test_gravity_depth_0_unlimited(self, gravity_repo: pathlib.Path) -> None:
7067 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "0"])
7068 data = json.loads(result.output)
7069 assert result.exit_code == 0
7070 assert data["max_depth"] == 0
7071
7072 def test_gravity_depth_1_direct_only(self, gravity_repo: pathlib.Path) -> None:
7073 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "1", "--top", "0"])
7074 data = json.loads(result.output)
7075 assert result.exit_code == 0
7076 # With depth=1, max_depth for any symbol should be at most 1.
7077 for sym in data["symbols"]:
7078 assert sym["max_depth"] <= 1
7079
7080 def test_gravity_depth_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7081 result = runner.invoke(cli, self.CMD + ["--depth", "-1"])
7082 assert result.exit_code != 0
7083
7084 # ── --kind filter ─────────────────────────────────────────────────────────
7085
7086 def test_gravity_kind_function_only(self, gravity_repo: pathlib.Path) -> None:
7087 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function", "--top", "0"])
7088 data = json.loads(result.output)
7089 assert result.exit_code == 0
7090 for sym in data["symbols"]:
7091 assert sym["kind"] == "function"
7092
7093 def test_gravity_kind_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7094 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
7095 assert result.exit_code != 0
7096
7097 # ── --file filter ─────────────────────────────────────────────────────────
7098
7099 def test_gravity_file_filter_scopes(self, gravity_repo: pathlib.Path) -> None:
7100 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py", "--top", "0"])
7101 data = json.loads(result.output)
7102 assert result.exit_code == 0
7103 for sym in data["symbols"]:
7104 assert "core.py" in sym["file"]
7105
7106 def test_gravity_file_filter_reflected_in_json(self, gravity_repo: pathlib.Path) -> None:
7107 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py"])
7108 data = json.loads(result.output)
7109 assert data["filters"]["file"] == "core.py"
7110
7111 # ── --min-gravity ─────────────────────────────────────────────────────────
7112
7113 def test_gravity_min_gravity_filters_low(self, gravity_repo: pathlib.Path) -> None:
7114 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "50.0", "--top", "0"])
7115 data = json.loads(result.output)
7116 for sym in data["symbols"]:
7117 assert sym["gravity_pct"] >= 50.0
7118
7119 def test_gravity_min_gravity_100_returns_few(self, gravity_repo: pathlib.Path) -> None:
7120 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "100.0"])
7121 assert result.exit_code == 0
7122
7123 def test_gravity_min_gravity_invalid_over_100(self, gravity_repo: pathlib.Path) -> None:
7124 result = runner.invoke(cli, self.CMD + ["--min-gravity", "101.0"])
7125 assert result.exit_code != 0
7126
7127 def test_gravity_min_gravity_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7128 result = runner.invoke(cli, self.CMD + ["--min-gravity", "-1.0"])
7129 assert result.exit_code != 0
7130
7131 # ── --explain ─────────────────────────────────────────────────────────────
7132
7133 def test_gravity_explain_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7134 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7135 assert result.exit_code == 0, result.output
7136
7137 def test_gravity_explain_shows_breakdown(self, gravity_repo: pathlib.Path) -> None:
7138 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7139 assert "Gravity breakdown" in result.output
7140
7141 def test_gravity_explain_shows_depth_distribution(
7142 self, gravity_repo: pathlib.Path
7143 ) -> None:
7144 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7145 assert "Depth distribution" in result.output
7146
7147 def test_gravity_explain_shows_deepest_callers(
7148 self, gravity_repo: pathlib.Path
7149 ) -> None:
7150 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7151 assert "Deepest callers" in result.output
7152
7153 def test_gravity_explain_missing_address_format(
7154 self, gravity_repo: pathlib.Path
7155 ) -> None:
7156 result = runner.invoke(cli, self.CMD + ["--explain", "no_double_colon"])
7157 assert result.exit_code != 0
7158
7159 def test_gravity_explain_unknown_symbol_exits_nonzero(
7160 self, gravity_repo: pathlib.Path
7161 ) -> None:
7162 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::no_such_fn"])
7163 assert result.exit_code != 0
7164
7165 def test_gravity_explain_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7166 result = runner.invoke(
7167 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7168 )
7169 assert result.exit_code == 0, result.output
7170
7171 def test_gravity_explain_json_schema(self, gravity_repo: pathlib.Path) -> None:
7172 result = runner.invoke(
7173 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7174 )
7175 data = json.loads(result.output)
7176 for key in (
7177 "address", "name", "kind", "file",
7178 "gravity_pct", "direct_dependents", "transitive_dependents",
7179 "max_depth", "depth_distribution",
7180 ):
7181 assert key in data, f"missing key: {key}"
7182
7183 # ── JSON leaderboard ──────────────────────────────────────────────────────
7184
7185 def test_gravity_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7186 result = runner.invoke(cli, self.CMD + ["--json"])
7187 assert result.exit_code == 0, result.output
7188
7189 def test_gravity_json_top_level_keys(self, gravity_repo: pathlib.Path) -> None:
7190 result = runner.invoke(cli, self.CMD + ["--json"])
7191 data = json.loads(result.output)
7192 for key in (
7193 "ref", "snapshot_id", "total_production_symbols",
7194 "max_depth", "include_tests", "filters", "symbols",
7195 ):
7196 assert key in data, f"missing key: {key}"
7197
7198 def test_gravity_json_symbol_schema(self, gravity_repo: pathlib.Path) -> None:
7199 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7200 data = json.loads(result.output)
7201 if data["symbols"]:
7202 sym = data["symbols"][0]
7203 for key in (
7204 "address", "name", "kind", "file",
7205 "gravity_pct", "direct_dependents",
7206 "transitive_dependents", "max_depth", "depth_distribution",
7207 ):
7208 assert key in sym, f"symbol missing key: {key}"
7209
7210 def test_gravity_json_gravity_pct_range(self, gravity_repo: pathlib.Path) -> None:
7211 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7212 data = json.loads(result.output)
7213 for sym in data["symbols"]:
7214 assert 0.0 <= sym["gravity_pct"] <= 100.0
7215
7216 def test_gravity_json_read_object_has_highest_gravity(
7217 self, gravity_repo: pathlib.Path
7218 ) -> None:
7219 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7220 data = json.loads(result.output)
7221 # read_object is called transitively by everything — should be near top.
7222 names = [s["name"] for s in data["symbols"]]
7223 if "read_object" in names and len(names) > 1:
7224 ro_idx = names.index("read_object")
7225 # read_object should be in the top half.
7226 assert ro_idx <= len(names) // 2 + 1
7227
7228 def test_gravity_json_leaf_fn_lower_gravity(
7229 self, gravity_repo: pathlib.Path
7230 ) -> None:
7231 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7232 data = json.loads(result.output)
7233 syms = {s["name"]: s for s in data["symbols"]}
7234 if "leaf_fn" in syms and "read_object" in syms:
7235 assert syms["leaf_fn"]["gravity_pct"] <= syms["read_object"]["gravity_pct"]
7236
7237 def test_gravity_json_include_tests_flag(self, gravity_repo: pathlib.Path) -> None:
7238 result = runner.invoke(cli, self.CMD + ["--json", "--include-tests"])
7239 data = json.loads(result.output)
7240 assert data["include_tests"] is True
7241
7242 def test_gravity_json_depth_reflected(self, gravity_repo: pathlib.Path) -> None:
7243 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
7244 data = json.loads(result.output)
7245 assert data["max_depth"] == 2
7246
7247 def test_gravity_json_filters_reflected(self, gravity_repo: pathlib.Path) -> None:
7248 result = runner.invoke(
7249 cli,
7250 self.CMD + ["--json", "--kind", "function", "--min-gravity", "5.0", "--top", "10"],
7251 )
7252 data = json.loads(result.output)
7253 assert data["filters"]["kind"] == "function"
7254 assert data["filters"]["min_gravity"] == 5.0
7255 assert data["filters"]["top"] == 10
7256
7257 def test_gravity_json_depth_distribution_is_dict(
7258 self, gravity_repo: pathlib.Path
7259 ) -> None:
7260 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7261 data = json.loads(result.output)
7262 for sym in data["symbols"]:
7263 assert isinstance(sym["depth_distribution"], dict)
7264
7265 # ── requires repo ─────────────────────────────────────────────────────────
7266
7267 def test_gravity_requires_repo(self, tmp_path: pathlib.Path) -> None:
7268 import os
7269 old = os.getcwd()
7270 try:
7271 os.chdir(tmp_path)
7272 result = runner.invoke(cli, self.CMD)
7273 assert result.exit_code != 0
7274 finally:
7275 os.chdir(old)
7276
7277
7278 # ---------------------------------------------------------------------------
7279 # muse code narrative
7280 # ---------------------------------------------------------------------------
7281
7282
7283 @pytest.fixture
7284 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
7285 """Repo with a symbol that has a rich multi-event history.
7286
7287 billing.py::compute_total goes through:
7288 commit 1: seed commit (different file — gives billing.py a parent context)
7289 commit 2: created (insert — billing.py added, compute_total appears as new symbol)
7290 commit 3: body rewritten (replace with impl keywords)
7291 commit 4: signature changed (replace with signature keywords)
7292 """
7293 # Commit 1 — seed so billing.py's creation is a delta, not the initial commit.
7294 (repo / "readme.txt").write_text("MuseHub billing module\n")
7295 r0 = runner.invoke(cli, ["commit", "-m", "chore: initial seed"])
7296 assert r0.exit_code == 0, r0.output
7297
7298 # Commit 2 — create billing.py (compute_total becomes a new symbol in delta).
7299 (repo / "billing.py").write_text(textwrap.dedent("""\
7300 def compute_total(items):
7301 total = 0
7302 for item in items:
7303 total += item["price"]
7304 return total
7305 """))
7306 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7307 assert r1.exit_code == 0, r1.output
7308
7309 # Commit 3 — body rewrite: implementation changed.
7310 (repo / "billing.py").write_text(textwrap.dedent("""\
7311 def compute_total(items):
7312 return sum(i["price"] for i in items)
7313 """))
7314 r2 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
7315 assert r2.exit_code == 0, r2.output
7316
7317 # Commit 4 — signature change.
7318 (repo / "billing.py").write_text(textwrap.dedent("""\
7319 def compute_total(items, currency="USD"):
7320 return sum(i["price"] for i in items)
7321 """))
7322 r3 = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
7323 assert r3.exit_code == 0, r3.output
7324
7325 return repo
7326
7327
7328 class TestNarrative:
7329 """Tests for ``muse code narrative``."""
7330
7331 CMD = ["code", "narrative"]
7332 ADDR = "billing.py::compute_total"
7333
7334 # ── basic correctness ─────────────────────────────────────────────────────
7335
7336 def test_narrative_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7337 result = runner.invoke(cli, self.CMD + [self.ADDR])
7338 assert result.exit_code == 0, result.output
7339
7340 def test_narrative_shows_symbol_name(self, narrative_repo: pathlib.Path) -> None:
7341 result = runner.invoke(cli, self.CMD + [self.ADDR])
7342 assert "compute_total" in result.output
7343
7344 def test_narrative_shows_file(self, narrative_repo: pathlib.Path) -> None:
7345 result = runner.invoke(cli, self.CMD + [self.ADDR])
7346 assert "billing.py" in result.output
7347
7348 def test_narrative_shows_born_event(self, narrative_repo: pathlib.Path) -> None:
7349 result = runner.invoke(cli, self.CMD + [self.ADDR])
7350 assert "Born" in result.output or "born" in result.output
7351
7352 def test_narrative_shows_life_summary(self, narrative_repo: pathlib.Path) -> None:
7353 result = runner.invoke(cli, self.CMD + [self.ADDR])
7354 assert "Life summary" in result.output or "Survival" in result.output
7355
7356 def test_narrative_shows_commit_id(self, narrative_repo: pathlib.Path) -> None:
7357 result = runner.invoke(cli, self.CMD + [self.ADDR])
7358 assert "commit" in result.output
7359
7360 def test_narrative_shows_survival(self, narrative_repo: pathlib.Path) -> None:
7361 result = runner.invoke(cli, self.CMD + [self.ADDR])
7362 assert "%" in result.output
7363
7364 # ── missing symbol ────────────────────────────────────────────────────────
7365
7366 def test_narrative_missing_symbol_exits_nonzero(
7367 self, narrative_repo: pathlib.Path
7368 ) -> None:
7369 result = runner.invoke(
7370 cli, self.CMD + ["billing.py::does_not_exist"]
7371 )
7372 assert result.exit_code != 0
7373
7374 def test_narrative_bad_address_no_colons_exits_nonzero(
7375 self, narrative_repo: pathlib.Path
7376 ) -> None:
7377 result = runner.invoke(cli, self.CMD + ["no_double_colon"])
7378 assert result.exit_code != 0
7379
7380 # ── --format prose ────────────────────────────────────────────────────────
7381
7382 def test_narrative_prose_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7383 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7384 assert result.exit_code == 0, result.output
7385
7386 def test_narrative_prose_contains_name(self, narrative_repo: pathlib.Path) -> None:
7387 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7388 assert "compute_total" in result.output
7389
7390 def test_narrative_prose_contains_content(self, narrative_repo: pathlib.Path) -> None:
7391 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7392 # Symbol name or some indication of the symbol's life should appear.
7393 assert "compute_total" in result.output or "rewritten" in result.output or "born" in result.output.lower()
7394
7395 def test_narrative_prose_no_timeline_label(
7396 self, narrative_repo: pathlib.Path
7397 ) -> None:
7398 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7399 # Timeline labels like "Born " should not appear in prose.
7400 assert "Life summary" not in result.output
7401
7402 def test_narrative_format_invalid_rejected(
7403 self, narrative_repo: pathlib.Path
7404 ) -> None:
7405 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "invalid"])
7406 assert result.exit_code != 0
7407
7408 # ── --json ────────────────────────────────────────────────────────────────
7409
7410 def test_narrative_json_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7411 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7412 assert result.exit_code == 0, result.output
7413
7414 def test_narrative_json_is_valid(self, narrative_repo: pathlib.Path) -> None:
7415 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7416 data = json.loads(result.output)
7417 assert isinstance(data, dict)
7418
7419 def test_narrative_json_top_level_keys(self, narrative_repo: pathlib.Path) -> None:
7420 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7421 data = json.loads(result.output)
7422 for key in (
7423 "address", "name", "kind", "file", "status",
7424 "born_date", "born_commit", "last_change_date", "last_change_commit",
7425 "calendar_age_days", "genetic_age_days",
7426 "impl_changes", "sig_changes", "renames",
7427 "est_survival_pct", "commits_analysed", "truncated", "events",
7428 ):
7429 assert key in data, f"missing key: {key}"
7430
7431 def test_narrative_json_address_matches(self, narrative_repo: pathlib.Path) -> None:
7432 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7433 data = json.loads(result.output)
7434 assert data["address"] == self.ADDR
7435
7436 def test_narrative_json_name_is_bare(self, narrative_repo: pathlib.Path) -> None:
7437 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7438 data = json.loads(result.output)
7439 assert data["name"] == "compute_total"
7440
7441 def test_narrative_json_file_is_file_part(self, narrative_repo: pathlib.Path) -> None:
7442 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7443 data = json.loads(result.output)
7444 assert data["file"] == "billing.py"
7445
7446 def test_narrative_json_status_alive(self, narrative_repo: pathlib.Path) -> None:
7447 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7448 data = json.loads(result.output)
7449 assert data["status"] == "alive"
7450
7451 def test_narrative_json_events_list(self, narrative_repo: pathlib.Path) -> None:
7452 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7453 data = json.loads(result.output)
7454 assert isinstance(data["events"], list)
7455 assert len(data["events"]) >= 1
7456
7457 def test_narrative_json_event_schema(self, narrative_repo: pathlib.Path) -> None:
7458 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7459 data = json.loads(result.output)
7460 ev = data["events"][0]
7461 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
7462 assert key in ev, f"event missing key: {key}"
7463
7464 def test_narrative_json_born_commit_set(self, narrative_repo: pathlib.Path) -> None:
7465 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7466 data = json.loads(result.output)
7467 assert data["born_commit"] != ""
7468
7469 def test_narrative_json_born_date_format(self, narrative_repo: pathlib.Path) -> None:
7470 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7471 data = json.loads(result.output)
7472 import re
7473 assert re.match(r"\d{4}-\d{2}-\d{2}", data["born_date"])
7474
7475 def test_narrative_json_impl_changes_at_least_one(
7476 self, narrative_repo: pathlib.Path
7477 ) -> None:
7478 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7479 data = json.loads(result.output)
7480 # We made at least one body rewrite commit.
7481 assert data["impl_changes"] >= 1
7482
7483 def test_narrative_json_commits_analysed_positive(
7484 self, narrative_repo: pathlib.Path
7485 ) -> None:
7486 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7487 data = json.loads(result.output)
7488 assert data["commits_analysed"] > 0
7489
7490 def test_narrative_json_survival_between_0_and_100(
7491 self, narrative_repo: pathlib.Path
7492 ) -> None:
7493 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7494 data = json.loads(result.output)
7495 assert 0 <= data["est_survival_pct"] <= 100
7496
7497 def test_narrative_json_calendar_age_nonnegative(
7498 self, narrative_repo: pathlib.Path
7499 ) -> None:
7500 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7501 data = json.loads(result.output)
7502 assert data["calendar_age_days"] >= 0
7503
7504 def test_narrative_json_events_oldest_first(
7505 self, narrative_repo: pathlib.Path
7506 ) -> None:
7507 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7508 data = json.loads(result.output)
7509 dates = [ev["date"] for ev in data["events"]]
7510 assert dates == sorted(dates)
7511
7512 def test_narrative_json_create_event_present(
7513 self, narrative_repo: pathlib.Path
7514 ) -> None:
7515 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7516 data = json.loads(result.output)
7517 types = [ev["event_type"] for ev in data["events"]]
7518 assert "create" in types
7519
7520 # ── --since ───────────────────────────────────────────────────────────────
7521
7522 def test_narrative_since_invalid_ref_exits_nonzero(
7523 self, narrative_repo: pathlib.Path
7524 ) -> None:
7525 result = runner.invoke(
7526 cli, self.CMD + [self.ADDR, "--since", "no_such_ref_xyz"]
7527 )
7528 assert result.exit_code != 0
7529
7530 # ── --max-commits ─────────────────────────────────────────────────────────
7531
7532 def test_narrative_max_commits_validation(
7533 self, narrative_repo: pathlib.Path
7534 ) -> None:
7535 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7536 assert result.exit_code != 0
7537
7538 def test_narrative_max_commits_1_finds_head_event(
7539 self, narrative_repo: pathlib.Path
7540 ) -> None:
7541 result = runner.invoke(
7542 cli, self.CMD + [self.ADDR, "--json", "--max-commits", "1"]
7543 )
7544 # With max-commits=1 we only see the HEAD commit; it must still succeed
7545 # if the HEAD commit touched our symbol, or fail gracefully if not.
7546 assert result.exit_code in (0, 1)
7547
7548 # ── --show-source ─────────────────────────────────────────────────────────
7549
7550 def test_narrative_show_source_exits_zero(
7551 self, narrative_repo: pathlib.Path
7552 ) -> None:
7553 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7554 assert result.exit_code == 0, result.output
7555
7556 def test_narrative_show_source_contains_def(
7557 self, narrative_repo: pathlib.Path
7558 ) -> None:
7559 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7560 # HEAD source should contain the function definition.
7561 assert "def compute_total" in result.output
7562
7563 # ── requires repo ─────────────────────────────────────────────────────────
7564
7565 def test_narrative_requires_repo(self, tmp_path: pathlib.Path) -> None:
7566 import os
7567 old = os.getcwd()
7568 try:
7569 os.chdir(tmp_path)
7570 result = runner.invoke(cli, self.CMD + [self.ADDR])
7571 assert result.exit_code != 0
7572 finally:
7573 os.chdir(old)
7574
7575
7576 # ---------------------------------------------------------------------------
7577 # contract
7578 # ---------------------------------------------------------------------------
7579
7580
7581 @pytest.fixture()
7582 def contract_repo(repo: pathlib.Path) -> pathlib.Path:
7583 """Repo designed to exercise every dimension of ``muse code contract``.
7584
7585 Layout::
7586
7587 billing.py — compute_total(items, currency="USD") → float
7588 services.py — place_order() calls compute_total with currency="EUR" → stored
7589 report.py — generate_report() calls compute_total(items) → stored (omits currency)
7590 audit.py — run_audit() calls compute_total(items) → discarded (bad caller)
7591 tests/test_billing.py — tests with assertions about compute_total
7592
7593 Commit history::
7594
7595 1. seed commit — readme.txt so symbol events are real insert ops
7596 2. billing.py added — compute_total created
7597 3. services.py, report.py, audit.py, tests/ added — callers in place
7598 4. billing.py updated — body rewrite (PATCH)
7599 5. billing.py updated — add currency param (MINOR)
7600 """
7601 import os
7602
7603 (repo / "readme.txt").write_text("# contract test repo\n")
7604 r0 = runner.invoke(cli, ["commit", "-m", "seed: initial readme"])
7605 assert r0.exit_code == 0, r0.output
7606
7607 (repo / "billing.py").write_text(textwrap.dedent("""\
7608 def compute_total(items):
7609 return sum(i["price"] for i in items)
7610 """))
7611 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7612 assert r1.exit_code == 0, r1.output
7613
7614 os.makedirs(repo / "tests", exist_ok=True)
7615 (repo / "services.py").write_text(textwrap.dedent("""\
7616 from billing import compute_total
7617
7618 def place_order(items):
7619 total = compute_total(items, currency="EUR")
7620 return total
7621 """))
7622 (repo / "report.py").write_text(textwrap.dedent("""\
7623 from billing import compute_total
7624
7625 def generate_report(items):
7626 result = compute_total(items)
7627 return result
7628 """))
7629 (repo / "audit.py").write_text(textwrap.dedent("""\
7630 from billing import compute_total
7631
7632 def run_audit(items):
7633 compute_total(items)
7634 """))
7635 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
7636 from billing import compute_total
7637
7638 def test_compute_total_basic():
7639 result = compute_total([{"price": 10}, {"price": 5}])
7640 assert result == 15
7641 assert result > 0
7642 assert isinstance(result, (int, float))
7643
7644 def test_compute_total_empty():
7645 result = compute_total([])
7646 assert result == 0
7647 """))
7648 r2 = runner.invoke(cli, ["commit", "-m", "feat: add callers and tests"])
7649 assert r2.exit_code == 0, r2.output
7650
7651 # body rewrite — PATCH
7652 (repo / "billing.py").write_text(textwrap.dedent("""\
7653 def compute_total(items):
7654 total = 0.0
7655 for item in items:
7656 total += float(item["price"])
7657 return total
7658 """))
7659 r3 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total"])
7660 assert r3.exit_code == 0, r3.output
7661
7662 # add currency param — MINOR
7663 (repo / "billing.py").write_text(textwrap.dedent("""\
7664 def compute_total(items, currency="USD"):
7665 total = 0.0
7666 for item in items:
7667 total += float(item["price"])
7668 return total
7669 """))
7670 r4 = runner.invoke(cli, ["commit", "-m", "feat: add optional currency param"])
7671 assert r4.exit_code == 0, r4.output
7672
7673 return repo
7674
7675
7676 class TestContract:
7677 """Tests for ``muse code contract``."""
7678
7679 CMD = ["code", "contract"]
7680 ADDR = "billing.py::compute_total"
7681
7682 # ── basic correctness ─────────────────────────────────────────────────────
7683
7684 def test_contract_exits_zero(self, contract_repo: pathlib.Path) -> None:
7685 result = runner.invoke(cli, self.CMD + [self.ADDR])
7686 assert result.exit_code == 0, result.output
7687
7688 def test_contract_shows_address(self, contract_repo: pathlib.Path) -> None:
7689 result = runner.invoke(cli, self.CMD + [self.ADDR])
7690 assert "compute_total" in result.output
7691
7692 def test_contract_shows_signature_section(self, contract_repo: pathlib.Path) -> None:
7693 result = runner.invoke(cli, self.CMD + [self.ADDR])
7694 assert "Signature" in result.output
7695
7696 def test_contract_shows_def_keyword(self, contract_repo: pathlib.Path) -> None:
7697 result = runner.invoke(cli, self.CMD + [self.ADDR])
7698 assert "def compute_total" in result.output
7699
7700 def test_contract_shows_stability_section(self, contract_repo: pathlib.Path) -> None:
7701 result = runner.invoke(cli, self.CMD + [self.ADDR])
7702 assert "Stability" in result.output
7703
7704 def test_contract_shows_commits_analysed(self, contract_repo: pathlib.Path) -> None:
7705 result = runner.invoke(cli, self.CMD + [self.ADDR])
7706 assert "commits" in result.output
7707
7708 def test_contract_shows_assessment(self, contract_repo: pathlib.Path) -> None:
7709 result = runner.invoke(cli, self.CMD + [self.ADDR])
7710 assert "Assessment" in result.output
7711
7712 def test_contract_shows_return_section(self, contract_repo: pathlib.Path) -> None:
7713 result = runner.invoke(cli, self.CMD + [self.ADDR])
7714 assert "Return value" in result.output
7715
7716 def test_contract_shows_parameters_section(self, contract_repo: pathlib.Path) -> None:
7717 result = runner.invoke(cli, self.CMD + [self.ADDR])
7718 assert "Parameters" in result.output
7719
7720 # ── call-site disposition detection ──────────────────────────────────────
7721
7722 def test_contract_detects_stored(self, contract_repo: pathlib.Path) -> None:
7723 result = runner.invoke(cli, self.CMD + [self.ADDR])
7724 assert "stored" in result.output
7725
7726 def test_contract_detects_discarded(self, contract_repo: pathlib.Path) -> None:
7727 result = runner.invoke(cli, self.CMD + [self.ADDR])
7728 assert "discarded" in result.output
7729
7730 def test_contract_warns_on_discarded(self, contract_repo: pathlib.Path) -> None:
7731 result = runner.invoke(cli, self.CMD + [self.ADDR])
7732 # audit.py discards the return — should surface a warning.
7733 assert "⚠" in result.output
7734
7735 # ── test assertions ───────────────────────────────────────────────────────
7736
7737 def test_contract_shows_test_assertions(self, contract_repo: pathlib.Path) -> None:
7738 result = runner.invoke(cli, self.CMD + [self.ADDR])
7739 assert "assert" in result.output.lower()
7740
7741 def test_contract_shows_assert_result_positive(
7742 self, contract_repo: pathlib.Path
7743 ) -> None:
7744 result = runner.invoke(cli, self.CMD + [self.ADDR])
7745 assert "result > 0" in result.output or "assert" in result.output
7746
7747 # ── --json ────────────────────────────────────────────────────────────────
7748
7749 def test_contract_json_exits_zero(self, contract_repo: pathlib.Path) -> None:
7750 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7751 assert result.exit_code == 0, result.output
7752
7753 def test_contract_json_is_valid(self, contract_repo: pathlib.Path) -> None:
7754 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7755 data = json.loads(result.output)
7756 assert isinstance(data, dict)
7757
7758 def test_contract_json_top_level_keys(self, contract_repo: pathlib.Path) -> None:
7759 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7760 data = json.loads(result.output)
7761 for key in (
7762 "address", "name", "kind", "signature", "parameters",
7763 "return_annotation", "call_sites", "caller_files",
7764 "return_dispositions", "arg_observations",
7765 "test_assertions", "commit_signals", "history",
7766 "preconditions", "postconditions", "warnings", "stability",
7767 ):
7768 assert key in data, f"missing top-level key: {key}"
7769
7770 def test_contract_json_address_matches(self, contract_repo: pathlib.Path) -> None:
7771 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7772 data = json.loads(result.output)
7773 assert data["address"] == self.ADDR
7774
7775 def test_contract_json_name_is_bare(self, contract_repo: pathlib.Path) -> None:
7776 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7777 data = json.loads(result.output)
7778 assert data["name"] == "compute_total"
7779
7780 def test_contract_json_kind_is_function(self, contract_repo: pathlib.Path) -> None:
7781 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7782 data = json.loads(result.output)
7783 assert data["kind"] in {"function", "async_function", "method", "async_method"}
7784
7785 def test_contract_json_signature_contains_def(
7786 self, contract_repo: pathlib.Path
7787 ) -> None:
7788 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7789 data = json.loads(result.output)
7790 assert "def compute_total" in data["signature"]
7791
7792 def test_contract_json_parameters_is_list(self, contract_repo: pathlib.Path) -> None:
7793 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7794 data = json.loads(result.output)
7795 assert isinstance(data["parameters"], list)
7796
7797 def test_contract_json_parameters_not_empty(self, contract_repo: pathlib.Path) -> None:
7798 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7799 data = json.loads(result.output)
7800 assert len(data["parameters"]) >= 1
7801
7802 def test_contract_json_parameters_schema(self, contract_repo: pathlib.Path) -> None:
7803 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7804 data = json.loads(result.output)
7805 p = data["parameters"][0]
7806 for key in ("name", "annotation", "has_default", "default_str"):
7807 assert key in p, f"parameter missing key: {key}"
7808
7809 def test_contract_json_items_param_present(self, contract_repo: pathlib.Path) -> None:
7810 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7811 data = json.loads(result.output)
7812 names = [p["name"] for p in data["parameters"]]
7813 assert "items" in names
7814
7815 def test_contract_json_currency_param_present(
7816 self, contract_repo: pathlib.Path
7817 ) -> None:
7818 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7819 data = json.loads(result.output)
7820 names = [p["name"] for p in data["parameters"]]
7821 assert "currency" in names
7822
7823 def test_contract_json_currency_has_default(self, contract_repo: pathlib.Path) -> None:
7824 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7825 data = json.loads(result.output)
7826 params = {p["name"]: p for p in data["parameters"]}
7827 assert params["currency"]["has_default"] is True
7828
7829 def test_contract_json_currency_default_str(self, contract_repo: pathlib.Path) -> None:
7830 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7831 data = json.loads(result.output)
7832 params = {p["name"]: p for p in data["parameters"]}
7833 assert params["currency"]["default_str"] == "'USD'"
7834
7835 def test_contract_json_call_sites_positive(self, contract_repo: pathlib.Path) -> None:
7836 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7837 data = json.loads(result.output)
7838 assert data["call_sites"] >= 1
7839
7840 def test_contract_json_caller_files_positive(self, contract_repo: pathlib.Path) -> None:
7841 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7842 data = json.loads(result.output)
7843 assert data["caller_files"] >= 1
7844
7845 def test_contract_json_return_dispositions_is_dict(
7846 self, contract_repo: pathlib.Path
7847 ) -> None:
7848 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7849 data = json.loads(result.output)
7850 assert isinstance(data["return_dispositions"], dict)
7851
7852 def test_contract_json_return_dispositions_keys(
7853 self, contract_repo: pathlib.Path
7854 ) -> None:
7855 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7856 data = json.loads(result.output)
7857 rd = data["return_dispositions"]
7858 for key in ("stored", "discarded", "returned", "asserted", "compared"):
7859 assert key in rd, f"return_dispositions missing: {key}"
7860
7861 def test_contract_json_discarded_count_at_least_one(
7862 self, contract_repo: pathlib.Path
7863 ) -> None:
7864 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7865 data = json.loads(result.output)
7866 # audit.py discards the return value
7867 assert data["return_dispositions"].get("discarded", 0) >= 1
7868
7869 def test_contract_json_stored_count_at_least_one(
7870 self, contract_repo: pathlib.Path
7871 ) -> None:
7872 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7873 data = json.loads(result.output)
7874 assert data["return_dispositions"].get("stored", 0) >= 1
7875
7876 def test_contract_json_test_assertions_is_list(
7877 self, contract_repo: pathlib.Path
7878 ) -> None:
7879 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7880 data = json.loads(result.output)
7881 assert isinstance(data["test_assertions"], list)
7882
7883 def test_contract_json_test_assertions_not_empty(
7884 self, contract_repo: pathlib.Path
7885 ) -> None:
7886 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7887 data = json.loads(result.output)
7888 assert len(data["test_assertions"]) >= 1
7889
7890 def test_contract_json_test_assertions_are_strings(
7891 self, contract_repo: pathlib.Path
7892 ) -> None:
7893 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7894 data = json.loads(result.output)
7895 for assertion in data["test_assertions"]:
7896 assert isinstance(assertion, str)
7897
7898 def test_contract_json_history_schema(self, contract_repo: pathlib.Path) -> None:
7899 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7900 data = json.loads(result.output)
7901 h = data["history"]
7902 for key in (
7903 "commits_analysed", "truncated", "major_bumps",
7904 "minor_bumps", "patch_bumps", "sig_changes",
7905 "impl_changes", "est_survival_pct",
7906 ):
7907 assert key in h, f"history missing key: {key}"
7908
7909 def test_contract_json_history_commits_positive(
7910 self, contract_repo: pathlib.Path
7911 ) -> None:
7912 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7913 data = json.loads(result.output)
7914 assert data["history"]["commits_analysed"] > 0
7915
7916 def test_contract_json_history_survival_0_to_100(
7917 self, contract_repo: pathlib.Path
7918 ) -> None:
7919 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7920 data = json.loads(result.output)
7921 pct = data["history"]["est_survival_pct"]
7922 assert 0 <= pct <= 100
7923
7924 def test_contract_json_commit_signals_is_list(
7925 self, contract_repo: pathlib.Path
7926 ) -> None:
7927 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7928 data = json.loads(result.output)
7929 assert isinstance(data["commit_signals"], list)
7930
7931 def test_contract_json_preconditions_is_list(
7932 self, contract_repo: pathlib.Path
7933 ) -> None:
7934 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7935 data = json.loads(result.output)
7936 assert isinstance(data["preconditions"], list)
7937
7938 def test_contract_json_postconditions_is_list(
7939 self, contract_repo: pathlib.Path
7940 ) -> None:
7941 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7942 data = json.loads(result.output)
7943 assert isinstance(data["postconditions"], list)
7944
7945 def test_contract_json_warnings_is_list(self, contract_repo: pathlib.Path) -> None:
7946 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7947 data = json.loads(result.output)
7948 assert isinstance(data["warnings"], list)
7949
7950 def test_contract_json_stability_valid_value(
7951 self, contract_repo: pathlib.Path
7952 ) -> None:
7953 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7954 data = json.loads(result.output)
7955 assert data["stability"] in {"stable", "evolving", "volatile", "dormant"}
7956
7957 def test_contract_json_arg_observations_is_list(
7958 self, contract_repo: pathlib.Path
7959 ) -> None:
7960 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7961 data = json.loads(result.output)
7962 assert isinstance(data["arg_observations"], list)
7963
7964 # ── input validation ──────────────────────────────────────────────────────
7965
7966 def test_contract_missing_address_exits_nonzero(
7967 self, contract_repo: pathlib.Path
7968 ) -> None:
7969 result = runner.invoke(cli, self.CMD)
7970 assert result.exit_code != 0
7971
7972 def test_contract_bad_address_format_exits_nonzero(
7973 self, contract_repo: pathlib.Path
7974 ) -> None:
7975 result = runner.invoke(cli, self.CMD + ["billing_no_colon"])
7976 assert result.exit_code != 0
7977
7978 def test_contract_unknown_address_exits_nonzero(
7979 self, contract_repo: pathlib.Path
7980 ) -> None:
7981 result = runner.invoke(cli, self.CMD + ["billing.py::nonexistent_fn_xyz"])
7982 assert result.exit_code != 0
7983
7984 def test_contract_max_commits_zero_rejected(
7985 self, contract_repo: pathlib.Path
7986 ) -> None:
7987 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7988 assert result.exit_code != 0
7989
7990 def test_contract_max_commits_one_succeeds(self, contract_repo: pathlib.Path) -> None:
7991 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "1"])
7992 assert result.exit_code == 0, result.output
7993
7994 # ── requires repo ─────────────────────────────────────────────────────────
7995
7996 def test_contract_requires_repo(self, tmp_path: pathlib.Path) -> None:
7997 import os
7998
7999 old = os.getcwd()
8000 try:
8001 os.chdir(tmp_path)
8002 result = runner.invoke(cli, self.CMD + [self.ADDR])
8003 assert result.exit_code != 0
8004 finally:
8005 os.chdir(old)
8006
8007 # ── history accuracy ──────────────────────────────────────────────────────
8008
8009 def test_contract_json_impl_changes_nonzero(
8010 self, contract_repo: pathlib.Path
8011 ) -> None:
8012 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8013 data = json.loads(result.output)
8014 # We made 2 body changes (perf rewrite + currency add).
8015 assert data["history"]["impl_changes"] >= 1
8016
8017 def test_contract_json_truncated_false_small_repo(
8018 self, contract_repo: pathlib.Path
8019 ) -> None:
8020 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8021 data = json.loads(result.output)
8022 assert data["history"]["truncated"] is False
8023
8024 def test_contract_json_postconditions_nonempty(
8025 self, contract_repo: pathlib.Path
8026 ) -> None:
8027 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8028 data = json.loads(result.output)
8029 # Should infer at least one postcondition (return value is stored).
8030 assert len(data["postconditions"]) >= 1
8031
8032 def test_contract_json_warnings_nonempty(self, contract_repo: pathlib.Path) -> None:
8033 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8034 data = json.loads(result.output)
8035 # Missing type annotations + discarded return should generate warnings.
8036 assert len(data["warnings"]) >= 1
8037
8038
8039 # ---------------------------------------------------------------------------
8040 # predict
8041 # ---------------------------------------------------------------------------
8042
8043
8044 @pytest.fixture()
8045 def predict_repo(repo: pathlib.Path) -> pathlib.Path:
8046 """Repo with commit history that produces clear prediction signals.
8047
8048 billing.py::compute_total — changed in every commit (high frequency)
8049 billing.py::apply_discount — always co-changes with compute_total (entanglement)
8050 services.py::place_order — changed only once (low confidence)
8051
8052 5 commits are made so that recency, frequency, and co-change signals
8053 are all detectable within the default horizon.
8054 """
8055 # Commit 1 — establish both symbols
8056 (repo / "billing.py").write_text(textwrap.dedent("""\
8057 def compute_total(items):
8058 return sum(i["price"] for i in items)
8059
8060 def apply_discount(total, rate):
8061 return total * (1 - rate)
8062 """))
8063 (repo / "services.py").write_text(textwrap.dedent("""\
8064 def place_order(items):
8065 return True
8066 """))
8067 r1 = runner.invoke(cli, ["commit", "-m", "feat: initial billing"])
8068 assert r1.exit_code == 0, r1.output
8069
8070 # Commits 2-5 — co-evolve compute_total and apply_discount together
8071 for i in range(2, 6):
8072 (repo / "billing.py").write_text(textwrap.dedent(f"""\
8073 def compute_total(items, rev={i}):
8074 total = 0.0
8075 for item in items:
8076 total += float(item["price"])
8077 return total
8078
8079 def apply_discount(total, rate, rev={i}):
8080 return max(0.0, total * (1 - rate))
8081 """))
8082 r = runner.invoke(cli, ["commit", "-m", f"refactor: billing revision {i}"])
8083 assert r.exit_code == 0, r.output
8084
8085 return repo
8086
8087
8088 class TestPredict:
8089 """Tests for ``muse code predict``."""
8090
8091 CMD = ["code", "predict"]
8092
8093 # ── basic correctness ─────────────────────────────────────────────────────
8094
8095 def test_predict_exits_zero(self, predict_repo: pathlib.Path) -> None:
8096 result = runner.invoke(cli, self.CMD)
8097 assert result.exit_code == 0, result.output
8098
8099 def test_predict_shows_header(self, predict_repo: pathlib.Path) -> None:
8100 result = runner.invoke(cli, self.CMD)
8101 assert "Predicted changes" in result.output
8102
8103 def test_predict_shows_horizon(self, predict_repo: pathlib.Path) -> None:
8104 result = runner.invoke(cli, self.CMD)
8105 assert "horizon:" in result.output
8106
8107 def test_predict_shows_commits_analysed(self, predict_repo: pathlib.Path) -> None:
8108 result = runner.invoke(cli, self.CMD)
8109 assert "analysed" in result.output
8110
8111 def test_predict_shows_compute_total(self, predict_repo: pathlib.Path) -> None:
8112 result = runner.invoke(cli, self.CMD)
8113 assert "compute_total" in result.output
8114
8115 def test_predict_shows_apply_discount(self, predict_repo: pathlib.Path) -> None:
8116 result = runner.invoke(cli, self.CMD)
8117 assert "apply_discount" in result.output
8118
8119 def test_predict_shows_score(self, predict_repo: pathlib.Path) -> None:
8120 result = runner.invoke(cli, self.CMD)
8121 # Scores are in N.NN format at the start of each prediction line.
8122 import re
8123 assert re.search(r"0\.\d{2}", result.output)
8124
8125 def test_predict_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8126 result = runner.invoke(cli, self.CMD)
8127 assert "↳" in result.output
8128
8129 def test_predict_high_confidence_band_present(
8130 self, predict_repo: pathlib.Path
8131 ) -> None:
8132 result = runner.invoke(cli, self.CMD)
8133 # compute_total changed 4/5 commits — should be HIGH or MEDIUM.
8134 assert "CONFIDENCE" in result.output
8135
8136 def test_predict_entanglement_signal(self, predict_repo: pathlib.Path) -> None:
8137 result = runner.invoke(cli, self.CMD + ["--horizon", "10"])
8138 # compute_total and apply_discount co-change → entanglement reason expected.
8139 assert "entangled" in result.output or "co-change" in result.output
8140
8141 # ── --top ─────────────────────────────────────────────────────────────────
8142
8143 def test_predict_top_1_shows_one_prediction(
8144 self, predict_repo: pathlib.Path
8145 ) -> None:
8146 result = runner.invoke(cli, self.CMD + ["--top", "1"])
8147 assert result.exit_code == 0, result.output
8148 # With --top 1 there is exactly one score line.
8149 import re
8150 scores = re.findall(r"^\s+0\.\d{2}\s+", result.output, re.MULTILINE)
8151 assert len(scores) == 1
8152
8153 def test_predict_top_0_shows_all(self, predict_repo: pathlib.Path) -> None:
8154 result = runner.invoke(cli, self.CMD + ["--top", "0"])
8155 assert result.exit_code == 0, result.output
8156 assert "compute_total" in result.output
8157
8158 # ── --min-confidence ──────────────────────────────────────────────────────
8159
8160 def test_predict_min_confidence_1_empty(self, predict_repo: pathlib.Path) -> None:
8161 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.0"])
8162 assert result.exit_code == 0, result.output
8163 # Nothing should reach score 1.0 exactly.
8164 assert "No predictions" in result.output or "compute_total" not in result.output
8165
8166 def test_predict_min_confidence_invalid_rejected(
8167 self, predict_repo: pathlib.Path
8168 ) -> None:
8169 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.5"])
8170 assert result.exit_code != 0
8171
8172 def test_predict_min_confidence_zero_shows_all(
8173 self, predict_repo: pathlib.Path
8174 ) -> None:
8175 result = runner.invoke(cli, self.CMD + ["--min-confidence", "0.0"])
8176 assert result.exit_code == 0, result.output
8177 assert "compute_total" in result.output
8178
8179 # ── --horizon ─────────────────────────────────────────────────────────────
8180
8181 def test_predict_horizon_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8182 result = runner.invoke(cli, self.CMD + ["--horizon", "1"])
8183 assert result.exit_code == 0, result.output
8184
8185 def test_predict_horizon_invalid_rejected(self, predict_repo: pathlib.Path) -> None:
8186 result = runner.invoke(cli, self.CMD + ["--horizon", "0"])
8187 assert result.exit_code != 0
8188
8189 def test_predict_max_commits_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8190 result = runner.invoke(cli, self.CMD + ["--max-commits", "1"])
8191 assert result.exit_code == 0, result.output
8192
8193 def test_predict_max_commits_invalid_rejected(
8194 self, predict_repo: pathlib.Path
8195 ) -> None:
8196 result = runner.invoke(cli, self.CMD + ["--max-commits", "0"])
8197 assert result.exit_code != 0
8198
8199 # ── --file ────────────────────────────────────────────────────────────────
8200
8201 def test_predict_file_filter_billing(self, predict_repo: pathlib.Path) -> None:
8202 result = runner.invoke(cli, self.CMD + ["--file", "billing.py"])
8203 assert result.exit_code == 0, result.output
8204 # Should show billing symbols.
8205 if "compute_total" in result.output or "apply_discount" in result.output:
8206 pass # expected
8207 # Should NOT show services.py symbols.
8208 assert "place_order" not in result.output
8209
8210 def test_predict_file_filter_nonexistent_empty(
8211 self, predict_repo: pathlib.Path
8212 ) -> None:
8213 result = runner.invoke(cli, self.CMD + ["--file", "nonexistent_xyz.py"])
8214 assert result.exit_code == 0, result.output
8215 assert "No predictions" in result.output
8216
8217 # ── --explain ─────────────────────────────────────────────────────────────
8218
8219 def test_predict_explain_exits_zero(self, predict_repo: pathlib.Path) -> None:
8220 result = runner.invoke(
8221 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8222 )
8223 assert result.exit_code == 0, result.output
8224
8225 def test_predict_explain_shows_signal_breakdown(
8226 self, predict_repo: pathlib.Path
8227 ) -> None:
8228 result = runner.invoke(
8229 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8230 )
8231 assert "signal breakdown" in result.output
8232
8233 def test_predict_explain_shows_all_signals(
8234 self, predict_repo: pathlib.Path
8235 ) -> None:
8236 result = runner.invoke(
8237 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8238 )
8239 for signal in ("recency", "frequency", "co_change", "sig_instability",
8240 "module_velocity"):
8241 assert signal in result.output, f"missing signal: {signal}"
8242
8243 def test_predict_explain_shows_bar(self, predict_repo: pathlib.Path) -> None:
8244 result = runner.invoke(
8245 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8246 )
8247 assert "█" in result.output or "░" in result.output
8248
8249 def test_predict_explain_shows_score(self, predict_repo: pathlib.Path) -> None:
8250 result = runner.invoke(
8251 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8252 )
8253 assert "Score:" in result.output
8254
8255 def test_predict_explain_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8256 result = runner.invoke(
8257 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8258 )
8259 assert "Reasons" in result.output
8260
8261 def test_predict_explain_bad_format_rejected(
8262 self, predict_repo: pathlib.Path
8263 ) -> None:
8264 result = runner.invoke(cli, self.CMD + ["--explain", "no_colon_here"])
8265 assert result.exit_code != 0
8266
8267 def test_predict_explain_unknown_addr_rejected(
8268 self, predict_repo: pathlib.Path
8269 ) -> None:
8270 result = runner.invoke(
8271 cli, self.CMD + ["--explain", "billing.py::nonexistent_fn_xyz"]
8272 )
8273 assert result.exit_code != 0
8274
8275 # ── --json ────────────────────────────────────────────────────────────────
8276
8277 def test_predict_json_exits_zero(self, predict_repo: pathlib.Path) -> None:
8278 result = runner.invoke(cli, self.CMD + ["--json"])
8279 assert result.exit_code == 0, result.output
8280
8281 def test_predict_json_is_valid(self, predict_repo: pathlib.Path) -> None:
8282 result = runner.invoke(cli, self.CMD + ["--json"])
8283 data = json.loads(result.output)
8284 assert isinstance(data, dict)
8285
8286 def test_predict_json_top_level_keys(self, predict_repo: pathlib.Path) -> None:
8287 result = runner.invoke(cli, self.CMD + ["--json"])
8288 data = json.loads(result.output)
8289 for key in (
8290 "generated_at", "horizon_commits", "max_commits",
8291 "commits_analysed", "truncated", "predictions",
8292 ):
8293 assert key in data, f"missing key: {key}"
8294
8295 def test_predict_json_predictions_is_list(self, predict_repo: pathlib.Path) -> None:
8296 result = runner.invoke(cli, self.CMD + ["--json"])
8297 data = json.loads(result.output)
8298 assert isinstance(data["predictions"], list)
8299
8300 def test_predict_json_predictions_not_empty(
8301 self, predict_repo: pathlib.Path
8302 ) -> None:
8303 result = runner.invoke(cli, self.CMD + ["--json"])
8304 data = json.loads(result.output)
8305 assert len(data["predictions"]) >= 1
8306
8307 def test_predict_json_prediction_schema(self, predict_repo: pathlib.Path) -> None:
8308 result = runner.invoke(cli, self.CMD + ["--json"])
8309 data = json.loads(result.output)
8310 pred = data["predictions"][0]
8311 for key in (
8312 "address", "name", "kind", "file", "score", "confidence",
8313 "reasons", "signals", "last_changed_commit", "last_changed_date",
8314 "top_partners",
8315 ):
8316 assert key in pred, f"prediction missing key: {key}"
8317
8318 def test_predict_json_signals_schema(self, predict_repo: pathlib.Path) -> None:
8319 result = runner.invoke(cli, self.CMD + ["--json"])
8320 data = json.loads(result.output)
8321 signals = data["predictions"][0]["signals"]
8322 for key in ("recency", "frequency", "co_change", "sig_instability",
8323 "module_velocity"):
8324 assert key in signals, f"signals missing key: {key}"
8325
8326 def test_predict_json_score_is_float(self, predict_repo: pathlib.Path) -> None:
8327 result = runner.invoke(cli, self.CMD + ["--json"])
8328 data = json.loads(result.output)
8329 assert isinstance(data["predictions"][0]["score"], float)
8330
8331 def test_predict_json_score_in_range(self, predict_repo: pathlib.Path) -> None:
8332 result = runner.invoke(cli, self.CMD + ["--json"])
8333 data = json.loads(result.output)
8334 for pred in data["predictions"]:
8335 assert 0.0 <= pred["score"] <= 1.0, (
8336 f"score out of range: {pred['score']}"
8337 )
8338
8339 def test_predict_json_confidence_valid(self, predict_repo: pathlib.Path) -> None:
8340 result = runner.invoke(cli, self.CMD + ["--json"])
8341 data = json.loads(result.output)
8342 for pred in data["predictions"]:
8343 assert pred["confidence"] in {"high", "medium", "low"}, (
8344 f"invalid confidence: {pred['confidence']}"
8345 )
8346
8347 def test_predict_json_sorted_by_score_desc(self, predict_repo: pathlib.Path) -> None:
8348 result = runner.invoke(cli, self.CMD + ["--json"])
8349 data = json.loads(result.output)
8350 scores = [p["score"] for p in data["predictions"]]
8351 assert scores == sorted(scores, reverse=True)
8352
8353 def test_predict_json_commits_analysed_positive(
8354 self, predict_repo: pathlib.Path
8355 ) -> None:
8356 result = runner.invoke(cli, self.CMD + ["--json"])
8357 data = json.loads(result.output)
8358 assert data["commits_analysed"] > 0
8359
8360 def test_predict_json_truncated_false_small_repo(
8361 self, predict_repo: pathlib.Path
8362 ) -> None:
8363 result = runner.invoke(cli, self.CMD + ["--json"])
8364 data = json.loads(result.output)
8365 assert data["truncated"] is False
8366
8367 def test_predict_json_top_partners_is_list(self, predict_repo: pathlib.Path) -> None:
8368 result = runner.invoke(cli, self.CMD + ["--json"])
8369 data = json.loads(result.output)
8370 for pred in data["predictions"]:
8371 assert isinstance(pred["top_partners"], list)
8372
8373 def test_predict_json_partner_schema(self, predict_repo: pathlib.Path) -> None:
8374 result = runner.invoke(cli, self.CMD + ["--json"])
8375 data = json.loads(result.output)
8376 # Find a prediction that has partners.
8377 for pred in data["predictions"]:
8378 if pred["top_partners"]:
8379 p = pred["top_partners"][0]
8380 for key in ("address", "co_change_rate", "co_change_commits"):
8381 assert key in p, f"partner missing key: {key}"
8382 break
8383
8384 def test_predict_json_co_change_rate_in_range(
8385 self, predict_repo: pathlib.Path
8386 ) -> None:
8387 result = runner.invoke(cli, self.CMD + ["--json"])
8388 data = json.loads(result.output)
8389 for pred in data["predictions"]:
8390 for p in pred["top_partners"]:
8391 assert 0.0 <= p["co_change_rate"] <= 1.0
8392
8393 def test_predict_json_top_1_returns_one(self, predict_repo: pathlib.Path) -> None:
8394 result = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
8395 data = json.loads(result.output)
8396 assert len(data["predictions"]) == 1
8397
8398 def test_predict_json_horizon_matches_arg(self, predict_repo: pathlib.Path) -> None:
8399 result = runner.invoke(cli, self.CMD + ["--json", "--horizon", "3"])
8400 data = json.loads(result.output)
8401 assert data["horizon_commits"] == 3
8402
8403 def test_predict_json_reasons_is_list(self, predict_repo: pathlib.Path) -> None:
8404 result = runner.invoke(cli, self.CMD + ["--json"])
8405 data = json.loads(result.output)
8406 for pred in data["predictions"]:
8407 assert isinstance(pred["reasons"], list)
8408 assert all(isinstance(r, str) for r in pred["reasons"])
8409
8410 # ── requires repo ─────────────────────────────────────────────────────────
8411
8412 def test_predict_requires_repo(self, tmp_path: pathlib.Path) -> None:
8413 import os
8414
8415 old = os.getcwd()
8416 try:
8417 os.chdir(tmp_path)
8418 result = runner.invoke(cli, self.CMD)
8419 assert result.exit_code != 0
8420 finally:
8421 os.chdir(old)
8422
8423
8424 # ---------------------------------------------------------------------------
8425 # Helpers
8426 # ---------------------------------------------------------------------------
8427
8428
8429 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
8430 """Return all commit IDs from the store, newest-first (by log order)."""
8431 from muse.core.commits import get_all_commits
8432 commits = get_all_commits(repo)
8433 return [c.commit_id for c in commits]
File History 1 commit
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 15 days ago