gabriel / muse public
test_code_commands.py python
8,319 lines 367.2 KB
Raw
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 42 days ago
1 """Integration tests for code-domain CLI commands.
2
3 Uses a real Muse repository initialised in tmp_path.
4
5 Coverage
6 --------
7 Provenance & Topology
8 muse lineage ADDRESS [--json]
9 muse api-surface [--diff REF] [--json]
10 muse codemap [--top N] [--json]
11 muse clones [--tier exact|near|both] [--json]
12 muse checkout-symbol ADDRESS --commit REF [--dry-run]
13 muse semantic-cherry-pick ADDRESS... --from REF [--dry-run] [--json]
14
15 Query & Temporal Search
16 muse query PREDICATE [--all-commits] [--json]
17 muse query-history PREDICATE [--from REF] [--to REF] [--json]
18
19 Index Commands
20 muse index status [--json]
21 muse index rebuild [--index NAME]
22
23 Refactor Detection
24 muse detect-refactor --json (schema_version in output)
25
26 Multi-Agent Coordination
27 muse reserve ADDRESS...
28 muse intent ADDRESS... --op OP
29 muse forecast [--json]
30 muse plan-merge OURS THEIRS [--json]
31 muse shard --agents N [--json]
32 muse reconcile [--json]
33
34 Structural Enforcement
35 muse breakage [--json]
36 muse invariants [--json]
37
38 Semantic Versioning Metadata
39 muse log shows SemVer for commits with bumps
40 muse commit stores sem_ver_bump in CommitRecord
41
42 Call-Graph Tier
43 muse impact ADDRESS [--json]
44 muse dead [--json]
45 muse coverage CLASS_ADDRESS [--json]
46 muse deps ADDRESS_OR_FILE [--json]
47 muse find-symbol [--name NAME] [--json]
48 muse patch ADDRESS FILE
49 """
50
51 import json
52 import pathlib
53 import textwrap
54
55 import pytest
56 from tests.cli_test_helper import CliRunner
57
58 from typing import TypedDict
59
60 from muse._version import __version__
61 cli = None # argparse migration — CliRunner ignores this arg
62 from muse.core.store import CommitDict, get_head_commit_id
63 from muse.core._types import Manifest
64
65 type _ImportsMap = dict[str, list[str]]
66 type _ImportsSetMap = dict[str, set[str]]
67 type _KindsMap = dict[str, int]
68
69 runner = CliRunner()
70
71
72 # ---------------------------------------------------------------------------
73 # Shared fixtures
74 # ---------------------------------------------------------------------------
75
76
77 @pytest.fixture
78 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
79 """Initialise a fresh code-domain Muse repo."""
80 monkeypatch.chdir(tmp_path)
81 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
82 result = runner.invoke(cli, ["init", "--domain", "code"])
83 assert result.exit_code == 0, result.output
84 return tmp_path
85
86
87 @pytest.fixture
88 def code_repo(repo: pathlib.Path) -> pathlib.Path:
89 """Repo with two Python commits for analysis commands."""
90 work = repo
91 # Commit 1 — define compute_total and Invoice class.
92 (work / "billing.py").write_text(textwrap.dedent("""\
93 class Invoice:
94 def compute_total(self, items):
95 return sum(items)
96
97 def apply_discount(self, total, pct):
98 return total * (1 - pct)
99
100 def process_order(invoice, items):
101 return invoice.compute_total(items)
102 """))
103 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
104 assert r.exit_code == 0, r.output
105
106 # Commit 2 — rename compute_total, add new function.
107 (work / "billing.py").write_text(textwrap.dedent("""\
108 class Invoice:
109 def compute_invoice_total(self, items):
110 return sum(items)
111
112 def apply_discount(self, total, pct):
113 return total * (1 - pct)
114
115 def generate_pdf(self):
116 return b"pdf"
117
118 def process_order(invoice, items):
119 return invoice.compute_invoice_total(items)
120
121 def send_email(address):
122 pass
123 """))
124 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
125 assert r.exit_code == 0, r.output
126 return repo
127
128
129 # ---------------------------------------------------------------------------
130 # muse lineage
131 # ---------------------------------------------------------------------------
132
133
134 class TestLineage:
135 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
136 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
137 assert result.exit_code == 0, result.output
138
139 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
141 assert result.exit_code == 0, result.output
142 data = json.loads(result.output)
143 assert isinstance(data, dict)
144 assert "events" in data
145
146 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
147 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_func"])
148 # Should not crash — exit 0 or 1, but no unhandled exception.
149 assert result.exit_code in (0, 1)
150
151 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
152 monkeypatch.chdir(tmp_path)
153 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
154 assert result.exit_code != 0
155
156
157 # ---------------------------------------------------------------------------
158 # muse api-surface
159 # ---------------------------------------------------------------------------
160
161
162 class TestApiSurface:
163 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
164 result = runner.invoke(cli, ["code", "api-surface"])
165 assert result.exit_code == 0, result.output
166
167 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
168 result = runner.invoke(cli, ["code", "api-surface", "--json"])
169 assert result.exit_code == 0
170 data = json.loads(result.output)
171 assert isinstance(data, dict)
172
173 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
174 commits = _all_commit_ids(code_repo)
175 if len(commits) >= 2:
176 result = runner.invoke(cli, ["code", "api-surface", "--diff", commits[-2]])
177 assert result.exit_code == 0
178
179 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
180 result = runner.invoke(cli, ["code", "api-surface"])
181 assert result.exit_code in (0, 1)
182
183
184 # ---------------------------------------------------------------------------
185 # muse codemap
186 # ---------------------------------------------------------------------------
187
188
189 class TestCodemap:
190 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
191 result = runner.invoke(cli, ["code", "codemap"])
192 assert result.exit_code == 0, result.output
193
194 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
195 result = runner.invoke(cli, ["code", "codemap", "--top", "3"])
196 assert result.exit_code == 0
197
198 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
199 result = runner.invoke(cli, ["code", "codemap", "--json"])
200 assert result.exit_code == 0
201 data = json.loads(result.output)
202 assert isinstance(data, dict)
203
204
205 # ---------------------------------------------------------------------------
206 # muse clones
207 # ---------------------------------------------------------------------------
208
209
210 class TestClones:
211 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
212 result = runner.invoke(cli, ["code", "clones"])
213 assert result.exit_code == 0, result.output
214
215 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
216 result = runner.invoke(cli, ["code", "clones", "--tier", "exact"])
217 assert result.exit_code == 0
218
219 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
220 result = runner.invoke(cli, ["code", "clones", "--tier", "near"])
221 assert result.exit_code == 0
222
223 def test_clones_json(self, code_repo: pathlib.Path) -> None:
224 result = runner.invoke(cli, ["code", "clones", "--tier", "both", "--json"])
225 assert result.exit_code == 0
226 data = json.loads(result.output)
227 assert isinstance(data, dict)
228
229
230 # ---------------------------------------------------------------------------
231 # muse checkout-symbol
232 # ---------------------------------------------------------------------------
233
234
235 class TestCheckoutSymbol:
236 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
237 commits = _all_commit_ids(code_repo)
238 if len(commits) < 2:
239 pytest.skip("need at least 2 commits")
240 first_commit = commits[-2] # oldest commit (list is newest-first)
241 result = runner.invoke(cli, [
242 "code", "checkout-symbol", "--commit", first_commit, "--dry-run",
243 "billing.py::Invoice.compute_total",
244 ])
245 # May fail if symbol is not present; should not crash unhandled.
246 assert result.exit_code in (0, 1, 2)
247
248 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
249 result = runner.invoke(cli, ["code", "checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
250 assert result.exit_code != 0
251
252
253 # ---------------------------------------------------------------------------
254 # muse semantic-cherry-pick
255 # ---------------------------------------------------------------------------
256
257
258 class TestSemanticCherryPick:
259 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
260 commits = _all_commit_ids(code_repo)
261 if len(commits) < 2:
262 pytest.skip("need at least 2 commits")
263 first_commit = commits[-2]
264 result = runner.invoke(cli, [
265 "code", "semantic-cherry-pick",
266 "--from", first_commit,
267 "--dry-run",
268 "billing.py::Invoice.compute_total",
269 ])
270 assert result.exit_code in (0, 1)
271
272 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
273 result = runner.invoke(cli, ["code", "semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
274 assert result.exit_code != 0
275
276
277 # ---------------------------------------------------------------------------
278 # muse query
279 # ---------------------------------------------------------------------------
280
281
282 class TestQueryV2:
283 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
284 result = runner.invoke(cli, ["code", "query", "kind=function"])
285 assert result.exit_code == 0, result.output
286
287 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
288 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
289 assert result.exit_code == 0
290 data = json.loads(result.output)
291 assert "schema_version" in data
292 assert data["schema_version"] == __version__
293
294 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
295 result = runner.invoke(cli, ["code", "query", "kind=function", "OR", "kind=method"])
296 assert result.exit_code == 0
297
298 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
299 result = runner.invoke(cli, ["code", "query", "NOT", "kind=import"])
300 assert result.exit_code == 0
301
302 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
303 result = runner.invoke(cli, ["code", "query", "--all-commits", "kind=function"])
304 assert result.exit_code == 0
305
306 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
307 result = runner.invoke(cli, ["code", "query", "name~=total"])
308 assert result.exit_code == 0
309 # Should find compute_invoice_total.
310 assert "total" in result.output.lower()
311
312 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
313 # query with kind=class to match everything of a known type.
314 result = runner.invoke(cli, ["code", "query", "kind=class"])
315 assert result.exit_code == 0
316 assert "Invoice" in result.output
317
318 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
319 result = runner.invoke(cli, ["code", "query", "lineno_gt=1"])
320 assert result.exit_code == 0
321
322 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
323 monkeypatch.chdir(tmp_path)
324 result = runner.invoke(cli, ["code", "query", "kind=function"])
325 assert result.exit_code != 0
326
327 # ── new v2.1 flags ────────────────────────────────────────────────────────
328
329 def test_query_count_only(self, code_repo: pathlib.Path) -> None:
330 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
331 assert result.exit_code == 0, result.output
332 # Output should be a single integer.
333 assert result.output.strip().isdigit()
334
335 def test_query_count_nonzero(self, code_repo: pathlib.Path) -> None:
336 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
337 assert int(result.output.strip()) >= 1
338
339 def test_query_limit_caps_results(self, code_repo: pathlib.Path) -> None:
340 all_r = runner.invoke(cli, ["code", "query", "kind=function"])
341 lim_r = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
342 assert lim_r.exit_code == 0, lim_r.output
343 # Limited output should be shorter than unlimited.
344 assert len(lim_r.output) <= len(all_r.output)
345
346 def test_query_limit_truncation_noted(self, code_repo: pathlib.Path) -> None:
347 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
348 assert "limited to 1" in result.output or "match" in result.output
349
350 def test_query_limit_zero_unlimited(self, code_repo: pathlib.Path) -> None:
351 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "0"])
352 assert result.exit_code == 0, result.output
353
354 def test_query_sort_name(self, code_repo: pathlib.Path) -> None:
355 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "name"])
356 assert result.exit_code == 0, result.output
357
358 def test_query_sort_size(self, code_repo: pathlib.Path) -> None:
359 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "size"])
360 assert result.exit_code == 0, result.output
361 # Size column should appear in output.
362 assert "L" in result.output
363
364 def test_query_sort_kind(self, code_repo: pathlib.Path) -> None:
365 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "kind"])
366 assert result.exit_code == 0, result.output
367
368 def test_query_sort_lineno(self, code_repo: pathlib.Path) -> None:
369 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "lineno"])
370 assert result.exit_code == 0, result.output
371
372 def test_query_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
373 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "zzz"])
374 assert result.exit_code != 0
375
376 def test_query_unique_bodies_exits_zero(self, code_repo: pathlib.Path) -> None:
377 result = runner.invoke(cli, ["code", "query", "kind=function", "--unique-bodies"])
378 assert result.exit_code == 0, result.output
379
380 def test_query_unique_bodies_count_lte_all(self, code_repo: pathlib.Path) -> None:
381 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
382 uniq_r = runner.invoke(cli, ["code", "query", "--count", "--unique-bodies", "kind=function"])
383 assert int(uniq_r.output.strip()) <= int(all_r.output.strip())
384
385 def test_query_size_gt_predicate(self, code_repo: pathlib.Path) -> None:
386 result = runner.invoke(cli, ["code", "query", "kind=function", "size_gt=0"])
387 assert result.exit_code == 0, result.output
388
389 def test_query_size_lt_predicate(self, code_repo: pathlib.Path) -> None:
390 result = runner.invoke(cli, ["code", "query", "kind=function", "size_lt=1000"])
391 assert result.exit_code == 0, result.output
392
393 def test_query_size_gt_excludes_small(self, code_repo: pathlib.Path) -> None:
394 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
395 large_r = runner.invoke(cli, ["code", "query", "--count", "kind=function", "size_gt=100"])
396 # Large-only count should be <= total.
397 assert int(large_r.output.strip()) <= int(all_r.output.strip())
398
399 def test_query_json_includes_size(self, code_repo: pathlib.Path) -> None:
400 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
401 data = json.loads(result.output)
402 for r in data["results"]:
403 assert "size" in r
404
405 def test_query_json_includes_sort_field(self, code_repo: pathlib.Path) -> None:
406 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--sort", "name"])
407 data = json.loads(result.output)
408 assert data["sort"] == "name"
409
410 def test_query_json_includes_unique_bodies(self, code_repo: pathlib.Path) -> None:
411 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--unique-bodies"])
412 data = json.loads(result.output)
413 assert data["unique_bodies"] is True
414
415 def test_query_since_without_all_commits_rejected(self, code_repo: pathlib.Path) -> None:
416 result = runner.invoke(cli, ["code", "query", "kind=function", "--since", "2026-01-01"])
417 assert result.exit_code != 0
418
419 def test_query_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
420 result = runner.invoke(
421 cli,
422 ["code", "query", "kind=function", "--all-commits", "--since", "not-a-date"],
423 )
424 assert result.exit_code != 0
425
426 def test_query_all_commits_since_future_empty(self, code_repo: pathlib.Path) -> None:
427 result = runner.invoke(
428 cli,
429 ["code", "query", "kind=function", "--all-commits", "--since", "2099-01-01"],
430 )
431 assert result.exit_code == 0, result.output
432 # Future date means no commits match.
433 assert "no symbols" in result.output.lower() or result.output.strip() == ""
434
435 def test_query_max_commits_caps_walk(self, code_repo: pathlib.Path) -> None:
436 result = runner.invoke(
437 cli,
438 ["code", "query", "kind=function", "--all-commits", "--max-commits", "1"],
439 )
440 assert result.exit_code == 0, result.output
441
442
443 # ---------------------------------------------------------------------------
444 # muse query-history
445 # ---------------------------------------------------------------------------
446
447
448 class TestQueryHistory:
449 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
450 result = runner.invoke(cli, ["code", "query-history", "kind=function"])
451 assert result.exit_code == 0, result.output
452
453 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
454 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=function"])
455 assert result.exit_code == 0
456 data = json.loads(result.output)
457 assert "schema_version" in data
458 assert data["schema_version"] == __version__
459 assert "results" in data
460
461 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
462 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
463 assert result.exit_code == 0
464
465 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
466 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
467 assert result.exit_code == 0
468 data = json.loads(result.output)
469 for entry in data.get("results", []):
470 assert "commit_count" in entry
471 assert "change_count" in entry
472
473 # ── new v2 flags ──────────────────────────────────────────────────────────
474
475 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
476 result = runner.invoke(
477 cli, ["code", "query-history", "--changed-only", "kind=function"]
478 )
479 assert result.exit_code == 0, result.output
480
481 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
482 result = runner.invoke(
483 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
484 )
485 assert result.exit_code == 0
486 data = json.loads(result.output)
487 for entry in data["results"]:
488 assert entry["change_count"] > 1
489
490 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
491 result = runner.invoke(
492 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
493 )
494 assert result.exit_code == 0, result.output
495
496 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
497 result = runner.invoke(
498 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
499 )
500 assert result.exit_code == 0, result.output
501
502 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
503 result = runner.invoke(
504 cli, ["code", "query-history", "--sort", "first", "kind=function"]
505 )
506 assert result.exit_code == 0, result.output
507
508 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
509 result = runner.invoke(
510 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
511 )
512 assert result.exit_code != 0
513
514 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
515 result = runner.invoke(
516 cli, ["code", "query-history", "--count", "kind=function"]
517 )
518 assert result.exit_code == 0, result.output
519 assert result.output.strip().isdigit()
520 assert int(result.output.strip()) >= 1
521
522 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
523 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
524 lim_r = runner.invoke(
525 cli, ["code", "query-history", "--limit", "1", "kind=function"]
526 )
527 assert lim_r.exit_code == 0, lim_r.output
528 assert len(lim_r.output) <= len(all_r.output)
529
530 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
531 result = runner.invoke(
532 cli, ["code", "query-history", "--limit", "1", "kind=function"]
533 )
534 assert "1" in result.output
535
536 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
537 result = runner.invoke(
538 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
539 )
540 assert result.exit_code == 0
541 data = json.loads(result.output)
542 for entry in data["results"]:
543 assert entry["change_count"] >= 2
544
545 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
546 result = runner.invoke(
547 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
548 )
549 assert result.exit_code != 0
550
551 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
552 result = runner.invoke(
553 cli, ["code", "query-history", "--introduced-only", "kind=function"]
554 )
555 assert result.exit_code == 0, result.output
556
557 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
558 result = runner.invoke(
559 cli, ["code", "query-history", "--removed-only", "kind=function"]
560 )
561 assert result.exit_code == 0, result.output
562
563 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
564 result = runner.invoke(
565 cli,
566 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
567 )
568 assert result.exit_code == 0
569 data = json.loads(result.output)
570 assert data["mode"] == "introduced-only"
571 assert "symbols_found" in data
572 for entry in data["results"]:
573 assert entry["status"] == "introduced"
574
575 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
576 result = runner.invoke(
577 cli,
578 ["code", "query-history", "--removed-only", "--json", "kind=function"],
579 )
580 assert result.exit_code == 0
581 data = json.loads(result.output)
582 assert data["mode"] == "removed-only"
583 assert "symbols_found" in data
584 for entry in data["results"]:
585 assert entry["status"] == "removed"
586
587 def test_query_history_mode_flags_mutually_exclusive(
588 self, code_repo: pathlib.Path
589 ) -> None:
590 result = runner.invoke(
591 cli,
592 [
593 "code", "query-history",
594 "--changed-only", "--introduced-only",
595 "kind=function",
596 ],
597 )
598 assert result.exit_code != 0
599
600 def test_query_history_json_has_full_commit_ids(
601 self, code_repo: pathlib.Path
602 ) -> None:
603 result = runner.invoke(
604 cli, ["code", "query-history", "--json", "kind=function"]
605 )
606 assert result.exit_code == 0
607 data = json.loads(result.output)
608 for entry in data["results"]:
609 # Full commit IDs should be present (not just 8-char short form).
610 assert len(entry["first_commit_id"]) > 8
611 assert "stable" in entry
612
613 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
614 result = runner.invoke(
615 cli,
616 ["code", "query-history", "--max-commits", "1", "kind=function"],
617 )
618 assert result.exit_code == 0, result.output
619
620 def test_query_history_introduced_count_only(
621 self, code_repo: pathlib.Path
622 ) -> None:
623 result = runner.invoke(
624 cli,
625 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
626 )
627 assert result.exit_code == 0
628 assert result.output.strip().isdigit()
629
630
631 # ---------------------------------------------------------------------------
632 # muse index
633 # ---------------------------------------------------------------------------
634
635
636 class TestIndexCommands:
637 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
638 result = runner.invoke(cli, ["code", "index", "status"])
639 assert result.exit_code == 0, result.output
640
641 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
642 result = runner.invoke(cli, ["code", "index", "status"])
643 # Indexes have not been built yet.
644 assert "absent" in result.output.lower() or result.exit_code == 0
645
646 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
647 result = runner.invoke(cli, ["code", "index", "rebuild"])
648 assert result.exit_code == 0, result.output
649
650 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
651 runner.invoke(cli, ["code", "index", "rebuild"])
652 idx_dir = code_repo / ".muse" / "indices"
653 assert idx_dir.exists()
654
655 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
656 runner.invoke(cli, ["code", "index", "rebuild"])
657 result = runner.invoke(cli, ["code", "index", "status"])
658 assert result.exit_code == 0
659 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
660 assert "entries" in result.output.lower() or "✅" in result.output
661
662 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
663 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
664 assert result.exit_code == 0
665
666 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
667 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
668 assert result.exit_code == 0
669
670
671 # ---------------------------------------------------------------------------
672 # muse detect-refactor
673 # ---------------------------------------------------------------------------
674
675
676 class TestHotspots:
677 """Tests for muse code hotspots."""
678
679 # ── basic correctness ────────────────────────────────────────────────────
680
681 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
682 result = runner.invoke(cli, ["code", "hotspots"])
683 assert result.exit_code == 0, result.output
684
685 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
686 """compute_invoice_total was modified across two commits — must appear."""
687 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
688 assert result.exit_code == 0, result.output
689 assert "billing.py" in result.output
690
691 def test_hotspots_excludes_imports_by_default(
692 self, code_repo: pathlib.Path
693 ) -> None:
694 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
695 assert result.exit_code == 0, result.output
696 assert "::import::" not in result.output
697
698 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
699 """--include-imports must surface import pseudo-symbols if any exist."""
700 result = runner.invoke(
701 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
702 )
703 assert result.exit_code == 0, result.output
704 # Just verify it runs cleanly; the repo may or may not have import ops.
705
706 # ── --kind filter (was broken before) ────────────────────────────────────
707
708 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
709 """--kind function must not return class symbols."""
710 result = runner.invoke(
711 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
712 )
713 assert result.exit_code == 0, result.output
714 for line in result.output.splitlines():
715 if "::" in line and "class" in line.lower():
716 # Make sure any class line is not a function kind result
717 # (Addresses that contain the word "class" in their name are OK)
718 pass # Name may contain "class" as substring
719
720 def test_kind_filter_function_returns_functions(
721 self, code_repo: pathlib.Path
722 ) -> None:
723 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
724 result_fn = runner.invoke(
725 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
726 )
727 assert result_fn.exit_code == 0, result_fn.output
728 # filtered result should have <= symbols than unfiltered
729 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
730 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
731 assert len(fn_lines) <= len(all_lines)
732
733 # ── --min filter ──────────────────────────────────────────────────────────
734
735 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
736 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
737 result_min = runner.invoke(
738 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
739 )
740 assert result_min.exit_code == 0, result_min.output
741 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
742 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
743 assert len(min_lines) <= len(all_lines)
744
745 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
746 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
747 assert result.exit_code == 1
748
749 # ── --language filter ─────────────────────────────────────────────────────
750
751 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
752 result = runner.invoke(
753 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
754 )
755 assert result.exit_code == 0, result.output
756 assert "billing.py" in result.output
757
758 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
759 result = runner.invoke(
760 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
761 )
762 assert result.exit_code == 0, result.output
763
764 # ── --top validation ──────────────────────────────────────────────────────
765
766 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
767 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
768 assert result.exit_code == 1
769
770 # ── JSON schema ───────────────────────────────────────────────────────────
771
772 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
773 result = runner.invoke(cli, ["code", "hotspots", "--json"])
774 assert result.exit_code == 0, result.output
775 data = json.loads(result.output)
776 for key in (
777 "from_ref", "to_ref", "commits_analysed", "truncated",
778 "filters", "hotspots",
779 ):
780 assert key in data, f"missing key: {key}"
781 assert isinstance(data["hotspots"], list)
782 assert isinstance(data["truncated"], bool)
783 assert isinstance(data["commits_analysed"], int)
784
785 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
786 result = runner.invoke(
787 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
788 )
789 data = json.loads(result.output)
790 assert data["filters"]["kind"] == "function"
791 assert data["filters"]["min_changes"] == 2
792 assert data["filters"]["include_imports"] is False
793
794 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
795 result = runner.invoke(cli, ["code", "hotspots", "--json"])
796 data = json.loads(result.output)
797 if data["hotspots"]:
798 entry = data["hotspots"][0]
799 assert "address" in entry
800 assert "changes" in entry
801 assert isinstance(entry["changes"], int)
802 assert entry["changes"] >= 1
803
804 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
805 result = runner.invoke(cli, ["code", "hotspots", "--json"])
806 data = json.loads(result.output)
807 addresses = [h["address"] for h in data["hotspots"]]
808 assert not any("::import::" in a for a in addresses)
809
810 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
811 result = runner.invoke(cli, ["code", "hotspots", "--json"])
812 data = json.loads(result.output)
813 counts = [h["changes"] for h in data["hotspots"]]
814 assert counts == sorted(counts, reverse=True)
815
816 # ── --max-commits truncation ──────────────────────────────────────────────
817
818 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
819 result = runner.invoke(
820 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
821 )
822 assert result.exit_code == 0, result.output
823 data = json.loads(result.output)
824 assert data["commits_analysed"] <= 1
825
826 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
827 result = runner.invoke(
828 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
829 )
830 data = json.loads(result.output)
831 assert data["truncated"] is True
832
833
834 class TestDetectRefactorV2:
835 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
836 """JSON output contains all required top-level fields."""
837 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
838 assert result.exit_code == 0, result.output
839 data = json.loads(result.output)
840 for field in ("schema_version", "from", "to", "commits_scanned",
841 "truncated", "total", "events"):
842 assert field in data, f"missing field '{field}'"
843 assert data["schema_version"] == __version__
844 assert isinstance(data["commits_scanned"], int)
845 assert isinstance(data["truncated"], bool)
846 assert isinstance(data["total"], int)
847 assert isinstance(data["events"], list)
848
849 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
850 """Each JSON event contains the required fields."""
851 # Run over the full history; code_repo has at least one rename event.
852 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
853 assert result.exit_code == 0, result.output
854 data = json.loads(result.output)
855 for ev in data["events"]:
856 for field in ("kind", "address", "detail",
857 "commit_id", "commit_message", "committed_at"):
858 assert field in ev, f"missing event field '{field}'"
859 assert ev["kind"] in ("rename", "move", "signature", "implementation")
860
861 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
862 """A commit that renames a symbol produces a 'rename' event."""
863 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
864 assert result.exit_code == 0, result.output
865 data = json.loads(result.output)
866 kinds = [e["kind"] for e in data["events"]]
867 assert "rename" in kinds, (
868 f"Expected at least one rename event; got: {sorted(set(kinds))}"
869 )
870
871 def test_detect_refactor_classifies_modified_as_implementation(
872 self, code_repo: pathlib.Path
873 ) -> None:
874 """Replace ops with '(modified)' in new_summary are classified as implementation.
875
876 Previously, only '(implementation changed)' triggered implementation
877 classification; '(modified)' was silently dropped.
878 """
879 import datetime
880 root = code_repo
881 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
882 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
883 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
884 branch = read_current_branch(root)
885 head_id = get_head_commit_id(root, branch)
886
887 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
888 message = "perf: optimise batch"
889 snap_manifest: Manifest = {}
890 snap_id = compute_snapshot_id(snap_manifest)
891 parent_ids = [head_id] if head_id else []
892 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
893 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
894 commit = CommitRecord(
895 commit_id=commit_id,
896 repo_id=repo_id,
897 branch=branch,
898 snapshot_id=snap_id,
899 message=message,
900 committed_at=now,
901 parent_commit_id=head_id,
902 author="test",
903 structured_delta=StructuredDelta(ops=[PatchOp(
904 op="patch",
905 address="billing.py",
906 child_ops=[ReplaceOp(
907 op="replace",
908 address="billing.py::process_batch",
909 new_summary="function process_batch (modified) L10–30",
910 old_summary="function process_batch",
911 )],
912 )]),
913 )
914 write_commit(root, commit)
915 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
916
917 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
918 assert result.exit_code == 0, result.output
919 data = json.loads(result.output)
920 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
921 addrs = [e["address"] for e in impl_events]
922 assert "billing.py::process_batch" in addrs, (
923 f"'(modified)' op not classified as implementation; events: {data['events']}"
924 )
925
926 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
927 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
928 import datetime
929 root = code_repo
930 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
931 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
932 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
933 branch = read_current_branch(root)
934 head_id = get_head_commit_id(root, branch)
935
936 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
937 message = "style: reformat"
938 snap_manifest: Manifest = {}
939 snap_id = compute_snapshot_id(snap_manifest)
940 parent_ids = [head_id] if head_id else []
941 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
942 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
943 commit = CommitRecord(
944 commit_id=commit_id,
945 repo_id=repo_id,
946 branch=branch,
947 snapshot_id=snap_id,
948 message=message,
949 committed_at=now,
950 parent_commit_id=head_id,
951 author="test",
952 structured_delta=StructuredDelta(ops=[PatchOp(
953 op="patch",
954 address="billing.py",
955 child_ops=[ReplaceOp(
956 op="replace",
957 address="billing.py::UniqueReformattedSymbol",
958 new_summary="reformatted — no semantic change",
959 old_summary="",
960 )],
961 )]),
962 )
963 write_commit(root, commit)
964 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
965
966 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
967 assert result.exit_code == 0, result.output
968 data = json.loads(result.output)
969 # The reformatted op must not appear as an event.
970 reformatted_events = [
971 e for e in data["events"]
972 if e["address"] == "billing.py::UniqueReformattedSymbol"
973 ]
974 assert reformatted_events == [], (
975 f"Reformatted op should be skipped; got: {reformatted_events}"
976 )
977
978 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
979 """When --max is hit, a truncation warning appears in human output."""
980 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
981 assert result.exit_code == 0, result.output
982 assert "incomplete" in result.output or "limit" in result.output
983
984 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
985 """When --max is hit, truncated=true in JSON."""
986 result = runner.invoke(
987 cli, ["code", "detect-refactor", "--max", "1", "--json"]
988 )
989 assert result.exit_code == 0, result.output
990 data = json.loads(result.output)
991 assert data["truncated"] is True
992 assert data["commits_scanned"] == 1
993
994 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
995 """--max 0 exits non-zero."""
996 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
997 assert result.exit_code != 0
998
999 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1000 """``--kind rename`` returns only rename events."""
1001 result = runner.invoke(
1002 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1003 )
1004 assert result.exit_code == 0, result.output
1005 data = json.loads(result.output)
1006 for ev in data["events"]:
1007 assert ev["kind"] == "rename"
1008
1009 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1010 """``--kind`` with an invalid value exits non-zero."""
1011 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1012 assert result.exit_code != 0
1013
1014 def test_detect_refactor_bfs_follows_merge_parent2(
1015 self, code_repo: pathlib.Path
1016 ) -> None:
1017 """BFS walk finds refactoring events on merged feature branches."""
1018 import datetime
1019 root = code_repo
1020 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
1021 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
1022 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
1023 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1024 branch = read_current_branch(root)
1025 head_id = get_head_commit_id(root, branch)
1026 assert head_id is not None
1027
1028 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1029 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1030
1031 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1032 feature_id = compute_commit_id([head_id], feat_snap_id, "perf: vectorise", feat_at.isoformat())
1033 write_commit(root, CommitRecord(
1034 commit_id=feature_id,
1035 repo_id=repo_id,
1036 branch="feat/perf",
1037 snapshot_id=feat_snap_id,
1038 message="perf: vectorise",
1039 committed_at=feat_at,
1040 parent_commit_id=head_id,
1041 author="test",
1042 structured_delta=StructuredDelta(ops=[PatchOp(
1043 op="patch",
1044 address="billing.py",
1045 child_ops=[ReplaceOp(
1046 op="replace",
1047 address="billing.py::vectorised_fn",
1048 new_summary="function vectorised_fn (implementation changed) L1–20",
1049 old_summary="function vectorised_fn",
1050 )],
1051 )]),
1052 ))
1053 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1054 merge_id = compute_commit_id([head_id, feature_id], merge_snap_id, "merge feat/perf", merge_at.isoformat())
1055 write_commit(root, CommitRecord(
1056 commit_id=merge_id,
1057 repo_id=repo_id,
1058 branch=branch,
1059 snapshot_id=merge_snap_id,
1060 message="merge feat/perf",
1061 committed_at=merge_at,
1062 parent_commit_id=head_id,
1063 parent2_commit_id=feature_id,
1064 author="test",
1065 ))
1066 (root / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
1067
1068 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1069 assert result.exit_code == 0, result.output
1070 data = json.loads(result.output)
1071 addrs = [e["address"] for e in data["events"]]
1072 assert "billing.py::vectorised_fn" in addrs, (
1073 "BFS must find the implementation event on the feature branch"
1074 )
1075
1076
1077 # ---------------------------------------------------------------------------
1078 # muse reserve
1079 # ---------------------------------------------------------------------------
1080
1081
1082 class TestReserve:
1083 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1084 result = runner.invoke(cli, [
1085 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1086 ])
1087 assert result.exit_code == 0, result.output
1088
1089 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1090 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1091 coord_dir = code_repo / ".muse" / "coordination" / "reservations"
1092 assert coord_dir.exists()
1093 files = list(coord_dir.glob("*.json"))
1094 assert len(files) >= 1
1095
1096 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1097 result = runner.invoke(cli, [
1098 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1099 ])
1100 assert result.exit_code == 0
1101 data = json.loads(result.output)
1102 assert "reservation_id" in data
1103
1104 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1105 result = runner.invoke(cli, [
1106 "coord", "reserve", "--run-id", "r3",
1107 "billing.py::process_order",
1108 "billing.py::Invoice.apply_discount",
1109 ])
1110 assert result.exit_code == 0
1111
1112 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1113 result = runner.invoke(cli, [
1114 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1115 "billing.py::process_order",
1116 ])
1117 assert result.exit_code == 0
1118
1119 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1120 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1121 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1122 # Should warn but not fail.
1123 assert result.exit_code == 0
1124 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1125
1126
1127 # ---------------------------------------------------------------------------
1128 # muse intent
1129 # ---------------------------------------------------------------------------
1130
1131
1132 class TestIntent:
1133 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1134 result = runner.invoke(cli, [
1135 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1136 "billing.py::process_order",
1137 ])
1138 assert result.exit_code == 0, result.output
1139
1140 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1141 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1142 idir = code_repo / ".muse" / "coordination" / "intents"
1143 assert idir.exists()
1144 assert len(list(idir.glob("*.json"))) >= 1
1145
1146 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1147 result = runner.invoke(cli, [
1148 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1149 ])
1150 assert result.exit_code == 0
1151 data = json.loads(result.output)
1152 assert "intent_id" in data or "operation" in data
1153
1154
1155 # ---------------------------------------------------------------------------
1156 # muse forecast
1157 # ---------------------------------------------------------------------------
1158
1159
1160 class TestForecast:
1161 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1162 result = runner.invoke(cli, ["coord", "forecast"])
1163 assert result.exit_code == 0, result.output
1164
1165 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1166 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1167 assert result.exit_code == 0
1168 data = json.loads(result.output)
1169 assert "conflicts" in data
1170
1171 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1172 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1173 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1174 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1175 assert result.exit_code == 0
1176 data = json.loads(result.output)
1177 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1178 assert "address_overlap" in types
1179
1180
1181 # ---------------------------------------------------------------------------
1182 # muse plan-merge
1183 # ---------------------------------------------------------------------------
1184
1185
1186 class TestPlanMerge:
1187 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1188 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1189 assert result.exit_code == 0, result.output
1190
1191 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1192 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1193 assert result.exit_code == 0
1194 data = json.loads(result.output)
1195 assert "conflicts" in data or isinstance(data, dict)
1196
1197 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1198 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1199 assert result.exit_code != 0
1200
1201
1202 # ---------------------------------------------------------------------------
1203 # muse shard
1204 # ---------------------------------------------------------------------------
1205
1206
1207 class TestShard:
1208 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1209 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1210 assert result.exit_code == 0, result.output
1211
1212 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1213 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1214 assert result.exit_code == 0
1215 data = json.loads(result.output)
1216 assert "shards" in data
1217
1218 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1219 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1220 assert result.exit_code == 0
1221
1222 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1223 # N larger than symbol count still works (produces fewer shards).
1224 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1225 assert result.exit_code == 0
1226
1227
1228 # ---------------------------------------------------------------------------
1229 # muse reconcile
1230 # ---------------------------------------------------------------------------
1231
1232
1233 class TestReconcile:
1234 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1235 result = runner.invoke(cli, ["coord", "reconcile"])
1236 assert result.exit_code == 0, result.output
1237
1238 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1239 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1240 assert result.exit_code == 0
1241 data = json.loads(result.output)
1242 assert isinstance(data, dict)
1243
1244
1245 # ---------------------------------------------------------------------------
1246 # muse breakage
1247 # ---------------------------------------------------------------------------
1248
1249
1250 class TestBreakage:
1251 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1252 result = runner.invoke(cli, ["code", "breakage"])
1253 assert result.exit_code == 0, result.output
1254
1255 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1256 result = runner.invoke(cli, ["code", "breakage", "--json"])
1257 assert result.exit_code == 0
1258 data = json.loads(result.output)
1259 # breakage JSON has "issues" list and error count.
1260 assert "issues" in data
1261 assert isinstance(data["issues"], list)
1262
1263 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1264 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1265 assert result.exit_code == 0
1266
1267 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1268 monkeypatch.chdir(tmp_path)
1269 result = runner.invoke(cli, ["code", "breakage"])
1270 assert result.exit_code != 0
1271
1272
1273 # ---------------------------------------------------------------------------
1274 # muse invariants
1275 # ---------------------------------------------------------------------------
1276
1277
1278 class TestInvariants:
1279 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1280 result = runner.invoke(cli, ["code", "invariants"])
1281 toml_path = code_repo / ".muse" / "invariants.toml"
1282 assert result.exit_code == 0 or toml_path.exists()
1283
1284 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1285 # Create empty invariants.toml
1286 (code_repo / ".muse" / "invariants.toml").write_text("# No rules\n")
1287 result = runner.invoke(cli, ["code", "invariants", "--json"])
1288 assert result.exit_code == 0
1289 # Output may be JSON or human-readable depending on rules count.
1290 output = result.output.strip()
1291 if output and not output.startswith("#"):
1292 try:
1293 data = json.loads(output)
1294 assert isinstance(data, dict)
1295 except json.JSONDecodeError:
1296 pass # Human-readable output is also acceptable.
1297
1298 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1299 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1300 [[rules]]
1301 type = "no_cycles"
1302 name = "no import cycles"
1303 """))
1304 result = runner.invoke(cli, ["code", "invariants"])
1305 assert result.exit_code == 0
1306
1307 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1308 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1309 [[rules]]
1310 type = "forbidden_dependency"
1311 name = "billing must not import utils"
1312 source_pattern = "billing.py"
1313 forbidden_pattern = "utils.py"
1314 """))
1315 result = runner.invoke(cli, ["code", "invariants"])
1316 assert result.exit_code == 0
1317
1318 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1319 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1320 [[rules]]
1321 type = "required_test"
1322 name = "billing must have tests"
1323 source_pattern = "billing.py"
1324 test_pattern = "test_billing.py"
1325 """))
1326 result = runner.invoke(cli, ["code", "invariants"])
1327 # May pass or fail depending on whether test_billing.py exists; should not crash.
1328 assert result.exit_code in (0, 1)
1329
1330 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1331 (code_repo / ".muse" / "invariants.toml").write_text("# empty\n")
1332 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1333 assert result.exit_code == 0
1334
1335
1336 # ---------------------------------------------------------------------------
1337 # muse commit — semantic versioning
1338 # ---------------------------------------------------------------------------
1339
1340
1341 class TestSemVerInCommit:
1342 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1343 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1344 commit_id = get_head_commit_id(code_repo, "main")
1345 assert commit_id is not None
1346 commit = read_commit(code_repo, commit_id)
1347 assert commit is not None
1348 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1349
1350 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1351 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1352 commit_id = get_head_commit_id(code_repo, "main")
1353 assert commit_id is not None
1354 commit = read_commit(code_repo, commit_id)
1355 assert commit is not None
1356 assert isinstance(commit.breaking_changes, list)
1357
1358 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1359 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1360 commit_id = get_head_commit_id(code_repo, "main")
1361 assert commit_id is not None
1362 commit = read_commit(code_repo, commit_id)
1363 assert commit is not None
1364 if commit.sem_ver_bump == "major":
1365 result = runner.invoke(cli, ["log"])
1366 assert "MAJOR" in result.output or "major" in result.output.lower()
1367
1368
1369 # ---------------------------------------------------------------------------
1370 # Call-graph tier — muse impact
1371 # ---------------------------------------------------------------------------
1372
1373
1374 class TestImpact:
1375 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1376 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1377 assert result.exit_code == 0, result.output
1378
1379 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1380 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1381 assert result.exit_code == 0
1382 data = json.loads(result.output)
1383 assert isinstance(data, dict)
1384 assert "blast_radius" in data
1385 assert "total" in data
1386 assert "commit_id" in data
1387 assert data["mode"] == "reverse"
1388
1389 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1390 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1391 assert result.exit_code in (0, 1)
1392
1393 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1394 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1395 assert result.exit_code == 0
1396 assert result.output.strip().isdigit()
1397
1398 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1399 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1400 assert result.exit_code == 1
1401
1402 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1403 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1404 assert result.exit_code == 0
1405
1406 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1407 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1408 assert result.exit_code == 0
1409 data = json.loads(result.output)
1410 assert data["mode"] == "forward"
1411 assert "callees" in data
1412 assert "total" in data
1413 assert "commit_id" in data
1414
1415 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1416 result = runner.invoke(cli, [
1417 "code", "impact", "--forward", "--compare", "HEAD",
1418 "--", "billing.py::process_order",
1419 ])
1420 assert result.exit_code == 1
1421
1422 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1423 result = runner.invoke(cli, [
1424 "code", "impact", "--file", "billing.py",
1425 "--", "billing.py::Invoice.compute_invoice_total",
1426 ])
1427 assert result.exit_code == 0
1428
1429 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1430 result = runner.invoke(cli, [
1431 "code", "impact", "--file", "billing.py", "--json",
1432 "--", "billing.py::Invoice.compute_invoice_total",
1433 ])
1434 assert result.exit_code == 0
1435 data = json.loads(result.output)
1436 assert data["file_filter"] == "billing.py"
1437 for depth_addrs in data["blast_radius"].values():
1438 for addr in depth_addrs:
1439 assert addr.startswith("billing.py::")
1440
1441 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1442 result = runner.invoke(cli, [
1443 "code", "impact", "--compare", "HEAD",
1444 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1445 ])
1446 assert result.exit_code == 0
1447 data = json.loads(result.output)
1448 assert "compare_commit_id" in data
1449 assert "added_callers" in data
1450 assert "removed_callers" in data
1451 assert "net_change" in data
1452 assert isinstance(data["added_callers"], list)
1453 assert isinstance(data["removed_callers"], list)
1454
1455 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1456 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1457 assert result.exit_code == 0
1458 assert result.output.strip().isdigit()
1459
1460
1461 # ---------------------------------------------------------------------------
1462 # Call-graph tier — muse dead
1463 # ---------------------------------------------------------------------------
1464
1465
1466 class TestDead:
1467 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1468 result = runner.invoke(cli, ["code", "dead"])
1469 assert result.exit_code == 0, result.output
1470
1471 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1472 result = runner.invoke(cli, ["code", "dead", "--json"])
1473 assert result.exit_code == 0
1474 data = json.loads(result.output)
1475 assert isinstance(data, dict)
1476 assert "results" in data
1477 assert "high_confidence_count" in data
1478 assert "total_files_scanned" in data
1479 assert "elapsed_seconds" in data
1480
1481 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1482 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1483 assert result.exit_code == 0
1484
1485 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1486 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1487 assert result.exit_code == 0
1488
1489 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1490 result = runner.invoke(cli, ["code", "dead", "--count"])
1491 assert result.exit_code == 0
1492 assert result.output.strip().isdigit()
1493
1494 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1495 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1496 assert result.exit_code == 0
1497 data = json.loads(result.output)
1498 assert "compare_commit_id" in data
1499 assert "new_dead" in data
1500 assert "recovered" in data
1501 assert "net_change" in data
1502 assert isinstance(data["new_dead"], list)
1503 assert isinstance(data["recovered"], list)
1504
1505 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1506 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1507 assert result.exit_code == 0
1508
1509 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1510 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1511 assert result.exit_code == 1
1512
1513 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1514 out_file = tmp_path / "allowlist.json"
1515 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1516 assert result.exit_code == 0
1517 if out_file.exists():
1518 data = json.loads(out_file.read_text())
1519 assert isinstance(data, list)
1520 assert all(isinstance(x, str) for x in data)
1521
1522 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1523 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1524 assert result.exit_code == 0
1525 data = json.loads(result.output)
1526 for c in data["results"]:
1527 assert c["confidence"] == "high"
1528
1529 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1530 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1531 assert result.exit_code == 0
1532
1533
1534 # ---------------------------------------------------------------------------
1535 # muse code cat
1536 # ---------------------------------------------------------------------------
1537
1538
1539 class TestCat:
1540 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1541 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1542 assert result.exit_code == 0, result.output
1543 assert "class Invoice" in result.output
1544
1545 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1546 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1547 assert result.exit_code == 0, result.output
1548 assert "def compute_invoice_total" in result.output
1549
1550 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1551 # Invoice is unique — short name should resolve.
1552 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1553 assert result.exit_code == 0
1554
1555 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1556 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1557 assert result.exit_code != 0
1558
1559 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1560 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1561 assert result.exit_code != 0
1562
1563 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1564 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1565 assert result.exit_code != 0
1566
1567 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1568 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1569 assert result.exit_code == 0
1570 # Line numbers prefix lines with digits.
1571 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1572 first_code_line = next((ln for ln in lines if ln.strip()), "")
1573 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1574
1575 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1576 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1577 assert result.exit_code == 0
1578 data = json.loads(result.output)
1579 assert "results" in data
1580 assert "errors" in data
1581 assert "source_ref" in data
1582 assert len(data["results"]) == 1
1583 r = data["results"][0]
1584 assert r["file_path"] == "billing.py"
1585 assert r["kind"] in ("class", "function", "method")
1586 assert isinstance(r["lineno"], int)
1587 assert isinstance(r["end_lineno"], int)
1588 assert "class Invoice" in r["source"]
1589
1590 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1591 result = runner.invoke(
1592 cli,
1593 [
1594 "code", "cat",
1595 "billing.py::Invoice",
1596 "billing.py::Invoice.compute_invoice_total",
1597 "--json",
1598 ],
1599 )
1600 assert result.exit_code == 0, result.output
1601 data = json.loads(result.output)
1602 assert len(data["results"]) == 2
1603
1604 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1605 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1606 assert result.exit_code == 0
1607 assert "Invoice" in result.output
1608
1609 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1610 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1611 assert result.exit_code == 0
1612
1613 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1614 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1615 assert result.exit_code == 0
1616 data = json.loads(result.output)
1617 assert len(data["results"]) > 0
1618 # Every result has required fields.
1619 for r in data["results"]:
1620 assert "address" in r
1621 assert "lineno" in r
1622 assert "source" in r
1623
1624 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1625 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1626 result_ctx = runner.invoke(
1627 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1628 )
1629 assert result_ctx.exit_code == 0
1630 # With context we get at least as many lines.
1631 plain_lines = result_plain.output.count("\n")
1632 ctx_lines = result_ctx.output.count("\n")
1633 assert ctx_lines >= plain_lines
1634
1635 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1636 # In --json mode a missing symbol goes to the errors field, not a crash.
1637 result = runner.invoke(
1638 cli,
1639 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1640 )
1641 # Output must be valid JSON (no stderr bleed into stdout).
1642 data = json.loads(result.output)
1643 assert len(data["results"]) == 1
1644 assert len(data["errors"]) == 1
1645 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1646
1647 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1648 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1649 assert result.exit_code == 0
1650 assert "working tree" in result.output
1651
1652 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1653 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1654 assert result.exit_code == 0
1655 assert "Invoice" in result.output
1656
1657 def test_cat_wrong_file_fallback_finds_symbol(
1658 self, code_repo: pathlib.Path, tmp_path: pathlib.Path
1659 ) -> None:
1660 """FILE::SYMBOL where SYMBOL lives in a different file — should fall back
1661 to a global snapshot search and cat it from its actual location, exit 0."""
1662 # Add a second file with a unique function the billing module doesn't have.
1663 work = pathlib.Path.cwd()
1664 (work / "utils.py").write_text(
1665 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1666 )
1667 runner.invoke(cli, ["commit", "-m", "Add utils"])
1668
1669 # Ask for utils.format_currency but specify the wrong file (billing.py).
1670 result = runner.invoke(
1671 cli, ["code", "cat", "billing.py::format_currency"]
1672 )
1673 assert result.exit_code == 0, result.output
1674 assert "format_currency" in result.output
1675
1676 def test_cat_wrong_file_fallback_json(self, code_repo: pathlib.Path) -> None:
1677 """Same fallback in --json mode: result is in results[], not errors[]."""
1678 work = pathlib.Path.cwd()
1679 (work / "utils.py").write_text(
1680 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1681 )
1682 runner.invoke(cli, ["commit", "-m", "Add utils"])
1683
1684 result = runner.invoke(
1685 cli, ["code", "cat", "billing.py::format_currency", "--json"]
1686 )
1687 assert result.exit_code == 0, result.output
1688 data = json.loads(result.output)
1689 assert len(data["results"]) == 1
1690 assert data["results"][0]["symbol"] == "format_currency"
1691 assert data["results"][0]["file_path"] == "utils.py"
1692
1693 def test_cat_wrong_file_fallback_ambiguous_exits_nonzero(
1694 self, code_repo: pathlib.Path
1695 ) -> None:
1696 """If the symbol exists in multiple files, fallback reports ambiguity and exits 1."""
1697 work = pathlib.Path.cwd()
1698 (work / "utils.py").write_text("def send_email(to): pass\n")
1699 runner.invoke(cli, ["commit", "-m", "Duplicate send_email in utils"])
1700
1701 # billing.py already has send_email; utils.py now also has it.
1702 result = runner.invoke(
1703 cli, ["code", "cat", "nope.py::send_email"]
1704 )
1705 assert result.exit_code != 0
1706
1707 def test_cat_truly_missing_symbol_still_errors(self, code_repo: pathlib.Path) -> None:
1708 """A symbol that doesn't exist anywhere in the snapshot still exits 1."""
1709 result = runner.invoke(cli, ["code", "cat", "billing.py::AbsolutelyNowhere"])
1710 assert result.exit_code != 0
1711
1712
1713 # ---------------------------------------------------------------------------
1714 # Call-graph tier — muse coverage
1715 # ---------------------------------------------------------------------------
1716
1717
1718 class TestCoverage:
1719 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1720 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1721 assert result.exit_code == 0, result.output
1722
1723 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1724 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1725 assert result.exit_code == 0
1726 data = json.loads(result.output)
1727 assert isinstance(data, dict)
1728 assert "methods" in data
1729 assert "total_methods" in data
1730 assert "covered" in data
1731 assert "percent" in data
1732 assert "commit_id" in data
1733 assert "filters" in data
1734 for m in data["methods"]:
1735 assert "address" in m
1736 assert "called" in m
1737 assert "callers" in m
1738
1739 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1740 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1741 assert result.exit_code in (0, 1)
1742
1743 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1744 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1745 assert result.exit_code == 0
1746 # Output should be "n/total" format
1747 assert "/" in result.output.strip()
1748
1749 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1750 result = runner.invoke(cli, [
1751 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1752 ])
1753 assert result.exit_code == 0
1754 data = json.loads(result.output)
1755 assert data["filters"]["exclude_dunder"] is True
1756 for m in data["methods"]:
1757 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1758
1759 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1760 result = runner.invoke(cli, [
1761 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1762 ])
1763 assert result.exit_code == 0
1764 data = json.loads(result.output)
1765 assert data["filters"]["exclude_private"] is True
1766
1767 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1768 result = runner.invoke(cli, [
1769 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1770 ])
1771 assert result.exit_code == 0
1772 data = json.loads(result.output)
1773 assert data["filters"]["min_callers"] == 2
1774
1775 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1776 result = runner.invoke(cli, [
1777 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1778 ])
1779 assert result.exit_code == 0
1780 data = json.loads(result.output)
1781 assert data["filters"]["exclude_self"] is True
1782 # All reported callers should be from a different file
1783 for m in data["methods"]:
1784 for caller in m["callers"]:
1785 assert not caller.startswith("billing.py::")
1786
1787 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1788 result = runner.invoke(cli, [
1789 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1790 ])
1791 assert result.exit_code == 0
1792 data = json.loads(result.output)
1793 assert "compare_commit_id" in data
1794 assert "newly_covered" in data
1795 assert "newly_uncovered" in data
1796 assert "percent_change" in data
1797
1798 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1799 result = runner.invoke(cli, [
1800 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1801 ])
1802 assert result.exit_code == 0
1803
1804 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1805 result = runner.invoke(cli, [
1806 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1807 ])
1808 assert result.exit_code == 0
1809
1810
1811 # ---------------------------------------------------------------------------
1812 # Call-graph tier — muse deps
1813 # ---------------------------------------------------------------------------
1814
1815
1816 class TestDeps:
1817 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1818 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1819 assert result.exit_code == 0, result.output
1820
1821 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1822 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1823 assert result.exit_code == 0
1824
1825 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1826 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1827 assert result.exit_code == 0
1828 data = json.loads(result.output)
1829 assert isinstance(data, dict)
1830
1831 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1832 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1833 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1834
1835 # ── new flags ──────────────────────────────────────────────────────────────
1836
1837 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1838 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1839 assert result.exit_code == 0, result.output
1840 assert result.output.strip().isdigit()
1841
1842 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1843 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1844 assert result.exit_code == 0, result.output
1845 assert result.output.strip().isdigit()
1846
1847 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1848 result = runner.invoke(
1849 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1850 )
1851 assert result.exit_code == 0, result.output
1852
1853 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1854 # --depth > 1 in file mode is fine (just filters imports as before).
1855 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1856 assert result.exit_code == 0, result.output
1857
1858 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1859 result = runner.invoke(
1860 cli,
1861 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1862 )
1863 assert result.exit_code != 0
1864
1865 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1866 result = runner.invoke(
1867 cli,
1868 ["code", "deps", "--reverse", "--depth", "2",
1869 "billing.py::Invoice.compute_invoice_total"],
1870 )
1871 assert result.exit_code == 0, result.output
1872
1873 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1874 result = runner.invoke(
1875 cli,
1876 ["code", "deps", "--transitive",
1877 "billing.py::Invoice.compute_invoice_total"],
1878 )
1879 assert result.exit_code == 0, result.output
1880
1881 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1882 result = runner.invoke(
1883 cli,
1884 ["code", "deps", "--transitive", "--count",
1885 "billing.py::Invoice.compute_invoice_total"],
1886 )
1887 assert result.exit_code == 0
1888 assert result.output.strip().isdigit()
1889
1890 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1891 result = runner.invoke(
1892 cli,
1893 ["code", "deps", "--transitive", "--json",
1894 "billing.py::Invoice.compute_invoice_total"],
1895 )
1896 assert result.exit_code == 0
1897 data = json.loads(result.output)
1898 assert "by_depth" in data
1899 assert data["transitive"] is True
1900
1901 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1902 result = runner.invoke(
1903 cli,
1904 ["code", "deps", "--reverse", "--depth", "2", "--json",
1905 "billing.py::Invoice.compute_invoice_total"],
1906 )
1907 assert result.exit_code == 0
1908 data = json.loads(result.output)
1909 assert "by_depth" in data
1910 assert data["depth"] == 2
1911
1912 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1913 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1914 assert result.exit_code != 0
1915
1916 def test_deps_empty_file_rel_in_symbol_rejected(
1917 self, code_repo: pathlib.Path
1918 ) -> None:
1919 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1920 assert result.exit_code != 0
1921
1922 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1923 result = runner.invoke(
1924 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1925 )
1926 assert result.exit_code == 0
1927 data = json.loads(result.output)
1928 assert "imported_by" in data
1929 assert isinstance(data["imported_by"], list)
1930
1931
1932 # ---------------------------------------------------------------------------
1933 # Call-graph tier — muse find-symbol
1934 # ---------------------------------------------------------------------------
1935
1936
1937 class TestFindSymbol:
1938 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1939 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1940 assert result.exit_code == 0, result.output
1941
1942 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
1943 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
1944 assert result.exit_code == 0
1945 data = json.loads(result.output)
1946 assert isinstance(data, dict)
1947 assert "results" in data
1948 assert "query" in data
1949 assert "total" in data
1950
1951 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
1952 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
1953 assert result.exit_code == 0
1954 assert result.output is not None
1955
1956 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
1957 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
1958 assert result.exit_code == 0
1959 assert "no matching" in result.output
1960
1961 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
1962 result = runner.invoke(cli, ["code", "find-symbol"])
1963 assert result.exit_code == 1
1964
1965 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
1966 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
1967 assert result.exit_code == 0
1968 assert result.output.strip().isdigit()
1969
1970 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1971 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
1972 assert result.exit_code == 1
1973
1974 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
1975 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
1976 assert result.exit_code == 1
1977
1978 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
1979 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
1980 assert result.exit_code == 1
1981
1982 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
1983 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
1984 assert result.exit_code == 1
1985
1986 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
1987 result = runner.invoke(cli, [
1988 "code", "find-symbol", "--name", "process_order",
1989 "--since", "2099-01-01",
1990 ])
1991 assert result.exit_code == 0
1992 assert "no matching" in result.output
1993
1994 def test_find_limit(self, code_repo: pathlib.Path) -> None:
1995 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
1996 assert result.exit_code == 0
1997
1998 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
1999 result = runner.invoke(cli, [
2000 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
2001 ])
2002 assert result.exit_code == 0
2003
2004 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
2005 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
2006 assert result.exit_code == 0
2007 data = json.loads(result.output)
2008 for ap in data["results"]:
2009 assert ap["name"].lower().startswith("process")
2010
2011 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
2012 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2013 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
2014 assert result_all.exit_code == 0
2015 assert result_first.exit_code == 0
2016 count_all = int(result_all.output.strip())
2017 count_first = int(result_first.output.strip())
2018 assert count_first <= count_all
2019
2020 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
2021 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
2022 assert result.exit_code == 0
2023 data = json.loads(result.output)
2024 assert "query" in data
2025 assert "results" in data
2026 assert "total" in data
2027 assert data["total"] == len(data["results"])
2028 if data["results"]:
2029 ap = data["results"][0]
2030 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
2031 assert key in ap
2032
2033
2034 # ---------------------------------------------------------------------------
2035 # Call-graph tier — muse patch
2036 # ---------------------------------------------------------------------------
2037
2038
2039 class TestPatch:
2040 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
2041 new_impl = textwrap.dedent("""\
2042 def send_email(address):
2043 return f"Sending to {address}"
2044 """)
2045 impl_file = code_repo / "send_email_impl.py"
2046 impl_file.write_text(new_impl)
2047 # patch takes ADDRESS SOURCE — put options before address.
2048 result = runner.invoke(cli, [
2049 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
2050 ])
2051 assert result.exit_code in (0, 1, 2)
2052
2053 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
2054 bad_impl = "def broken(\n not valid python at all{"
2055 bad_file = code_repo / "bad.py"
2056 bad_file.write_text(bad_impl)
2057 result = runner.invoke(cli, [
2058 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2059 ])
2060 # Invalid syntax must be rejected or command handles gracefully.
2061 assert result.exit_code in (0, 1, 2)
2062
2063
2064 # ---------------------------------------------------------------------------
2065 # Security — path traversal guards
2066 # ---------------------------------------------------------------------------
2067
2068
2069 class TestPatchPathTraversal:
2070 """patch must reject addresses whose file component escapes the repo root."""
2071
2072 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2073 body = code_repo / "body.py"
2074 body.write_text("def foo(): pass\n")
2075 result = runner.invoke(cli, [
2076 "code", "patch",
2077 "--body", str(body),
2078 "../../etc/passwd::foo",
2079 ])
2080 assert result.exit_code == 1
2081
2082 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2083 body = code_repo / "body.py"
2084 body.write_text("def foo(): pass\n")
2085 result = runner.invoke(cli, [
2086 "code", "patch",
2087 "--body", str(body),
2088 "../../../tmp/evil::foo",
2089 ])
2090 assert result.exit_code == 1
2091
2092 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2093 """--json flag returns parseable JSON on a dry-run."""
2094 body = code_repo / "body.py"
2095 body.write_text("def send_email(address):\n return address\n")
2096 result = runner.invoke(cli, [
2097 "code", "patch",
2098 "--body", str(body),
2099 "--dry-run",
2100 "--json",
2101 "billing.py::send_email",
2102 ])
2103 # Address may or may not exist; if it exits 0 the output must be JSON.
2104 if result.exit_code == 0:
2105 data = json.loads(result.output)
2106 assert data["address"] == "billing.py::send_email"
2107 assert data["dry_run"] is True
2108
2109
2110 class TestCheckoutSymbolPathTraversal:
2111 """checkout-symbol must reject addresses whose file component escapes root."""
2112
2113 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2114 result = runner.invoke(cli, [
2115 "code", "checkout-symbol",
2116 "--commit", "HEAD",
2117 "../../etc/passwd::foo",
2118 ])
2119 assert result.exit_code == 1
2120
2121 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2122 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2123 result = runner.invoke(cli, [
2124 "code", "checkout-symbol",
2125 "--commit", "HEAD",
2126 "--json",
2127 "billing.py::nonexistent_func_xyz",
2128 ])
2129 # Either exits 1 (symbol not found) — but must not crash.
2130 assert result.exit_code in (0, 1)
2131
2132
2133 class TestSemanticCherryPickPathTraversal:
2134 """semantic-cherry-pick must reject addresses that escape the repo root."""
2135
2136 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2137 result = runner.invoke(cli, [
2138 "code", "semantic-cherry-pick",
2139 "--from", "HEAD",
2140 "../../etc/passwd::foo",
2141 ])
2142 # The traversal-rejected symbol is recorded as not_found but the
2143 # command exits 0 (failed symbols don't abort the batch).
2144 # The key invariant is that no file outside the repo is written.
2145 # We assert exit_code is 0 (graceful) and the output does NOT write.
2146 assert result.exit_code in (0, 1)
2147 # No file was created outside the repo.
2148 assert not pathlib.Path("/etc/passwd_copy").exists()
2149
2150 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2151 result = runner.invoke(cli, [
2152 "code", "semantic-cherry-pick",
2153 "--from", "HEAD",
2154 "--json",
2155 "../../etc/passwd::foo",
2156 ])
2157 assert result.exit_code in (0, 1)
2158 if result.exit_code == 0:
2159 data = json.loads(result.output)
2160 assert data["applied"] == 0
2161 # The traversal-escaped address should be marked as not_found
2162 results = data.get("results", [])
2163 assert any(r["status"] == "not_found" for r in results)
2164
2165
2166 # ---------------------------------------------------------------------------
2167 # muse code blame
2168 # ---------------------------------------------------------------------------
2169
2170
2171 @pytest.fixture
2172 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2173 """Repo with four commits: seed → creation → modification → rename.
2174
2175 A seed commit is required so that the billing.py creation commit has
2176 a parent (and therefore a structured_delta with insert ops).
2177
2178 Timeline (oldest → newest):
2179 commit 0: README.md only (seed — gives billing.py commit a parent)
2180 commit 1: billing.py created — defines compute_total + process_order
2181 commit 2: compute_total implementation modified (same name)
2182 commit 3: compute_total renamed to compute_invoice_total
2183 """
2184 work = repo
2185
2186 # Seed commit so billing.py introduction has a parent and structured_delta.
2187 (work / "README.md").write_text("# Billing module\n")
2188 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2189 assert r.exit_code == 0, r.output
2190
2191 (work / "billing.py").write_text(textwrap.dedent("""\
2192 def compute_total(items):
2193 return sum(items)
2194
2195 def process_order(items):
2196 return compute_total(items)
2197 """))
2198 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2199 assert r.exit_code == 0, r.output
2200
2201 (work / "billing.py").write_text(textwrap.dedent("""\
2202 def compute_total(items):
2203 # faster implementation
2204 return sum(x for x in items)
2205
2206 def process_order(items):
2207 return compute_total(items)
2208 """))
2209 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2210 assert r.exit_code == 0, r.output
2211
2212 (work / "billing.py").write_text(textwrap.dedent("""\
2213 def compute_invoice_total(items):
2214 # faster implementation
2215 return sum(x for x in items)
2216
2217 def process_order(items):
2218 return compute_invoice_total(items)
2219 """))
2220 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2221 assert r.exit_code == 0, r.output
2222
2223 return repo
2224
2225
2226 class TestBlame:
2227 """Tests for muse code blame."""
2228
2229 # ── address validation ───────────────────────────────────────────────────
2230
2231 def test_invalid_address_no_separator_exits_error(
2232 self, blame_repo: pathlib.Path
2233 ) -> None:
2234 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2235 assert result.exit_code == 1
2236 assert "Invalid address" in result.output or "::" in result.output
2237
2238 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2239 result = runner.invoke(
2240 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2241 )
2242 assert result.exit_code == 1
2243
2244 # ── basic correctness (no rename involved) ───────────────────────────────
2245
2246 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2247 """A symbol that was never renamed should have created + modified events."""
2248 result = runner.invoke(
2249 cli, ["code", "blame", "billing.py::process_order", "--json"]
2250 )
2251 assert result.exit_code == 0, result.output
2252 data = json.loads(result.output)
2253 kinds = [ev["event"] for ev in data["events"]]
2254 assert "created" in kinds
2255
2256 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2257 result = runner.invoke(
2258 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2259 )
2260 assert result.exit_code == 0
2261 assert "no events found" in result.output
2262
2263 # ── rename tracking — new name (the critical regression) ─────────────────
2264
2265 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2266 """Blaming the POST-rename name must find the rename event."""
2267 result = runner.invoke(
2268 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2269 )
2270 assert result.exit_code == 0, result.output
2271 data = json.loads(result.output)
2272 kinds = [ev["event"] for ev in data["events"]]
2273 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2274
2275 def test_blame_new_name_follows_into_old_history(
2276 self, blame_repo: pathlib.Path
2277 ) -> None:
2278 """After finding the rename, blame must continue tracking the old name.
2279
2280 The symbol was created as compute_total → modified → renamed.
2281 Blaming compute_invoice_total should find ALL three events.
2282 """
2283 result = runner.invoke(
2284 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2285 )
2286 assert result.exit_code == 0, result.output
2287 data = json.loads(result.output)
2288 kinds = [ev["event"] for ev in data["events"]]
2289 assert "created" in kinds, f"Expected created event, got: {kinds}"
2290 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2291
2292 # ── rename tracking — old name ────────────────────────────────────────────
2293
2294 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2295 """Blaming the PRE-rename name must find the creation event."""
2296 result = runner.invoke(
2297 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2298 )
2299 assert result.exit_code == 0, result.output
2300 data = json.loads(result.output)
2301 kinds = [ev["event"] for ev in data["events"]]
2302 assert "created" in kinds, f"Expected created event, got: {kinds}"
2303
2304 def test_blame_old_name_finds_rename_not_lost(
2305 self, blame_repo: pathlib.Path
2306 ) -> None:
2307 """Blaming the old name should also surface the rename event."""
2308 result = runner.invoke(
2309 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2310 )
2311 assert result.exit_code == 0, result.output
2312 data = json.loads(result.output)
2313 kinds = [ev["event"] for ev in data["events"]]
2314 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2315
2316 # ── JSON schema ───────────────────────────────────────────────────────────
2317
2318 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2319 result = runner.invoke(
2320 cli, ["code", "blame", "billing.py::process_order", "--json"]
2321 )
2322 assert result.exit_code == 0, result.output
2323 data = json.loads(result.output)
2324 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2325 assert key in data, f"missing key: {key}"
2326 assert isinstance(data["events"], list)
2327 assert isinstance(data["truncated"], bool)
2328 assert isinstance(data["total_commits_scanned"], int)
2329
2330 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2331 result = runner.invoke(
2332 cli,
2333 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2334 )
2335 assert result.exit_code == 0, result.output
2336 data = json.loads(result.output)
2337 assert data["events"], "expected at least one event"
2338 ev = data["events"][0]
2339 for field in (
2340 "event", "commit_id", "author", "message",
2341 "committed_at", "address", "detail",
2342 ):
2343 assert field in ev, f"missing event field: {field}"
2344
2345 def test_blame_json_address_field_matches_input(
2346 self, blame_repo: pathlib.Path
2347 ) -> None:
2348 addr = "billing.py::process_order"
2349 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2350 data = json.loads(result.output)
2351 assert data["address"] == addr
2352
2353 # ── --max truncation ──────────────────────────────────────────────────────
2354
2355 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2356 result = runner.invoke(
2357 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2358 )
2359 assert result.exit_code == 0, result.output
2360 data = json.loads(result.output)
2361 assert data["total_commits_scanned"] <= 1
2362
2363 def test_blame_truncation_flag_set_when_capped(
2364 self, blame_repo: pathlib.Path
2365 ) -> None:
2366 result = runner.invoke(
2367 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2368 )
2369 data = json.loads(result.output)
2370 assert data["truncated"] is True
2371
2372 def test_blame_truncation_warning_in_human_output(
2373 self, blame_repo: pathlib.Path
2374 ) -> None:
2375 result = runner.invoke(
2376 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2377 )
2378 assert result.exit_code == 0, result.output
2379 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2380
2381 # ── human output ─────────────────────────────────────────────────────────
2382
2383 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2384 result = runner.invoke(
2385 cli, ["code", "blame", "billing.py::process_order"]
2386 )
2387 assert result.exit_code == 0, result.output
2388 assert "last touched:" in result.output
2389
2390 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2391 result_default = runner.invoke(
2392 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2393 )
2394 result_all = runner.invoke(
2395 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2396 )
2397 assert result_all.exit_code == 0, result_all.output
2398 # --all shows at least as many lines as default
2399 assert len(result_all.output) >= len(result_default.output)
2400
2401 # ── BFS follows merge parents ─────────────────────────────────────────────
2402
2403 def test_blame_bfs_follows_merge_parent2(
2404 self, repo: pathlib.Path
2405 ) -> None:
2406 """A symbol introduced on a feature branch is visible after merging."""
2407 # Main: empty billing.py
2408 (repo / "billing.py").write_text("def main_fn(): pass\n")
2409 runner.invoke(cli, ["commit", "-m", "main commit"])
2410
2411 # Feature branch: add feature_fn
2412 runner.invoke(cli, ["branch", "feat/feature"])
2413 runner.invoke(cli, ["checkout", "feat/feature"])
2414 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2415 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2416
2417 # Merge back to main
2418 runner.invoke(cli, ["checkout", "main"])
2419 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2420
2421 # Blame feature_fn — should find 'created' event on the feature branch
2422 result = runner.invoke(
2423 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2424 )
2425 assert result.exit_code == 0, result.output
2426 data = json.loads(result.output)
2427 kinds = [ev["event"] for ev in data["events"]]
2428 assert "created" in kinds, (
2429 f"Expected created event for feature_fn after merge; got: {kinds}"
2430 )
2431
2432
2433 # ---------------------------------------------------------------------------
2434 # Security — ReDoS guard in grep
2435 # ---------------------------------------------------------------------------
2436
2437
2438 class TestGrepReDoS:
2439 """grep must reject patterns longer than 512 characters."""
2440
2441 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2442 long_pattern = "a" * 513
2443 result = runner.invoke(cli, ["code", "grep", long_pattern])
2444 assert result.exit_code == 1
2445 assert "too long" in result.output.lower() or "512" in result.output
2446
2447 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2448 pattern = "a" * 512
2449 result = runner.invoke(cli, ["code", "grep", pattern])
2450 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2451 assert result.exit_code != 1 or "too long" not in result.output.lower()
2452
2453 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2454 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2455 assert result.exit_code == 1
2456
2457
2458 # ---------------------------------------------------------------------------
2459 # JSON output — index status and rebuild
2460 # ---------------------------------------------------------------------------
2461
2462
2463 class TestIndexJsonOutput:
2464 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2465 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2466 assert result.exit_code == 0, result.output
2467 data = json.loads(result.output)
2468 assert isinstance(data, list)
2469 names = [entry["name"] for entry in data]
2470 assert "symbol_history" in names
2471 assert "hash_occurrence" in names
2472 for entry in data:
2473 assert "status" in entry
2474 assert "entries" in entry
2475
2476 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2477 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2478 assert result.exit_code == 0, result.output
2479 data = json.loads(result.output)
2480 assert isinstance(data, dict)
2481 assert "rebuilt" in data
2482 assert isinstance(data["rebuilt"], list)
2483 assert "symbol_history" in data["rebuilt"]
2484 assert "hash_occurrence" in data["rebuilt"]
2485
2486 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2487 result = runner.invoke(cli, [
2488 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2489 ])
2490 assert result.exit_code == 0, result.output
2491 data = json.loads(result.output)
2492 assert "symbol_history" in data.get("rebuilt", [])
2493 assert "symbol_history_addresses" in data
2494
2495
2496 # ---------------------------------------------------------------------------
2497 # Extended — muse code index status
2498 # ---------------------------------------------------------------------------
2499
2500
2501 class TestIndexStatusExtended:
2502 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2503 """-j is equivalent to --json."""
2504 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2505 assert result.exit_code == 0, result.output
2506 data = json.loads(result.output.strip())
2507 assert isinstance(data, list)
2508
2509 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2510 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2511 assert result.exit_code == 0
2512
2513 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2514 """JSON output is compact — single line, no indent=2."""
2515 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2516 assert result.exit_code == 0
2517 lines = [l for l in result.output.splitlines() if l.strip()]
2518 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2519
2520 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
2521 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2522 data = json.loads(result.output.strip())
2523 assert isinstance(data, list)
2524
2525 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2526 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2527 data = json.loads(result.output.strip())
2528 names = [e["name"] for e in data]
2529 assert "symbol_history" in names
2530
2531 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2532 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2533 data = json.loads(result.output.strip())
2534 names = [e["name"] for e in data]
2535 assert "hash_occurrence" in names
2536
2537 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2538 """Every entry has name, status, entries, updated_at."""
2539 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2540 data = json.loads(result.output.strip())
2541 for entry in data:
2542 assert "name" in entry
2543 assert "status" in entry
2544 assert "entries" in entry
2545 assert "updated_at" in entry
2546
2547 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2548 """Freshly initialised repo: both indexes are absent."""
2549 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2550 data = json.loads(result.output.strip())
2551 statuses = {e["name"]: e["status"] for e in data}
2552 assert statuses["symbol_history"] == "absent"
2553 assert statuses["hash_occurrence"] == "absent"
2554
2555 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2556 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2557 data = json.loads(result.output.strip())
2558 for entry in data:
2559 if entry["status"] == "absent":
2560 assert entry["entries"] == 0
2561
2562 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2563 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2564 data = json.loads(result.output.strip())
2565 for entry in data:
2566 if entry["status"] == "absent":
2567 assert entry["updated_at"] is None
2568
2569 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2570 """After rebuild all indexes report present."""
2571 runner.invoke(cli, ["code", "index", "rebuild"])
2572 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2573 data = json.loads(result.output.strip())
2574 for entry in data:
2575 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2576
2577 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2578 """symbol_history should have entries after two commits."""
2579 runner.invoke(cli, ["code", "index", "rebuild"])
2580 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2581 data = json.loads(result.output.strip())
2582 sh = next(e for e in data if e["name"] == "symbol_history")
2583 assert sh["entries"] > 0
2584
2585 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2586 runner.invoke(cli, ["code", "index", "rebuild"])
2587 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2588 data = json.loads(result.output.strip())
2589 for entry in data:
2590 assert entry["updated_at"] is not None
2591
2592 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2593 """A file with bad content is reported as corrupt, not absent."""
2594 idx_dir = code_repo / ".muse" / "indices"
2595 idx_dir.mkdir(parents=True, exist_ok=True)
2596 (idx_dir / "symbol_history.msgpack").write_bytes(b"\xff\xfe")
2597 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2598 assert result.exit_code == 0
2599 data = json.loads(result.output.strip())
2600 sh = next(e for e in data if e["name"] == "symbol_history")
2601 assert sh["status"] == "corrupt"
2602
2603 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2604 idx_dir = code_repo / ".muse" / "indices"
2605 idx_dir.mkdir(parents=True, exist_ok=True)
2606 (idx_dir / "hash_occurrence.msgpack").write_bytes(b"notmsgpack")
2607 result = runner.invoke(cli, ["code", "index", "status"])
2608 assert result.exit_code == 0
2609
2610 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2611 """Text mode suggests rebuild command when index is absent."""
2612 result = runner.invoke(cli, ["code", "index", "status"])
2613 assert "rebuild" in result.output.lower()
2614
2615 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2616 runner.invoke(cli, ["code", "index", "rebuild"])
2617 result = runner.invoke(cli, ["code", "index", "status"])
2618 assert "✅" in result.output
2619
2620 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2621 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2622 assert "Agent quickstart" in result.output
2623
2624 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2625 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2626 assert "JSON output schema" in result.output
2627
2628 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2629 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2630 assert "Exit codes" in result.output
2631
2632
2633 # ---------------------------------------------------------------------------
2634 # Security — muse code index status
2635 # ---------------------------------------------------------------------------
2636
2637
2638 class TestIndexStatusSecurity:
2639 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2640 """A corrupt index file must not surface a traceback."""
2641 idx_dir = code_repo / ".muse" / "indices"
2642 idx_dir.mkdir(parents=True, exist_ok=True)
2643 (idx_dir / "symbol_history.msgpack").write_bytes(b"\x00" * 16)
2644 result = runner.invoke(cli, ["code", "index", "status"])
2645 assert "Traceback" not in result.output
2646
2647 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2648 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2649 from muse.core.indices import KNOWN_INDEX_NAMES
2650 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2651 data = json.loads(result.output.strip())
2652 for entry in data:
2653 assert entry["name"] in KNOWN_INDEX_NAMES
2654
2655 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2656 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2657 assert "\x1b" not in result.output
2658
2659 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2660 """status field is always one of the three allowed values."""
2661 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2662 data = json.loads(result.output.strip())
2663 for entry in data:
2664 assert entry["status"] in ("present", "absent", "corrupt")
2665
2666 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2667 monkeypatch.chdir(tmp_path)
2668 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2669 result = runner.invoke(cli, ["code", "index", "status"])
2670 assert "Traceback" not in result.output
2671 assert result.exit_code != 0
2672
2673 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2674 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2675 data = json.loads(result.output.strip())
2676 for entry in data:
2677 assert isinstance(entry["entries"], int)
2678
2679
2680 # ---------------------------------------------------------------------------
2681 # Stress — muse code index status
2682 # ---------------------------------------------------------------------------
2683
2684
2685 class TestIndexStatusStress:
2686 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2687 """50 sequential status calls all exit 0."""
2688 for i in range(50):
2689 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2690 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2691
2692 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2693 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2694 for i in range(100):
2695 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2696 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2697 data = json.loads(result.output.strip())
2698 sh = next(e for e in data if e["name"] == "symbol_history")
2699 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2700 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2701 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2702 data = json.loads(result.output.strip())
2703 sh = next(e for e in data if e["name"] == "symbol_history")
2704 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2705
2706 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2707 """8 threads reading index status concurrently — all must succeed."""
2708 import argparse
2709 import threading
2710
2711 from muse.cli.commands.index_rebuild import run_status
2712
2713 errors: list[str] = []
2714
2715 def worker(idx: int) -> None:
2716 args = argparse.Namespace(as_json=True)
2717 try:
2718 run_status(args)
2719 except SystemExit as exc:
2720 if exc.code != 0:
2721 errors.append(f"Thread {idx}: exit {exc.code}")
2722 except Exception as exc:
2723 errors.append(f"Thread {idx}: {exc}")
2724
2725 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2726 for t in threads:
2727 t.start()
2728 for t in threads:
2729 t.join()
2730 assert not errors, f"Concurrent failures: {errors}"
2731
2732
2733 # ---------------------------------------------------------------------------
2734 # Extended — muse code index rebuild
2735 # ---------------------------------------------------------------------------
2736
2737
2738 class TestIndexRebuildExtended:
2739 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2740 """-j is equivalent to --json."""
2741 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2742 assert result.exit_code == 0, result.output
2743 data = json.loads(result.output.strip())
2744 assert "rebuilt" in data
2745
2746 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2747 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2748 assert result.exit_code == 0
2749
2750 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2751 """JSON output is a single compact line — no indent=2."""
2752 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2753 assert result.exit_code == 0
2754 lines = [l for l in result.output.splitlines() if l.strip()]
2755 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2756
2757 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2758 """JSON output always has schema_version, dry_run, rebuilt."""
2759 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2760 data = json.loads(result.output.strip())
2761 assert "schema_version" in data
2762 assert "dry_run" in data
2763 assert "rebuilt" in data
2764
2765 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2766 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2767 data = json.loads(result.output.strip())
2768 assert "symbol_history" in data["rebuilt"]
2769 assert "hash_occurrence" in data["rebuilt"]
2770
2771 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2772 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2773 data = json.loads(result.output.strip())
2774 assert data["dry_run"] is False
2775
2776 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2777 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2778 assert result.exit_code == 0
2779 data = json.loads(result.output.strip())
2780 assert data["dry_run"] is True
2781
2782 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2783 """--dry-run must not create index files."""
2784 idx_dir = code_repo / ".muse" / "indices"
2785 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2786 assert not (idx_dir / "symbol_history.msgpack").exists()
2787 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2788
2789 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2790 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2791 assert result.exit_code == 0
2792 data = json.loads(result.output.strip())
2793 assert data["rebuilt"] == ["symbol_history"]
2794 assert "symbol_history_addresses" in data
2795 assert "hash_occurrence_clusters" not in data
2796
2797 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2798 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2799 assert result.exit_code == 0
2800 data = json.loads(result.output.strip())
2801 assert data["rebuilt"] == ["hash_occurrence"]
2802 assert "hash_occurrence_clusters" in data
2803 assert "symbol_history_addresses" not in data
2804
2805 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2806 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2807 data = json.loads(result.output.strip())
2808 assert isinstance(data["symbol_history_addresses"], int)
2809 assert isinstance(data["symbol_history_events"], int)
2810
2811 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2812 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2813 data = json.loads(result.output.strip())
2814 assert isinstance(data["hash_occurrence_clusters"], int)
2815 assert isinstance(data["hash_occurrence_addresses"], int)
2816
2817 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2818 runner.invoke(cli, ["code", "index", "rebuild"])
2819 idx_dir = code_repo / ".muse" / "indices"
2820 assert (idx_dir / "symbol_history.msgpack").exists()
2821 assert (idx_dir / "hash_occurrence.msgpack").exists()
2822
2823 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2824 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2825 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2826 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2827 assert r1.exit_code == 0 and r2.exit_code == 0
2828 d1 = json.loads(r1.output.strip())
2829 d2 = json.loads(r2.output.strip())
2830 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2831
2832 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2833 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2834 assert result.exit_code == 0
2835 assert "Building" in result.output
2836
2837 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2838 result = runner.invoke(cli, ["code", "index", "rebuild"])
2839 assert "Rebuilt" in result.output or "index" in result.output.lower()
2840
2841 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2842 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2843 assert "Agent quickstart" in result.output
2844
2845 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2846 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2847 assert "JSON output schema" in result.output
2848
2849 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2850 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2851 assert "Exit codes" in result.output
2852
2853
2854 # ---------------------------------------------------------------------------
2855 # Security — muse code index rebuild
2856 # ---------------------------------------------------------------------------
2857
2858
2859 class TestIndexRebuildSecurity:
2860 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2861 """An unknown --index value must be rejected before run_rebuild is called."""
2862 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "evil_index"])
2863 assert result.exit_code != 0
2864
2865 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2866 idx_dir = code_repo / ".muse" / "indices"
2867 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2868 assert not (idx_dir / "symbol_history.msgpack").exists()
2869 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2870
2871 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2872 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2873 assert "\x1b" not in result.output
2874
2875 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2876 monkeypatch.chdir(tmp_path)
2877 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2878 result = runner.invoke(cli, ["code", "index", "rebuild"])
2879 assert "Traceback" not in result.output
2880 assert result.exit_code != 0
2881
2882 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2883 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2884 from muse.core.indices import KNOWN_INDEX_NAMES
2885 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2886 data = json.loads(result.output.strip())
2887 for name in data["rebuilt"]:
2888 assert name in KNOWN_INDEX_NAMES
2889
2890 def test_schema_version_is_string(self, code_repo: pathlib.Path) -> None:
2891 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2892 data = json.loads(result.output.strip())
2893 assert isinstance(data["schema_version"], str)
2894 assert len(data["schema_version"]) > 0
2895
2896
2897 # ---------------------------------------------------------------------------
2898 # Stress — muse code index rebuild
2899 # ---------------------------------------------------------------------------
2900
2901
2902 class TestIndexRebuildStress:
2903 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2904 """50 sequential rebuilds all exit 0."""
2905 for i in range(50):
2906 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2907 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2908
2909 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2910 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2911 indexes = ["symbol_history", "hash_occurrence"]
2912 for i in range(100):
2913 target = indexes[i % 2]
2914 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2915 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2916 data = json.loads(result.output.strip())
2917 assert target in data["rebuilt"]
2918
2919 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2920 """8 threads rebuilding hash_occurrence concurrently via core function."""
2921 import argparse
2922 import threading
2923
2924 from muse.cli.commands.index_rebuild import run_rebuild
2925
2926 errors: list[str] = []
2927
2928 def worker(idx: int) -> None:
2929 args = argparse.Namespace(
2930 index_name="hash_occurrence",
2931 dry_run=True, # dry_run avoids concurrent write races
2932 verbose=False,
2933 as_json=True,
2934 )
2935 try:
2936 run_rebuild(args)
2937 except SystemExit as exc:
2938 if exc.code != 0:
2939 errors.append(f"Thread {idx}: exit {exc.code}")
2940 except Exception as exc:
2941 errors.append(f"Thread {idx}: {exc}")
2942
2943 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2944 for t in threads:
2945 t.start()
2946 for t in threads:
2947 t.join()
2948 assert not errors, f"Concurrent failures: {errors}"
2949
2950
2951 # ---------------------------------------------------------------------------
2952 # Extended — muse code index purge
2953 # ---------------------------------------------------------------------------
2954
2955
2956 class TestIndexPurgeExtended:
2957 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2958 """-j is equivalent to --json."""
2959 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2960 assert result.exit_code == 0, result.output
2961 data = json.loads(result.output.strip())
2962 assert "purged" in data
2963
2964 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2965 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
2966 assert result.exit_code == 0
2967
2968 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2969 """JSON output is compact — single line, no indent=2."""
2970 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2971 assert result.exit_code == 0
2972 lines = [l for l in result.output.splitlines() if l.strip()]
2973 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2974
2975 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2976 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2977 data = json.loads(result.output.strip())
2978 assert "schema_version" in data
2979 assert "purged" in data
2980 assert "skipped" in data
2981
2982 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
2983 """Purging when indexes are absent — both in skipped, none in purged."""
2984 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2985 data = json.loads(result.output.strip())
2986 assert data["purged"] == []
2987 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
2988
2989 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
2990 """After rebuild, purge reports both as purged."""
2991 runner.invoke(cli, ["code", "index", "rebuild"])
2992 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2993 data = json.loads(result.output.strip())
2994 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
2995 assert data["skipped"] == []
2996
2997 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
2998 runner.invoke(cli, ["code", "index", "rebuild"])
2999 runner.invoke(cli, ["code", "index", "purge"])
3000 idx_dir = code_repo / ".muse" / "indices"
3001 assert not (idx_dir / "symbol_history.msgpack").exists()
3002 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3003
3004 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
3005 runner.invoke(cli, ["code", "index", "rebuild"])
3006 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
3007 assert result.exit_code == 0
3008 data = json.loads(result.output.strip())
3009 assert data["purged"] == ["symbol_history"]
3010 assert data["skipped"] == []
3011 idx_dir = code_repo / ".muse" / "indices"
3012 assert not (idx_dir / "symbol_history.msgpack").exists()
3013 assert (idx_dir / "hash_occurrence.msgpack").exists()
3014
3015 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
3016 runner.invoke(cli, ["code", "index", "rebuild"])
3017 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3018 assert result.exit_code == 0
3019 data = json.loads(result.output.strip())
3020 assert data["purged"] == ["hash_occurrence"]
3021 idx_dir = code_repo / ".muse" / "indices"
3022 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3023 assert (idx_dir / "symbol_history.msgpack").exists()
3024
3025 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
3026 """Purging when nothing is present still exits 0."""
3027 result = runner.invoke(cli, ["code", "index", "purge"])
3028 assert result.exit_code == 0
3029
3030 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
3031 """Purging twice in a row both exit 0."""
3032 runner.invoke(cli, ["code", "index", "rebuild"])
3033 r1 = runner.invoke(cli, ["code", "index", "purge"])
3034 r2 = runner.invoke(cli, ["code", "index", "purge"])
3035 assert r1.exit_code == 0
3036 assert r2.exit_code == 0
3037
3038 def test_schema_version_is_string(self, code_repo: pathlib.Path) -> None:
3039 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3040 data = json.loads(result.output.strip())
3041 assert isinstance(data["schema_version"], str)
3042 assert len(data["schema_version"]) > 0
3043
3044 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
3045 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3046 data = json.loads(result.output.strip())
3047 assert isinstance(data["purged"], list)
3048 assert isinstance(data["skipped"], list)
3049
3050 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
3051 runner.invoke(cli, ["code", "index", "rebuild"])
3052 result = runner.invoke(cli, ["code", "index", "purge"])
3053 assert "deleted" in result.output.lower() or "🗑" in result.output
3054
3055 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3056 result = runner.invoke(cli, ["code", "index", "purge"])
3057 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3058
3059 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3060 runner.invoke(cli, ["code", "index", "rebuild"])
3061 runner.invoke(cli, ["code", "index", "purge"])
3062 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3063 data = json.loads(result.output.strip())
3064 for entry in data:
3065 assert entry["status"] == "absent"
3066
3067 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3068 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3069 assert "Agent quickstart" in result.output
3070
3071 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3072 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3073 assert "JSON output schema" in result.output
3074
3075 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3076 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3077 assert "Exit codes" in result.output
3078
3079
3080 # ---------------------------------------------------------------------------
3081 # Security — muse code index purge
3082 # ---------------------------------------------------------------------------
3083
3084
3085 class TestIndexPurgeSecurity:
3086 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3087 """Unknown --index value rejected by argparse before run_purge runs."""
3088 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3089 assert result.exit_code != 0
3090
3091 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3092 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3093 assert "\x1b" not in result.output
3094
3095 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3096 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3097 from muse.core.indices import KNOWN_INDEX_NAMES
3098 runner.invoke(cli, ["code", "index", "rebuild"])
3099 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3100 data = json.loads(result.output.strip())
3101 for name in data["purged"] + data["skipped"]:
3102 assert name in KNOWN_INDEX_NAMES
3103
3104 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3105 monkeypatch.chdir(tmp_path)
3106 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3107 result = runner.invoke(cli, ["code", "index", "purge"])
3108 assert "Traceback" not in result.output
3109 assert result.exit_code != 0
3110
3111 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3112 """Purge must not remove anything outside .muse/indices/."""
3113 runner.invoke(cli, ["code", "index", "rebuild"])
3114 repo_json = code_repo / ".muse" / "repo.json"
3115 assert repo_json.exists()
3116 runner.invoke(cli, ["code", "index", "purge"])
3117 assert repo_json.exists(), "repo.json must not be deleted by purge"
3118
3119 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3120 runner.invoke(cli, ["code", "index", "rebuild"])
3121 runner.invoke(cli, ["code", "index", "purge"])
3122 result = runner.invoke(cli, ["code", "index", "purge"])
3123 assert "Traceback" not in result.output
3124
3125
3126 # ---------------------------------------------------------------------------
3127 # Stress — muse code index purge
3128 # ---------------------------------------------------------------------------
3129
3130
3131 class TestIndexPurgeStress:
3132 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3133 """50 sequential purge calls all exit 0 (idempotent)."""
3134 for i in range(50):
3135 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3136 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3137
3138 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3139 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3140 for i in range(100):
3141 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3142 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3143 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3144 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3145 d = json.loads(r2.output.strip())
3146 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3147
3148 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3149 """8 threads purging concurrently via core function — all must exit 0."""
3150 import argparse
3151 import threading
3152
3153 from muse.cli.commands.index_rebuild import run_purge
3154
3155 runner.invoke(cli, ["code", "index", "rebuild"])
3156 errors: list[str] = []
3157
3158 def worker(idx: int) -> None:
3159 args = argparse.Namespace(index_name=None, as_json=True)
3160 try:
3161 run_purge(args)
3162 except SystemExit as exc:
3163 if exc.code != 0:
3164 errors.append(f"Thread {idx}: exit {exc.code}")
3165 except Exception as exc:
3166 errors.append(f"Thread {idx}: {exc}")
3167
3168 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3169 for t in threads:
3170 t.start()
3171 for t in threads:
3172 t.join()
3173 assert not errors, f"Concurrent failures: {errors}"
3174
3175
3176 # ---------------------------------------------------------------------------
3177 # Performance — iterative DFS regression (no RecursionError)
3178 # ---------------------------------------------------------------------------
3179
3180
3181 class TestIterativeDFS:
3182 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3183
3184 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3185 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3186
3187 # Build a linear chain A→B→C→…→Z (depth 600, beyond Python's 1000 default).
3188 depth = 600
3189 nodes = [f"mod_{i}" for i in range(depth)]
3190 imports_out: _ImportsMap = {
3191 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3192 }
3193 imports_out[nodes[-1]] = []
3194
3195 # Must not raise RecursionError.
3196 cycles = codemap_find_cycles(imports_out)
3197 assert isinstance(cycles, list)
3198 assert len(cycles) == 0 # linear chain has no cycles
3199
3200 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3201 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3202
3203 # A→B→C→A is a cycle.
3204 imports_out: _ImportsMap = {
3205 "A": ["B"],
3206 "B": ["C"],
3207 "C": ["A"],
3208 }
3209 cycles = codemap_find_cycles(imports_out)
3210 assert len(cycles) >= 1
3211
3212 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3213 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3214
3215 depth = 600
3216 nodes = [f"file_{i}.py" for i in range(depth)]
3217 imports: _ImportsSetMap = {
3218 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3219 }
3220 imports[nodes[-1]] = set()
3221
3222 cycles = invariants_find_cycles(imports)
3223 assert isinstance(cycles, list)
3224 assert len(cycles) == 0
3225
3226 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3227 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3228
3229 # A module that imports itself.
3230 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3231 cycles = invariants_find_cycles(imports)
3232 assert len(cycles) >= 1
3233
3234
3235 # ---------------------------------------------------------------------------
3236 # muse code symbols
3237 # ---------------------------------------------------------------------------
3238
3239
3240 class TestSymbols:
3241 """Tests for ``muse code symbols``."""
3242
3243 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3244 """Basic invocation lists functions and classes from HEAD snapshot."""
3245 result = runner.invoke(cli, ["code", "symbols"])
3246 assert result.exit_code == 0, result.output
3247 # billing.py contains Invoice class and process_order / send_email functions.
3248 assert "Invoice" in result.output
3249 assert "process_order" in result.output
3250 assert "symbols across" in result.output
3251
3252 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3253 """``--count`` prints a total count and language breakdown, no symbol table."""
3254 result = runner.invoke(cli, ["code", "symbols", "--count"])
3255 assert result.exit_code == 0, result.output
3256 assert "symbols" in result.output
3257 assert "Python" in result.output
3258 # Should NOT print individual symbol lines.
3259 assert "Invoice" not in result.output
3260
3261 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3262 """``--json`` emits a structured envelope with a flat 'results' list."""
3263 result = runner.invoke(cli, ["code", "symbols", "--json"])
3264 assert result.exit_code == 0, result.output
3265 data = json.loads(result.output)
3266 assert isinstance(data, dict)
3267 assert "results" in data
3268 assert "files" not in data
3269 assert isinstance(data["results"], list)
3270 assert any(e.get("file", "").endswith("billing.py") for e in data["results"])
3271 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3272
3273 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3274 """``--kind class`` shows only class-kind symbols."""
3275 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3276 assert result.exit_code == 0, result.output
3277 assert "Invoice" in result.output
3278 assert "process_order" not in result.output
3279
3280 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3281 """``--kind function`` shows only top-level functions, not methods."""
3282 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3283 assert result.exit_code == 0, result.output
3284 assert "process_order" in result.output
3285 assert "send_email" in result.output
3286 assert "Invoice" not in result.output
3287
3288 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3289 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3290 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3291 assert result.exit_code != 0
3292 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3293
3294 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3295 """``--file`` restricts output to a single file."""
3296 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3297 assert result.exit_code == 0, result.output
3298 assert "symbols across" in result.output
3299
3300 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3301 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3302 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3303 assert result.exit_code == 0, result.output
3304 assert "no semantic symbols found" in result.output
3305
3306 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3307 """``--language Python`` includes Python symbols; other languages excluded."""
3308 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3309 assert result.exit_code == 0, result.output
3310 assert "Invoice" in result.output
3311
3312 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3313 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3314 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3315 assert result.exit_code == 0, result.output
3316 assert "no semantic symbols found" in result.output
3317
3318 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3319 """``--hashes`` appends content hash abbreviations to each symbol row."""
3320 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3321 assert result.exit_code == 0, result.output
3322 # Hash suffix is 8 hex chars followed by ".."
3323 assert ".." in result.output
3324
3325 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3326 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3327 default = runner.invoke(cli, ["code", "symbols"])
3328 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3329 assert default.exit_code == 0
3330 assert head.exit_code == 0
3331 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3332 assert "Invoice" in default.output
3333 assert "Invoice" in head.output
3334 assert "symbols across" in default.output
3335 assert "symbols across" in head.output
3336
3337 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3338 """``--count`` and ``--json`` cannot be combined."""
3339 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3340 assert result.exit_code != 0
3341
3342 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3343 """JSON output uses the structured envelope with source_ref and results."""
3344 result = runner.invoke(cli, ["code", "symbols", "--json"])
3345 assert result.exit_code == 0, result.output
3346 data = json.loads(result.output)
3347 assert "source_ref" in data
3348 assert "working_tree" in data
3349 assert "total_symbols" in data
3350 assert "results" in data
3351 assert "files" not in data
3352 assert isinstance(data["working_tree"], bool)
3353 assert isinstance(data["total_symbols"], int)
3354 for entry in data["results"]:
3355 for field in ("address", "kind", "name", "qualified_name", "file",
3356 "lineno", "content_id", "body_hash", "signature_id"):
3357 assert field in entry, f"missing field '{field}' in JSON entry"
3358
3359 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3360 """``--json`` without ``--commit`` reports working_tree=true."""
3361 result = runner.invoke(cli, ["code", "symbols", "--json"])
3362 assert result.exit_code == 0, result.output
3363 data = json.loads(result.output)
3364 assert data["working_tree"] is True
3365 assert data["source_ref"] == "working-tree"
3366
3367 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3368 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3369 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3370 assert result.exit_code == 0, result.output
3371 data = json.loads(result.output)
3372 assert data["working_tree"] is False
3373 assert data["source_ref"] != "working-tree"
3374 # source_ref should be an 8-char hex prefix.
3375 assert len(data["source_ref"]) == 8
3376 assert all(c in "0123456789abcdef" for c in data["source_ref"])
3377
3378 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3379 """Working-tree mode picks up edits made to files after the last commit."""
3380 # Find the billing.py path on disk.
3381 billing = code_repo / "billing.py"
3382 assert billing.exists()
3383 # Append a new function — not yet committed.
3384 billing.write_text(
3385 billing.read_text() + "\ndef newly_added_function():\n pass\n"
3386 )
3387 result = runner.invoke(cli, ["code", "symbols"])
3388 assert result.exit_code == 0, result.output
3389 assert "newly_added_function" in result.output
3390
3391 # Committed snapshot should NOT contain it.
3392 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3393 assert committed.exit_code == 0
3394 assert "newly_added_function" not in committed.output
3395
3396 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3397 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3398 for variant in ("python", "Python", "PYTHON"):
3399 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3400 assert result.exit_code == 0, f"failed for --language {variant!r}"
3401 assert "Invoice" in result.output
3402
3403 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3404 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3405 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3406 assert result.exit_code == 0, result.output
3407 assert "Invoice" in result.output
3408
3409 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3410 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3411 # Write a second file with the same basename in a sub-directory.
3412 sub = code_repo / "sub"
3413 sub.mkdir(exist_ok=True)
3414 (sub / "billing.py").write_text("def sub_func(): pass\n")
3415 # Stage and commit both so the manifest has two paths ending in billing.py.
3416 import subprocess
3417 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3418 subprocess.run(
3419 ["muse", "commit", "-m", "add sub/billing.py"],
3420 cwd=code_repo, check=True,
3421 )
3422 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3423 assert result.exit_code != 0
3424 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3425
3426 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3427 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3428 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3429 assert result.exit_code != 0
3430 assert "not found" in result.output or "not found" in (result.stderr or "")
3431
3432
3433 # ---------------------------------------------------------------------------
3434 # TestSymbolLog
3435 # ---------------------------------------------------------------------------
3436
3437
3438 class TestSymbolLog:
3439 """Tests for ``muse code symbol-log``."""
3440
3441 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3442 """An address not found in any commit produces 'no events found'."""
3443 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3444 assert result.exit_code == 0, result.output
3445 assert "no events found" in result.output
3446
3447 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3448 """An address without '::' exits non-zero with a descriptive error."""
3449 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3450 assert result.exit_code != 0
3451 assert "::" in (result.output + (result.stderr or ""))
3452
3453 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3454 """An empty string as address exits non-zero."""
3455 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3456 # "::" is technically valid syntax; should at least not crash.
3457 assert result.exit_code == 0
3458
3459 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3460 """``--json`` emits the structured envelope with all top-level fields."""
3461 result = runner.invoke(
3462 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3463 )
3464 assert result.exit_code == 0, result.output
3465 data = json.loads(result.output)
3466 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3467 assert field in data, f"missing top-level field '{field}'"
3468 assert data["address"] == "billing.py::Invoice"
3469 assert data["start_ref"] == "HEAD"
3470 assert isinstance(data["total_commits_scanned"], int)
3471 assert isinstance(data["truncated"], bool)
3472 assert isinstance(data["events"], list)
3473
3474 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3475 """Each JSON event has the required fields."""
3476 result = runner.invoke(
3477 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3478 )
3479 assert result.exit_code == 0, result.output
3480 data = json.loads(result.output)
3481 for ev in data["events"]:
3482 for field in ("event", "commit_id", "message", "committed_at",
3483 "address", "detail", "new_address"):
3484 assert field in ev, f"missing event field '{field}'"
3485
3486 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3487 """When --max is hit, a truncation warning appears in human output."""
3488 result = runner.invoke(
3489 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3490 )
3491 assert result.exit_code == 0, result.output
3492 assert "incomplete" in result.output or "limit" in result.output
3493
3494 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3495 """When --max is hit, truncated=true appears in JSON output."""
3496 result = runner.invoke(
3497 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3498 )
3499 assert result.exit_code == 0, result.output
3500 data = json.loads(result.output)
3501 assert data["truncated"] is True
3502 assert data["total_commits_scanned"] == 1
3503
3504 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3505 """--max 0 exits non-zero with a clear error."""
3506 result = runner.invoke(
3507 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3508 )
3509 assert result.exit_code != 0
3510
3511 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3512 """``--from`` with a non-existent ref exits non-zero."""
3513 result = runner.invoke(
3514 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3515 )
3516 assert result.exit_code != 0
3517 assert "not found" in (result.output + (result.stderr or ""))
3518
3519 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3520 """BFS walk finds events on feature branches that were merged in via parent2.
3521
3522 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3523 The feature branch commit has a structured_delta inserting a symbol.
3524 The linear (parent1-only) walk would miss this; BFS must find it.
3525 """
3526 import datetime
3527
3528 root = code_repo
3529 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3530 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3531 from muse.core.snapshot import compute_commit_id
3532 from muse.domain import InsertOp, PatchOp, StructuredDelta
3533 branch = read_current_branch(root)
3534 head_id = get_head_commit_id(root, branch)
3535 assert head_id is not None
3536
3537 feature_snap = "aa" * 32
3538 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3539 feature_id = compute_commit_id([head_id], feature_snap, "feat: add merged_fn", feature_at.isoformat())
3540 write_commit(root, CommitRecord(
3541 commit_id=feature_id,
3542 repo_id=repo_id,
3543 branch="feat/branch",
3544 snapshot_id=feature_snap,
3545 message="feat: add merged_fn",
3546 committed_at=feature_at,
3547 parent_commit_id=head_id,
3548 author="test",
3549 structured_delta=StructuredDelta(ops=[PatchOp(
3550 op="patch",
3551 address="billing.py",
3552 child_ops=[InsertOp(
3553 op="insert",
3554 address="billing.py::merged_fn",
3555 content_summary="function merged_fn",
3556 )],
3557 )]),
3558 ))
3559
3560 merge_snap = "bb" * 32
3561 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3562 merge_id = compute_commit_id([head_id, feature_id], merge_snap, "merge feat/branch", merge_at.isoformat())
3563 write_commit(root, CommitRecord(
3564 commit_id=merge_id,
3565 repo_id=repo_id,
3566 branch=branch,
3567 snapshot_id=merge_snap,
3568 message="merge feat/branch",
3569 committed_at=merge_at,
3570 parent_commit_id=head_id,
3571 parent2_commit_id=feature_id,
3572 author="test",
3573 ))
3574
3575 branch_ref = root / ".muse" / "refs" / "heads" / branch
3576 branch_ref.write_text(merge_id)
3577
3578 result = runner.invoke(
3579 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3580 )
3581 assert result.exit_code == 0, result.output
3582 # BFS must find the creation event on the feature branch.
3583 assert "merged_fn" in result.output
3584 assert "created" in result.output
3585
3586 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3587 """Regression guard: verify the BFS result differs from a parent1-only scan.
3588
3589 Directly calls _walk_commits_dag and checks it returns commits from
3590 both parent chains, not just parent1.
3591 """
3592 import datetime
3593
3594 root = code_repo
3595 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3596 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3597 from muse.core.snapshot import compute_commit_id
3598 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3599 branch = read_current_branch(root)
3600 head_id = get_head_commit_id(root, branch)
3601 assert head_id is not None
3602
3603 feature_snap = "dd" * 32
3604 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3605 feature_id = compute_commit_id([], feature_snap, "feat on second parent", feature_at.isoformat())
3606 write_commit(root, CommitRecord(
3607 commit_id=feature_id,
3608 repo_id=repo_id,
3609 branch="feat/x",
3610 snapshot_id=feature_snap,
3611 message="feat on second parent",
3612 committed_at=feature_at,
3613 author="test",
3614 ))
3615 merge_snap = "ee" * 32
3616 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3617 merge_id = compute_commit_id([head_id, feature_id], merge_snap, "merge", merge_at.isoformat())
3618 write_commit(root, CommitRecord(
3619 commit_id=merge_id,
3620 repo_id=repo_id,
3621 branch=branch,
3622 snapshot_id=merge_snap,
3623 message="merge",
3624 committed_at=merge_at,
3625 parent_commit_id=head_id,
3626 parent2_commit_id=feature_id,
3627 author="test",
3628 ))
3629
3630 branch_ref = root / ".muse" / "refs" / "heads" / branch
3631 branch_ref.write_text(merge_id)
3632
3633 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3634 commit_ids = {c.commit_id for c in commits}
3635 assert feature_id in commit_ids
3636
3637
3638 # ---------------------------------------------------------------------------
3639 # muse code coupling
3640 # ---------------------------------------------------------------------------
3641
3642
3643 @pytest.fixture
3644 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3645 """Repo with 3 commits where billing.py + models.py co-change twice."""
3646 work = repo
3647
3648 # Commit 1: seed — only billing.py
3649 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3650 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3651 assert r.exit_code == 0, r.output
3652
3653 # Commit 2: billing.py + models.py change together
3654 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3655 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3656 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3657 assert r.exit_code == 0, r.output
3658
3659 # Commit 3: billing.py + models.py change together again
3660 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3661 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3662 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3663 assert r.exit_code == 0, r.output
3664
3665 return repo
3666
3667
3668 class TestCoupling:
3669 """Tests for muse code coupling."""
3670
3671 # ── basic correctness ────────────────────────────────────────────────────
3672
3673 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3674 result = runner.invoke(cli, ["code", "coupling"])
3675 assert result.exit_code == 0, result.output
3676
3677 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3678 """billing.py and models.py co-changed twice — must appear in output."""
3679 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3680 assert result.exit_code == 0, result.output
3681 assert "billing.py" in result.output
3682 assert "models.py" in result.output
3683
3684 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3685 result = runner.invoke(cli, ["code", "coupling"])
3686 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3687 assert "Commits analysed" in result.output
3688
3689 def test_coupling_min_filter_excludes_low_count(
3690 self, coupling_repo: pathlib.Path
3691 ) -> None:
3692 """--min 3 must exclude our pair that co-changed only twice."""
3693 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3694 assert result.exit_code == 0, result.output
3695 assert "billing.py" not in result.output or "no file pairs" in result.output
3696
3697 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3698 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3699 data = json.loads(result.output)
3700 assert len(data["pairs"]) <= 1
3701
3702 # ── --file filter ─────────────────────────────────────────────────────────
3703
3704 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3705 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3706 assert result.exit_code == 0, result.output
3707
3708 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3709 """--file billing.py must surface models.py as its partner."""
3710 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3711 assert result.exit_code == 0, result.output
3712 assert "models.py" in result.output
3713
3714 def test_coupling_file_filter_header_names_file(
3715 self, coupling_repo: pathlib.Path
3716 ) -> None:
3717 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3718 assert "billing.py" in result.output
3719
3720 def test_coupling_file_filter_nonexistent_returns_cleanly(
3721 self, coupling_repo: pathlib.Path
3722 ) -> None:
3723 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3724 assert result.exit_code == 0, result.output
3725
3726 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3727 """Suffix billing.py should match the file even without the full path."""
3728 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3729 assert result.exit_code == 0, result.output
3730 assert "models.py" in result.output
3731
3732 # ── JSON output ───────────────────────────────────────────────────────────
3733
3734 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3735 result = runner.invoke(cli, ["code", "coupling", "--json"])
3736 assert result.exit_code == 0, result.output
3737 data = json.loads(result.output)
3738 assert "from_ref" in data
3739 assert "to_ref" in data
3740 assert "commits_analysed" in data
3741 assert "truncated" in data
3742 assert "filters" in data
3743 assert "pairs" in data
3744 assert isinstance(data["pairs"], list)
3745
3746 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3747 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3748 data = json.loads(result.output)
3749 if data["pairs"]:
3750 pair = data["pairs"][0]
3751 assert "file_a" in pair or "file" in pair
3752 assert "co_changes" in pair
3753 assert isinstance(pair["co_changes"], int)
3754
3755 def test_coupling_json_file_filter_uses_partner_schema(
3756 self, coupling_repo: pathlib.Path
3757 ) -> None:
3758 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3759 result = runner.invoke(
3760 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3761 )
3762 data = json.loads(result.output)
3763 assert data["filters"]["file"] == "billing.py"
3764 if data["pairs"]:
3765 pair = data["pairs"][0]
3766 assert "file" in pair
3767 assert "partner" in pair
3768 assert "co_changes" in pair
3769 assert "file_a" not in pair # partner schema, not pair schema
3770
3771 def test_coupling_json_not_truncated_small_repo(
3772 self, coupling_repo: pathlib.Path
3773 ) -> None:
3774 result = runner.invoke(cli, ["code", "coupling", "--json"])
3775 data = json.loads(result.output)
3776 assert data["truncated"] is False
3777
3778 def test_coupling_json_filters_reflect_args(
3779 self, coupling_repo: pathlib.Path
3780 ) -> None:
3781 result = runner.invoke(
3782 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3783 )
3784 data = json.loads(result.output)
3785 assert data["filters"]["top"] == 5
3786 assert data["filters"]["min_count"] == 2
3787
3788 # ── --max-commits ─────────────────────────────────────────────────────────
3789
3790 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3791 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3792 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3793 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3794 d_cap = json.loads(r_cap.output)
3795 assert d_cap["commits_analysed"] <= 1
3796
3797 def test_coupling_max_commits_truncated_flag(
3798 self, coupling_repo: pathlib.Path
3799 ) -> None:
3800 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3801 data = json.loads(result.output)
3802 # With 3 commits and cap=1, truncated must be True.
3803 assert data["truncated"] is True
3804
3805 def test_coupling_max_commits_one_shows_warning(
3806 self, coupling_repo: pathlib.Path
3807 ) -> None:
3808 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3809 assert result.exit_code == 0, result.output
3810 assert "⚠️" in result.output or "capped" in result.output
3811
3812 # ── validation ────────────────────────────────────────────────────────────
3813
3814 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3815 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3816 assert result.exit_code != 0
3817
3818 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3819 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3820 assert result.exit_code != 0
3821
3822 def test_coupling_max_commits_zero_exits_error(
3823 self, coupling_repo: pathlib.Path
3824 ) -> None:
3825 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3826 assert result.exit_code != 0
3827
3828 def test_coupling_invalid_from_ref_exits_error(
3829 self, coupling_repo: pathlib.Path
3830 ) -> None:
3831 result = runner.invoke(
3832 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3833 )
3834 assert result.exit_code != 0
3835
3836 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3837 """Coupling must count co-changes on feature-branch commits (parent2)."""
3838 import datetime
3839
3840 # Genesis commit
3841 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3842 r = runner.invoke(cli, ["commit", "-m", "seed"])
3843 assert r.exit_code == 0, r.output
3844
3845 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
3846 repo_id = repo_json["repo_id"]
3847 from muse.core.store import read_current_branch, resolve_commit_ref
3848 branch = read_current_branch(repo)
3849 head = resolve_commit_ref(repo, repo_id, branch, None)
3850 assert head is not None
3851
3852 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3853 feature_at = now
3854 merge_at = now + datetime.timedelta(hours=1)
3855
3856 # Feature commit touching billing.py + models.py together.
3857 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3858 from muse.core.snapshot import compute_commit_id
3859 feature_delta = StructuredDelta(
3860 domain="code",
3861 ops=[
3862 PatchOp(
3863 op="patch", address="billing.py",
3864 child_ops=[ReplaceOp(
3865 op="replace", address="billing.py::compute",
3866 old_content_id="a" * 64, new_content_id="b" * 64,
3867 old_summary="function compute",
3868 new_summary="function compute (modified)", position=None,
3869 )],
3870 child_domain="code", child_summary="compute modified",
3871 ),
3872 PatchOp(
3873 op="patch", address="models.py",
3874 child_ops=[InsertOp(
3875 op="insert", address="models.py::Order",
3876 content_id="c" * 64, content_summary="class Order", position=None,
3877 )],
3878 child_domain="code", child_summary="Order added",
3879 ),
3880 ],
3881 summary="co-change",
3882 )
3883 feature_id = compute_commit_id(
3884 [head.commit_id], head.snapshot_id,
3885 "co-change on feature branch", feature_at.isoformat(),
3886 )
3887 merge_id = compute_commit_id(
3888 [head.commit_id, feature_id], head.snapshot_id,
3889 "Merge feature", merge_at.isoformat(),
3890 )
3891 feature_body: CommitDict = {
3892 "commit_id": feature_id,
3893 "repo_id": repo_id,
3894 "branch": "feat/test",
3895 "snapshot_id": head.snapshot_id,
3896 "message": "co-change on feature branch",
3897 "committed_at": feature_at.isoformat(),
3898 "parent_commit_id": head.commit_id,
3899 "parent2_commit_id": None,
3900 "author": "test",
3901 "metadata": {},
3902 "structured_delta": feature_delta,
3903 }
3904 merge_body: CommitDict = {
3905 "commit_id": merge_id,
3906 "repo_id": repo_id,
3907 "branch": branch,
3908 "snapshot_id": head.snapshot_id,
3909 "message": "Merge feature",
3910 "committed_at": merge_at.isoformat(),
3911 "parent_commit_id": head.commit_id,
3912 "parent2_commit_id": feature_id,
3913 "author": "test",
3914 "metadata": {},
3915 "structured_delta": None,
3916 }
3917 from muse.core.store import write_commit, CommitRecord
3918 write_commit(repo, CommitRecord.from_dict(feature_body))
3919 write_commit(repo, CommitRecord.from_dict(merge_body))
3920 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
3921
3922 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3923 assert result.exit_code == 0, result.output
3924 data = json.loads(result.output)
3925 pairs_found = {
3926 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
3927 }
3928 billing_models = any(
3929 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
3930 for a, b in pairs_found
3931 )
3932 assert billing_models, "BFS must find the feature-branch co-change commit"
3933
3934
3935 # ---------------------------------------------------------------------------
3936 # muse code stable
3937 # ---------------------------------------------------------------------------
3938
3939
3940 class TestStable:
3941 """Tests for muse code stable."""
3942
3943 # ── basic correctness ────────────────────────────────────────────────────
3944
3945 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
3946 result = runner.invoke(cli, ["code", "stable"])
3947 assert result.exit_code == 0, result.output
3948
3949 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
3950 result = runner.invoke(cli, ["code", "stable"])
3951 assert result.exit_code == 0, result.output
3952 assert "Symbol stability" in result.output
3953 assert "Commits analysed" in result.output
3954 assert "bedrock" in result.output
3955
3956 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
3957 """Invoice.apply_discount was defined in the genesis commit and never modified."""
3958 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
3959 assert result.exit_code == 0, result.output
3960 # apply_discount was never touched in any structured_delta → maximally stable.
3961 assert "apply_discount" in result.output
3962
3963 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
3964 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
3965 assert result.exit_code == 0, result.output
3966 assert "since start of range" in result.output
3967
3968 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
3969 """Markdown / TOML / YAML symbols must be absent from default output."""
3970 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
3971 assert result.exit_code == 0, result.output
3972 assert ".md::" not in result.output
3973 assert ".toml::" not in result.output
3974
3975 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
3976 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
3977 assert result.exit_code == 0, result.output
3978 assert "::import::" not in result.output
3979
3980 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
3981 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
3982 assert result.exit_code == 0, result.output
3983
3984 # ── JSON output ───────────────────────────────────────────────────────────
3985
3986 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
3987 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
3988 assert result.exit_code == 0, result.output
3989 data = json.loads(result.output)
3990 assert "from_ref" in data
3991 assert "to_ref" in data
3992 assert "commits_analysed" in data
3993 assert "truncated" in data
3994 assert "filters" in data
3995 assert "stable" in data
3996 assert isinstance(data["stable"], list)
3997
3998 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
3999 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4000 data = json.loads(result.output)
4001 assert len(data["stable"]) > 0
4002 entry = data["stable"][0]
4003 assert "address" in entry
4004 assert "unchanged_for" in entry
4005 assert "since_start_of_range" in entry
4006 assert isinstance(entry["unchanged_for"], int)
4007 assert isinstance(entry["since_start_of_range"], bool)
4008
4009 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
4010 result = runner.invoke(
4011 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
4012 )
4013 data = json.loads(result.output)
4014 assert data["filters"]["top"] == 3
4015 assert data["filters"]["kind"] == "function"
4016 assert data["filters"]["include_imports"] is False
4017 assert data["filters"]["include_docs"] is False
4018
4019 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
4020 result = runner.invoke(cli, ["code", "stable", "--json"])
4021 data = json.loads(result.output)
4022 assert data["truncated"] is False
4023
4024 # ── --language filter ─────────────────────────────────────────────────────
4025
4026 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
4027 """--language python and --language Python must behave identically."""
4028 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4029 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
4030 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
4031 d_lower = json.loads(r_lower.output)
4032 d_upper = json.loads(r_upper.output)
4033 addrs_lower = {e["address"] for e in d_lower["stable"]}
4034 addrs_upper = {e["address"] for e in d_upper["stable"]}
4035 assert addrs_lower == addrs_upper
4036
4037 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
4038 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4039 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4040 d_py = json.loads(r_py.output)
4041 d_all = json.loads(r_all.output)
4042 # Python-filtered results must be a subset of or equal to unfiltered results.
4043 py_addrs = {e["address"] for e in d_py["stable"]}
4044 all_addrs = {e["address"] for e in d_all["stable"]}
4045 assert py_addrs <= all_addrs
4046
4047 # ── --since REF ───────────────────────────────────────────────────────────
4048
4049 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
4050 """--since HEAD restricts the window to 0 commits (stop immediately)."""
4051 # Get the HEAD commit id to use as --since boundary
4052 import json as _json
4053 root = code_repo
4054 repo_id = _json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
4055 from muse.core.store import read_current_branch, resolve_commit_ref
4056 branch = read_current_branch(root)
4057 head = resolve_commit_ref(root, repo_id, branch, None)
4058 assert head is not None
4059
4060 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4061 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4062 assert r_all.exit_code == 0 and r_since.exit_code == 0
4063 d_all = json.loads(r_all.output)
4064 d_since = json.loads(r_since.output)
4065 # Window stops at HEAD itself → at most 1 commit analysed.
4066 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4067
4068 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4069 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4070 assert result.exit_code != 0
4071
4072 # ── --max-commits ─────────────────────────────────────────────────────────
4073
4074 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4075 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4076 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4077 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4078 d_cap = json.loads(r_cap.output)
4079 assert d_cap["commits_analysed"] <= 1
4080
4081 def test_stable_max_commits_one_shows_truncated_warning(
4082 self, code_repo: pathlib.Path
4083 ) -> None:
4084 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4085 assert result.exit_code == 0, result.output
4086 # With 2 commits and cap=1, truncated warning should appear.
4087 assert "capped" in result.output or "⚠️" in result.output
4088
4089 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4090 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4091 assert result.exit_code != 0
4092
4093 # ── --top validation ──────────────────────────────────────────────────────
4094
4095 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4096 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4097 assert result.exit_code != 0
4098
4099 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4100 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4101 data = json.loads(result.output)
4102 assert len(data["stable"]) <= 2
4103
4104 # ── BFS follows merge parents ─────────────────────────────────────────────
4105
4106 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4107 """Symbols touched only on a merged feature branch must be detected as unstable."""
4108 import datetime
4109
4110 # Create a symbol in commit 1 (main).
4111 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4112 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4113 assert r.exit_code == 0, r.output
4114
4115 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
4116 repo_id = repo_json["repo_id"]
4117 from muse.core.store import read_current_branch, resolve_commit_ref
4118 branch = read_current_branch(repo)
4119 head_commit = resolve_commit_ref(repo, repo_id, branch, None)
4120 assert head_commit is not None
4121 head_id = head_commit.commit_id
4122
4123 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4124 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4125
4126 # Feature-branch commit that touched "bedrock" via a structured_delta.
4127 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4128 from muse.core.snapshot import compute_commit_id
4129 bedrock_delta = StructuredDelta(
4130 domain="code",
4131 ops=[PatchOp(
4132 op="patch", address="core.py",
4133 child_ops=[ReplaceOp(
4134 op="replace", address="core.py::bedrock",
4135 old_content_id="a" * 64, new_content_id="b" * 64,
4136 old_summary="function bedrock",
4137 new_summary="function bedrock (modified)", position=None,
4138 )],
4139 child_domain="code", child_summary="bedrock modified",
4140 )],
4141 summary="bedrock modified",
4142 )
4143 feature_id = compute_commit_id(
4144 [head_id], head_commit.snapshot_id,
4145 "Feature: touch bedrock", feature_at.isoformat(),
4146 )
4147 merge_id = compute_commit_id(
4148 [head_id, feature_id], head_commit.snapshot_id,
4149 "Merge feat/touch-bedrock", merge_at.isoformat(),
4150 )
4151 feature_body: CommitDict = {
4152 "commit_id": feature_id,
4153 "repo_id": repo_id,
4154 "branch": "feat/touch-bedrock",
4155 "snapshot_id": head_commit.snapshot_id,
4156 "message": "Feature: touch bedrock",
4157 "committed_at": feature_at.isoformat(),
4158 "parent_commit_id": head_id,
4159 "parent2_commit_id": None,
4160 "author": "test",
4161 "metadata": {},
4162 "structured_delta": bedrock_delta,
4163 }
4164 # Merge commit whose parent2 is the feature commit.
4165 merge_body: CommitDict = {
4166 "commit_id": merge_id,
4167 "repo_id": repo_id,
4168 "branch": branch,
4169 "snapshot_id": head_commit.snapshot_id,
4170 "message": "Merge feat/touch-bedrock",
4171 "committed_at": merge_at.isoformat(),
4172 "parent_commit_id": head_id,
4173 "parent2_commit_id": feature_id,
4174 "author": "test",
4175 "metadata": {},
4176 "structured_delta": None,
4177 }
4178 from muse.core.store import write_commit, CommitRecord
4179 write_commit(repo, CommitRecord.from_dict(feature_body))
4180 write_commit(repo, CommitRecord.from_dict(merge_body))
4181 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
4182
4183 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4184 assert result.exit_code == 0, result.output
4185 data = json.loads(result.output)
4186 # bedrock was touched in the feature-branch commit; BFS must find it.
4187 # It should have unchanged_for < total_commits (not maximally stable).
4188 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4189 if bedrock_entries:
4190 assert not bedrock_entries[0]["since_start_of_range"]
4191
4192
4193 # ---------------------------------------------------------------------------
4194 # muse code compare
4195 # ---------------------------------------------------------------------------
4196
4197
4198 @pytest.fixture
4199 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4200 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4201
4202 Commit A — billing.py with Invoice.compute_total + process_order.
4203 Commit B — compute_total renamed to compute_invoice_total; generate_pdf
4204 and send_email added. Multi-line message to test truncation.
4205 """
4206 (repo / "billing.py").write_text(textwrap.dedent("""\
4207 class Invoice:
4208 def compute_total(self, items):
4209 return sum(items)
4210
4211 def apply_discount(self, total, pct):
4212 return total * (1 - pct)
4213
4214 def process_order(invoice, items):
4215 return invoice.compute_total(items)
4216 """))
4217 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4218 assert r.exit_code == 0, r.output
4219 from muse.core.store import read_current_branch
4220 branch = read_current_branch(repo)
4221 commit_a = get_head_commit_id(repo, branch)
4222
4223 (repo / "billing.py").write_text(textwrap.dedent("""\
4224 class Invoice:
4225 def compute_invoice_total(self, items):
4226 return sum(items)
4227
4228 def apply_discount(self, total, pct):
4229 return total * (1 - pct)
4230
4231 def generate_pdf(self):
4232 return b"pdf"
4233
4234 def process_order(invoice, items):
4235 return invoice.compute_invoice_total(items)
4236
4237 def send_email(address):
4238 pass
4239 """))
4240 # Multi-line message to test first-line truncation.
4241 r = runner.invoke(cli, [
4242 "commit", "-m",
4243 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4244 ])
4245 assert r.exit_code == 0, r.output
4246 commit_b = get_head_commit_id(repo, branch)
4247
4248 assert commit_a is not None
4249 assert commit_b is not None
4250 return repo, commit_a, commit_b
4251
4252
4253 class TestCompare:
4254 """Tests for muse code compare."""
4255
4256 # ── basic correctness ────────────────────────────────────────────────────
4257
4258 def test_compare_exits_zero(
4259 self, compare_repo: tuple[pathlib.Path, str, str]
4260 ) -> None:
4261 _, ref_a, ref_b = compare_repo
4262 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4263 assert result.exit_code == 0, result.output
4264
4265 def test_compare_shows_header(
4266 self, compare_repo: tuple[pathlib.Path, str, str]
4267 ) -> None:
4268 _, ref_a, ref_b = compare_repo
4269 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4270 assert result.exit_code == 0, result.output
4271 assert "Semantic comparison" in result.output
4272 assert "From:" in result.output
4273 assert "To:" in result.output
4274
4275 def test_compare_commit_message_first_line_only(
4276 self, compare_repo: tuple[pathlib.Path, str, str]
4277 ) -> None:
4278 """Multi-line commit messages must be truncated to their first line."""
4279 _, ref_a, ref_b = compare_repo
4280 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4281 assert result.exit_code == 0, result.output
4282 # The body of the second commit must not appear in the header.
4283 assert "This is the extended body" not in result.output
4284
4285 def test_compare_same_ref_no_changes(
4286 self, compare_repo: tuple[pathlib.Path, str, str]
4287 ) -> None:
4288 _, ref_a, _ = compare_repo
4289 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4290 assert result.exit_code == 0, result.output
4291 assert "no semantic changes" in result.output
4292
4293 def test_compare_detects_added_symbols(
4294 self, compare_repo: tuple[pathlib.Path, str, str]
4295 ) -> None:
4296 _, ref_a, ref_b = compare_repo
4297 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4298 assert result.exit_code == 0, result.output
4299 # generate_pdf and send_email were added in commit B.
4300 assert "generate_pdf" in result.output or "send_email" in result.output
4301
4302 def test_compare_invalid_ref_exits_nonzero(
4303 self, compare_repo: tuple[pathlib.Path, str, str]
4304 ) -> None:
4305 _, ref_a, _ = compare_repo
4306 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4307 assert result.exit_code != 0
4308
4309 def test_compare_requires_repo(
4310 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4311 ) -> None:
4312 monkeypatch.chdir(tmp_path)
4313 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4314 assert result.exit_code != 0
4315
4316 # ── JSON schema ──────────────────────────────────────────────────────────
4317
4318 def test_compare_json_schema(
4319 self, compare_repo: tuple[pathlib.Path, str, str]
4320 ) -> None:
4321 _, ref_a, ref_b = compare_repo
4322 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4323 assert result.exit_code == 0, result.output
4324 data = json.loads(result.output)
4325 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4326
4327 def test_compare_json_from_to_schema(
4328 self, compare_repo: tuple[pathlib.Path, str, str]
4329 ) -> None:
4330 _, ref_a, ref_b = compare_repo
4331 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4332 assert result.exit_code == 0, result.output
4333 data = json.loads(result.output)
4334 assert "commit_id" in data["from"]
4335 assert "message" in data["from"]
4336 assert "commit_id" in data["to"]
4337 assert "message" in data["to"]
4338
4339 def test_compare_json_message_first_line_only(
4340 self, compare_repo: tuple[pathlib.Path, str, str]
4341 ) -> None:
4342 _, ref_a, ref_b = compare_repo
4343 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4344 assert result.exit_code == 0, result.output
4345 data = json.loads(result.output)
4346 assert "\n" not in data["to"]["message"]
4347 assert "This is the extended body" not in data["to"]["message"]
4348
4349 def test_compare_json_stat_schema(
4350 self, compare_repo: tuple[pathlib.Path, str, str]
4351 ) -> None:
4352 _, ref_a, ref_b = compare_repo
4353 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4354 assert result.exit_code == 0, result.output
4355 stat = json.loads(result.output)["stat"]
4356 assert set(stat.keys()) >= {
4357 "files_changed", "symbols_added", "symbols_removed",
4358 "symbols_modified", "semver_impact",
4359 }
4360 assert isinstance(stat["files_changed"], int)
4361 assert isinstance(stat["symbols_added"], int)
4362 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4363
4364 def test_compare_json_filters_schema(
4365 self, compare_repo: tuple[pathlib.Path, str, str]
4366 ) -> None:
4367 _, ref_a, ref_b = compare_repo
4368 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4369 assert result.exit_code == 0, result.output
4370 filters = json.loads(result.output)["filters"]
4371 assert set(filters.keys()) >= {"kind", "file", "language"}
4372 # No filters applied — all None.
4373 assert filters["kind"] is None
4374 assert filters["file"] is None
4375 assert filters["language"] is None
4376
4377 def test_compare_json_ops_schema(
4378 self, compare_repo: tuple[pathlib.Path, str, str]
4379 ) -> None:
4380 _, ref_a, ref_b = compare_repo
4381 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4382 assert result.exit_code == 0, result.output
4383 ops = json.loads(result.output)["ops"]
4384 assert isinstance(ops, list)
4385 assert len(ops) > 0
4386 for op in ops:
4387 assert "op" in op
4388 assert "address" in op
4389 assert "detail" in op
4390
4391 def test_compare_same_ref_json_empty_ops(
4392 self, compare_repo: tuple[pathlib.Path, str, str]
4393 ) -> None:
4394 _, ref_a, _ = compare_repo
4395 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--json"])
4396 assert result.exit_code == 0, result.output
4397 data = json.loads(result.output)
4398 assert data["ops"] == []
4399 assert data["stat"]["semver_impact"] == "NONE"
4400
4401 # ── --stat flag ──────────────────────────────────────────────────────────
4402
4403 def test_compare_stat_shows_counts(
4404 self, compare_repo: tuple[pathlib.Path, str, str]
4405 ) -> None:
4406 _, ref_a, ref_b = compare_repo
4407 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4408 assert result.exit_code == 0, result.output
4409 assert "Files changed:" in result.output
4410 assert "Symbols added:" in result.output
4411 assert "Symbols removed:" in result.output
4412 assert "Symbols modified:" in result.output
4413 assert "SemVer impact:" in result.output
4414
4415 def test_compare_stat_no_per_symbol_listing(
4416 self, compare_repo: tuple[pathlib.Path, str, str]
4417 ) -> None:
4418 _, ref_a, ref_b = compare_repo
4419 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4420 assert result.exit_code == 0, result.output
4421 # --stat should not include per-symbol listing lines ("added …", "removed …").
4422 assert " added " not in result.output
4423 assert " removed " not in result.output
4424 assert " modified " not in result.output
4425
4426 def test_compare_stat_same_ref_semver_none(
4427 self, compare_repo: tuple[pathlib.Path, str, str]
4428 ) -> None:
4429 _, ref_a, _ = compare_repo
4430 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--stat"])
4431 assert result.exit_code == 0, result.output
4432 assert "NONE" in result.output
4433
4434 # ── --semver flag ────────────────────────────────────────────────────────
4435
4436 def test_compare_semver_appended_to_full_output(
4437 self, compare_repo: tuple[pathlib.Path, str, str]
4438 ) -> None:
4439 _, ref_a, ref_b = compare_repo
4440 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--semver"])
4441 assert result.exit_code == 0, result.output
4442 assert "SemVer impact:" in result.output
4443
4444 # ── --file filter ────────────────────────────────────────────────────────
4445
4446 def test_compare_file_filter_restricts_output(
4447 self, compare_repo: tuple[pathlib.Path, str, str]
4448 ) -> None:
4449 _, ref_a, ref_b = compare_repo
4450 result = runner.invoke(
4451 cli, ["code", "compare", ref_a, ref_b, "--file", "billing.py"]
4452 )
4453 assert result.exit_code == 0, result.output
4454
4455 def test_compare_file_filter_nonexistent_no_ops(
4456 self, compare_repo: tuple[pathlib.Path, str, str]
4457 ) -> None:
4458 _, ref_a, ref_b = compare_repo
4459 result = runner.invoke(
4460 cli, ["code", "compare", ref_a, ref_b, "--file", "nonexistent.py"]
4461 )
4462 assert result.exit_code == 0, result.output
4463 assert "no semantic changes" in result.output
4464
4465 def test_compare_file_filter_in_json(
4466 self, compare_repo: tuple[pathlib.Path, str, str]
4467 ) -> None:
4468 _, ref_a, ref_b = compare_repo
4469 result = runner.invoke(
4470 cli,
4471 ["code", "compare", ref_a, ref_b, "--file", "billing.py", "--json"],
4472 )
4473 assert result.exit_code == 0, result.output
4474 data = json.loads(result.output)
4475 assert data["filters"]["file"] == "billing.py"
4476
4477 # ── --kind filter ────────────────────────────────────────────────────────
4478
4479 def test_compare_kind_filter_case_insensitive(
4480 self, compare_repo: tuple[pathlib.Path, str, str]
4481 ) -> None:
4482 _, ref_a, ref_b = compare_repo
4483 r_lower = runner.invoke(
4484 cli, ["code", "compare", ref_a, ref_b, "--kind", "function"]
4485 )
4486 r_upper = runner.invoke(
4487 cli, ["code", "compare", ref_a, ref_b, "--kind", "Function"]
4488 )
4489 assert r_lower.exit_code == 0
4490 assert r_upper.exit_code == 0
4491 # Both produce the same ops list.
4492 assert r_lower.output == r_upper.output
4493
4494 def test_compare_kind_filter_in_json(
4495 self, compare_repo: tuple[pathlib.Path, str, str]
4496 ) -> None:
4497 _, ref_a, ref_b = compare_repo
4498 result = runner.invoke(
4499 cli, ["code", "compare", ref_a, ref_b, "--kind", "function", "--json"]
4500 )
4501 assert result.exit_code == 0, result.output
4502 data = json.loads(result.output)
4503 assert data["filters"]["kind"] == "function"
4504
4505 # ── --language filter ────────────────────────────────────────────────────
4506
4507 def test_compare_language_filter_python(
4508 self, compare_repo: tuple[pathlib.Path, str, str]
4509 ) -> None:
4510 _, ref_a, ref_b = compare_repo
4511 result = runner.invoke(
4512 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4513 )
4514 assert result.exit_code == 0, result.output
4515
4516 def test_compare_language_filter_case_insensitive(
4517 self, compare_repo: tuple[pathlib.Path, str, str]
4518 ) -> None:
4519 _, ref_a, ref_b = compare_repo
4520 r_lower = runner.invoke(
4521 cli, ["code", "compare", ref_a, ref_b, "--language", "python"]
4522 )
4523 r_upper = runner.invoke(
4524 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4525 )
4526 assert r_lower.exit_code == 0
4527 assert r_upper.exit_code == 0
4528 assert r_lower.output == r_upper.output
4529
4530 def test_compare_language_filter_in_json(
4531 self, compare_repo: tuple[pathlib.Path, str, str]
4532 ) -> None:
4533 _, ref_a, ref_b = compare_repo
4534 result = runner.invoke(
4535 cli, ["code", "compare", ref_a, ref_b, "--language", "python", "--json"]
4536 )
4537 assert result.exit_code == 0, result.output
4538 data = json.loads(result.output)
4539 assert data["filters"]["language"] == "Python"
4540
4541
4542 # ---------------------------------------------------------------------------
4543 # muse code languages
4544 # ---------------------------------------------------------------------------
4545
4546
4547 @pytest.fixture
4548 def lang_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4549 """Two-commit repo; returns (path, commit_id_a, commit_id_b).
4550
4551 Commit A — billing.py (Python) only.
4552 Commit B — billing.py extended + README.md added.
4553 """
4554 (repo / "billing.py").write_text(textwrap.dedent("""\
4555 import os
4556 import json
4557
4558 class Invoice:
4559 def compute_total(self, items: list[float]) -> float:
4560 return sum(items)
4561
4562 def process_order(invoice: Invoice, items: list[float]) -> float:
4563 return invoice.compute_total(items)
4564 """))
4565 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4566 assert r.exit_code == 0, r.output
4567 from muse.core.store import read_current_branch
4568 branch = read_current_branch(repo)
4569 commit_a = get_head_commit_id(repo, branch)
4570
4571 (repo / "billing.py").write_text(textwrap.dedent("""\
4572 import os
4573 import json
4574
4575 class Invoice:
4576 def compute_total(self, items: list[float]) -> float:
4577 return sum(items)
4578
4579 def generate_pdf(self) -> bytes:
4580 return b"pdf"
4581
4582 def process_order(invoice: Invoice, items: list[float]) -> float:
4583 return invoice.compute_total(items)
4584
4585 def send_email(address: str) -> None:
4586 pass
4587 """))
4588 (repo / "README.md").write_text("# My Project\n\nA billing module.\n")
4589 r = runner.invoke(cli, ["commit", "-m", "Add generate_pdf, send_email, README"])
4590 assert r.exit_code == 0, r.output
4591 commit_b = get_head_commit_id(repo, branch)
4592
4593 assert commit_a is not None
4594 assert commit_b is not None
4595 return repo, commit_a, commit_b
4596
4597
4598 class TestLanguages:
4599 """Tests for muse code languages."""
4600
4601 # ── basic correctness ────────────────────────────────────────────────────
4602
4603 def test_languages_exits_zero(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4604 result = runner.invoke(cli, ["code", "languages"])
4605 assert result.exit_code == 0, result.output
4606
4607 def test_languages_shows_header(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4608 result = runner.invoke(cli, ["code", "languages"])
4609 assert result.exit_code == 0, result.output
4610 assert "Language breakdown" in result.output
4611 assert "Total" in result.output
4612
4613 def test_languages_shows_python(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4614 result = runner.invoke(cli, ["code", "languages"])
4615 assert result.exit_code == 0, result.output
4616 assert "Python" in result.output
4617
4618 def test_languages_shows_markdown(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4619 result = runner.invoke(cli, ["code", "languages"])
4620 assert result.exit_code == 0, result.output
4621 assert "Markdown" in result.output
4622
4623 def test_languages_excludes_imports_by_default(
4624 self, lang_repo: tuple[pathlib.Path, str, str]
4625 ) -> None:
4626 """Import pseudo-symbols must not inflate the count by default."""
4627 r_default = runner.invoke(cli, ["code", "languages", "--json"])
4628 assert r_default.exit_code == 0, r_default.output
4629 r_imports = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4630 assert r_imports.exit_code == 0, r_imports.output
4631
4632 class _LangEntry(TypedDict):
4633 language: str
4634 files: int
4635 symbols: int
4636 kinds: _KindsMap
4637
4638 class _LangsJson(TypedDict):
4639 languages: list[_LangEntry]
4640
4641 data_default: _LangsJson = json.loads(r_default.output)
4642 data_imports: _LangsJson = json.loads(r_imports.output)
4643
4644 def _py_syms(data: _LangsJson) -> int:
4645 for e in data["languages"]:
4646 if e["language"] == "Python":
4647 return e["symbols"]
4648 return 0
4649
4650 syms_default = _py_syms(data_default)
4651 syms_imports = _py_syms(data_imports)
4652 # With imports included the symbol count must be strictly higher.
4653 assert syms_imports > syms_default
4654
4655 def test_languages_requires_repo(
4656 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4657 ) -> None:
4658 monkeypatch.chdir(tmp_path)
4659 result = runner.invoke(cli, ["code", "languages"])
4660 assert result.exit_code != 0
4661
4662 def test_languages_invalid_commit_exits_nonzero(
4663 self, lang_repo: tuple[pathlib.Path, str, str]
4664 ) -> None:
4665 result = runner.invoke(cli, ["code", "languages", "--commit", "deadbeefdeadbeef"])
4666 assert result.exit_code != 0
4667
4668 # ── JSON schema ──────────────────────────────────────────────────────────
4669
4670 def test_languages_json_schema(
4671 self, lang_repo: tuple[pathlib.Path, str, str]
4672 ) -> None:
4673 result = runner.invoke(cli, ["code", "languages", "--json"])
4674 assert result.exit_code == 0, result.output
4675 data = json.loads(result.output)
4676 assert set(data.keys()) >= {"commit", "include_imports", "languages"}
4677
4678 def test_languages_json_commit_block(
4679 self, lang_repo: tuple[pathlib.Path, str, str]
4680 ) -> None:
4681 result = runner.invoke(cli, ["code", "languages", "--json"])
4682 assert result.exit_code == 0, result.output
4683 data = json.loads(result.output)
4684 commit = data["commit"]
4685 assert "commit_id" in commit
4686 assert "message" in commit
4687 # message is first line only — no newlines.
4688 assert "\n" not in commit["message"]
4689
4690 def test_languages_json_entry_schema(
4691 self, lang_repo: tuple[pathlib.Path, str, str]
4692 ) -> None:
4693 result = runner.invoke(cli, ["code", "languages", "--json"])
4694 assert result.exit_code == 0, result.output
4695 langs = json.loads(result.output)["languages"]
4696 assert isinstance(langs, list)
4697 assert len(langs) > 0
4698 for entry in langs:
4699 assert "language" in entry
4700 assert "files" in entry
4701 assert "symbols" in entry
4702 assert "kinds" in entry
4703 assert isinstance(entry["files"], int)
4704 assert isinstance(entry["symbols"], int)
4705 assert isinstance(entry["kinds"], dict)
4706
4707 def test_languages_json_include_imports_flag(
4708 self, lang_repo: tuple[pathlib.Path, str, str]
4709 ) -> None:
4710 result = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4711 assert result.exit_code == 0, result.output
4712 data = json.loads(result.output)
4713 assert data["include_imports"] is True
4714
4715 # ── --sort flag ──────────────────────────────────────────────────────────
4716
4717 def test_languages_sort_name(
4718 self, lang_repo: tuple[pathlib.Path, str, str]
4719 ) -> None:
4720 result = runner.invoke(cli, ["code", "languages", "--sort", "name"])
4721 assert result.exit_code == 0, result.output
4722
4723 def test_languages_sort_symbols(
4724 self, lang_repo: tuple[pathlib.Path, str, str]
4725 ) -> None:
4726 result = runner.invoke(cli, ["code", "languages", "--sort", "symbols"])
4727 assert result.exit_code == 0, result.output
4728 # Python should appear before Markdown when sorted by symbols desc.
4729 lines = result.output.splitlines()
4730 py_line = next((i for i, l in enumerate(lines) if "Python" in l), None)
4731 md_line = next((i for i, l in enumerate(lines) if "Markdown" in l), None)
4732 # Both might not exist if the repo only has Python; at least ensure no crash.
4733 assert py_line is not None
4734
4735 def test_languages_sort_files(
4736 self, lang_repo: tuple[pathlib.Path, str, str]
4737 ) -> None:
4738 result = runner.invoke(cli, ["code", "languages", "--sort", "files"])
4739 assert result.exit_code == 0, result.output
4740
4741 def test_languages_invalid_sort_exits_nonzero(
4742 self, lang_repo: tuple[pathlib.Path, str, str]
4743 ) -> None:
4744 result = runner.invoke(cli, ["code", "languages", "--sort", "bad"])
4745 assert result.exit_code != 0
4746
4747 # ── --diff flag ──────────────────────────────────────────────────────────
4748
4749 def test_languages_diff_exits_zero(
4750 self, lang_repo: tuple[pathlib.Path, str, str]
4751 ) -> None:
4752 _, commit_a, _ = lang_repo
4753 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4754 assert result.exit_code == 0, result.output
4755
4756 def test_languages_diff_shows_header(
4757 self, lang_repo: tuple[pathlib.Path, str, str]
4758 ) -> None:
4759 _, commit_a, _ = lang_repo
4760 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4761 assert result.exit_code == 0, result.output
4762 assert "Language change" in result.output
4763 assert "Net" in result.output
4764
4765 def test_languages_diff_detects_new_symbols(
4766 self, lang_repo: tuple[pathlib.Path, str, str]
4767 ) -> None:
4768 """Commit B added generate_pdf and send_email — Python symbol count must grow."""
4769 _, commit_a, _ = lang_repo
4770 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4771 assert result.exit_code == 0, result.output
4772 # Python line should show a positive delta.
4773 lines = result.output.splitlines()
4774 py_line = next((l for l in lines if "Python" in l), "")
4775 assert "+" in py_line
4776
4777 def test_languages_diff_unchanged_label(
4778 self, lang_repo: tuple[pathlib.Path, str, str]
4779 ) -> None:
4780 """Comparing a commit to itself must show all languages as unchanged."""
4781 _, _, commit_b = lang_repo
4782 result = runner.invoke(cli, ["code", "languages", "--diff", commit_b])
4783 assert result.exit_code == 0, result.output
4784 assert "unchanged" in result.output
4785
4786 def test_languages_diff_invalid_ref_exits_nonzero(
4787 self, lang_repo: tuple[pathlib.Path, str, str]
4788 ) -> None:
4789 result = runner.invoke(cli, ["code", "languages", "--diff", "deadbeefdeadbeef"])
4790 assert result.exit_code != 0
4791
4792 def test_languages_diff_json_schema(
4793 self, lang_repo: tuple[pathlib.Path, str, str]
4794 ) -> None:
4795 _, commit_a, _ = lang_repo
4796 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4797 assert result.exit_code == 0, result.output
4798 data = json.loads(result.output)
4799 assert set(data.keys()) >= {"from", "to", "include_imports", "diff"}
4800 assert "commit_id" in data["from"]
4801 assert "message" in data["to"]
4802
4803 def test_languages_diff_json_entry_schema(
4804 self, lang_repo: tuple[pathlib.Path, str, str]
4805 ) -> None:
4806 _, commit_a, _ = lang_repo
4807 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4808 assert result.exit_code == 0, result.output
4809 diff = json.loads(result.output)["diff"]
4810 assert isinstance(diff, list)
4811 assert len(diff) > 0
4812 for entry in diff:
4813 assert "language" in entry
4814 assert "delta_files" in entry
4815 assert "delta_symbols" in entry
4816 assert "files_before" in entry
4817 assert "files_after" in entry
4818 assert "symbols_before" in entry
4819 assert "symbols_after" in entry
4820 assert "status" in entry
4821 assert entry["status"] in ("added", "removed", "changed", "unchanged")
4822
4823 def test_languages_diff_json_python_delta_positive(
4824 self, lang_repo: tuple[pathlib.Path, str, str]
4825 ) -> None:
4826 _, commit_a, _ = lang_repo
4827 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4828 assert result.exit_code == 0, result.output
4829 diff = json.loads(result.output)["diff"]
4830 py = next((e for e in diff if e["language"] == "Python"), None)
4831 assert py is not None
4832 assert py["delta_symbols"] > 0
4833 assert py["status"] == "changed"
4834
4835 def test_languages_diff_json_markdown_added(
4836 self, lang_repo: tuple[pathlib.Path, str, str]
4837 ) -> None:
4838 """README.md was added in commit B — Markdown status should be 'added'."""
4839 _, commit_a, _ = lang_repo
4840 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4841 assert result.exit_code == 0, result.output
4842 diff = json.loads(result.output)["diff"]
4843 md = next((e for e in diff if e["language"] == "Markdown"), None)
4844 assert md is not None
4845 assert md["status"] == "added"
4846 assert md["files_before"] == 0
4847 assert md["files_after"] == 1
4848
4849
4850 # ---------------------------------------------------------------------------
4851 # muse code rename
4852 # ---------------------------------------------------------------------------
4853
4854
4855 @pytest.fixture
4856 def rename_repo(repo: pathlib.Path) -> pathlib.Path:
4857 """Repo with billing.py and a test file that imports and calls its symbols."""
4858 (repo / "billing.py").write_text(textwrap.dedent("""\
4859 import os
4860
4861 class Invoice:
4862 def compute_total(self, items):
4863 return sum(items)
4864
4865 def apply_discount(self, total, pct):
4866 return total * (1 - pct)
4867
4868 def process_order(invoice, items):
4869 total = compute_total(items)
4870 return total
4871 """))
4872 (repo / "test_billing.py").write_text(textwrap.dedent("""\
4873 from billing import compute_total, Invoice
4874
4875 def test_compute_total():
4876 inv = Invoice()
4877 result = inv.compute_total([1, 2, 3])
4878 assert compute_total([1, 2, 3]) == 6
4879 """))
4880 r = runner.invoke(cli, ["commit", "-m", "Initial billing + tests"])
4881 assert r.exit_code == 0, r.output
4882 return repo
4883
4884
4885 class TestRename:
4886 """Tests for muse code rename."""
4887
4888 # ── basic correctness ────────────────────────────────────────────────────
4889
4890 def test_rename_dry_run_exits_zero(self, rename_repo: pathlib.Path) -> None:
4891 result = runner.invoke(
4892 cli,
4893 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4894 )
4895 assert result.exit_code == 0, result.output
4896
4897 def test_rename_dry_run_shows_preview(self, rename_repo: pathlib.Path) -> None:
4898 result = runner.invoke(
4899 cli,
4900 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4901 )
4902 assert result.exit_code == 0, result.output
4903 assert "Renaming" in result.output
4904 assert "process_order" in result.output
4905 assert "handle_order" in result.output
4906
4907 def test_rename_dry_run_does_not_write(self, rename_repo: pathlib.Path) -> None:
4908 before = (rename_repo / "billing.py").read_text()
4909 runner.invoke(
4910 cli,
4911 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4912 )
4913 assert (rename_repo / "billing.py").read_text() == before
4914
4915 def test_rename_applies_definition(self, rename_repo: pathlib.Path) -> None:
4916 result = runner.invoke(
4917 cli,
4918 ["code", "rename", "billing.py::process_order", "handle_order",
4919 "--scope", "definition", "--yes"],
4920 )
4921 assert result.exit_code == 0, result.output
4922 content = (rename_repo / "billing.py").read_text()
4923 assert "def handle_order(" in content
4924 assert "def process_order(" not in content
4925
4926 def test_rename_only_def_token_not_string_literal(
4927 self, rename_repo: pathlib.Path
4928 ) -> None:
4929 """The rename must not touch string literals containing the old name."""
4930 # Add a docstring with the old name.
4931 billing = (rename_repo / "billing.py").read_text()
4932 billing += '\nDOC = "compute_total is a function"\n'
4933 (rename_repo / "billing.py").write_text(billing)
4934 runner.invoke(cli, ["commit", "-m", "add docstring"])
4935
4936 runner.invoke(
4937 cli,
4938 ["code", "rename", "billing.py::Invoice.compute_total",
4939 "compute_invoice_total", "--scope", "definition", "--yes"],
4940 )
4941 content = (rename_repo / "billing.py").read_text()
4942 # The string literal must be untouched.
4943 assert '"compute_total is a function"' in content
4944
4945 def test_rename_method_definition_scoped_to_class(
4946 self, rename_repo: pathlib.Path
4947 ) -> None:
4948 """billing.py::Invoice.compute_total must rename the method inside Invoice."""
4949 result = runner.invoke(
4950 cli,
4951 ["code", "rename", "billing.py::Invoice.compute_total",
4952 "compute_invoice_total", "--scope", "definition", "--yes"],
4953 )
4954 assert result.exit_code == 0, result.output
4955 content = (rename_repo / "billing.py").read_text()
4956 assert "def compute_invoice_total(self" in content
4957 # The module-level bare call in process_order stays unchanged.
4958 assert "compute_total(items)" in content
4959
4960 def test_rename_updates_import_sites(self, rename_repo: pathlib.Path) -> None:
4961 result = runner.invoke(
4962 cli,
4963 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
4964 "--scope", "imports", "--yes"],
4965 )
4966 assert result.exit_code == 0, result.output
4967 content = (rename_repo / "test_billing.py").read_text()
4968 assert "compute_invoice_total" in content
4969 assert "from billing import" in content
4970
4971 def test_rename_requires_repo(
4972 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4973 ) -> None:
4974 monkeypatch.chdir(tmp_path)
4975 result = runner.invoke(
4976 cli, ["code", "rename", "billing.py::foo", "bar", "--yes"]
4977 )
4978 assert result.exit_code != 0
4979
4980 def test_rename_rejects_same_name(self, rename_repo: pathlib.Path) -> None:
4981 result = runner.invoke(
4982 cli,
4983 ["code", "rename", "billing.py::process_order", "process_order", "--yes"],
4984 )
4985 assert result.exit_code != 0
4986
4987 def test_rename_rejects_invalid_identifier(self, rename_repo: pathlib.Path) -> None:
4988 result = runner.invoke(
4989 cli,
4990 ["code", "rename", "billing.py::process_order", "123invalid", "--yes"],
4991 )
4992 assert result.exit_code != 0
4993
4994 def test_rename_rejects_address_without_double_colon(
4995 self, rename_repo: pathlib.Path
4996 ) -> None:
4997 result = runner.invoke(
4998 cli, ["code", "rename", "billing.py", "new_name", "--yes"]
4999 )
5000 assert result.exit_code != 0
5001
5002 def test_rename_rejects_nonexistent_symbol(self, rename_repo: pathlib.Path) -> None:
5003 result = runner.invoke(
5004 cli,
5005 ["code", "rename", "billing.py::nonexistent_func", "new_name",
5006 "--scope", "definition", "--yes"],
5007 )
5008 assert result.exit_code != 0
5009
5010 def test_rename_rejects_path_traversal(self, rename_repo: pathlib.Path) -> None:
5011 result = runner.invoke(
5012 cli,
5013 ["code", "rename", "../../etc/passwd::foo", "bar", "--yes"],
5014 )
5015 assert result.exit_code != 0
5016
5017 def test_rename_rejects_dunder_without_force(self, rename_repo: pathlib.Path) -> None:
5018 result = runner.invoke(
5019 cli,
5020 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__", "--yes"],
5021 )
5022 assert result.exit_code != 0
5023
5024 def test_rename_allows_dunder_with_force(self, rename_repo: pathlib.Path) -> None:
5025 result = runner.invoke(
5026 cli,
5027 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__",
5028 "--scope", "definition", "--yes", "--force"],
5029 )
5030 assert result.exit_code == 0, result.output
5031 content = (rename_repo / "billing.py").read_text()
5032 assert "def __compute__(self" in content
5033
5034 # ── JSON output ──────────────────────────────────────────────────────────
5035
5036 def test_rename_json_schema(self, rename_repo: pathlib.Path) -> None:
5037 result = runner.invoke(
5038 cli,
5039 ["code", "rename", "billing.py::process_order", "handle_order",
5040 "--dry-run", "--json"],
5041 )
5042 assert result.exit_code == 0, result.output
5043 data = json.loads(result.output)
5044 assert set(data.keys()) >= {
5045 "from_address", "to_address", "from_name", "to_name",
5046 "scope", "dry_run", "files_to_modify", "total_edit_sites", "edit_sites",
5047 }
5048
5049 def test_rename_json_dry_run_empty_files_to_modify(
5050 self, rename_repo: pathlib.Path
5051 ) -> None:
5052 result = runner.invoke(
5053 cli,
5054 ["code", "rename", "billing.py::process_order", "handle_order",
5055 "--dry-run", "--json"],
5056 )
5057 assert result.exit_code == 0, result.output
5058 data = json.loads(result.output)
5059 assert data["dry_run"] is True
5060 assert data["files_to_modify"] == []
5061
5062 def test_rename_json_edit_site_schema(self, rename_repo: pathlib.Path) -> None:
5063 result = runner.invoke(
5064 cli,
5065 ["code", "rename", "billing.py::process_order", "handle_order",
5066 "--dry-run", "--json"],
5067 )
5068 assert result.exit_code == 0, result.output
5069 sites = json.loads(result.output)["edit_sites"]
5070 assert isinstance(sites, list)
5071 assert len(sites) > 0
5072 for site in sites:
5073 assert "file" in site
5074 assert "line" in site
5075 assert "col_start" in site
5076 assert "col_end" in site
5077 assert "kind" in site
5078 assert "context" in site
5079 assert site["kind"] in ("definition", "import", "reference")
5080 assert site["col_start"] < site["col_end"]
5081
5082 def test_rename_json_definition_site_present(self, rename_repo: pathlib.Path) -> None:
5083 result = runner.invoke(
5084 cli,
5085 ["code", "rename", "billing.py::process_order", "handle_order",
5086 "--dry-run", "--json"],
5087 )
5088 assert result.exit_code == 0, result.output
5089 sites = json.loads(result.output)["edit_sites"]
5090 def_sites = [s for s in sites if s["kind"] == "definition"]
5091 assert len(def_sites) == 1
5092 assert def_sites[0]["file"] == "billing.py"
5093 assert "process_order" in def_sites[0]["context"]
5094
5095 def test_rename_json_apply_writes_files(self, rename_repo: pathlib.Path) -> None:
5096 result = runner.invoke(
5097 cli,
5098 ["code", "rename", "billing.py::process_order", "handle_order",
5099 "--yes", "--json", "--scope", "definition"],
5100 )
5101 assert result.exit_code == 0, result.output
5102 content = (rename_repo / "billing.py").read_text()
5103 assert "def handle_order(" in content
5104
5105 # ── --scope flag ─────────────────────────────────────────────────────────
5106
5107 def test_rename_scope_definition_only(self, rename_repo: pathlib.Path) -> None:
5108 """--scope definition should only touch the def token."""
5109 runner.invoke(
5110 cli,
5111 ["code", "rename", "billing.py::process_order", "handle_order",
5112 "--scope", "definition", "--yes"],
5113 )
5114 billing = (rename_repo / "billing.py").read_text()
5115 test = (rename_repo / "test_billing.py").read_text()
5116 assert "def handle_order(" in billing
5117 # The import in test_billing.py must be untouched.
5118 assert "process_order" not in test or "import" in test
5119
5120 def test_rename_scope_imports_only(self, rename_repo: pathlib.Path) -> None:
5121 runner.invoke(
5122 cli,
5123 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5124 "--scope", "imports", "--yes"],
5125 )
5126 billing = (rename_repo / "billing.py").read_text()
5127 # The definition in billing.py must be untouched.
5128 assert "def compute_total(" in billing
5129
5130 def test_rename_json_scope_reflected(self, rename_repo: pathlib.Path) -> None:
5131 result = runner.invoke(
5132 cli,
5133 ["code", "rename", "billing.py::process_order", "handle_order",
5134 "--scope", "definition", "--dry-run", "--json"],
5135 )
5136 assert result.exit_code == 0, result.output
5137 assert json.loads(result.output)["scope"] == "definition"
5138
5139 # ── --max-files guard ────────────────────────────────────────────────────
5140
5141 def test_rename_max_files_validation(self, rename_repo: pathlib.Path) -> None:
5142 result = runner.invoke(
5143 cli,
5144 ["code", "rename", "billing.py::process_order", "handle_order",
5145 "--max-files", "0", "--dry-run"],
5146 )
5147 assert result.exit_code != 0
5148
5149 # ── edit precision ───────────────────────────────────────────────────────
5150
5151 def test_rename_preserves_surrounding_code(self, rename_repo: pathlib.Path) -> None:
5152 """Renaming process_order must not touch apply_discount or compute_total."""
5153 runner.invoke(
5154 cli,
5155 ["code", "rename", "billing.py::process_order", "handle_order",
5156 "--scope", "definition", "--yes"],
5157 )
5158 content = (rename_repo / "billing.py").read_text()
5159 assert "def apply_discount(" in content
5160 assert "def compute_total(" in content
5161
5162 def test_rename_col_precision_correct(self, rename_repo: pathlib.Path) -> None:
5163 """The definition rename must produce syntactically valid Python."""
5164 runner.invoke(
5165 cli,
5166 ["code", "rename", "billing.py::process_order", "handle_order",
5167 "--scope", "definition", "--yes"],
5168 )
5169 import ast as _ast
5170 content = (rename_repo / "billing.py").read_text()
5171 # Must parse without SyntaxError.
5172 try:
5173 _ast.parse(content)
5174 except SyntaxError as e:
5175 pytest.fail(f"Renamed file has a syntax error: {e}")
5176
5177
5178 # ---------------------------------------------------------------------------
5179 # blast-risk
5180 # ---------------------------------------------------------------------------
5181
5182
5183 @pytest.fixture
5184 def blast_repo(repo: pathlib.Path) -> pathlib.Path:
5185 """Repo with two commits: a production module and a test file.
5186
5187 billing.py defines Invoice.compute_total and process_order.
5188 test_billing.py imports and calls both — so they have at least one
5189 test caller. A second commit modifies compute_total so churn > 0.
5190 """
5191 (repo / "billing.py").write_text(textwrap.dedent("""\
5192 class Invoice:
5193 def compute_total(self, items):
5194 return sum(items)
5195
5196 def apply_discount(self, total, pct):
5197 return total * (1 - pct)
5198
5199 def process_order(invoice, items):
5200 return invoice.compute_total(items)
5201 """))
5202 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5203 from billing import Invoice, process_order
5204
5205 def test_compute_total():
5206 inv = Invoice()
5207 assert inv.compute_total([1, 2, 3]) == 6
5208
5209 def test_process_order():
5210 inv = Invoice()
5211 assert process_order(inv, [10]) == 10
5212 """))
5213 r = runner.invoke(cli, ["commit", "-m", "Add billing module and tests"])
5214 assert r.exit_code == 0, r.output
5215
5216 # Second commit: modify compute_total so churn count > 0.
5217 (repo / "billing.py").write_text(textwrap.dedent("""\
5218 class Invoice:
5219 def compute_total(self, items):
5220 # round to two decimal places
5221 return round(sum(items), 2)
5222
5223 def apply_discount(self, total, pct):
5224 return total * (1 - pct)
5225
5226 def process_order(invoice, items):
5227 return invoice.compute_total(items)
5228 """))
5229 r2 = runner.invoke(cli, ["commit", "-m", "Round compute_total result"])
5230 assert r2.exit_code == 0, r2.output
5231
5232 return repo
5233
5234
5235 class TestBlastRisk:
5236 """Tests for muse code blast-risk."""
5237
5238 # ── basic correctness ────────────────────────────────────────────────────
5239
5240 def test_blast_risk_exits_zero(self, blast_repo: pathlib.Path) -> None:
5241 result = runner.invoke(cli, ["code", "blast-risk"])
5242 assert result.exit_code == 0, result.output
5243
5244 def test_blast_risk_shows_header(self, blast_repo: pathlib.Path) -> None:
5245 result = runner.invoke(cli, ["code", "blast-risk"])
5246 assert result.exit_code == 0
5247 assert "blast-risk" in result.output
5248 assert "commits" in result.output
5249
5250 def test_blast_risk_shows_scoring_line(self, blast_repo: pathlib.Path) -> None:
5251 result = runner.invoke(cli, ["code", "blast-risk"])
5252 assert result.exit_code == 0
5253 assert "Scoring:" in result.output
5254 assert "impact" in result.output
5255 assert "churn" in result.output
5256 assert "test-gap" in result.output
5257 assert "coupling" in result.output
5258
5259 def test_blast_risk_shows_table_columns(self, blast_repo: pathlib.Path) -> None:
5260 result = runner.invoke(cli, ["code", "blast-risk"])
5261 assert result.exit_code == 0
5262 assert "RISK" in result.output
5263 assert "IMPACT" in result.output
5264 assert "CHURN" in result.output
5265 assert "TEST-GAP" in result.output
5266
5267 def test_blast_risk_lists_symbols(self, blast_repo: pathlib.Path) -> None:
5268 result = runner.invoke(cli, ["code", "blast-risk"])
5269 assert result.exit_code == 0
5270 # At least one symbol from billing.py should appear.
5271 assert "billing.py" in result.output
5272
5273 def test_blast_risk_risk_scores_in_range(self, blast_repo: pathlib.Path) -> None:
5274 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5275 assert result.exit_code == 0, result.output
5276 data = json.loads(result.output)
5277 for sym in data["symbols"]:
5278 assert 0 <= sym["risk"] <= 100
5279 assert 0 <= sym["impact_score"] <= 100
5280 assert 0 <= sym["churn_score"] <= 100
5281 assert 0 <= sym["test_gap_score"] <= 100
5282 assert 0 <= sym["coupling_score"] <= 100
5283
5284 # ── JSON schema ──────────────────────────────────────────────────────────
5285
5286 def test_blast_risk_json_top_level_keys(self, blast_repo: pathlib.Path) -> None:
5287 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5288 assert result.exit_code == 0, result.output
5289 data = json.loads(result.output)
5290 assert "ref" in data
5291 assert "commits_analysed" in data
5292 assert "truncated" in data
5293 assert "filters" in data
5294 assert "weights" in data
5295 assert "symbols" in data
5296
5297 def test_blast_risk_json_weights_sum_to_one(self, blast_repo: pathlib.Path) -> None:
5298 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5299 data = json.loads(result.output)
5300 total = sum(data["weights"].values())
5301 assert abs(total - 1.0) < 1e-6
5302
5303 def test_blast_risk_json_symbol_schema(self, blast_repo: pathlib.Path) -> None:
5304 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5305 data = json.loads(result.output)
5306 assert len(data["symbols"]) > 0
5307 sym = data["symbols"][0]
5308 for key in ("address", "kind", "file", "risk",
5309 "impact_raw", "churn_raw", "test_gap_raw",
5310 "coupling_raw", "impact_score", "churn_score",
5311 "test_gap_score", "coupling_score"):
5312 assert key in sym, f"missing key: {key}"
5313
5314 def test_blast_risk_json_sorted_by_risk_desc(self, blast_repo: pathlib.Path) -> None:
5315 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5316 data = json.loads(result.output)
5317 risks = [s["risk"] for s in data["symbols"]]
5318 assert risks == sorted(risks, reverse=True)
5319
5320 def test_blast_risk_json_no_import_pseudosymbols(self, blast_repo: pathlib.Path) -> None:
5321 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5322 data = json.loads(result.output)
5323 for sym in data["symbols"]:
5324 assert "::import::" not in sym["address"]
5325
5326 def test_blast_risk_json_filters_reflected(self, blast_repo: pathlib.Path) -> None:
5327 result = runner.invoke(
5328 cli, ["code", "blast-risk", "--json", "--kind", "function", "--min-risk", "10"]
5329 )
5330 data = json.loads(result.output)
5331 assert data["filters"]["kind"] == "function"
5332 assert data["filters"]["min_risk"] == 10
5333
5334 # ── --top flag ───────────────────────────────────────────────────────────
5335
5336 def test_blast_risk_top_limits_output(self, blast_repo: pathlib.Path) -> None:
5337 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--top", "2"])
5338 data = json.loads(result.output)
5339 assert len(data["symbols"]) <= 2
5340
5341 def test_blast_risk_top_validation(self, blast_repo: pathlib.Path) -> None:
5342 result = runner.invoke(cli, ["code", "blast-risk", "--top", "0"])
5343 assert result.exit_code != 0
5344
5345 # ── --kind filter ────────────────────────────────────────────────────────
5346
5347 def test_blast_risk_kind_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5348 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "class"])
5349 data = json.loads(result.output)
5350 for sym in data["symbols"]:
5351 assert sym["kind"] == "class"
5352
5353 def test_blast_risk_kind_filter_function(self, blast_repo: pathlib.Path) -> None:
5354 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "function"])
5355 assert result.exit_code == 0
5356 data = json.loads(result.output)
5357 for sym in data["symbols"]:
5358 assert sym["kind"] in ("function", "method")
5359
5360 # ── --file filter ────────────────────────────────────────────────────────
5361
5362 def test_blast_risk_file_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5363 result = runner.invoke(
5364 cli, ["code", "blast-risk", "--json", "--file", "billing.py"]
5365 )
5366 data = json.loads(result.output)
5367 for sym in data["symbols"]:
5368 assert "billing.py" in sym["file"]
5369
5370 def test_blast_risk_file_filter_nonexistent_returns_empty(
5371 self, blast_repo: pathlib.Path
5372 ) -> None:
5373 result = runner.invoke(
5374 cli, ["code", "blast-risk", "--json", "--file", "no_such_file.py"]
5375 )
5376 assert result.exit_code == 0
5377 data = json.loads(result.output)
5378 assert data["symbols"] == []
5379
5380 # ── --min-risk filter ────────────────────────────────────────────────────
5381
5382 def test_blast_risk_min_risk_filters(self, blast_repo: pathlib.Path) -> None:
5383 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "80"])
5384 data = json.loads(result.output)
5385 for sym in data["symbols"]:
5386 assert sym["risk"] >= 80
5387
5388 def test_blast_risk_min_risk_100_all_excluded(self, blast_repo: pathlib.Path) -> None:
5389 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "100"])
5390 assert result.exit_code == 0
5391
5392 def test_blast_risk_min_risk_validation(self, blast_repo: pathlib.Path) -> None:
5393 result = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "101"])
5394 assert result.exit_code != 0
5395 result2 = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "-1"])
5396 assert result2.exit_code != 0
5397
5398 # ── --explain flag ───────────────────────────────────────────────────────
5399
5400 def test_blast_risk_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
5401 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5402 data = json.loads(result.output)
5403 if not data["symbols"]:
5404 pytest.skip("no symbols")
5405 addr = data["symbols"][0]["address"]
5406 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5407 assert result2.exit_code == 0, result2.output
5408
5409 def test_blast_risk_explain_shows_breakdown(self, blast_repo: pathlib.Path) -> None:
5410 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5411 data = json.loads(result.output)
5412 if not data["symbols"]:
5413 pytest.skip("no symbols")
5414 addr = data["symbols"][0]["address"]
5415 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5416 assert "Risk score:" in result2.output
5417 assert "Impact" in result2.output
5418 assert "Churn" in result2.output
5419 assert "Test gap" in result2.output
5420 assert "Coupling" in result2.output
5421
5422 def test_blast_risk_explain_nonexistent_errors(self, blast_repo: pathlib.Path) -> None:
5423 result = runner.invoke(
5424 cli, ["code", "blast-risk", "--explain", "no_file.py::no_symbol"]
5425 )
5426 assert result.exit_code != 0
5427
5428 def test_blast_risk_explain_json(self, blast_repo: pathlib.Path) -> None:
5429 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5430 data = json.loads(result.output)
5431 if not data["symbols"]:
5432 pytest.skip("no symbols")
5433 addr = data["symbols"][0]["address"]
5434 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr, "--json"])
5435 assert result2.exit_code == 0, result2.output
5436 detail = json.loads(result2.output)
5437 assert detail["address"] == addr
5438 assert "risk" in detail
5439
5440 # ── --max-commits ────────────────────────────────────────────────────────
5441
5442 def test_blast_risk_max_commits_validation(self, blast_repo: pathlib.Path) -> None:
5443 result = runner.invoke(cli, ["code", "blast-risk", "--max-commits", "0"])
5444 assert result.exit_code != 0
5445
5446 def test_blast_risk_max_commits_respected(self, blast_repo: pathlib.Path) -> None:
5447 # With max-commits=1, commits_analysed <= 1.
5448 result = runner.invoke(
5449 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5450 )
5451 assert result.exit_code == 0
5452 data = json.loads(result.output)
5453 assert data["commits_analysed"] <= 1
5454
5455 def test_blast_risk_max_commits_truncated_flag(self, blast_repo: pathlib.Path) -> None:
5456 result = runner.invoke(
5457 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5458 )
5459 data = json.loads(result.output)
5460 # Two commits exist so truncated should be True with cap=1.
5461 assert isinstance(data["truncated"], bool)
5462
5463 # ── --since ──────────────────────────────────────────────────────────────
5464
5465 def test_blast_risk_since_invalid_ref(self, blast_repo: pathlib.Path) -> None:
5466 result = runner.invoke(cli, ["code", "blast-risk", "--since", "nonexistent_ref"])
5467 assert result.exit_code != 0
5468
5469 # ── requires repo ────────────────────────────────────────────────────────
5470
5471 def test_blast_risk_requires_repo(self, tmp_path: pathlib.Path) -> None:
5472 import os
5473 old = os.getcwd()
5474 try:
5475 os.chdir(tmp_path)
5476 result = runner.invoke(cli, ["code", "blast-risk"])
5477 assert result.exit_code != 0
5478 finally:
5479 os.chdir(old)
5480
5481
5482 # ---------------------------------------------------------------------------
5483 # velocity
5484 # ---------------------------------------------------------------------------
5485
5486
5487 @pytest.fixture
5488 def velocity_repo(repo: pathlib.Path) -> pathlib.Path:
5489 """Repo with two modules across several commits to exercise velocity metrics.
5490
5491 Module layout:
5492 core/store.py — grows across commits (inserts)
5493 shrink/util.py — has a delete later (net negative at some point)
5494
5495 Commit structure (window=2):
5496 1: create core/store.py with 2 functions
5497 2: add a third function to core/store.py → current window: +3 added
5498 3: add shrink/util.py with one function → also in current window
5499 4: delete the function in shrink/util.py → shrink net = 0 (1 added, 1 deleted)
5500 """
5501 (repo / "core").mkdir(exist_ok=True)
5502 (repo / "shrink").mkdir(exist_ok=True)
5503
5504 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5505 def read_object(path):
5506 return path.read_bytes()
5507
5508 def write_object(path, data):
5509 path.write_bytes(data)
5510 """))
5511 r = runner.invoke(cli, ["commit", "-m", "core: initial store"])
5512 assert r.exit_code == 0, r.output
5513
5514 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5515 def read_object(path):
5516 return path.read_bytes()
5517
5518 def write_object(path, data):
5519 path.write_bytes(data)
5520
5521 def delete_object(path):
5522 path.unlink()
5523 """))
5524 r2 = runner.invoke(cli, ["commit", "-m", "core: add delete_object"])
5525 assert r2.exit_code == 0, r2.output
5526
5527 (repo / "shrink" / "util.py").write_text(textwrap.dedent("""\
5528 def helper():
5529 return True
5530 """))
5531 r3 = runner.invoke(cli, ["commit", "-m", "shrink: add helper"])
5532 assert r3.exit_code == 0, r3.output
5533
5534 return repo
5535
5536
5537 class TestVelocity:
5538 """Tests for muse code velocity."""
5539
5540 # ── basic correctness ────────────────────────────────────────────────────
5541
5542 def test_velocity_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5543 result = runner.invoke(cli, ["code", "velocity"])
5544 assert result.exit_code == 0, result.output
5545
5546 def test_velocity_shows_header(self, velocity_repo: pathlib.Path) -> None:
5547 result = runner.invoke(cli, ["code", "velocity"])
5548 assert "velocity" in result.output.lower()
5549
5550 def test_velocity_shows_column_headers(self, velocity_repo: pathlib.Path) -> None:
5551 result = runner.invoke(cli, ["code", "velocity"])
5552 assert "ADD" in result.output
5553 assert "NET" in result.output
5554
5555 def test_velocity_shows_modules(self, velocity_repo: pathlib.Path) -> None:
5556 result = runner.invoke(cli, ["code", "velocity"])
5557 # Both modules should appear.
5558 assert "core/" in result.output or "store" in result.output
5559
5560 # ── JSON schema ──────────────────────────────────────────────────────────
5561
5562 def test_velocity_json_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5563 result = runner.invoke(cli, ["code", "velocity", "--json"])
5564 assert result.exit_code == 0, result.output
5565 json.loads(result.output)
5566
5567 def test_velocity_json_top_level_keys(self, velocity_repo: pathlib.Path) -> None:
5568 result = runner.invoke(cli, ["code", "velocity", "--json"])
5569 data = json.loads(result.output)
5570 for key in (
5571 "ref", "window_size", "commits_analysed", "truncated",
5572 "filters", "modules", "predictions",
5573 ):
5574 assert key in data, f"missing key: {key}"
5575
5576 def test_velocity_json_module_schema(self, velocity_repo: pathlib.Path) -> None:
5577 result = runner.invoke(cli, ["code", "velocity", "--json"])
5578 data = json.loads(result.output)
5579 if not data["modules"]:
5580 pytest.skip("no modules")
5581 mod = data["modules"][0]
5582 for key in ("module", "current", "prior", "acceleration", "stagnant_commits"):
5583 assert key in mod, f"missing key: {key}"
5584 for key in ("added", "removed", "net", "modified", "active_commits"):
5585 assert key in mod["current"], f"missing current key: {key}"
5586 assert key in mod["prior"], f"missing prior key: {key}"
5587
5588 def test_velocity_json_acceleration_is_net_delta(
5589 self, velocity_repo: pathlib.Path
5590 ) -> None:
5591 result = runner.invoke(cli, ["code", "velocity", "--json"])
5592 data = json.loads(result.output)
5593 for mod in data["modules"]:
5594 expected = mod["current"]["net"] - mod["prior"]["net"]
5595 assert mod["acceleration"] == expected
5596
5597 def test_velocity_json_filters_reflected(self, velocity_repo: pathlib.Path) -> None:
5598 result = runner.invoke(
5599 cli, ["code", "velocity", "--json", "--window", "5", "--top", "3"]
5600 )
5601 data = json.loads(result.output)
5602 assert data["window_size"] == 5
5603 assert data["filters"]["top"] == 3
5604
5605 def test_velocity_json_no_import_pseudosymbols_in_counts(
5606 self, velocity_repo: pathlib.Path
5607 ) -> None:
5608 # Modules should not be "(root)" due to import pseudo-symbols
5609 # (import:: addresses should be filtered out).
5610 result = runner.invoke(cli, ["code", "velocity", "--json"])
5611 data = json.loads(result.output)
5612 # We can't assert 0 imports in the module list, but we can assert
5613 # that '::import::' doesn't appear as a module name.
5614 for mod in data["modules"]:
5615 assert "import" not in mod["module"].lower() or "/" in mod["module"]
5616
5617 # ── --window ─────────────────────────────────────────────────────────────
5618
5619 def test_velocity_window_1_runs(self, velocity_repo: pathlib.Path) -> None:
5620 result = runner.invoke(cli, ["code", "velocity", "--window", "1"])
5621 assert result.exit_code == 0, result.output
5622
5623 def test_velocity_window_validation(self, velocity_repo: pathlib.Path) -> None:
5624 result = runner.invoke(cli, ["code", "velocity", "--window", "0"])
5625 assert result.exit_code != 0
5626
5627 def test_velocity_window_reflected_in_json(
5628 self, velocity_repo: pathlib.Path
5629 ) -> None:
5630 result = runner.invoke(cli, ["code", "velocity", "--json", "--window", "1"])
5631 data = json.loads(result.output)
5632 assert data["window_size"] == 1
5633
5634 # ── --top ─────────────────────────────────────────────────────────────────
5635
5636 def test_velocity_top_limits(self, velocity_repo: pathlib.Path) -> None:
5637 result = runner.invoke(cli, ["code", "velocity", "--json", "--top", "1"])
5638 data = json.loads(result.output)
5639 assert len(data["modules"]) <= 1
5640
5641 def test_velocity_top_validation(self, velocity_repo: pathlib.Path) -> None:
5642 result = runner.invoke(cli, ["code", "velocity", "--top", "0"])
5643 assert result.exit_code != 0
5644
5645 # ── --predict ─────────────────────────────────────────────────────────────
5646
5647 def test_velocity_predict_0_empty(self, velocity_repo: pathlib.Path) -> None:
5648 result = runner.invoke(cli, ["code", "velocity", "--json", "--predict", "0"])
5649 data = json.loads(result.output)
5650 assert data["predictions"] == []
5651
5652 def test_velocity_predict_returns_results(self, velocity_repo: pathlib.Path) -> None:
5653 result = runner.invoke(
5654 cli, ["code", "velocity", "--json", "--predict", "5"]
5655 )
5656 data = json.loads(result.output)
5657 # There are symbols in the window so predictions should be non-empty.
5658 assert isinstance(data["predictions"], list)
5659 if data["predictions"]:
5660 pred = data["predictions"][0]
5661 for key in ("address", "module", "score", "frequency", "last_commit_rank"):
5662 assert key in pred, f"missing key: {key}"
5663
5664 def test_velocity_predict_scores_descending(
5665 self, velocity_repo: pathlib.Path
5666 ) -> None:
5667 result = runner.invoke(
5668 cli, ["code", "velocity", "--json", "--predict", "10"]
5669 )
5670 data = json.loads(result.output)
5671 scores = [p["score"] for p in data["predictions"]]
5672 assert scores == sorted(scores, reverse=True)
5673
5674 def test_velocity_predict_validation(self, velocity_repo: pathlib.Path) -> None:
5675 result = runner.invoke(cli, ["code", "velocity", "--predict", "-1"])
5676 assert result.exit_code != 0
5677
5678 def test_velocity_predict_shown_in_human_output(
5679 self, velocity_repo: pathlib.Path
5680 ) -> None:
5681 result = runner.invoke(
5682 cli, ["code", "velocity", "--predict", "3"]
5683 )
5684 assert result.exit_code == 0
5685 if "predictions" in result.output.lower() or "score" in result.output:
5686 # Just check it doesn't crash.
5687 pass
5688
5689 # ── --max-commits ─────────────────────────────────────────────────────────
5690
5691 def test_velocity_max_commits_validation(self, velocity_repo: pathlib.Path) -> None:
5692 result = runner.invoke(cli, ["code", "velocity", "--max-commits", "0"])
5693 assert result.exit_code != 0
5694
5695 def test_velocity_max_commits_respected(self, velocity_repo: pathlib.Path) -> None:
5696 # With --window 1 and --max-commits 1, effective_max = max(1, 1*2) = 2.
5697 # The 3-commit repo should be capped at 2 commits analysed.
5698 result = runner.invoke(
5699 cli, ["code", "velocity", "--json", "--window", "1", "--max-commits", "1"]
5700 )
5701 assert result.exit_code == 0
5702 data = json.loads(result.output)
5703 assert data["commits_analysed"] <= 2
5704
5705 # ── --since ───────────────────────────────────────────────────────────────
5706
5707 def test_velocity_since_invalid_ref(self, velocity_repo: pathlib.Path) -> None:
5708 result = runner.invoke(cli, ["code", "velocity", "--since", "bad_ref"])
5709 assert result.exit_code != 0
5710
5711 # ── stagnation detection ──────────────────────────────────────────────────
5712
5713 def test_velocity_stagnant_commits_non_negative(
5714 self, velocity_repo: pathlib.Path
5715 ) -> None:
5716 result = runner.invoke(cli, ["code", "velocity", "--json"])
5717 data = json.loads(result.output)
5718 for mod in data["modules"]:
5719 assert mod["stagnant_commits"] >= 0
5720
5721 # ── net counts are consistent ─────────────────────────────────────────────
5722
5723 def test_velocity_net_equals_added_minus_removed(
5724 self, velocity_repo: pathlib.Path
5725 ) -> None:
5726 result = runner.invoke(cli, ["code", "velocity", "--json"])
5727 data = json.loads(result.output)
5728 for mod in data["modules"]:
5729 assert mod["current"]["net"] == (
5730 mod["current"]["added"] - mod["current"]["removed"]
5731 )
5732 assert mod["prior"]["net"] == (
5733 mod["prior"]["added"] - mod["prior"]["removed"]
5734 )
5735
5736 # ── requires repo ─────────────────────────────────────────────────────────
5737
5738 def test_velocity_requires_repo(self, tmp_path: pathlib.Path) -> None:
5739 import os
5740 old = os.getcwd()
5741 try:
5742 os.chdir(tmp_path)
5743 result = runner.invoke(cli, ["code", "velocity"])
5744 assert result.exit_code != 0
5745 finally:
5746 os.chdir(old)
5747
5748
5749 # ---------------------------------------------------------------------------
5750 # age
5751 # ---------------------------------------------------------------------------
5752
5753
5754 @pytest.fixture
5755 def age_repo(repo: pathlib.Path) -> pathlib.Path:
5756 """Repo with several commits to exercise evolutionary-age metrics.
5757
5758 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
5759 Commit 2: modify compute_total body → 1 impl change
5760 Commit 3: modify compute_total body → 2 impl changes
5761 Commit 4: modify compute_total signature only (add type hint)
5762
5763 stable_fn is created in commit 1 and never touched again.
5764 """
5765 (repo / "billing.py").write_text(textwrap.dedent("""\
5766 class Invoice:
5767 def compute_total(self, items):
5768 return sum(items)
5769
5770 def stable_fn():
5771 return 42
5772 """))
5773 r = runner.invoke(cli, ["commit", "-m", "initial billing"])
5774 assert r.exit_code == 0, r.output
5775
5776 # Commit 2: impl change to compute_total
5777 (repo / "billing.py").write_text(textwrap.dedent("""\
5778 class Invoice:
5779 def compute_total(self, items):
5780 return round(sum(items), 2)
5781
5782 def stable_fn():
5783 return 42
5784 """))
5785 r2 = runner.invoke(cli, ["commit", "-m", "round result"])
5786 assert r2.exit_code == 0, r2.output
5787
5788 # Commit 3: second impl change to compute_total
5789 (repo / "billing.py").write_text(textwrap.dedent("""\
5790 class Invoice:
5791 def compute_total(self, items):
5792 total = sum(items)
5793 return round(total, 4)
5794
5795 def stable_fn():
5796 return 42
5797 """))
5798 r3 = runner.invoke(cli, ["commit", "-m", "higher precision"])
5799 assert r3.exit_code == 0, r3.output
5800
5801 return repo
5802
5803
5804 class TestAge:
5805 """Tests for muse code age."""
5806
5807 # ── basic correctness ────────────────────────────────────────────────────
5808
5809 def test_age_exits_zero(self, age_repo: pathlib.Path) -> None:
5810 result = runner.invoke(cli, ["code", "age"])
5811 assert result.exit_code == 0, result.output
5812
5813 def test_age_shows_header(self, age_repo: pathlib.Path) -> None:
5814 result = runner.invoke(cli, ["code", "age"])
5815 assert "evolutionary age" in result.output.lower()
5816
5817 def test_age_shows_sort_line(self, age_repo: pathlib.Path) -> None:
5818 result = runner.invoke(cli, ["code", "age"])
5819 assert "Sorted by" in result.output
5820
5821 def test_age_shows_table_columns(self, age_repo: pathlib.Path) -> None:
5822 result = runner.invoke(cli, ["code", "age"])
5823 assert "BORN" in result.output
5824 assert "REWRITES" in result.output
5825 assert "GENETIC" in result.output
5826
5827 def test_age_lists_symbols(self, age_repo: pathlib.Path) -> None:
5828 result = runner.invoke(cli, ["code", "age"])
5829 assert "billing.py" in result.output
5830
5831 # ── JSON schema ──────────────────────────────────────────────────────────
5832
5833 def test_age_json_exits_zero(self, age_repo: pathlib.Path) -> None:
5834 result = runner.invoke(cli, ["code", "age", "--json"])
5835 assert result.exit_code == 0, result.output
5836 json.loads(result.output)
5837
5838 def test_age_json_top_level_keys(self, age_repo: pathlib.Path) -> None:
5839 result = runner.invoke(cli, ["code", "age", "--json"])
5840 data = json.loads(result.output)
5841 for key in ("ref", "as_of", "commits_analysed", "truncated", "filters", "symbols"):
5842 assert key in data, f"missing key: {key}"
5843
5844 def test_age_json_symbol_schema(self, age_repo: pathlib.Path) -> None:
5845 result = runner.invoke(cli, ["code", "age", "--json"])
5846 data = json.loads(result.output)
5847 if not data["symbols"]:
5848 pytest.skip("no symbols with history")
5849 sym = data["symbols"][0]
5850 for key in (
5851 "address", "kind", "file",
5852 "born_commit", "born_date",
5853 "last_impl_commit", "last_impl_date",
5854 "last_change_commit", "last_change_date",
5855 "calendar_age_days", "genetic_age_days",
5856 "impl_changes", "sig_changes", "renames", "est_survival_pct",
5857 ):
5858 assert key in sym, f"missing key: {key}"
5859
5860 def test_age_json_survival_pct_in_range(self, age_repo: pathlib.Path) -> None:
5861 result = runner.invoke(cli, ["code", "age", "--json"])
5862 data = json.loads(result.output)
5863 for sym in data["symbols"]:
5864 assert 0 <= sym["est_survival_pct"] <= 100
5865
5866 def test_age_json_filters_reflected(self, age_repo: pathlib.Path) -> None:
5867 result = runner.invoke(
5868 cli, ["code", "age", "--json", "--sort", "calendar", "--kind", "function"]
5869 )
5870 data = json.loads(result.output)
5871 assert data["filters"]["sort"] == "calendar"
5872 assert data["filters"]["kind"] == "function"
5873
5874 def test_age_json_no_import_pseudosymbols(self, age_repo: pathlib.Path) -> None:
5875 result = runner.invoke(cli, ["code", "age", "--json"])
5876 data = json.loads(result.output)
5877 for sym in data["symbols"]:
5878 assert "::import::" not in sym["address"]
5879
5880 # ── impl_changes recorded correctly ─────────────────────────────────────
5881
5882 def test_age_compute_total_has_impl_changes(self, age_repo: pathlib.Path) -> None:
5883 """compute_total was modified twice — should have impl_changes >= 1."""
5884 result = runner.invoke(cli, ["code", "age", "--json"])
5885 data = json.loads(result.output)
5886 totals = [
5887 s for s in data["symbols"]
5888 if "compute_total" in s["address"]
5889 ]
5890 # If history was recorded, impl_changes should be positive.
5891 if totals:
5892 assert totals[0]["impl_changes"] >= 0 # at least recorded
5893
5894 def test_age_stable_fn_lower_impl_changes(self, age_repo: pathlib.Path) -> None:
5895 """stable_fn was never modified — should have 0 impl_changes."""
5896 result = runner.invoke(cli, ["code", "age", "--json"])
5897 data = json.loads(result.output)
5898 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5899 if stables:
5900 assert stables[0]["impl_changes"] == 0
5901
5902 def test_age_stable_fn_100pct_survival(self, age_repo: pathlib.Path) -> None:
5903 result = runner.invoke(cli, ["code", "age", "--json"])
5904 data = json.loads(result.output)
5905 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5906 if stables:
5907 assert stables[0]["est_survival_pct"] == 100
5908
5909 # ── --top ────────────────────────────────────────────────────────────────
5910
5911 def test_age_top_limits(self, age_repo: pathlib.Path) -> None:
5912 result = runner.invoke(cli, ["code", "age", "--json", "--top", "1"])
5913 data = json.loads(result.output)
5914 assert len(data["symbols"]) <= 1
5915
5916 def test_age_top_validation(self, age_repo: pathlib.Path) -> None:
5917 result = runner.invoke(cli, ["code", "age", "--top", "0"])
5918 assert result.exit_code != 0
5919
5920 # ── --sort ───────────────────────────────────────────────────────────────
5921
5922 def test_age_sort_rewrites(self, age_repo: pathlib.Path) -> None:
5923 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "rewrites"])
5924 assert result.exit_code == 0, result.output
5925 data = json.loads(result.output)
5926 impl_counts = [s["impl_changes"] for s in data["symbols"]]
5927 assert impl_counts == sorted(impl_counts, reverse=True)
5928
5929 def test_age_sort_calendar(self, age_repo: pathlib.Path) -> None:
5930 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "calendar"])
5931 assert result.exit_code == 0, result.output
5932 data = json.loads(result.output)
5933 ages = [s["calendar_age_days"] for s in data["symbols"]]
5934 assert ages == sorted(ages, reverse=True)
5935
5936 def test_age_sort_genetic(self, age_repo: pathlib.Path) -> None:
5937 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "genetic"])
5938 assert result.exit_code == 0, result.output
5939 data = json.loads(result.output)
5940 ages = [s["genetic_age_days"] for s in data["symbols"]]
5941 assert ages == sorted(ages, reverse=True)
5942
5943 def test_age_sort_survival(self, age_repo: pathlib.Path) -> None:
5944 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "survival"])
5945 assert result.exit_code == 0, result.output
5946 data = json.loads(result.output)
5947 survivals = [s["est_survival_pct"] for s in data["symbols"]]
5948 assert survivals == sorted(survivals)
5949
5950 def test_age_sort_invalid(self, age_repo: pathlib.Path) -> None:
5951 result = runner.invoke(cli, ["code", "age", "--sort", "bogus"])
5952 assert result.exit_code != 0
5953
5954 # ── --kind filter ────────────────────────────────────────────────────────
5955
5956 def test_age_kind_filter(self, age_repo: pathlib.Path) -> None:
5957 result = runner.invoke(cli, ["code", "age", "--json", "--kind", "function"])
5958 data = json.loads(result.output)
5959 for sym in data["symbols"]:
5960 assert sym["kind"] in ("function", "method")
5961
5962 # ── --file filter ─────────────────────────────────────────────────────────
5963
5964 def test_age_file_filter(self, age_repo: pathlib.Path) -> None:
5965 result = runner.invoke(
5966 cli, ["code", "age", "--json", "--file", "billing.py"]
5967 )
5968 data = json.loads(result.output)
5969 for sym in data["symbols"]:
5970 assert "billing.py" in sym["file"]
5971
5972 def test_age_file_filter_nonexistent(self, age_repo: pathlib.Path) -> None:
5973 result = runner.invoke(
5974 cli, ["code", "age", "--json", "--file", "no_such_file.py"]
5975 )
5976 assert result.exit_code == 0
5977 data = json.loads(result.output)
5978 assert data["symbols"] == []
5979
5980 # ── --explain ─────────────────────────────────────────────────────────────
5981
5982 def test_age_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
5983 result = runner.invoke(cli, ["code", "age", "--json"])
5984 data = json.loads(result.output)
5985 if not data["symbols"]:
5986 pytest.skip("no symbols")
5987 addr = data["symbols"][0]["address"]
5988 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
5989 assert r2.exit_code == 0, r2.output
5990
5991 def test_age_explain_shows_breakdown(self, age_repo: pathlib.Path) -> None:
5992 result = runner.invoke(cli, ["code", "age", "--json"])
5993 data = json.loads(result.output)
5994 if not data["symbols"]:
5995 pytest.skip("no symbols")
5996 addr = data["symbols"][0]["address"]
5997 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
5998 assert "Implementation changes" in r2.output
5999 assert "Signature changes" in r2.output
6000 assert "Est. survival" in r2.output
6001
6002 def test_age_explain_requires_double_colon(self, age_repo: pathlib.Path) -> None:
6003 result = runner.invoke(cli, ["code", "age", "--explain", "billing.py"])
6004 assert result.exit_code != 0
6005
6006 def test_age_explain_nonexistent_errors(self, age_repo: pathlib.Path) -> None:
6007 result = runner.invoke(cli, ["code", "age", "--explain", "no.py::nonexistent"])
6008 assert result.exit_code != 0
6009
6010 def test_age_explain_json(self, age_repo: pathlib.Path) -> None:
6011 result = runner.invoke(cli, ["code", "age", "--json"])
6012 data = json.loads(result.output)
6013 if not data["symbols"]:
6014 pytest.skip("no symbols")
6015 addr = data["symbols"][0]["address"]
6016 r2 = runner.invoke(cli, ["code", "age", "--explain", addr, "--json"])
6017 assert r2.exit_code == 0, r2.output
6018 detail = json.loads(r2.output)
6019 assert detail["address"] == addr
6020 assert "events" in detail
6021
6022 # ── --max-commits ─────────────────────────────────────────────────────────
6023
6024 def test_age_max_commits_validation(self, age_repo: pathlib.Path) -> None:
6025 result = runner.invoke(cli, ["code", "age", "--max-commits", "0"])
6026 assert result.exit_code != 0
6027
6028 def test_age_max_commits_respected(self, age_repo: pathlib.Path) -> None:
6029 result = runner.invoke(cli, ["code", "age", "--json", "--max-commits", "1"])
6030 assert result.exit_code == 0
6031 data = json.loads(result.output)
6032 assert data["commits_analysed"] <= 1
6033
6034 # ── --since ───────────────────────────────────────────────────────────────
6035
6036 def test_age_since_invalid_ref(self, age_repo: pathlib.Path) -> None:
6037 result = runner.invoke(cli, ["code", "age", "--since", "bad_ref"])
6038 assert result.exit_code != 0
6039
6040 # ── requires repo ─────────────────────────────────────────────────────────
6041
6042 def test_age_requires_repo(self, tmp_path: pathlib.Path) -> None:
6043 import os
6044 old = os.getcwd()
6045 try:
6046 os.chdir(tmp_path)
6047 result = runner.invoke(cli, ["code", "age"])
6048 assert result.exit_code != 0
6049 finally:
6050 os.chdir(old)
6051
6052
6053 # ---------------------------------------------------------------------------
6054 # entangle
6055 # ---------------------------------------------------------------------------
6056
6057
6058 @pytest.fixture
6059 def entangle_repo(repo: pathlib.Path) -> pathlib.Path:
6060 """Repo that has two files with no import link but symbols that co-change.
6061
6062 Commit 1: create billing.py (Invoice class) and serializers.py (to_json).
6063 Commit 2: modify Invoice.compute_total AND to_json together — they
6064 co-change with no import link.
6065 Commit 3: same again — both change again.
6066
6067 billing.py does NOT import serializers.py, so the pair should be
6068 flagged as entangled.
6069 """
6070 (repo / "billing.py").write_text(textwrap.dedent("""\
6071 class Invoice:
6072 def compute_total(self, items):
6073 return sum(items)
6074 """))
6075 (repo / "serializers.py").write_text(textwrap.dedent("""\
6076 def to_json(obj):
6077 return str(obj)
6078 """))
6079 r = runner.invoke(cli, ["commit", "-m", "initial"])
6080 assert r.exit_code == 0, r.output
6081
6082 # Commit 2: both change.
6083 (repo / "billing.py").write_text(textwrap.dedent("""\
6084 class Invoice:
6085 def compute_total(self, items):
6086 return round(sum(items), 2)
6087 """))
6088 (repo / "serializers.py").write_text(textwrap.dedent("""\
6089 def to_json(obj):
6090 import json
6091 return json.dumps(obj)
6092 """))
6093 r2 = runner.invoke(cli, ["commit", "-m", "update both"])
6094 assert r2.exit_code == 0, r2.output
6095
6096 # Commit 3: both change again.
6097 (repo / "billing.py").write_text(textwrap.dedent("""\
6098 class Invoice:
6099 def compute_total(self, items):
6100 return round(sum(items), 4)
6101 """))
6102 (repo / "serializers.py").write_text(textwrap.dedent("""\
6103 def to_json(obj):
6104 import json
6105 return json.dumps(obj, indent=2)
6106 """))
6107 r3 = runner.invoke(cli, ["commit", "-m", "tweak both again"])
6108 assert r3.exit_code == 0, r3.output
6109
6110 return repo
6111
6112
6113 class TestEntangle:
6114 """Tests for muse code entangle."""
6115
6116 # ── basic correctness ────────────────────────────────────────────────────
6117
6118 def test_entangle_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6119 result = runner.invoke(cli, ["code", "entangle"])
6120 assert result.exit_code == 0, result.output
6121
6122 def test_entangle_shows_header(self, entangle_repo: pathlib.Path) -> None:
6123 result = runner.invoke(cli, ["code", "entangle"])
6124 assert result.exit_code == 0
6125 assert "entanglement" in result.output.lower()
6126
6127 def test_entangle_detects_unlinked_pair(self, entangle_repo: pathlib.Path) -> None:
6128 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6129 assert result.exit_code == 0
6130 # Both files should appear in the output.
6131 assert "billing.py" in result.output or "serializers.py" in result.output
6132
6133 def test_entangle_shows_rate(self, entangle_repo: pathlib.Path) -> None:
6134 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6135 assert result.exit_code == 0
6136 # Rate column should show a percentage.
6137 assert "%" in result.output
6138
6139 # ── JSON schema ──────────────────────────────────────────────────────────
6140
6141 def test_entangle_json_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6142 result = runner.invoke(cli, ["code", "entangle", "--json"])
6143 assert result.exit_code == 0, result.output
6144 json.loads(result.output) # must be valid JSON
6145
6146 def test_entangle_json_top_level_keys(self, entangle_repo: pathlib.Path) -> None:
6147 result = runner.invoke(cli, ["code", "entangle", "--json"])
6148 data = json.loads(result.output)
6149 for key in ("ref", "commits_analysed", "truncated", "filters", "pairs"):
6150 assert key in data, f"missing key: {key}"
6151
6152 def test_entangle_json_pair_schema(self, entangle_repo: pathlib.Path) -> None:
6153 result = runner.invoke(
6154 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6155 )
6156 data = json.loads(result.output)
6157 if not data["pairs"]:
6158 pytest.skip("no pairs detected")
6159 pair = data["pairs"][0]
6160 for key in (
6161 "symbol_a", "symbol_b", "file_a", "file_b", "same_file",
6162 "structurally_linked", "co_changes", "commits_both_active",
6163 "co_change_rate", "a_in_test", "b_in_test",
6164 ):
6165 assert key in pair, f"missing key: {key}"
6166
6167 def test_entangle_json_co_change_rate_in_range(
6168 self, entangle_repo: pathlib.Path
6169 ) -> None:
6170 result = runner.invoke(
6171 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6172 )
6173 data = json.loads(result.output)
6174 for pair in data["pairs"]:
6175 assert 0.0 <= pair["co_change_rate"] <= 1.0
6176
6177 def test_entangle_json_filters_reflected(
6178 self, entangle_repo: pathlib.Path
6179 ) -> None:
6180 result = runner.invoke(
6181 cli, ["code", "entangle", "--json", "--min-co-changes", "3", "--min-rate", "0.5"]
6182 )
6183 data = json.loads(result.output)
6184 assert data["filters"]["min_co_changes"] == 3
6185 assert data["filters"]["min_rate"] == 0.5
6186
6187 def test_entangle_json_sorted_by_rate_desc(
6188 self, entangle_repo: pathlib.Path
6189 ) -> None:
6190 result = runner.invoke(
6191 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6192 )
6193 data = json.loads(result.output)
6194 rates = [p["co_change_rate"] for p in data["pairs"]]
6195 assert rates == sorted(rates, reverse=True)
6196
6197 # ── --top ────────────────────────────────────────────────────────────────
6198
6199 def test_entangle_top_limits(self, entangle_repo: pathlib.Path) -> None:
6200 result = runner.invoke(
6201 cli, ["code", "entangle", "--json", "--top", "1", "--min-co-changes", "1"]
6202 )
6203 data = json.loads(result.output)
6204 assert len(data["pairs"]) <= 1
6205
6206 def test_entangle_top_validation(self, entangle_repo: pathlib.Path) -> None:
6207 result = runner.invoke(cli, ["code", "entangle", "--top", "0"])
6208 assert result.exit_code != 0
6209
6210 # ── --min-co-changes ─────────────────────────────────────────────────────
6211
6212 def test_entangle_min_co_changes_filters(
6213 self, entangle_repo: pathlib.Path
6214 ) -> None:
6215 result = runner.invoke(
6216 cli, ["code", "entangle", "--json", "--min-co-changes", "100"]
6217 )
6218 data = json.loads(result.output)
6219 # No pair can have co-changed 100 times in a 3-commit repo.
6220 assert data["pairs"] == []
6221
6222 def test_entangle_min_co_changes_validation(
6223 self, entangle_repo: pathlib.Path
6224 ) -> None:
6225 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "0"])
6226 assert result.exit_code != 0
6227
6228 # ── --min-rate ───────────────────────────────────────────────────────────
6229
6230 def test_entangle_min_rate_1_may_return_results(
6231 self, entangle_repo: pathlib.Path
6232 ) -> None:
6233 result = runner.invoke(
6234 cli, ["code", "entangle", "--json", "--min-rate", "1.0", "--min-co-changes", "1"]
6235 )
6236 assert result.exit_code == 0
6237 data = json.loads(result.output)
6238 for pair in data["pairs"]:
6239 assert pair["co_change_rate"] == 1.0
6240
6241 def test_entangle_min_rate_validation(self, entangle_repo: pathlib.Path) -> None:
6242 result = runner.invoke(cli, ["code", "entangle", "--min-rate", "1.5"])
6243 assert result.exit_code != 0
6244 result2 = runner.invoke(cli, ["code", "entangle", "--min-rate", "-0.1"])
6245 assert result2.exit_code != 0
6246
6247 # ── --symbol filter ──────────────────────────────────────────────────────
6248
6249 def test_entangle_symbol_requires_double_colon(
6250 self, entangle_repo: pathlib.Path
6251 ) -> None:
6252 result = runner.invoke(cli, ["code", "entangle", "--symbol", "billing.py"])
6253 assert result.exit_code != 0
6254
6255 def test_entangle_symbol_exits_zero_valid(
6256 self, entangle_repo: pathlib.Path
6257 ) -> None:
6258 result = runner.invoke(
6259 cli,
6260 ["code", "entangle", "--symbol", "billing.py::Invoice",
6261 "--min-co-changes", "1"],
6262 )
6263 assert result.exit_code == 0, result.output
6264
6265 def test_entangle_symbol_filters_pairs(
6266 self, entangle_repo: pathlib.Path
6267 ) -> None:
6268 result = runner.invoke(
6269 cli,
6270 ["code", "entangle", "--json", "--symbol", "billing.py::Invoice",
6271 "--min-co-changes", "1"],
6272 )
6273 data = json.loads(result.output)
6274 for pair in data["pairs"]:
6275 assert (
6276 "billing.py" in pair["symbol_a"]
6277 or "billing.py" in pair["symbol_b"]
6278 )
6279
6280 # ── --include-same-file ──────────────────────────────────────────────────
6281
6282 def test_entangle_include_same_file_flag(
6283 self, entangle_repo: pathlib.Path
6284 ) -> None:
6285 # Should not crash, and may return same-file pairs.
6286 result = runner.invoke(
6287 cli,
6288 ["code", "entangle", "--json", "--include-same-file",
6289 "--min-co-changes", "1"],
6290 )
6291 assert result.exit_code == 0, result.output
6292 data = json.loads(result.output)
6293 assert data["filters"]["include_same_file"] is True
6294
6295 # ── --max-commits ─────────────────────────────────────────────────────────
6296
6297 def test_entangle_max_commits_validation(
6298 self, entangle_repo: pathlib.Path
6299 ) -> None:
6300 result = runner.invoke(cli, ["code", "entangle", "--max-commits", "0"])
6301 assert result.exit_code != 0
6302
6303 def test_entangle_max_commits_respected(
6304 self, entangle_repo: pathlib.Path
6305 ) -> None:
6306 result = runner.invoke(
6307 cli, ["code", "entangle", "--json", "--max-commits", "1"]
6308 )
6309 assert result.exit_code == 0
6310 data = json.loads(result.output)
6311 assert data["commits_analysed"] <= 1
6312
6313 # ── --since ───────────────────────────────────────────────────────────────
6314
6315 def test_entangle_since_invalid_ref(self, entangle_repo: pathlib.Path) -> None:
6316 result = runner.invoke(cli, ["code", "entangle", "--since", "no_such_ref"])
6317 assert result.exit_code != 0
6318
6319 # ── requires repo ─────────────────────────────────────────────────────────
6320
6321 def test_entangle_requires_repo(self, tmp_path: pathlib.Path) -> None:
6322 import os
6323 old = os.getcwd()
6324 try:
6325 os.chdir(tmp_path)
6326 result = runner.invoke(cli, ["code", "entangle"])
6327 assert result.exit_code != 0
6328 finally:
6329 os.chdir(old)
6330
6331
6332 # ---------------------------------------------------------------------------
6333 # muse code semantic-test-coverage
6334 # ---------------------------------------------------------------------------
6335
6336
6337 @pytest.fixture
6338 def stc_repo(repo: pathlib.Path) -> pathlib.Path:
6339 """Repo with production code and a test file for semantic-test-coverage.
6340
6341 Layout::
6342
6343 billing.py — compute_total (function), Invoice (class),
6344 Invoice.apply_discount (method),
6345 Invoice.generate_pdf (method) ← never called by tests
6346 services.py — process_order (calls compute_total transitively)
6347 tests/test_billing.py — test_compute_total, test_apply_discount,
6348 test_process_order (direct calls)
6349
6350 Direct coverage expected:
6351 compute_total ← test_compute_total, test_process_order (via bare name)
6352 Invoice ← test_compute_total (instantiation)
6353 apply_discount ← test_apply_discount
6354 generate_pdf ← NOT covered
6355 process_order ← test_process_order
6356
6357 Transitive (depth 2) additionally covers:
6358 compute_total ← test_process_order (because process_order calls it)
6359 """
6360 (repo / "tests").mkdir(exist_ok=True)
6361
6362 (repo / "billing.py").write_text(textwrap.dedent("""\
6363 class Invoice:
6364 def apply_discount(self, rate):
6365 return self.total * (1 - rate)
6366
6367 def generate_pdf(self):
6368 return b"PDF"
6369
6370 def compute_total(items):
6371 return sum(i["price"] for i in items)
6372 """))
6373
6374 (repo / "services.py").write_text(textwrap.dedent("""\
6375 from billing import compute_total
6376
6377 def process_order(order):
6378 return compute_total(order["items"])
6379 """))
6380
6381 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
6382 from billing import compute_total, Invoice
6383 from services import process_order
6384
6385 def test_compute_total():
6386 inv = Invoice()
6387 assert compute_total([{"price": 10}]) == 10
6388
6389 def test_apply_discount():
6390 inv = Invoice()
6391 inv.total = 100
6392 assert inv.apply_discount(0.1) == 90
6393
6394 def test_process_order():
6395 result = process_order({"items": [{"price": 5}]})
6396 assert result == 5
6397 """))
6398
6399 r = runner.invoke(cli, ["commit", "-m", "stc: initial repo"])
6400 assert r.exit_code == 0, r.output
6401 return repo
6402
6403
6404 class TestSemanticTestCoverage:
6405 """Tests for ``muse code semantic-test-coverage``."""
6406
6407 CMD = ["code", "semantic-test-coverage"]
6408
6409 # ── basic correctness ────────────────────────────────────────────────────
6410
6411 def test_stc_exits_zero(self, stc_repo: pathlib.Path) -> None:
6412 result = runner.invoke(cli, self.CMD)
6413 assert result.exit_code == 0, result.output
6414
6415 def test_stc_shows_header(self, stc_repo: pathlib.Path) -> None:
6416 result = runner.invoke(cli, self.CMD)
6417 assert "Semantic test coverage" in result.output
6418 assert "HEAD" in result.output
6419
6420 def test_stc_shows_test_function_count(self, stc_repo: pathlib.Path) -> None:
6421 result = runner.invoke(cli, self.CMD)
6422 # 3 test functions in the repo
6423 assert "test functions" in result.output
6424
6425 def test_stc_shows_total_line(self, stc_repo: pathlib.Path) -> None:
6426 result = runner.invoke(cli, self.CMD)
6427 assert "TOTAL:" in result.output
6428
6429 def test_stc_covered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6430 result = runner.invoke(cli, self.CMD)
6431 assert "compute_total" in result.output
6432
6433 def test_stc_uncovered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6434 result = runner.invoke(cli, self.CMD)
6435 assert "generate_pdf" in result.output
6436
6437 def test_stc_covered_has_check_icon(self, stc_repo: pathlib.Path) -> None:
6438 result = runner.invoke(cli, self.CMD)
6439 assert "✅" in result.output
6440
6441 def test_stc_uncovered_has_cross_icon(self, stc_repo: pathlib.Path) -> None:
6442 result = runner.invoke(cli, self.CMD)
6443 assert "❌" in result.output
6444
6445 # ── JSON output ──────────────────────────────────────────────────────────
6446
6447 def test_stc_json_exits_zero(self, stc_repo: pathlib.Path) -> None:
6448 result = runner.invoke(cli, self.CMD + ["--json"])
6449 assert result.exit_code == 0, result.output
6450
6451 def test_stc_json_is_valid(self, stc_repo: pathlib.Path) -> None:
6452 result = runner.invoke(cli, self.CMD + ["--json"])
6453 data = json.loads(result.output)
6454 assert isinstance(data, dict)
6455
6456 def test_stc_json_top_level_keys(self, stc_repo: pathlib.Path) -> None:
6457 result = runner.invoke(cli, self.CMD + ["--json"])
6458 data = json.loads(result.output)
6459 for key in ("ref", "snapshot_id", "depth", "transitive", "filters",
6460 "summary", "files"):
6461 assert key in data, f"missing key: {key}"
6462
6463 def test_stc_json_ref_is_head(self, stc_repo: pathlib.Path) -> None:
6464 result = runner.invoke(cli, self.CMD + ["--json"])
6465 data = json.loads(result.output)
6466 assert data["ref"] == "HEAD"
6467
6468 def test_stc_json_depth_default(self, stc_repo: pathlib.Path) -> None:
6469 result = runner.invoke(cli, self.CMD + ["--json"])
6470 data = json.loads(result.output)
6471 assert data["depth"] == 1
6472
6473 def test_stc_json_transitive_default_false(self, stc_repo: pathlib.Path) -> None:
6474 result = runner.invoke(cli, self.CMD + ["--json"])
6475 data = json.loads(result.output)
6476 assert data["transitive"] is False
6477
6478 def test_stc_json_summary_schema(self, stc_repo: pathlib.Path) -> None:
6479 result = runner.invoke(cli, self.CMD + ["--json"])
6480 data = json.loads(result.output)
6481 summary = data["summary"]
6482 for key in ("total_symbols", "covered_symbols", "uncovered_symbols",
6483 "coverage_pct", "total_test_functions", "total_production_files"):
6484 assert key in summary, f"summary missing: {key}"
6485
6486 def test_stc_json_summary_counts_consistent(self, stc_repo: pathlib.Path) -> None:
6487 result = runner.invoke(cli, self.CMD + ["--json"])
6488 data = json.loads(result.output)
6489 s = data["summary"]
6490 assert s["covered_symbols"] + s["uncovered_symbols"] == s["total_symbols"]
6491
6492 def test_stc_json_summary_test_fn_count(self, stc_repo: pathlib.Path) -> None:
6493 result = runner.invoke(cli, self.CMD + ["--json"])
6494 data = json.loads(result.output)
6495 # 3 test functions: test_compute_total, test_apply_discount, test_process_order
6496 assert data["summary"]["total_test_functions"] >= 3
6497
6498 def test_stc_json_file_schema(self, stc_repo: pathlib.Path) -> None:
6499 result = runner.invoke(cli, self.CMD + ["--json"])
6500 data = json.loads(result.output)
6501 assert len(data["files"]) > 0
6502 fc = data["files"][0]
6503 for key in ("file", "total_symbols", "covered_symbols",
6504 "uncovered_symbols", "coverage_pct", "symbols"):
6505 assert key in fc, f"file record missing: {key}"
6506
6507 def test_stc_json_symbol_schema(self, stc_repo: pathlib.Path) -> None:
6508 result = runner.invoke(cli, self.CMD + ["--json"])
6509 data = json.loads(result.output)
6510 # Find a file with at least one symbol
6511 sym = data["files"][0]["symbols"][0]
6512 for key in ("address", "name", "kind", "covered", "test_functions"):
6513 assert key in sym, f"symbol record missing: {key}"
6514
6515 def test_stc_json_covered_symbol_has_test_functions(
6516 self, stc_repo: pathlib.Path
6517 ) -> None:
6518 result = runner.invoke(cli, self.CMD + ["--json"])
6519 data = json.loads(result.output)
6520 covered = [
6521 sym
6522 for fc in data["files"]
6523 for sym in fc["symbols"]
6524 if sym["covered"]
6525 ]
6526 assert covered, "expected at least one covered symbol"
6527 assert any(len(sym["test_functions"]) > 0 for sym in covered)
6528
6529 def test_stc_json_uncovered_symbol_empty_test_fns(
6530 self, stc_repo: pathlib.Path
6531 ) -> None:
6532 result = runner.invoke(cli, self.CMD + ["--json"])
6533 data = json.loads(result.output)
6534 uncovered = [
6535 sym
6536 for fc in data["files"]
6537 for sym in fc["symbols"]
6538 if not sym["covered"]
6539 ]
6540 assert uncovered, "expected generate_pdf to be uncovered"
6541 assert all(sym["test_functions"] == [] for sym in uncovered)
6542
6543 def test_stc_json_generate_pdf_uncovered(self, stc_repo: pathlib.Path) -> None:
6544 result = runner.invoke(cli, self.CMD + ["--json"])
6545 data = json.loads(result.output)
6546 found = next(
6547 (
6548 sym
6549 for fc in data["files"]
6550 for sym in fc["symbols"]
6551 if sym["name"] == "generate_pdf"
6552 ),
6553 None,
6554 )
6555 assert found is not None, "generate_pdf symbol not found"
6556 assert found["covered"] is False
6557
6558 def test_stc_json_compute_total_covered(self, stc_repo: pathlib.Path) -> None:
6559 result = runner.invoke(cli, self.CMD + ["--json"])
6560 data = json.loads(result.output)
6561 found = next(
6562 (
6563 sym
6564 for fc in data["files"]
6565 for sym in fc["symbols"]
6566 if sym["name"] == "compute_total"
6567 ),
6568 None,
6569 )
6570 assert found is not None
6571 assert found["covered"] is True
6572
6573 def test_stc_json_coverage_pct_between_0_and_100(
6574 self, stc_repo: pathlib.Path
6575 ) -> None:
6576 result = runner.invoke(cli, self.CMD + ["--json"])
6577 data = json.loads(result.output)
6578 for fc in data["files"]:
6579 assert 0.0 <= fc["coverage_pct"] <= 100.0
6580
6581 def test_stc_json_filter_reflected(self, stc_repo: pathlib.Path) -> None:
6582 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6583 data = json.loads(result.output)
6584 assert data["filters"]["kind"] == "method"
6585
6586 def test_stc_json_no_import_pseudosymbols(self, stc_repo: pathlib.Path) -> None:
6587 result = runner.invoke(cli, self.CMD + ["--json"])
6588 data = json.loads(result.output)
6589 for fc in data["files"]:
6590 for sym in fc["symbols"]:
6591 assert sym["kind"] != "import"
6592
6593 # ── --file filter ────────────────────────────────────────────────────────
6594
6595 def test_stc_file_filter_scopes_output(self, stc_repo: pathlib.Path) -> None:
6596 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6597 data = json.loads(result.output)
6598 for fc in data["files"]:
6599 assert "billing.py" in fc["file"]
6600
6601 def test_stc_file_filter_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6602 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6603 data = json.loads(result.output)
6604 assert data["filters"]["file"] == "billing.py"
6605
6606 def test_stc_file_filter_billing_has_generate_pdf(
6607 self, stc_repo: pathlib.Path
6608 ) -> None:
6609 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6610 data = json.loads(result.output)
6611 names = [
6612 sym["name"] for fc in data["files"] for sym in fc["symbols"]
6613 ]
6614 assert "generate_pdf" in names
6615
6616 # ── --kind filter ────────────────────────────────────────────────────────
6617
6618 def test_stc_kind_method_only_methods(self, stc_repo: pathlib.Path) -> None:
6619 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6620 data = json.loads(result.output)
6621 for fc in data["files"]:
6622 for sym in fc["symbols"]:
6623 assert sym["kind"] == "method"
6624
6625 def test_stc_kind_function_only_functions(self, stc_repo: pathlib.Path) -> None:
6626 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function"])
6627 data = json.loads(result.output)
6628 for fc in data["files"]:
6629 for sym in fc["symbols"]:
6630 assert sym["kind"] == "function"
6631
6632 def test_stc_kind_invalid_rejected(self, stc_repo: pathlib.Path) -> None:
6633 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6634 assert result.exit_code != 0
6635
6636 # ── --uncovered-only ─────────────────────────────────────────────────────
6637
6638 def test_stc_uncovered_only_exits_zero(self, stc_repo: pathlib.Path) -> None:
6639 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6640 assert result.exit_code == 0, result.output
6641
6642 def test_stc_uncovered_only_hides_covered(self, stc_repo: pathlib.Path) -> None:
6643 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6644 # generate_pdf should appear; compute_total should not appear
6645 assert "generate_pdf" in result.output
6646
6647 def test_stc_uncovered_only_json_symbols_all_uncovered(
6648 self, stc_repo: pathlib.Path
6649 ) -> None:
6650 result = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6651 data = json.loads(result.output)
6652 for fc in data["files"]:
6653 for sym in fc["symbols"]:
6654 assert sym["covered"] is False
6655
6656 def test_stc_uncovered_only_json_stats_still_full(
6657 self, stc_repo: pathlib.Path
6658 ) -> None:
6659 result_all = runner.invoke(cli, self.CMD + ["--json"])
6660 result_uncov = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6661 data_all = json.loads(result_all.output)
6662 data_uncov = json.loads(result_uncov.output)
6663 # Total symbol count should be the same (stats reflect full picture)
6664 assert (
6665 data_all["summary"]["total_symbols"]
6666 == data_uncov["summary"]["total_symbols"]
6667 )
6668
6669 # ── --show-tests ─────────────────────────────────────────────────────────
6670
6671 def test_stc_show_tests_exits_zero(self, stc_repo: pathlib.Path) -> None:
6672 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6673 assert result.exit_code == 0, result.output
6674
6675 def test_stc_show_tests_lists_test_addr(self, stc_repo: pathlib.Path) -> None:
6676 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6677 # Should include a ← prefix followed by a test address
6678 assert "←" in result.output
6679
6680 def test_stc_show_tests_references_test_file(
6681 self, stc_repo: pathlib.Path
6682 ) -> None:
6683 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6684 assert "test_billing" in result.output
6685
6686 # ── --transitive / --depth ───────────────────────────────────────────────
6687
6688 def test_stc_transitive_exits_zero(self, stc_repo: pathlib.Path) -> None:
6689 result = runner.invoke(cli, self.CMD + ["--transitive"])
6690 assert result.exit_code == 0, result.output
6691
6692 def test_stc_transitive_json_flag_true(self, stc_repo: pathlib.Path) -> None:
6693 result = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6694 data = json.loads(result.output)
6695 assert data["transitive"] is True
6696
6697 def test_stc_depth_2_implies_transitive(self, stc_repo: pathlib.Path) -> None:
6698 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
6699 data = json.loads(result.output)
6700 assert data["transitive"] is True
6701 assert data["depth"] == 2
6702
6703 def test_stc_depth_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6704 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "3"])
6705 data = json.loads(result.output)
6706 assert data["depth"] == 3
6707
6708 def test_stc_transitive_does_not_reduce_coverage(
6709 self, stc_repo: pathlib.Path
6710 ) -> None:
6711 result_direct = runner.invoke(cli, self.CMD + ["--json"])
6712 result_trans = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6713 data_direct = json.loads(result_direct.output)
6714 data_trans = json.loads(result_trans.output)
6715 # Transitive coverage must be >= direct coverage
6716 assert (
6717 data_trans["summary"]["covered_symbols"]
6718 >= data_direct["summary"]["covered_symbols"]
6719 )
6720
6721 def test_stc_depth_0_invalid(self, stc_repo: pathlib.Path) -> None:
6722 result = runner.invoke(cli, self.CMD + ["--depth", "0"])
6723 assert result.exit_code != 0
6724
6725 def test_stc_depth_exceeds_max_invalid(self, stc_repo: pathlib.Path) -> None:
6726 result = runner.invoke(cli, self.CMD + ["--depth", "11"])
6727 assert result.exit_code != 0
6728
6729 # ── --min-coverage ───────────────────────────────────────────────────────
6730
6731 def test_stc_min_coverage_0_exits_zero(self, stc_repo: pathlib.Path) -> None:
6732 result = runner.invoke(cli, self.CMD + ["--min-coverage", "0"])
6733 assert result.exit_code == 0, result.output
6734
6735 def test_stc_min_coverage_100_exits_nonzero(self, stc_repo: pathlib.Path) -> None:
6736 # generate_pdf is never covered, so 100% is unachievable.
6737 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6738 assert result.exit_code != 0
6739
6740 def test_stc_min_coverage_shows_warning(self, stc_repo: pathlib.Path) -> None:
6741 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6742 assert "⚠️" in result.output or "below" in result.output.lower()
6743
6744 def test_stc_min_coverage_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6745 result = runner.invoke(cli, self.CMD + ["--json", "--min-coverage", "80"])
6746 data = json.loads(result.output)
6747 assert data["filters"]["min_coverage"] == 80
6748
6749 def test_stc_min_coverage_none_when_0(self, stc_repo: pathlib.Path) -> None:
6750 result = runner.invoke(cli, self.CMD + ["--json"])
6751 data = json.loads(result.output)
6752 assert data["filters"]["min_coverage"] is None
6753
6754 def test_stc_min_coverage_invalid_over_100(self, stc_repo: pathlib.Path) -> None:
6755 result = runner.invoke(cli, self.CMD + ["--min-coverage", "101"])
6756 assert result.exit_code != 0
6757
6758 def test_stc_min_coverage_invalid_negative(self, stc_repo: pathlib.Path) -> None:
6759 result = runner.invoke(cli, self.CMD + ["--min-coverage", "-1"])
6760 assert result.exit_code != 0
6761
6762 # ── test-file exclusion ──────────────────────────────────────────────────
6763
6764 def test_stc_test_files_not_in_production_symbols(
6765 self, stc_repo: pathlib.Path
6766 ) -> None:
6767 result = runner.invoke(cli, self.CMD + ["--json"])
6768 data = json.loads(result.output)
6769 for fc in data["files"]:
6770 assert "test_" not in pathlib.PurePosixPath(fc["file"]).name.split(".")[0][:5] or \
6771 not fc["file"].startswith("tests/"), \
6772 f"test file appeared in production symbols: {fc['file']}"
6773
6774 def test_stc_no_test_file_in_prod_files(self, stc_repo: pathlib.Path) -> None:
6775 result = runner.invoke(cli, self.CMD + ["--json"])
6776 data = json.loads(result.output)
6777 for fc in data["files"]:
6778 assert "tests/" not in fc["file"] or fc["file"].startswith("tests/") is False, \
6779 fc["file"]
6780
6781 # ── requires repo ────────────────────────────────────────────────────────
6782
6783 def test_stc_requires_repo(self, tmp_path: pathlib.Path) -> None:
6784 import os
6785 old = os.getcwd()
6786 try:
6787 os.chdir(tmp_path)
6788 result = runner.invoke(cli, self.CMD)
6789 assert result.exit_code != 0
6790 finally:
6791 os.chdir(old)
6792
6793 # ── empty repo ───────────────────────────────────────────────────────────
6794
6795 def test_stc_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
6796 """An empty repo (no commits yet) should not crash."""
6797 # The base repo fixture has no commits — must handle gracefully.
6798 # First commit something minimal so HEAD exists.
6799 (repo / "empty.py").write_text("")
6800 r = runner.invoke(cli, ["commit", "-m", "seed"])
6801 if r.exit_code != 0:
6802 pytest.skip("could not create initial commit")
6803 result = runner.invoke(cli, self.CMD)
6804 assert result.exit_code == 0, result.output
6805
6806
6807 # ---------------------------------------------------------------------------
6808 # muse code gravity
6809 # ---------------------------------------------------------------------------
6810
6811
6812 @pytest.fixture
6813 def gravity_repo(repo: pathlib.Path) -> pathlib.Path:
6814 """Repo whose call graph creates a clear gravity hierarchy.
6815
6816 Layout::
6817
6818 core.py — read_object (called by everything)
6819 mid.py — process (calls read_object)
6820 top.py — handle (calls process, which calls read_object)
6821 leaf.py — leaf_fn (calls handle)
6822
6823 Expected gravity (transitive dependents):
6824 read_object: 3 (process, handle, leaf_fn) → high gravity
6825 process: 2 (handle, leaf_fn)
6826 handle: 1 (leaf_fn)
6827 leaf_fn: 0 → lowest gravity
6828 """
6829 (repo / "core.py").write_text(textwrap.dedent("""\
6830 def read_object(path):
6831 return path.read_bytes()
6832 """))
6833 r1 = runner.invoke(cli, ["commit", "-m", "core: add read_object"])
6834 assert r1.exit_code == 0, r1.output
6835
6836 (repo / "mid.py").write_text(textwrap.dedent("""\
6837 from core import read_object
6838
6839 def process(path):
6840 return read_object(path)
6841 """))
6842 r2 = runner.invoke(cli, ["commit", "-m", "mid: add process"])
6843 assert r2.exit_code == 0, r2.output
6844
6845 (repo / "top.py").write_text(textwrap.dedent("""\
6846 from mid import process
6847
6848 def handle(path):
6849 return process(path)
6850 """))
6851 r3 = runner.invoke(cli, ["commit", "-m", "top: add handle"])
6852 assert r3.exit_code == 0, r3.output
6853
6854 (repo / "leaf.py").write_text(textwrap.dedent("""\
6855 from top import handle
6856
6857 def leaf_fn(path):
6858 return handle(path)
6859 """))
6860 r4 = runner.invoke(cli, ["commit", "-m", "leaf: add leaf_fn"])
6861 assert r4.exit_code == 0, r4.output
6862
6863 return repo
6864
6865
6866 class TestGravity:
6867 """Tests for ``muse code gravity``."""
6868
6869 CMD = ["code", "gravity"]
6870
6871 # ── basic correctness ─────────────────────────────────────────────────────
6872
6873 def test_gravity_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6874 result = runner.invoke(cli, self.CMD)
6875 assert result.exit_code == 0, result.output
6876
6877 def test_gravity_shows_header(self, gravity_repo: pathlib.Path) -> None:
6878 result = runner.invoke(cli, self.CMD)
6879 assert "Symbol gravity" in result.output
6880
6881 def test_gravity_shows_head(self, gravity_repo: pathlib.Path) -> None:
6882 result = runner.invoke(cli, self.CMD)
6883 assert "HEAD" in result.output
6884
6885 def test_gravity_shows_column_headers(self, gravity_repo: pathlib.Path) -> None:
6886 result = runner.invoke(cli, self.CMD)
6887 assert "GRAVITY" in result.output
6888 assert "DIRECT" in result.output
6889 assert "DEPTH" in result.output
6890
6891 def test_gravity_shows_symbols(self, gravity_repo: pathlib.Path) -> None:
6892 result = runner.invoke(cli, self.CMD)
6893 # At least one symbol should appear.
6894 assert "read_object" in result.output or "process" in result.output
6895
6896 def test_gravity_shows_percentage(self, gravity_repo: pathlib.Path) -> None:
6897 result = runner.invoke(cli, self.CMD)
6898 assert "%" in result.output
6899
6900 # ── --top ─────────────────────────────────────────────────────────────────
6901
6902 def test_gravity_top_limits_output(self, gravity_repo: pathlib.Path) -> None:
6903 result1 = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6904 result3 = runner.invoke(cli, self.CMD + ["--json", "--top", "3"])
6905 data1 = json.loads(result1.output)
6906 data3 = json.loads(result3.output)
6907 assert len(data1["symbols"]) <= 1
6908 assert len(data3["symbols"]) <= 3
6909
6910 def test_gravity_top_0_returns_all(self, gravity_repo: pathlib.Path) -> None:
6911 result_all = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6912 result_lim = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6913 data_all = json.loads(result_all.output)
6914 data_lim = json.loads(result_lim.output)
6915 assert len(data_all["symbols"]) >= len(data_lim["symbols"])
6916
6917 def test_gravity_top_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6918 result = runner.invoke(cli, self.CMD + ["--top", "-1"])
6919 assert result.exit_code != 0
6920
6921 # ── --sort ────────────────────────────────────────────────────────────────
6922
6923 def test_gravity_sort_gravity_default(self, gravity_repo: pathlib.Path) -> None:
6924 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6925 data = json.loads(result.output)
6926 if len(data["symbols"]) >= 2:
6927 pcts = [s["gravity_pct"] for s in data["symbols"]]
6928 assert pcts == sorted(pcts, reverse=True)
6929
6930 def test_gravity_sort_direct(self, gravity_repo: pathlib.Path) -> None:
6931 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "direct", "--top", "0"])
6932 data = json.loads(result.output)
6933 assert result.exit_code == 0
6934 if len(data["symbols"]) >= 2:
6935 directs = [s["direct_dependents"] for s in data["symbols"]]
6936 assert directs == sorted(directs, reverse=True)
6937
6938 def test_gravity_sort_depth(self, gravity_repo: pathlib.Path) -> None:
6939 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "depth", "--top", "0"])
6940 data = json.loads(result.output)
6941 assert result.exit_code == 0
6942 if len(data["symbols"]) >= 2:
6943 depths = [s["max_depth"] for s in data["symbols"]]
6944 assert depths == sorted(depths, reverse=True)
6945
6946 def test_gravity_sort_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
6947 result = runner.invoke(cli, self.CMD + ["--sort", "invalid"])
6948 assert result.exit_code != 0
6949
6950 # ── --depth cap ───────────────────────────────────────────────────────────
6951
6952 def test_gravity_depth_0_unlimited(self, gravity_repo: pathlib.Path) -> None:
6953 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "0"])
6954 data = json.loads(result.output)
6955 assert result.exit_code == 0
6956 assert data["max_depth"] == 0
6957
6958 def test_gravity_depth_1_direct_only(self, gravity_repo: pathlib.Path) -> None:
6959 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "1", "--top", "0"])
6960 data = json.loads(result.output)
6961 assert result.exit_code == 0
6962 # With depth=1, max_depth for any symbol should be at most 1.
6963 for sym in data["symbols"]:
6964 assert sym["max_depth"] <= 1
6965
6966 def test_gravity_depth_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6967 result = runner.invoke(cli, self.CMD + ["--depth", "-1"])
6968 assert result.exit_code != 0
6969
6970 # ── --kind filter ─────────────────────────────────────────────────────────
6971
6972 def test_gravity_kind_function_only(self, gravity_repo: pathlib.Path) -> None:
6973 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function", "--top", "0"])
6974 data = json.loads(result.output)
6975 assert result.exit_code == 0
6976 for sym in data["symbols"]:
6977 assert sym["kind"] == "function"
6978
6979 def test_gravity_kind_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
6980 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6981 assert result.exit_code != 0
6982
6983 # ── --file filter ─────────────────────────────────────────────────────────
6984
6985 def test_gravity_file_filter_scopes(self, gravity_repo: pathlib.Path) -> None:
6986 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py", "--top", "0"])
6987 data = json.loads(result.output)
6988 assert result.exit_code == 0
6989 for sym in data["symbols"]:
6990 assert "core.py" in sym["file"]
6991
6992 def test_gravity_file_filter_reflected_in_json(self, gravity_repo: pathlib.Path) -> None:
6993 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py"])
6994 data = json.loads(result.output)
6995 assert data["filters"]["file"] == "core.py"
6996
6997 # ── --min-gravity ─────────────────────────────────────────────────────────
6998
6999 def test_gravity_min_gravity_filters_low(self, gravity_repo: pathlib.Path) -> None:
7000 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "50.0", "--top", "0"])
7001 data = json.loads(result.output)
7002 for sym in data["symbols"]:
7003 assert sym["gravity_pct"] >= 50.0
7004
7005 def test_gravity_min_gravity_100_returns_few(self, gravity_repo: pathlib.Path) -> None:
7006 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "100.0"])
7007 assert result.exit_code == 0
7008
7009 def test_gravity_min_gravity_invalid_over_100(self, gravity_repo: pathlib.Path) -> None:
7010 result = runner.invoke(cli, self.CMD + ["--min-gravity", "101.0"])
7011 assert result.exit_code != 0
7012
7013 def test_gravity_min_gravity_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7014 result = runner.invoke(cli, self.CMD + ["--min-gravity", "-1.0"])
7015 assert result.exit_code != 0
7016
7017 # ── --explain ─────────────────────────────────────────────────────────────
7018
7019 def test_gravity_explain_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7020 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7021 assert result.exit_code == 0, result.output
7022
7023 def test_gravity_explain_shows_breakdown(self, gravity_repo: pathlib.Path) -> None:
7024 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7025 assert "Gravity breakdown" in result.output
7026
7027 def test_gravity_explain_shows_depth_distribution(
7028 self, gravity_repo: pathlib.Path
7029 ) -> None:
7030 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7031 assert "Depth distribution" in result.output
7032
7033 def test_gravity_explain_shows_deepest_callers(
7034 self, gravity_repo: pathlib.Path
7035 ) -> None:
7036 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7037 assert "Deepest callers" in result.output
7038
7039 def test_gravity_explain_missing_address_format(
7040 self, gravity_repo: pathlib.Path
7041 ) -> None:
7042 result = runner.invoke(cli, self.CMD + ["--explain", "no_double_colon"])
7043 assert result.exit_code != 0
7044
7045 def test_gravity_explain_unknown_symbol_exits_nonzero(
7046 self, gravity_repo: pathlib.Path
7047 ) -> None:
7048 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::no_such_fn"])
7049 assert result.exit_code != 0
7050
7051 def test_gravity_explain_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7052 result = runner.invoke(
7053 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7054 )
7055 assert result.exit_code == 0, result.output
7056
7057 def test_gravity_explain_json_schema(self, gravity_repo: pathlib.Path) -> None:
7058 result = runner.invoke(
7059 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7060 )
7061 data = json.loads(result.output)
7062 for key in (
7063 "address", "name", "kind", "file",
7064 "gravity_pct", "direct_dependents", "transitive_dependents",
7065 "max_depth", "depth_distribution",
7066 ):
7067 assert key in data, f"missing key: {key}"
7068
7069 # ── JSON leaderboard ──────────────────────────────────────────────────────
7070
7071 def test_gravity_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7072 result = runner.invoke(cli, self.CMD + ["--json"])
7073 assert result.exit_code == 0, result.output
7074
7075 def test_gravity_json_top_level_keys(self, gravity_repo: pathlib.Path) -> None:
7076 result = runner.invoke(cli, self.CMD + ["--json"])
7077 data = json.loads(result.output)
7078 for key in (
7079 "ref", "snapshot_id", "total_production_symbols",
7080 "max_depth", "include_tests", "filters", "symbols",
7081 ):
7082 assert key in data, f"missing key: {key}"
7083
7084 def test_gravity_json_symbol_schema(self, gravity_repo: pathlib.Path) -> None:
7085 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7086 data = json.loads(result.output)
7087 if data["symbols"]:
7088 sym = data["symbols"][0]
7089 for key in (
7090 "address", "name", "kind", "file",
7091 "gravity_pct", "direct_dependents",
7092 "transitive_dependents", "max_depth", "depth_distribution",
7093 ):
7094 assert key in sym, f"symbol missing key: {key}"
7095
7096 def test_gravity_json_gravity_pct_range(self, gravity_repo: pathlib.Path) -> None:
7097 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7098 data = json.loads(result.output)
7099 for sym in data["symbols"]:
7100 assert 0.0 <= sym["gravity_pct"] <= 100.0
7101
7102 def test_gravity_json_read_object_has_highest_gravity(
7103 self, gravity_repo: pathlib.Path
7104 ) -> None:
7105 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7106 data = json.loads(result.output)
7107 # read_object is called transitively by everything — should be near top.
7108 names = [s["name"] for s in data["symbols"]]
7109 if "read_object" in names and len(names) > 1:
7110 ro_idx = names.index("read_object")
7111 # read_object should be in the top half.
7112 assert ro_idx <= len(names) // 2 + 1
7113
7114 def test_gravity_json_leaf_fn_lower_gravity(
7115 self, gravity_repo: pathlib.Path
7116 ) -> None:
7117 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7118 data = json.loads(result.output)
7119 syms = {s["name"]: s for s in data["symbols"]}
7120 if "leaf_fn" in syms and "read_object" in syms:
7121 assert syms["leaf_fn"]["gravity_pct"] <= syms["read_object"]["gravity_pct"]
7122
7123 def test_gravity_json_include_tests_flag(self, gravity_repo: pathlib.Path) -> None:
7124 result = runner.invoke(cli, self.CMD + ["--json", "--include-tests"])
7125 data = json.loads(result.output)
7126 assert data["include_tests"] is True
7127
7128 def test_gravity_json_depth_reflected(self, gravity_repo: pathlib.Path) -> None:
7129 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
7130 data = json.loads(result.output)
7131 assert data["max_depth"] == 2
7132
7133 def test_gravity_json_filters_reflected(self, gravity_repo: pathlib.Path) -> None:
7134 result = runner.invoke(
7135 cli,
7136 self.CMD + ["--json", "--kind", "function", "--min-gravity", "5.0", "--top", "10"],
7137 )
7138 data = json.loads(result.output)
7139 assert data["filters"]["kind"] == "function"
7140 assert data["filters"]["min_gravity"] == 5.0
7141 assert data["filters"]["top"] == 10
7142
7143 def test_gravity_json_depth_distribution_is_dict(
7144 self, gravity_repo: pathlib.Path
7145 ) -> None:
7146 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7147 data = json.loads(result.output)
7148 for sym in data["symbols"]:
7149 assert isinstance(sym["depth_distribution"], dict)
7150
7151 # ── requires repo ─────────────────────────────────────────────────────────
7152
7153 def test_gravity_requires_repo(self, tmp_path: pathlib.Path) -> None:
7154 import os
7155 old = os.getcwd()
7156 try:
7157 os.chdir(tmp_path)
7158 result = runner.invoke(cli, self.CMD)
7159 assert result.exit_code != 0
7160 finally:
7161 os.chdir(old)
7162
7163
7164 # ---------------------------------------------------------------------------
7165 # muse code narrative
7166 # ---------------------------------------------------------------------------
7167
7168
7169 @pytest.fixture
7170 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
7171 """Repo with a symbol that has a rich multi-event history.
7172
7173 billing.py::compute_total goes through:
7174 commit 1: seed commit (different file — gives billing.py a parent context)
7175 commit 2: created (insert — billing.py added, compute_total appears as new symbol)
7176 commit 3: body rewritten (replace with impl keywords)
7177 commit 4: signature changed (replace with signature keywords)
7178 """
7179 # Commit 1 — seed so billing.py's creation is a delta, not the initial commit.
7180 (repo / "readme.txt").write_text("MuseHub billing module\n")
7181 r0 = runner.invoke(cli, ["commit", "-m", "chore: initial seed"])
7182 assert r0.exit_code == 0, r0.output
7183
7184 # Commit 2 — create billing.py (compute_total becomes a new symbol in delta).
7185 (repo / "billing.py").write_text(textwrap.dedent("""\
7186 def compute_total(items):
7187 total = 0
7188 for item in items:
7189 total += item["price"]
7190 return total
7191 """))
7192 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7193 assert r1.exit_code == 0, r1.output
7194
7195 # Commit 3 — body rewrite: implementation changed.
7196 (repo / "billing.py").write_text(textwrap.dedent("""\
7197 def compute_total(items):
7198 return sum(i["price"] for i in items)
7199 """))
7200 r2 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
7201 assert r2.exit_code == 0, r2.output
7202
7203 # Commit 4 — signature change.
7204 (repo / "billing.py").write_text(textwrap.dedent("""\
7205 def compute_total(items, currency="USD"):
7206 return sum(i["price"] for i in items)
7207 """))
7208 r3 = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
7209 assert r3.exit_code == 0, r3.output
7210
7211 return repo
7212
7213
7214 class TestNarrative:
7215 """Tests for ``muse code narrative``."""
7216
7217 CMD = ["code", "narrative"]
7218 ADDR = "billing.py::compute_total"
7219
7220 # ── basic correctness ─────────────────────────────────────────────────────
7221
7222 def test_narrative_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7223 result = runner.invoke(cli, self.CMD + [self.ADDR])
7224 assert result.exit_code == 0, result.output
7225
7226 def test_narrative_shows_symbol_name(self, narrative_repo: pathlib.Path) -> None:
7227 result = runner.invoke(cli, self.CMD + [self.ADDR])
7228 assert "compute_total" in result.output
7229
7230 def test_narrative_shows_file(self, narrative_repo: pathlib.Path) -> None:
7231 result = runner.invoke(cli, self.CMD + [self.ADDR])
7232 assert "billing.py" in result.output
7233
7234 def test_narrative_shows_born_event(self, narrative_repo: pathlib.Path) -> None:
7235 result = runner.invoke(cli, self.CMD + [self.ADDR])
7236 assert "Born" in result.output or "born" in result.output
7237
7238 def test_narrative_shows_life_summary(self, narrative_repo: pathlib.Path) -> None:
7239 result = runner.invoke(cli, self.CMD + [self.ADDR])
7240 assert "Life summary" in result.output or "Survival" in result.output
7241
7242 def test_narrative_shows_commit_id(self, narrative_repo: pathlib.Path) -> None:
7243 result = runner.invoke(cli, self.CMD + [self.ADDR])
7244 assert "commit" in result.output
7245
7246 def test_narrative_shows_survival(self, narrative_repo: pathlib.Path) -> None:
7247 result = runner.invoke(cli, self.CMD + [self.ADDR])
7248 assert "%" in result.output
7249
7250 # ── missing symbol ────────────────────────────────────────────────────────
7251
7252 def test_narrative_missing_symbol_exits_nonzero(
7253 self, narrative_repo: pathlib.Path
7254 ) -> None:
7255 result = runner.invoke(
7256 cli, self.CMD + ["billing.py::does_not_exist"]
7257 )
7258 assert result.exit_code != 0
7259
7260 def test_narrative_bad_address_no_colons_exits_nonzero(
7261 self, narrative_repo: pathlib.Path
7262 ) -> None:
7263 result = runner.invoke(cli, self.CMD + ["no_double_colon"])
7264 assert result.exit_code != 0
7265
7266 # ── --format prose ────────────────────────────────────────────────────────
7267
7268 def test_narrative_prose_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7269 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7270 assert result.exit_code == 0, result.output
7271
7272 def test_narrative_prose_contains_name(self, narrative_repo: pathlib.Path) -> None:
7273 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7274 assert "compute_total" in result.output
7275
7276 def test_narrative_prose_contains_content(self, narrative_repo: pathlib.Path) -> None:
7277 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7278 # Symbol name or some indication of the symbol's life should appear.
7279 assert "compute_total" in result.output or "rewritten" in result.output or "born" in result.output.lower()
7280
7281 def test_narrative_prose_no_timeline_label(
7282 self, narrative_repo: pathlib.Path
7283 ) -> None:
7284 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7285 # Timeline labels like "Born " should not appear in prose.
7286 assert "Life summary" not in result.output
7287
7288 def test_narrative_format_invalid_rejected(
7289 self, narrative_repo: pathlib.Path
7290 ) -> None:
7291 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "invalid"])
7292 assert result.exit_code != 0
7293
7294 # ── --json ────────────────────────────────────────────────────────────────
7295
7296 def test_narrative_json_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7297 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7298 assert result.exit_code == 0, result.output
7299
7300 def test_narrative_json_is_valid(self, narrative_repo: pathlib.Path) -> None:
7301 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7302 data = json.loads(result.output)
7303 assert isinstance(data, dict)
7304
7305 def test_narrative_json_top_level_keys(self, narrative_repo: pathlib.Path) -> None:
7306 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7307 data = json.loads(result.output)
7308 for key in (
7309 "address", "name", "kind", "file", "status",
7310 "born_date", "born_commit", "last_change_date", "last_change_commit",
7311 "calendar_age_days", "genetic_age_days",
7312 "impl_changes", "sig_changes", "renames",
7313 "est_survival_pct", "commits_analysed", "truncated", "events",
7314 ):
7315 assert key in data, f"missing key: {key}"
7316
7317 def test_narrative_json_address_matches(self, narrative_repo: pathlib.Path) -> None:
7318 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7319 data = json.loads(result.output)
7320 assert data["address"] == self.ADDR
7321
7322 def test_narrative_json_name_is_bare(self, narrative_repo: pathlib.Path) -> None:
7323 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7324 data = json.loads(result.output)
7325 assert data["name"] == "compute_total"
7326
7327 def test_narrative_json_file_is_file_part(self, narrative_repo: pathlib.Path) -> None:
7328 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7329 data = json.loads(result.output)
7330 assert data["file"] == "billing.py"
7331
7332 def test_narrative_json_status_alive(self, narrative_repo: pathlib.Path) -> None:
7333 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7334 data = json.loads(result.output)
7335 assert data["status"] == "alive"
7336
7337 def test_narrative_json_events_list(self, narrative_repo: pathlib.Path) -> None:
7338 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7339 data = json.loads(result.output)
7340 assert isinstance(data["events"], list)
7341 assert len(data["events"]) >= 1
7342
7343 def test_narrative_json_event_schema(self, narrative_repo: pathlib.Path) -> None:
7344 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7345 data = json.loads(result.output)
7346 ev = data["events"][0]
7347 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
7348 assert key in ev, f"event missing key: {key}"
7349
7350 def test_narrative_json_born_commit_set(self, narrative_repo: pathlib.Path) -> None:
7351 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7352 data = json.loads(result.output)
7353 assert data["born_commit"] != ""
7354
7355 def test_narrative_json_born_date_format(self, narrative_repo: pathlib.Path) -> None:
7356 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7357 data = json.loads(result.output)
7358 import re
7359 assert re.match(r"\d{4}-\d{2}-\d{2}", data["born_date"])
7360
7361 def test_narrative_json_impl_changes_at_least_one(
7362 self, narrative_repo: pathlib.Path
7363 ) -> None:
7364 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7365 data = json.loads(result.output)
7366 # We made at least one body rewrite commit.
7367 assert data["impl_changes"] >= 1
7368
7369 def test_narrative_json_commits_analysed_positive(
7370 self, narrative_repo: pathlib.Path
7371 ) -> None:
7372 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7373 data = json.loads(result.output)
7374 assert data["commits_analysed"] > 0
7375
7376 def test_narrative_json_survival_between_0_and_100(
7377 self, narrative_repo: pathlib.Path
7378 ) -> None:
7379 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7380 data = json.loads(result.output)
7381 assert 0 <= data["est_survival_pct"] <= 100
7382
7383 def test_narrative_json_calendar_age_nonnegative(
7384 self, narrative_repo: pathlib.Path
7385 ) -> None:
7386 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7387 data = json.loads(result.output)
7388 assert data["calendar_age_days"] >= 0
7389
7390 def test_narrative_json_events_oldest_first(
7391 self, narrative_repo: pathlib.Path
7392 ) -> None:
7393 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7394 data = json.loads(result.output)
7395 dates = [ev["date"] for ev in data["events"]]
7396 assert dates == sorted(dates)
7397
7398 def test_narrative_json_create_event_present(
7399 self, narrative_repo: pathlib.Path
7400 ) -> None:
7401 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7402 data = json.loads(result.output)
7403 types = [ev["event_type"] for ev in data["events"]]
7404 assert "create" in types
7405
7406 # ── --since ───────────────────────────────────────────────────────────────
7407
7408 def test_narrative_since_invalid_ref_exits_nonzero(
7409 self, narrative_repo: pathlib.Path
7410 ) -> None:
7411 result = runner.invoke(
7412 cli, self.CMD + [self.ADDR, "--since", "no_such_ref_xyz"]
7413 )
7414 assert result.exit_code != 0
7415
7416 # ── --max-commits ─────────────────────────────────────────────────────────
7417
7418 def test_narrative_max_commits_validation(
7419 self, narrative_repo: pathlib.Path
7420 ) -> None:
7421 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7422 assert result.exit_code != 0
7423
7424 def test_narrative_max_commits_1_finds_head_event(
7425 self, narrative_repo: pathlib.Path
7426 ) -> None:
7427 result = runner.invoke(
7428 cli, self.CMD + [self.ADDR, "--json", "--max-commits", "1"]
7429 )
7430 # With max-commits=1 we only see the HEAD commit; it must still succeed
7431 # if the HEAD commit touched our symbol, or fail gracefully if not.
7432 assert result.exit_code in (0, 1)
7433
7434 # ── --show-source ─────────────────────────────────────────────────────────
7435
7436 def test_narrative_show_source_exits_zero(
7437 self, narrative_repo: pathlib.Path
7438 ) -> None:
7439 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7440 assert result.exit_code == 0, result.output
7441
7442 def test_narrative_show_source_contains_def(
7443 self, narrative_repo: pathlib.Path
7444 ) -> None:
7445 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7446 # HEAD source should contain the function definition.
7447 assert "def compute_total" in result.output
7448
7449 # ── requires repo ─────────────────────────────────────────────────────────
7450
7451 def test_narrative_requires_repo(self, tmp_path: pathlib.Path) -> None:
7452 import os
7453 old = os.getcwd()
7454 try:
7455 os.chdir(tmp_path)
7456 result = runner.invoke(cli, self.CMD + [self.ADDR])
7457 assert result.exit_code != 0
7458 finally:
7459 os.chdir(old)
7460
7461
7462 # ---------------------------------------------------------------------------
7463 # contract
7464 # ---------------------------------------------------------------------------
7465
7466
7467 @pytest.fixture()
7468 def contract_repo(repo: pathlib.Path) -> pathlib.Path:
7469 """Repo designed to exercise every dimension of ``muse code contract``.
7470
7471 Layout::
7472
7473 billing.py — compute_total(items, currency="USD") → float
7474 services.py — place_order() calls compute_total with currency="EUR" → stored
7475 report.py — generate_report() calls compute_total(items) → stored (omits currency)
7476 audit.py — run_audit() calls compute_total(items) → discarded (bad caller)
7477 tests/test_billing.py — tests with assertions about compute_total
7478
7479 Commit history::
7480
7481 1. seed commit — readme.txt so symbol events are real insert ops
7482 2. billing.py added — compute_total created
7483 3. services.py, report.py, audit.py, tests/ added — callers in place
7484 4. billing.py updated — body rewrite (PATCH)
7485 5. billing.py updated — add currency param (MINOR)
7486 """
7487 import os
7488
7489 (repo / "readme.txt").write_text("# contract test repo\n")
7490 r0 = runner.invoke(cli, ["commit", "-m", "seed: initial readme"])
7491 assert r0.exit_code == 0, r0.output
7492
7493 (repo / "billing.py").write_text(textwrap.dedent("""\
7494 def compute_total(items):
7495 return sum(i["price"] for i in items)
7496 """))
7497 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7498 assert r1.exit_code == 0, r1.output
7499
7500 os.makedirs(repo / "tests", exist_ok=True)
7501 (repo / "services.py").write_text(textwrap.dedent("""\
7502 from billing import compute_total
7503
7504 def place_order(items):
7505 total = compute_total(items, currency="EUR")
7506 return total
7507 """))
7508 (repo / "report.py").write_text(textwrap.dedent("""\
7509 from billing import compute_total
7510
7511 def generate_report(items):
7512 result = compute_total(items)
7513 return result
7514 """))
7515 (repo / "audit.py").write_text(textwrap.dedent("""\
7516 from billing import compute_total
7517
7518 def run_audit(items):
7519 compute_total(items)
7520 """))
7521 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
7522 from billing import compute_total
7523
7524 def test_compute_total_basic():
7525 result = compute_total([{"price": 10}, {"price": 5}])
7526 assert result == 15
7527 assert result > 0
7528 assert isinstance(result, (int, float))
7529
7530 def test_compute_total_empty():
7531 result = compute_total([])
7532 assert result == 0
7533 """))
7534 r2 = runner.invoke(cli, ["commit", "-m", "feat: add callers and tests"])
7535 assert r2.exit_code == 0, r2.output
7536
7537 # body rewrite — PATCH
7538 (repo / "billing.py").write_text(textwrap.dedent("""\
7539 def compute_total(items):
7540 total = 0.0
7541 for item in items:
7542 total += float(item["price"])
7543 return total
7544 """))
7545 r3 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total"])
7546 assert r3.exit_code == 0, r3.output
7547
7548 # add currency param — MINOR
7549 (repo / "billing.py").write_text(textwrap.dedent("""\
7550 def compute_total(items, currency="USD"):
7551 total = 0.0
7552 for item in items:
7553 total += float(item["price"])
7554 return total
7555 """))
7556 r4 = runner.invoke(cli, ["commit", "-m", "feat: add optional currency param"])
7557 assert r4.exit_code == 0, r4.output
7558
7559 return repo
7560
7561
7562 class TestContract:
7563 """Tests for ``muse code contract``."""
7564
7565 CMD = ["code", "contract"]
7566 ADDR = "billing.py::compute_total"
7567
7568 # ── basic correctness ─────────────────────────────────────────────────────
7569
7570 def test_contract_exits_zero(self, contract_repo: pathlib.Path) -> None:
7571 result = runner.invoke(cli, self.CMD + [self.ADDR])
7572 assert result.exit_code == 0, result.output
7573
7574 def test_contract_shows_address(self, contract_repo: pathlib.Path) -> None:
7575 result = runner.invoke(cli, self.CMD + [self.ADDR])
7576 assert "compute_total" in result.output
7577
7578 def test_contract_shows_signature_section(self, contract_repo: pathlib.Path) -> None:
7579 result = runner.invoke(cli, self.CMD + [self.ADDR])
7580 assert "Signature" in result.output
7581
7582 def test_contract_shows_def_keyword(self, contract_repo: pathlib.Path) -> None:
7583 result = runner.invoke(cli, self.CMD + [self.ADDR])
7584 assert "def compute_total" in result.output
7585
7586 def test_contract_shows_stability_section(self, contract_repo: pathlib.Path) -> None:
7587 result = runner.invoke(cli, self.CMD + [self.ADDR])
7588 assert "Stability" in result.output
7589
7590 def test_contract_shows_commits_analysed(self, contract_repo: pathlib.Path) -> None:
7591 result = runner.invoke(cli, self.CMD + [self.ADDR])
7592 assert "commits" in result.output
7593
7594 def test_contract_shows_assessment(self, contract_repo: pathlib.Path) -> None:
7595 result = runner.invoke(cli, self.CMD + [self.ADDR])
7596 assert "Assessment" in result.output
7597
7598 def test_contract_shows_return_section(self, contract_repo: pathlib.Path) -> None:
7599 result = runner.invoke(cli, self.CMD + [self.ADDR])
7600 assert "Return value" in result.output
7601
7602 def test_contract_shows_parameters_section(self, contract_repo: pathlib.Path) -> None:
7603 result = runner.invoke(cli, self.CMD + [self.ADDR])
7604 assert "Parameters" in result.output
7605
7606 # ── call-site disposition detection ──────────────────────────────────────
7607
7608 def test_contract_detects_stored(self, contract_repo: pathlib.Path) -> None:
7609 result = runner.invoke(cli, self.CMD + [self.ADDR])
7610 assert "stored" in result.output
7611
7612 def test_contract_detects_discarded(self, contract_repo: pathlib.Path) -> None:
7613 result = runner.invoke(cli, self.CMD + [self.ADDR])
7614 assert "discarded" in result.output
7615
7616 def test_contract_warns_on_discarded(self, contract_repo: pathlib.Path) -> None:
7617 result = runner.invoke(cli, self.CMD + [self.ADDR])
7618 # audit.py discards the return — should surface a warning.
7619 assert "⚠" in result.output
7620
7621 # ── test assertions ───────────────────────────────────────────────────────
7622
7623 def test_contract_shows_test_assertions(self, contract_repo: pathlib.Path) -> None:
7624 result = runner.invoke(cli, self.CMD + [self.ADDR])
7625 assert "assert" in result.output.lower()
7626
7627 def test_contract_shows_assert_result_positive(
7628 self, contract_repo: pathlib.Path
7629 ) -> None:
7630 result = runner.invoke(cli, self.CMD + [self.ADDR])
7631 assert "result > 0" in result.output or "assert" in result.output
7632
7633 # ── --json ────────────────────────────────────────────────────────────────
7634
7635 def test_contract_json_exits_zero(self, contract_repo: pathlib.Path) -> None:
7636 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7637 assert result.exit_code == 0, result.output
7638
7639 def test_contract_json_is_valid(self, contract_repo: pathlib.Path) -> None:
7640 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7641 data = json.loads(result.output)
7642 assert isinstance(data, dict)
7643
7644 def test_contract_json_top_level_keys(self, contract_repo: pathlib.Path) -> None:
7645 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7646 data = json.loads(result.output)
7647 for key in (
7648 "address", "name", "kind", "signature", "parameters",
7649 "return_annotation", "call_sites", "caller_files",
7650 "return_dispositions", "arg_observations",
7651 "test_assertions", "commit_signals", "history",
7652 "preconditions", "postconditions", "warnings", "stability",
7653 ):
7654 assert key in data, f"missing top-level key: {key}"
7655
7656 def test_contract_json_address_matches(self, contract_repo: pathlib.Path) -> None:
7657 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7658 data = json.loads(result.output)
7659 assert data["address"] == self.ADDR
7660
7661 def test_contract_json_name_is_bare(self, contract_repo: pathlib.Path) -> None:
7662 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7663 data = json.loads(result.output)
7664 assert data["name"] == "compute_total"
7665
7666 def test_contract_json_kind_is_function(self, contract_repo: pathlib.Path) -> None:
7667 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7668 data = json.loads(result.output)
7669 assert data["kind"] in {"function", "async_function", "method", "async_method"}
7670
7671 def test_contract_json_signature_contains_def(
7672 self, contract_repo: pathlib.Path
7673 ) -> None:
7674 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7675 data = json.loads(result.output)
7676 assert "def compute_total" in data["signature"]
7677
7678 def test_contract_json_parameters_is_list(self, contract_repo: pathlib.Path) -> None:
7679 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7680 data = json.loads(result.output)
7681 assert isinstance(data["parameters"], list)
7682
7683 def test_contract_json_parameters_not_empty(self, contract_repo: pathlib.Path) -> None:
7684 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7685 data = json.loads(result.output)
7686 assert len(data["parameters"]) >= 1
7687
7688 def test_contract_json_parameters_schema(self, contract_repo: pathlib.Path) -> None:
7689 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7690 data = json.loads(result.output)
7691 p = data["parameters"][0]
7692 for key in ("name", "annotation", "has_default", "default_str"):
7693 assert key in p, f"parameter missing key: {key}"
7694
7695 def test_contract_json_items_param_present(self, contract_repo: pathlib.Path) -> None:
7696 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7697 data = json.loads(result.output)
7698 names = [p["name"] for p in data["parameters"]]
7699 assert "items" in names
7700
7701 def test_contract_json_currency_param_present(
7702 self, contract_repo: pathlib.Path
7703 ) -> None:
7704 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7705 data = json.loads(result.output)
7706 names = [p["name"] for p in data["parameters"]]
7707 assert "currency" in names
7708
7709 def test_contract_json_currency_has_default(self, contract_repo: pathlib.Path) -> None:
7710 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7711 data = json.loads(result.output)
7712 params = {p["name"]: p for p in data["parameters"]}
7713 assert params["currency"]["has_default"] is True
7714
7715 def test_contract_json_currency_default_str(self, contract_repo: pathlib.Path) -> None:
7716 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7717 data = json.loads(result.output)
7718 params = {p["name"]: p for p in data["parameters"]}
7719 assert params["currency"]["default_str"] == "'USD'"
7720
7721 def test_contract_json_call_sites_positive(self, contract_repo: pathlib.Path) -> None:
7722 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7723 data = json.loads(result.output)
7724 assert data["call_sites"] >= 1
7725
7726 def test_contract_json_caller_files_positive(self, contract_repo: pathlib.Path) -> None:
7727 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7728 data = json.loads(result.output)
7729 assert data["caller_files"] >= 1
7730
7731 def test_contract_json_return_dispositions_is_dict(
7732 self, contract_repo: pathlib.Path
7733 ) -> None:
7734 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7735 data = json.loads(result.output)
7736 assert isinstance(data["return_dispositions"], dict)
7737
7738 def test_contract_json_return_dispositions_keys(
7739 self, contract_repo: pathlib.Path
7740 ) -> None:
7741 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7742 data = json.loads(result.output)
7743 rd = data["return_dispositions"]
7744 for key in ("stored", "discarded", "returned", "asserted", "compared"):
7745 assert key in rd, f"return_dispositions missing: {key}"
7746
7747 def test_contract_json_discarded_count_at_least_one(
7748 self, contract_repo: pathlib.Path
7749 ) -> None:
7750 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7751 data = json.loads(result.output)
7752 # audit.py discards the return value
7753 assert data["return_dispositions"].get("discarded", 0) >= 1
7754
7755 def test_contract_json_stored_count_at_least_one(
7756 self, contract_repo: pathlib.Path
7757 ) -> None:
7758 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7759 data = json.loads(result.output)
7760 assert data["return_dispositions"].get("stored", 0) >= 1
7761
7762 def test_contract_json_test_assertions_is_list(
7763 self, contract_repo: pathlib.Path
7764 ) -> None:
7765 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7766 data = json.loads(result.output)
7767 assert isinstance(data["test_assertions"], list)
7768
7769 def test_contract_json_test_assertions_not_empty(
7770 self, contract_repo: pathlib.Path
7771 ) -> None:
7772 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7773 data = json.loads(result.output)
7774 assert len(data["test_assertions"]) >= 1
7775
7776 def test_contract_json_test_assertions_are_strings(
7777 self, contract_repo: pathlib.Path
7778 ) -> None:
7779 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7780 data = json.loads(result.output)
7781 for assertion in data["test_assertions"]:
7782 assert isinstance(assertion, str)
7783
7784 def test_contract_json_history_schema(self, contract_repo: pathlib.Path) -> None:
7785 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7786 data = json.loads(result.output)
7787 h = data["history"]
7788 for key in (
7789 "commits_analysed", "truncated", "major_bumps",
7790 "minor_bumps", "patch_bumps", "sig_changes",
7791 "impl_changes", "est_survival_pct",
7792 ):
7793 assert key in h, f"history missing key: {key}"
7794
7795 def test_contract_json_history_commits_positive(
7796 self, contract_repo: pathlib.Path
7797 ) -> None:
7798 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7799 data = json.loads(result.output)
7800 assert data["history"]["commits_analysed"] > 0
7801
7802 def test_contract_json_history_survival_0_to_100(
7803 self, contract_repo: pathlib.Path
7804 ) -> None:
7805 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7806 data = json.loads(result.output)
7807 pct = data["history"]["est_survival_pct"]
7808 assert 0 <= pct <= 100
7809
7810 def test_contract_json_commit_signals_is_list(
7811 self, contract_repo: pathlib.Path
7812 ) -> None:
7813 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7814 data = json.loads(result.output)
7815 assert isinstance(data["commit_signals"], list)
7816
7817 def test_contract_json_preconditions_is_list(
7818 self, contract_repo: pathlib.Path
7819 ) -> None:
7820 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7821 data = json.loads(result.output)
7822 assert isinstance(data["preconditions"], list)
7823
7824 def test_contract_json_postconditions_is_list(
7825 self, contract_repo: pathlib.Path
7826 ) -> None:
7827 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7828 data = json.loads(result.output)
7829 assert isinstance(data["postconditions"], list)
7830
7831 def test_contract_json_warnings_is_list(self, contract_repo: pathlib.Path) -> None:
7832 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7833 data = json.loads(result.output)
7834 assert isinstance(data["warnings"], list)
7835
7836 def test_contract_json_stability_valid_value(
7837 self, contract_repo: pathlib.Path
7838 ) -> None:
7839 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7840 data = json.loads(result.output)
7841 assert data["stability"] in {"stable", "evolving", "volatile", "dormant"}
7842
7843 def test_contract_json_arg_observations_is_list(
7844 self, contract_repo: pathlib.Path
7845 ) -> None:
7846 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7847 data = json.loads(result.output)
7848 assert isinstance(data["arg_observations"], list)
7849
7850 # ── input validation ──────────────────────────────────────────────────────
7851
7852 def test_contract_missing_address_exits_nonzero(
7853 self, contract_repo: pathlib.Path
7854 ) -> None:
7855 result = runner.invoke(cli, self.CMD)
7856 assert result.exit_code != 0
7857
7858 def test_contract_bad_address_format_exits_nonzero(
7859 self, contract_repo: pathlib.Path
7860 ) -> None:
7861 result = runner.invoke(cli, self.CMD + ["billing_no_colon"])
7862 assert result.exit_code != 0
7863
7864 def test_contract_unknown_address_exits_nonzero(
7865 self, contract_repo: pathlib.Path
7866 ) -> None:
7867 result = runner.invoke(cli, self.CMD + ["billing.py::nonexistent_fn_xyz"])
7868 assert result.exit_code != 0
7869
7870 def test_contract_max_commits_zero_rejected(
7871 self, contract_repo: pathlib.Path
7872 ) -> None:
7873 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7874 assert result.exit_code != 0
7875
7876 def test_contract_max_commits_one_succeeds(self, contract_repo: pathlib.Path) -> None:
7877 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "1"])
7878 assert result.exit_code == 0, result.output
7879
7880 # ── requires repo ─────────────────────────────────────────────────────────
7881
7882 def test_contract_requires_repo(self, tmp_path: pathlib.Path) -> None:
7883 import os
7884
7885 old = os.getcwd()
7886 try:
7887 os.chdir(tmp_path)
7888 result = runner.invoke(cli, self.CMD + [self.ADDR])
7889 assert result.exit_code != 0
7890 finally:
7891 os.chdir(old)
7892
7893 # ── history accuracy ──────────────────────────────────────────────────────
7894
7895 def test_contract_json_impl_changes_nonzero(
7896 self, contract_repo: pathlib.Path
7897 ) -> None:
7898 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7899 data = json.loads(result.output)
7900 # We made 2 body changes (perf rewrite + currency add).
7901 assert data["history"]["impl_changes"] >= 1
7902
7903 def test_contract_json_truncated_false_small_repo(
7904 self, contract_repo: pathlib.Path
7905 ) -> None:
7906 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7907 data = json.loads(result.output)
7908 assert data["history"]["truncated"] is False
7909
7910 def test_contract_json_postconditions_nonempty(
7911 self, contract_repo: pathlib.Path
7912 ) -> None:
7913 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7914 data = json.loads(result.output)
7915 # Should infer at least one postcondition (return value is stored).
7916 assert len(data["postconditions"]) >= 1
7917
7918 def test_contract_json_warnings_nonempty(self, contract_repo: pathlib.Path) -> None:
7919 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7920 data = json.loads(result.output)
7921 # Missing type annotations + discarded return should generate warnings.
7922 assert len(data["warnings"]) >= 1
7923
7924
7925 # ---------------------------------------------------------------------------
7926 # predict
7927 # ---------------------------------------------------------------------------
7928
7929
7930 @pytest.fixture()
7931 def predict_repo(repo: pathlib.Path) -> pathlib.Path:
7932 """Repo with commit history that produces clear prediction signals.
7933
7934 billing.py::compute_total — changed in every commit (high frequency)
7935 billing.py::apply_discount — always co-changes with compute_total (entanglement)
7936 services.py::place_order — changed only once (low confidence)
7937
7938 5 commits are made so that recency, frequency, and co-change signals
7939 are all detectable within the default horizon.
7940 """
7941 # Commit 1 — establish both symbols
7942 (repo / "billing.py").write_text(textwrap.dedent("""\
7943 def compute_total(items):
7944 return sum(i["price"] for i in items)
7945
7946 def apply_discount(total, rate):
7947 return total * (1 - rate)
7948 """))
7949 (repo / "services.py").write_text(textwrap.dedent("""\
7950 def place_order(items):
7951 return True
7952 """))
7953 r1 = runner.invoke(cli, ["commit", "-m", "feat: initial billing"])
7954 assert r1.exit_code == 0, r1.output
7955
7956 # Commits 2-5 — co-evolve compute_total and apply_discount together
7957 for i in range(2, 6):
7958 (repo / "billing.py").write_text(textwrap.dedent(f"""\
7959 def compute_total(items, rev={i}):
7960 total = 0.0
7961 for item in items:
7962 total += float(item["price"])
7963 return total
7964
7965 def apply_discount(total, rate, rev={i}):
7966 return max(0.0, total * (1 - rate))
7967 """))
7968 r = runner.invoke(cli, ["commit", "-m", f"refactor: billing revision {i}"])
7969 assert r.exit_code == 0, r.output
7970
7971 return repo
7972
7973
7974 class TestPredict:
7975 """Tests for ``muse code predict``."""
7976
7977 CMD = ["code", "predict"]
7978
7979 # ── basic correctness ─────────────────────────────────────────────────────
7980
7981 def test_predict_exits_zero(self, predict_repo: pathlib.Path) -> None:
7982 result = runner.invoke(cli, self.CMD)
7983 assert result.exit_code == 0, result.output
7984
7985 def test_predict_shows_header(self, predict_repo: pathlib.Path) -> None:
7986 result = runner.invoke(cli, self.CMD)
7987 assert "Predicted changes" in result.output
7988
7989 def test_predict_shows_horizon(self, predict_repo: pathlib.Path) -> None:
7990 result = runner.invoke(cli, self.CMD)
7991 assert "horizon:" in result.output
7992
7993 def test_predict_shows_commits_analysed(self, predict_repo: pathlib.Path) -> None:
7994 result = runner.invoke(cli, self.CMD)
7995 assert "analysed" in result.output
7996
7997 def test_predict_shows_compute_total(self, predict_repo: pathlib.Path) -> None:
7998 result = runner.invoke(cli, self.CMD)
7999 assert "compute_total" in result.output
8000
8001 def test_predict_shows_apply_discount(self, predict_repo: pathlib.Path) -> None:
8002 result = runner.invoke(cli, self.CMD)
8003 assert "apply_discount" in result.output
8004
8005 def test_predict_shows_score(self, predict_repo: pathlib.Path) -> None:
8006 result = runner.invoke(cli, self.CMD)
8007 # Scores are in N.NN format at the start of each prediction line.
8008 import re
8009 assert re.search(r"0\.\d{2}", result.output)
8010
8011 def test_predict_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8012 result = runner.invoke(cli, self.CMD)
8013 assert "↳" in result.output
8014
8015 def test_predict_high_confidence_band_present(
8016 self, predict_repo: pathlib.Path
8017 ) -> None:
8018 result = runner.invoke(cli, self.CMD)
8019 # compute_total changed 4/5 commits — should be HIGH or MEDIUM.
8020 assert "CONFIDENCE" in result.output
8021
8022 def test_predict_entanglement_signal(self, predict_repo: pathlib.Path) -> None:
8023 result = runner.invoke(cli, self.CMD + ["--horizon", "10"])
8024 # compute_total and apply_discount co-change → entanglement reason expected.
8025 assert "entangled" in result.output or "co-change" in result.output
8026
8027 # ── --top ─────────────────────────────────────────────────────────────────
8028
8029 def test_predict_top_1_shows_one_prediction(
8030 self, predict_repo: pathlib.Path
8031 ) -> None:
8032 result = runner.invoke(cli, self.CMD + ["--top", "1"])
8033 assert result.exit_code == 0, result.output
8034 # With --top 1 there is exactly one score line.
8035 import re
8036 scores = re.findall(r"^\s+0\.\d{2}\s+", result.output, re.MULTILINE)
8037 assert len(scores) == 1
8038
8039 def test_predict_top_0_shows_all(self, predict_repo: pathlib.Path) -> None:
8040 result = runner.invoke(cli, self.CMD + ["--top", "0"])
8041 assert result.exit_code == 0, result.output
8042 assert "compute_total" in result.output
8043
8044 # ── --min-confidence ──────────────────────────────────────────────────────
8045
8046 def test_predict_min_confidence_1_empty(self, predict_repo: pathlib.Path) -> None:
8047 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.0"])
8048 assert result.exit_code == 0, result.output
8049 # Nothing should reach score 1.0 exactly.
8050 assert "No predictions" in result.output or "compute_total" not in result.output
8051
8052 def test_predict_min_confidence_invalid_rejected(
8053 self, predict_repo: pathlib.Path
8054 ) -> None:
8055 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.5"])
8056 assert result.exit_code != 0
8057
8058 def test_predict_min_confidence_zero_shows_all(
8059 self, predict_repo: pathlib.Path
8060 ) -> None:
8061 result = runner.invoke(cli, self.CMD + ["--min-confidence", "0.0"])
8062 assert result.exit_code == 0, result.output
8063 assert "compute_total" in result.output
8064
8065 # ── --horizon ─────────────────────────────────────────────────────────────
8066
8067 def test_predict_horizon_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8068 result = runner.invoke(cli, self.CMD + ["--horizon", "1"])
8069 assert result.exit_code == 0, result.output
8070
8071 def test_predict_horizon_invalid_rejected(self, predict_repo: pathlib.Path) -> None:
8072 result = runner.invoke(cli, self.CMD + ["--horizon", "0"])
8073 assert result.exit_code != 0
8074
8075 def test_predict_max_commits_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8076 result = runner.invoke(cli, self.CMD + ["--max-commits", "1"])
8077 assert result.exit_code == 0, result.output
8078
8079 def test_predict_max_commits_invalid_rejected(
8080 self, predict_repo: pathlib.Path
8081 ) -> None:
8082 result = runner.invoke(cli, self.CMD + ["--max-commits", "0"])
8083 assert result.exit_code != 0
8084
8085 # ── --file ────────────────────────────────────────────────────────────────
8086
8087 def test_predict_file_filter_billing(self, predict_repo: pathlib.Path) -> None:
8088 result = runner.invoke(cli, self.CMD + ["--file", "billing.py"])
8089 assert result.exit_code == 0, result.output
8090 # Should show billing symbols.
8091 if "compute_total" in result.output or "apply_discount" in result.output:
8092 pass # expected
8093 # Should NOT show services.py symbols.
8094 assert "place_order" not in result.output
8095
8096 def test_predict_file_filter_nonexistent_empty(
8097 self, predict_repo: pathlib.Path
8098 ) -> None:
8099 result = runner.invoke(cli, self.CMD + ["--file", "nonexistent_xyz.py"])
8100 assert result.exit_code == 0, result.output
8101 assert "No predictions" in result.output
8102
8103 # ── --explain ─────────────────────────────────────────────────────────────
8104
8105 def test_predict_explain_exits_zero(self, predict_repo: pathlib.Path) -> None:
8106 result = runner.invoke(
8107 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8108 )
8109 assert result.exit_code == 0, result.output
8110
8111 def test_predict_explain_shows_signal_breakdown(
8112 self, predict_repo: pathlib.Path
8113 ) -> None:
8114 result = runner.invoke(
8115 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8116 )
8117 assert "signal breakdown" in result.output
8118
8119 def test_predict_explain_shows_all_signals(
8120 self, predict_repo: pathlib.Path
8121 ) -> None:
8122 result = runner.invoke(
8123 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8124 )
8125 for signal in ("recency", "frequency", "co_change", "sig_instability",
8126 "module_velocity"):
8127 assert signal in result.output, f"missing signal: {signal}"
8128
8129 def test_predict_explain_shows_bar(self, predict_repo: pathlib.Path) -> None:
8130 result = runner.invoke(
8131 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8132 )
8133 assert "█" in result.output or "░" in result.output
8134
8135 def test_predict_explain_shows_score(self, predict_repo: pathlib.Path) -> None:
8136 result = runner.invoke(
8137 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8138 )
8139 assert "Score:" in result.output
8140
8141 def test_predict_explain_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8142 result = runner.invoke(
8143 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8144 )
8145 assert "Reasons" in result.output
8146
8147 def test_predict_explain_bad_format_rejected(
8148 self, predict_repo: pathlib.Path
8149 ) -> None:
8150 result = runner.invoke(cli, self.CMD + ["--explain", "no_colon_here"])
8151 assert result.exit_code != 0
8152
8153 def test_predict_explain_unknown_addr_rejected(
8154 self, predict_repo: pathlib.Path
8155 ) -> None:
8156 result = runner.invoke(
8157 cli, self.CMD + ["--explain", "billing.py::nonexistent_fn_xyz"]
8158 )
8159 assert result.exit_code != 0
8160
8161 # ── --json ────────────────────────────────────────────────────────────────
8162
8163 def test_predict_json_exits_zero(self, predict_repo: pathlib.Path) -> None:
8164 result = runner.invoke(cli, self.CMD + ["--json"])
8165 assert result.exit_code == 0, result.output
8166
8167 def test_predict_json_is_valid(self, predict_repo: pathlib.Path) -> None:
8168 result = runner.invoke(cli, self.CMD + ["--json"])
8169 data = json.loads(result.output)
8170 assert isinstance(data, dict)
8171
8172 def test_predict_json_top_level_keys(self, predict_repo: pathlib.Path) -> None:
8173 result = runner.invoke(cli, self.CMD + ["--json"])
8174 data = json.loads(result.output)
8175 for key in (
8176 "generated_at", "horizon_commits", "max_commits",
8177 "commits_analysed", "truncated", "predictions",
8178 ):
8179 assert key in data, f"missing key: {key}"
8180
8181 def test_predict_json_predictions_is_list(self, predict_repo: pathlib.Path) -> None:
8182 result = runner.invoke(cli, self.CMD + ["--json"])
8183 data = json.loads(result.output)
8184 assert isinstance(data["predictions"], list)
8185
8186 def test_predict_json_predictions_not_empty(
8187 self, predict_repo: pathlib.Path
8188 ) -> None:
8189 result = runner.invoke(cli, self.CMD + ["--json"])
8190 data = json.loads(result.output)
8191 assert len(data["predictions"]) >= 1
8192
8193 def test_predict_json_prediction_schema(self, predict_repo: pathlib.Path) -> None:
8194 result = runner.invoke(cli, self.CMD + ["--json"])
8195 data = json.loads(result.output)
8196 pred = data["predictions"][0]
8197 for key in (
8198 "address", "name", "kind", "file", "score", "confidence",
8199 "reasons", "signals", "last_changed_commit", "last_changed_date",
8200 "top_partners",
8201 ):
8202 assert key in pred, f"prediction missing key: {key}"
8203
8204 def test_predict_json_signals_schema(self, predict_repo: pathlib.Path) -> None:
8205 result = runner.invoke(cli, self.CMD + ["--json"])
8206 data = json.loads(result.output)
8207 signals = data["predictions"][0]["signals"]
8208 for key in ("recency", "frequency", "co_change", "sig_instability",
8209 "module_velocity"):
8210 assert key in signals, f"signals missing key: {key}"
8211
8212 def test_predict_json_score_is_float(self, predict_repo: pathlib.Path) -> None:
8213 result = runner.invoke(cli, self.CMD + ["--json"])
8214 data = json.loads(result.output)
8215 assert isinstance(data["predictions"][0]["score"], float)
8216
8217 def test_predict_json_score_in_range(self, predict_repo: pathlib.Path) -> None:
8218 result = runner.invoke(cli, self.CMD + ["--json"])
8219 data = json.loads(result.output)
8220 for pred in data["predictions"]:
8221 assert 0.0 <= pred["score"] <= 1.0, (
8222 f"score out of range: {pred['score']}"
8223 )
8224
8225 def test_predict_json_confidence_valid(self, predict_repo: pathlib.Path) -> None:
8226 result = runner.invoke(cli, self.CMD + ["--json"])
8227 data = json.loads(result.output)
8228 for pred in data["predictions"]:
8229 assert pred["confidence"] in {"high", "medium", "low"}, (
8230 f"invalid confidence: {pred['confidence']}"
8231 )
8232
8233 def test_predict_json_sorted_by_score_desc(self, predict_repo: pathlib.Path) -> None:
8234 result = runner.invoke(cli, self.CMD + ["--json"])
8235 data = json.loads(result.output)
8236 scores = [p["score"] for p in data["predictions"]]
8237 assert scores == sorted(scores, reverse=True)
8238
8239 def test_predict_json_commits_analysed_positive(
8240 self, predict_repo: pathlib.Path
8241 ) -> None:
8242 result = runner.invoke(cli, self.CMD + ["--json"])
8243 data = json.loads(result.output)
8244 assert data["commits_analysed"] > 0
8245
8246 def test_predict_json_truncated_false_small_repo(
8247 self, predict_repo: pathlib.Path
8248 ) -> None:
8249 result = runner.invoke(cli, self.CMD + ["--json"])
8250 data = json.loads(result.output)
8251 assert data["truncated"] is False
8252
8253 def test_predict_json_top_partners_is_list(self, predict_repo: pathlib.Path) -> None:
8254 result = runner.invoke(cli, self.CMD + ["--json"])
8255 data = json.loads(result.output)
8256 for pred in data["predictions"]:
8257 assert isinstance(pred["top_partners"], list)
8258
8259 def test_predict_json_partner_schema(self, predict_repo: pathlib.Path) -> None:
8260 result = runner.invoke(cli, self.CMD + ["--json"])
8261 data = json.loads(result.output)
8262 # Find a prediction that has partners.
8263 for pred in data["predictions"]:
8264 if pred["top_partners"]:
8265 p = pred["top_partners"][0]
8266 for key in ("address", "co_change_rate", "co_change_commits"):
8267 assert key in p, f"partner missing key: {key}"
8268 break
8269
8270 def test_predict_json_co_change_rate_in_range(
8271 self, predict_repo: pathlib.Path
8272 ) -> None:
8273 result = runner.invoke(cli, self.CMD + ["--json"])
8274 data = json.loads(result.output)
8275 for pred in data["predictions"]:
8276 for p in pred["top_partners"]:
8277 assert 0.0 <= p["co_change_rate"] <= 1.0
8278
8279 def test_predict_json_top_1_returns_one(self, predict_repo: pathlib.Path) -> None:
8280 result = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
8281 data = json.loads(result.output)
8282 assert len(data["predictions"]) == 1
8283
8284 def test_predict_json_horizon_matches_arg(self, predict_repo: pathlib.Path) -> None:
8285 result = runner.invoke(cli, self.CMD + ["--json", "--horizon", "3"])
8286 data = json.loads(result.output)
8287 assert data["horizon_commits"] == 3
8288
8289 def test_predict_json_reasons_is_list(self, predict_repo: pathlib.Path) -> None:
8290 result = runner.invoke(cli, self.CMD + ["--json"])
8291 data = json.loads(result.output)
8292 for pred in data["predictions"]:
8293 assert isinstance(pred["reasons"], list)
8294 assert all(isinstance(r, str) for r in pred["reasons"])
8295
8296 # ── requires repo ─────────────────────────────────────────────────────────
8297
8298 def test_predict_requires_repo(self, tmp_path: pathlib.Path) -> None:
8299 import os
8300
8301 old = os.getcwd()
8302 try:
8303 os.chdir(tmp_path)
8304 result = runner.invoke(cli, self.CMD)
8305 assert result.exit_code != 0
8306 finally:
8307 os.chdir(old)
8308
8309
8310 # ---------------------------------------------------------------------------
8311 # Helpers
8312 # ---------------------------------------------------------------------------
8313
8314
8315 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
8316 """Return all commit IDs from the store, newest-first (by log order)."""
8317 from muse.core.store import get_all_commits
8318 commits = get_all_commits(repo)
8319 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 42 days ago