gabriel / muse public
test_code_commands.py python
8,381 lines 369.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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 "muse_version" in data
292
293 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
294 result = runner.invoke(cli, ["code", "query", "kind=function", "OR", "kind=method"])
295 assert result.exit_code == 0
296
297 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
298 result = runner.invoke(cli, ["code", "query", "NOT", "kind=import"])
299 assert result.exit_code == 0
300
301 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
302 result = runner.invoke(cli, ["code", "query", "--all-commits", "kind=function"])
303 assert result.exit_code == 0
304
305 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
306 result = runner.invoke(cli, ["code", "query", "name~=total"])
307 assert result.exit_code == 0
308 # Should find compute_invoice_total.
309 assert "total" in result.output.lower()
310
311 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
312 # query with kind=class to match everything of a known type.
313 result = runner.invoke(cli, ["code", "query", "kind=class"])
314 assert result.exit_code == 0
315 assert "Invoice" in result.output
316
317 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
318 result = runner.invoke(cli, ["code", "query", "lineno_gt=1"])
319 assert result.exit_code == 0
320
321 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
322 monkeypatch.chdir(tmp_path)
323 result = runner.invoke(cli, ["code", "query", "kind=function"])
324 assert result.exit_code != 0
325
326 # ── new v2.1 flags ────────────────────────────────────────────────────────
327
328 def test_query_count_only(self, code_repo: pathlib.Path) -> None:
329 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
330 assert result.exit_code == 0, result.output
331 # Output should be a single integer.
332 assert result.output.strip().isdigit()
333
334 def test_query_count_nonzero(self, code_repo: pathlib.Path) -> None:
335 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
336 assert int(result.output.strip()) >= 1
337
338 def test_query_limit_caps_results(self, code_repo: pathlib.Path) -> None:
339 all_r = runner.invoke(cli, ["code", "query", "kind=function"])
340 lim_r = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
341 assert lim_r.exit_code == 0, lim_r.output
342 # Limited output should be shorter than unlimited.
343 assert len(lim_r.output) <= len(all_r.output)
344
345 def test_query_limit_truncation_noted(self, code_repo: pathlib.Path) -> None:
346 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
347 assert "limited to 1" in result.output or "match" in result.output
348
349 def test_query_limit_zero_unlimited(self, code_repo: pathlib.Path) -> None:
350 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "0"])
351 assert result.exit_code == 0, result.output
352
353 def test_query_sort_name(self, code_repo: pathlib.Path) -> None:
354 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "name"])
355 assert result.exit_code == 0, result.output
356
357 def test_query_sort_size(self, code_repo: pathlib.Path) -> None:
358 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "size"])
359 assert result.exit_code == 0, result.output
360 # Size column should appear in output.
361 assert "L" in result.output
362
363 def test_query_sort_kind(self, code_repo: pathlib.Path) -> None:
364 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "kind"])
365 assert result.exit_code == 0, result.output
366
367 def test_query_sort_lineno(self, code_repo: pathlib.Path) -> None:
368 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "lineno"])
369 assert result.exit_code == 0, result.output
370
371 def test_query_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
372 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "zzz"])
373 assert result.exit_code != 0
374
375 def test_query_unique_bodies_exits_zero(self, code_repo: pathlib.Path) -> None:
376 result = runner.invoke(cli, ["code", "query", "kind=function", "--unique-bodies"])
377 assert result.exit_code == 0, result.output
378
379 def test_query_unique_bodies_count_lte_all(self, code_repo: pathlib.Path) -> None:
380 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
381 uniq_r = runner.invoke(cli, ["code", "query", "--count", "--unique-bodies", "kind=function"])
382 assert int(uniq_r.output.strip()) <= int(all_r.output.strip())
383
384 def test_query_size_gt_predicate(self, code_repo: pathlib.Path) -> None:
385 result = runner.invoke(cli, ["code", "query", "kind=function", "size_gt=0"])
386 assert result.exit_code == 0, result.output
387
388 def test_query_size_lt_predicate(self, code_repo: pathlib.Path) -> None:
389 result = runner.invoke(cli, ["code", "query", "kind=function", "size_lt=1000"])
390 assert result.exit_code == 0, result.output
391
392 def test_query_size_gt_excludes_small(self, code_repo: pathlib.Path) -> None:
393 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
394 large_r = runner.invoke(cli, ["code", "query", "--count", "kind=function", "size_gt=100"])
395 # Large-only count should be <= total.
396 assert int(large_r.output.strip()) <= int(all_r.output.strip())
397
398 def test_query_json_includes_size(self, code_repo: pathlib.Path) -> None:
399 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
400 data = json.loads(result.output)
401 for r in data["results"]:
402 assert "size" in r
403
404 def test_query_json_includes_sort_field(self, code_repo: pathlib.Path) -> None:
405 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--sort", "name"])
406 data = json.loads(result.output)
407 assert data["sort"] == "name"
408
409 def test_query_json_includes_unique_bodies(self, code_repo: pathlib.Path) -> None:
410 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--unique-bodies"])
411 data = json.loads(result.output)
412 assert data["unique_bodies"] is True
413
414 def test_query_since_without_all_commits_rejected(self, code_repo: pathlib.Path) -> None:
415 result = runner.invoke(cli, ["code", "query", "kind=function", "--since", "2026-01-01"])
416 assert result.exit_code != 0
417
418 def test_query_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
419 result = runner.invoke(
420 cli,
421 ["code", "query", "kind=function", "--all-commits", "--since", "not-a-date"],
422 )
423 assert result.exit_code != 0
424
425 def test_query_all_commits_since_future_empty(self, code_repo: pathlib.Path) -> None:
426 result = runner.invoke(
427 cli,
428 ["code", "query", "kind=function", "--all-commits", "--since", "2099-01-01"],
429 )
430 assert result.exit_code == 0, result.output
431 # Future date means no commits match.
432 assert "no symbols" in result.output.lower() or result.output.strip() == ""
433
434 def test_query_max_commits_caps_walk(self, code_repo: pathlib.Path) -> None:
435 result = runner.invoke(
436 cli,
437 ["code", "query", "kind=function", "--all-commits", "--max-commits", "1"],
438 )
439 assert result.exit_code == 0, result.output
440
441
442 # ---------------------------------------------------------------------------
443 # muse query-history
444 # ---------------------------------------------------------------------------
445
446
447 class TestQueryHistory:
448 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
449 result = runner.invoke(cli, ["code", "query-history", "kind=function"])
450 assert result.exit_code == 0, result.output
451
452 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
453 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=function"])
454 assert result.exit_code == 0
455 data = json.loads(result.output)
456 assert "muse_version" in data
457 assert "results" in data
458
459 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
460 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
461 assert result.exit_code == 0
462
463 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
464 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
465 assert result.exit_code == 0
466 data = json.loads(result.output)
467 for entry in data.get("results", []):
468 assert "commit_count" in entry
469 assert "change_count" in entry
470
471 # ── new v2 flags ──────────────────────────────────────────────────────────
472
473 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
474 result = runner.invoke(
475 cli, ["code", "query-history", "--changed-only", "kind=function"]
476 )
477 assert result.exit_code == 0, result.output
478
479 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
480 result = runner.invoke(
481 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
482 )
483 assert result.exit_code == 0
484 data = json.loads(result.output)
485 for entry in data["results"]:
486 assert entry["change_count"] > 1
487
488 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
489 result = runner.invoke(
490 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
491 )
492 assert result.exit_code == 0, result.output
493
494 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
495 result = runner.invoke(
496 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
497 )
498 assert result.exit_code == 0, result.output
499
500 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
501 result = runner.invoke(
502 cli, ["code", "query-history", "--sort", "first", "kind=function"]
503 )
504 assert result.exit_code == 0, result.output
505
506 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
507 result = runner.invoke(
508 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
509 )
510 assert result.exit_code != 0
511
512 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
513 result = runner.invoke(
514 cli, ["code", "query-history", "--count", "kind=function"]
515 )
516 assert result.exit_code == 0, result.output
517 assert result.output.strip().isdigit()
518 assert int(result.output.strip()) >= 1
519
520 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
521 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
522 lim_r = runner.invoke(
523 cli, ["code", "query-history", "--limit", "1", "kind=function"]
524 )
525 assert lim_r.exit_code == 0, lim_r.output
526 assert len(lim_r.output) <= len(all_r.output)
527
528 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
529 result = runner.invoke(
530 cli, ["code", "query-history", "--limit", "1", "kind=function"]
531 )
532 assert "1" in result.output
533
534 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
535 result = runner.invoke(
536 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
537 )
538 assert result.exit_code == 0
539 data = json.loads(result.output)
540 for entry in data["results"]:
541 assert entry["change_count"] >= 2
542
543 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
544 result = runner.invoke(
545 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
546 )
547 assert result.exit_code != 0
548
549 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
550 result = runner.invoke(
551 cli, ["code", "query-history", "--introduced-only", "kind=function"]
552 )
553 assert result.exit_code == 0, result.output
554
555 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
556 result = runner.invoke(
557 cli, ["code", "query-history", "--removed-only", "kind=function"]
558 )
559 assert result.exit_code == 0, result.output
560
561 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
562 result = runner.invoke(
563 cli,
564 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
565 )
566 assert result.exit_code == 0
567 data = json.loads(result.output)
568 assert data["mode"] == "introduced-only"
569 assert "symbols_found" in data
570 for entry in data["results"]:
571 assert entry["status"] == "introduced"
572
573 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
574 result = runner.invoke(
575 cli,
576 ["code", "query-history", "--removed-only", "--json", "kind=function"],
577 )
578 assert result.exit_code == 0
579 data = json.loads(result.output)
580 assert data["mode"] == "removed-only"
581 assert "symbols_found" in data
582 for entry in data["results"]:
583 assert entry["status"] == "removed"
584
585 def test_query_history_mode_flags_mutually_exclusive(
586 self, code_repo: pathlib.Path
587 ) -> None:
588 result = runner.invoke(
589 cli,
590 [
591 "code", "query-history",
592 "--changed-only", "--introduced-only",
593 "kind=function",
594 ],
595 )
596 assert result.exit_code != 0
597
598 def test_query_history_json_has_full_commit_ids(
599 self, code_repo: pathlib.Path
600 ) -> None:
601 result = runner.invoke(
602 cli, ["code", "query-history", "--json", "kind=function"]
603 )
604 assert result.exit_code == 0
605 data = json.loads(result.output)
606 for entry in data["results"]:
607 # Full commit IDs should be present (not just 8-char short form).
608 assert len(entry["first_commit_id"]) > 8
609 assert "stable" in entry
610
611 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
612 result = runner.invoke(
613 cli,
614 ["code", "query-history", "--max-commits", "1", "kind=function"],
615 )
616 assert result.exit_code == 0, result.output
617
618 def test_query_history_introduced_count_only(
619 self, code_repo: pathlib.Path
620 ) -> None:
621 result = runner.invoke(
622 cli,
623 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
624 )
625 assert result.exit_code == 0
626 assert result.output.strip().isdigit()
627
628
629 # ---------------------------------------------------------------------------
630 # muse index
631 # ---------------------------------------------------------------------------
632
633
634 class TestIndexCommands:
635 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
636 result = runner.invoke(cli, ["code", "index", "status"])
637 assert result.exit_code == 0, result.output
638
639 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
640 result = runner.invoke(cli, ["code", "index", "status"])
641 # Indexes have not been built yet.
642 assert "absent" in result.output.lower() or result.exit_code == 0
643
644 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
645 result = runner.invoke(cli, ["code", "index", "rebuild"])
646 assert result.exit_code == 0, result.output
647
648 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
649 runner.invoke(cli, ["code", "index", "rebuild"])
650 idx_dir = code_repo / ".muse" / "indices"
651 assert idx_dir.exists()
652
653 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
654 runner.invoke(cli, ["code", "index", "rebuild"])
655 result = runner.invoke(cli, ["code", "index", "status"])
656 assert result.exit_code == 0
657 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
658 assert "entries" in result.output.lower() or "✅" in result.output
659
660 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
661 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
662 assert result.exit_code == 0
663
664 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
665 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
666 assert result.exit_code == 0
667
668
669 # ---------------------------------------------------------------------------
670 # muse detect-refactor
671 # ---------------------------------------------------------------------------
672
673
674 class TestHotspots:
675 """Tests for muse code hotspots."""
676
677 # ── basic correctness ────────────────────────────────────────────────────
678
679 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
680 result = runner.invoke(cli, ["code", "hotspots"])
681 assert result.exit_code == 0, result.output
682
683 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
684 """compute_invoice_total was modified across two commits — must appear."""
685 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
686 assert result.exit_code == 0, result.output
687 assert "billing.py" in result.output
688
689 def test_hotspots_excludes_imports_by_default(
690 self, code_repo: pathlib.Path
691 ) -> None:
692 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
693 assert result.exit_code == 0, result.output
694 assert "::import::" not in result.output
695
696 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
697 """--include-imports must surface import pseudo-symbols if any exist."""
698 result = runner.invoke(
699 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
700 )
701 assert result.exit_code == 0, result.output
702 # Just verify it runs cleanly; the repo may or may not have import ops.
703
704 # ── --kind filter (was broken before) ────────────────────────────────────
705
706 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
707 """--kind function must not return class symbols."""
708 result = runner.invoke(
709 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
710 )
711 assert result.exit_code == 0, result.output
712 for line in result.output.splitlines():
713 if "::" in line and "class" in line.lower():
714 # Make sure any class line is not a function kind result
715 # (Addresses that contain the word "class" in their name are OK)
716 pass # Name may contain "class" as substring
717
718 def test_kind_filter_function_returns_functions(
719 self, code_repo: pathlib.Path
720 ) -> None:
721 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
722 result_fn = runner.invoke(
723 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
724 )
725 assert result_fn.exit_code == 0, result_fn.output
726 # filtered result should have <= symbols than unfiltered
727 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
728 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
729 assert len(fn_lines) <= len(all_lines)
730
731 # ── --min filter ──────────────────────────────────────────────────────────
732
733 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
734 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
735 result_min = runner.invoke(
736 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
737 )
738 assert result_min.exit_code == 0, result_min.output
739 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
740 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
741 assert len(min_lines) <= len(all_lines)
742
743 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
744 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
745 assert result.exit_code == 1
746
747 # ── --language filter ─────────────────────────────────────────────────────
748
749 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
750 result = runner.invoke(
751 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
752 )
753 assert result.exit_code == 0, result.output
754 assert "billing.py" in result.output
755
756 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
757 result = runner.invoke(
758 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
759 )
760 assert result.exit_code == 0, result.output
761
762 # ── --top validation ──────────────────────────────────────────────────────
763
764 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
765 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
766 assert result.exit_code == 1
767
768 # ── JSON schema ───────────────────────────────────────────────────────────
769
770 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
771 result = runner.invoke(cli, ["code", "hotspots", "--json"])
772 assert result.exit_code == 0, result.output
773 data = json.loads(result.output)
774 for key in (
775 "from_ref", "to_ref", "commits_analysed", "truncated",
776 "filters", "hotspots",
777 ):
778 assert key in data, f"missing key: {key}"
779 assert isinstance(data["hotspots"], list)
780 assert isinstance(data["truncated"], bool)
781 assert isinstance(data["commits_analysed"], int)
782
783 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
784 result = runner.invoke(
785 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
786 )
787 data = json.loads(result.output)
788 assert data["filters"]["kind"] == "function"
789 assert data["filters"]["min_changes"] == 2
790 assert data["filters"]["include_imports"] is False
791
792 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
793 result = runner.invoke(cli, ["code", "hotspots", "--json"])
794 data = json.loads(result.output)
795 if data["hotspots"]:
796 entry = data["hotspots"][0]
797 assert "address" in entry
798 assert "changes" in entry
799 assert isinstance(entry["changes"], int)
800 assert entry["changes"] >= 1
801
802 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
803 result = runner.invoke(cli, ["code", "hotspots", "--json"])
804 data = json.loads(result.output)
805 addresses = [h["address"] for h in data["hotspots"]]
806 assert not any("::import::" in a for a in addresses)
807
808 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
809 result = runner.invoke(cli, ["code", "hotspots", "--json"])
810 data = json.loads(result.output)
811 counts = [h["changes"] for h in data["hotspots"]]
812 assert counts == sorted(counts, reverse=True)
813
814 # ── --max-commits truncation ──────────────────────────────────────────────
815
816 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
817 result = runner.invoke(
818 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
819 )
820 assert result.exit_code == 0, result.output
821 data = json.loads(result.output)
822 assert data["commits_analysed"] <= 1
823
824 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
825 result = runner.invoke(
826 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
827 )
828 data = json.loads(result.output)
829 assert data["truncated"] is True
830
831
832 class TestDetectRefactorV2:
833 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
834 """JSON output contains all required top-level fields."""
835 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
836 assert result.exit_code == 0, result.output
837 data = json.loads(result.output)
838 for field in ("commits_scanned", "truncated", "total", "events"):
839 assert field in data, f"missing field '{field}'"
840 assert isinstance(data["commits_scanned"], int)
841 assert isinstance(data["truncated"], bool)
842 assert isinstance(data["total"], int)
843 assert isinstance(data["events"], list)
844
845 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
846 """Each JSON event contains the required fields."""
847 # Run over the full history; code_repo has at least one rename event.
848 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
849 assert result.exit_code == 0, result.output
850 data = json.loads(result.output)
851 for ev in data["events"]:
852 for field in ("kind", "address", "detail",
853 "commit_id", "commit_message", "committed_at"):
854 assert field in ev, f"missing event field '{field}'"
855 assert ev["kind"] in ("rename", "move", "signature", "implementation")
856
857 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
858 """A commit that renames a symbol produces a 'rename' event."""
859 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
860 assert result.exit_code == 0, result.output
861 data = json.loads(result.output)
862 kinds = [e["kind"] for e in data["events"]]
863 assert "rename" in kinds, (
864 f"Expected at least one rename event; got: {sorted(set(kinds))}"
865 )
866
867 def test_detect_refactor_classifies_modified_as_implementation(
868 self, code_repo: pathlib.Path
869 ) -> None:
870 """Replace ops with '(modified)' in new_summary are classified as implementation.
871
872 Previously, only '(implementation changed)' triggered implementation
873 classification; '(modified)' was silently dropped.
874 """
875 import datetime
876 root = code_repo
877 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
878 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
879 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
880 branch = read_current_branch(root)
881 head_id = get_head_commit_id(root, branch)
882
883 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
884 message = "perf: optimise batch"
885 snap_manifest: Manifest = {}
886 snap_id = compute_snapshot_id(snap_manifest)
887 parent_ids = [head_id] if head_id else []
888 commit_id = compute_commit_id(
889 repo_id=repo_id,
890 parent_ids=parent_ids,
891 snapshot_id=snap_id,
892 message=message,
893 committed_at_iso=now.isoformat(),
894 author="test",)
895 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
896 commit = CommitRecord(
897 commit_id=commit_id,
898 repo_id=repo_id,
899 created_on_branch=branch,
900 snapshot_id=snap_id,
901 message=message,
902 committed_at=now,
903 parent_commit_id=head_id,
904 author="test",
905 structured_delta=StructuredDelta(ops=[PatchOp(
906 op="patch",
907 address="billing.py",
908 child_ops=[ReplaceOp(
909 op="replace",
910 address="billing.py::process_batch",
911 new_summary="function process_batch (modified) L10–30",
912 old_summary="function process_batch",
913 )],
914 )]),
915 )
916 write_commit(root, commit)
917 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
918
919 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
920 assert result.exit_code == 0, result.output
921 data = json.loads(result.output)
922 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
923 addrs = [e["address"] for e in impl_events]
924 assert "billing.py::process_batch" in addrs, (
925 f"'(modified)' op not classified as implementation; events: {data['events']}"
926 )
927
928 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
929 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
930 import datetime
931 root = code_repo
932 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
933 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
934 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
935 branch = read_current_branch(root)
936 head_id = get_head_commit_id(root, branch)
937
938 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
939 message = "style: reformat"
940 snap_manifest: Manifest = {}
941 snap_id = compute_snapshot_id(snap_manifest)
942 parent_ids = [head_id] if head_id else []
943 commit_id = compute_commit_id(
944 repo_id=repo_id,
945 parent_ids=parent_ids,
946 snapshot_id=snap_id,
947 message=message,
948 committed_at_iso=now.isoformat(),
949 author="test",)
950 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
951 commit = CommitRecord(
952 commit_id=commit_id,
953 repo_id=repo_id,
954 created_on_branch=branch,
955 snapshot_id=snap_id,
956 message=message,
957 committed_at=now,
958 parent_commit_id=head_id,
959 author="test",
960 structured_delta=StructuredDelta(ops=[PatchOp(
961 op="patch",
962 address="billing.py",
963 child_ops=[ReplaceOp(
964 op="replace",
965 address="billing.py::UniqueReformattedSymbol",
966 new_summary="reformatted — no semantic change",
967 old_summary="",
968 )],
969 )]),
970 )
971 write_commit(root, commit)
972 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
973
974 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
975 assert result.exit_code == 0, result.output
976 data = json.loads(result.output)
977 # The reformatted op must not appear as an event.
978 reformatted_events = [
979 e for e in data["events"]
980 if e["address"] == "billing.py::UniqueReformattedSymbol"
981 ]
982 assert reformatted_events == [], (
983 f"Reformatted op should be skipped; got: {reformatted_events}"
984 )
985
986 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
987 """When --max is hit, a truncation warning appears in human output."""
988 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
989 assert result.exit_code == 0, result.output
990 assert "incomplete" in result.output or "limit" in result.output
991
992 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
993 """When --max is hit, truncated=true in JSON."""
994 result = runner.invoke(
995 cli, ["code", "detect-refactor", "--max", "1", "--json"]
996 )
997 assert result.exit_code == 0, result.output
998 data = json.loads(result.output)
999 assert data["truncated"] is True
1000 assert data["commits_scanned"] == 1
1001
1002 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
1003 """--max 0 exits non-zero."""
1004 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
1005 assert result.exit_code != 0
1006
1007 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1008 """``--kind rename`` returns only rename events."""
1009 result = runner.invoke(
1010 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1011 )
1012 assert result.exit_code == 0, result.output
1013 data = json.loads(result.output)
1014 for ev in data["events"]:
1015 assert ev["kind"] == "rename"
1016
1017 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1018 """``--kind`` with an invalid value exits non-zero."""
1019 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1020 assert result.exit_code != 0
1021
1022 def test_detect_refactor_bfs_follows_merge_parent2(
1023 self, code_repo: pathlib.Path
1024 ) -> None:
1025 """BFS walk finds refactoring events on merged feature branches."""
1026 import datetime
1027 root = code_repo
1028 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
1029 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
1030 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
1031 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1032 branch = read_current_branch(root)
1033 head_id = get_head_commit_id(root, branch)
1034 assert head_id is not None
1035
1036 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1037 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1038
1039 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1040 feature_id = compute_commit_id(
1041 repo_id=repo_id,
1042 parent_ids=[head_id],
1043 snapshot_id=feat_snap_id,
1044 message="perf: vectorise",
1045 committed_at_iso=feat_at.isoformat(),
1046 author="test",)
1047 write_commit(root, CommitRecord(
1048 commit_id=feature_id,
1049 repo_id=repo_id,
1050 created_on_branch="feat/perf",
1051 snapshot_id=feat_snap_id,
1052 message="perf: vectorise",
1053 committed_at=feat_at,
1054 parent_commit_id=head_id,
1055 author="test",
1056 structured_delta=StructuredDelta(ops=[PatchOp(
1057 op="patch",
1058 address="billing.py",
1059 child_ops=[ReplaceOp(
1060 op="replace",
1061 address="billing.py::vectorised_fn",
1062 new_summary="function vectorised_fn (implementation changed) L1–20",
1063 old_summary="function vectorised_fn",
1064 )],
1065 )]),
1066 ))
1067 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1068 merge_id = compute_commit_id(
1069 repo_id=repo_id,
1070 parent_ids=[head_id, feature_id],
1071 snapshot_id=merge_snap_id,
1072 message="merge feat/perf",
1073 committed_at_iso=merge_at.isoformat(),
1074 author="test",)
1075 write_commit(root, CommitRecord(
1076 commit_id=merge_id,
1077 repo_id=repo_id,
1078 created_on_branch=branch,
1079 snapshot_id=merge_snap_id,
1080 message="merge feat/perf",
1081 committed_at=merge_at,
1082 parent_commit_id=head_id,
1083 parent2_commit_id=feature_id,
1084 author="test",
1085 ))
1086 (root / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
1087
1088 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1089 assert result.exit_code == 0, result.output
1090 data = json.loads(result.output)
1091 addrs = [e["address"] for e in data["events"]]
1092 assert "billing.py::vectorised_fn" in addrs, (
1093 "BFS must find the implementation event on the feature branch"
1094 )
1095
1096
1097 # ---------------------------------------------------------------------------
1098 # muse reserve
1099 # ---------------------------------------------------------------------------
1100
1101
1102 class TestReserve:
1103 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1104 result = runner.invoke(cli, [
1105 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1106 ])
1107 assert result.exit_code == 0, result.output
1108
1109 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1110 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1111 coord_dir = code_repo / ".muse" / "coordination" / "reservations"
1112 assert coord_dir.exists()
1113 files = list(coord_dir.glob("*.json"))
1114 assert len(files) >= 1
1115
1116 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1117 result = runner.invoke(cli, [
1118 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1119 ])
1120 assert result.exit_code == 0
1121 data = json.loads(result.output)
1122 assert "reservation_id" in data
1123
1124 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1125 result = runner.invoke(cli, [
1126 "coord", "reserve", "--run-id", "r3",
1127 "billing.py::process_order",
1128 "billing.py::Invoice.apply_discount",
1129 ])
1130 assert result.exit_code == 0
1131
1132 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1133 result = runner.invoke(cli, [
1134 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1135 "billing.py::process_order",
1136 ])
1137 assert result.exit_code == 0
1138
1139 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1140 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1141 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1142 # Should warn but not fail.
1143 assert result.exit_code == 0
1144 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1145
1146
1147 # ---------------------------------------------------------------------------
1148 # muse intent
1149 # ---------------------------------------------------------------------------
1150
1151
1152 class TestIntent:
1153 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1154 result = runner.invoke(cli, [
1155 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1156 "billing.py::process_order",
1157 ])
1158 assert result.exit_code == 0, result.output
1159
1160 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1161 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1162 idir = code_repo / ".muse" / "coordination" / "intents"
1163 assert idir.exists()
1164 assert len(list(idir.glob("*.json"))) >= 1
1165
1166 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1167 result = runner.invoke(cli, [
1168 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1169 ])
1170 assert result.exit_code == 0
1171 data = json.loads(result.output)
1172 assert "intent_id" in data or "operation" in data
1173
1174
1175 # ---------------------------------------------------------------------------
1176 # muse forecast
1177 # ---------------------------------------------------------------------------
1178
1179
1180 class TestForecast:
1181 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1182 result = runner.invoke(cli, ["coord", "forecast"])
1183 assert result.exit_code == 0, result.output
1184
1185 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1186 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1187 assert result.exit_code == 0
1188 data = json.loads(result.output)
1189 assert "conflicts" in data
1190
1191 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1192 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1193 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1194 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1195 assert result.exit_code == 0
1196 data = json.loads(result.output)
1197 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1198 assert "address_overlap" in types
1199
1200
1201 # ---------------------------------------------------------------------------
1202 # muse plan-merge
1203 # ---------------------------------------------------------------------------
1204
1205
1206 class TestPlanMerge:
1207 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1208 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1209 assert result.exit_code == 0, result.output
1210
1211 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1212 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1213 assert result.exit_code == 0
1214 data = json.loads(result.output)
1215 assert "conflicts" in data or isinstance(data, dict)
1216
1217 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1218 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1219 assert result.exit_code != 0
1220
1221
1222 # ---------------------------------------------------------------------------
1223 # muse shard
1224 # ---------------------------------------------------------------------------
1225
1226
1227 class TestShard:
1228 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1229 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1230 assert result.exit_code == 0, result.output
1231
1232 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1233 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1234 assert result.exit_code == 0
1235 data = json.loads(result.output)
1236 assert "shards" in data
1237
1238 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1239 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1240 assert result.exit_code == 0
1241
1242 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1243 # N larger than symbol count still works (produces fewer shards).
1244 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1245 assert result.exit_code == 0
1246
1247
1248 # ---------------------------------------------------------------------------
1249 # muse reconcile
1250 # ---------------------------------------------------------------------------
1251
1252
1253 class TestReconcile:
1254 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1255 result = runner.invoke(cli, ["coord", "reconcile"])
1256 assert result.exit_code == 0, result.output
1257
1258 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1259 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1260 assert result.exit_code == 0
1261 data = json.loads(result.output)
1262 assert isinstance(data, dict)
1263
1264
1265 # ---------------------------------------------------------------------------
1266 # muse breakage
1267 # ---------------------------------------------------------------------------
1268
1269
1270 class TestBreakage:
1271 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1272 result = runner.invoke(cli, ["code", "breakage"])
1273 assert result.exit_code == 0, result.output
1274
1275 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1276 result = runner.invoke(cli, ["code", "breakage", "--json"])
1277 assert result.exit_code == 0
1278 data = json.loads(result.output)
1279 # breakage JSON has "issues" list and error count.
1280 assert "issues" in data
1281 assert isinstance(data["issues"], list)
1282
1283 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1284 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1285 assert result.exit_code == 0
1286
1287 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1288 monkeypatch.chdir(tmp_path)
1289 result = runner.invoke(cli, ["code", "breakage"])
1290 assert result.exit_code != 0
1291
1292
1293 # ---------------------------------------------------------------------------
1294 # muse invariants
1295 # ---------------------------------------------------------------------------
1296
1297
1298 class TestInvariants:
1299 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1300 result = runner.invoke(cli, ["code", "invariants"])
1301 toml_path = code_repo / ".muse" / "invariants.toml"
1302 assert result.exit_code == 0 or toml_path.exists()
1303
1304 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1305 # Create empty invariants.toml
1306 (code_repo / ".muse" / "invariants.toml").write_text("# No rules\n")
1307 result = runner.invoke(cli, ["code", "invariants", "--json"])
1308 assert result.exit_code == 0
1309 # Output may be JSON or human-readable depending on rules count.
1310 output = result.output.strip()
1311 if output and not output.startswith("#"):
1312 try:
1313 data = json.loads(output)
1314 assert isinstance(data, dict)
1315 except json.JSONDecodeError:
1316 pass # Human-readable output is also acceptable.
1317
1318 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1319 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1320 [[rules]]
1321 type = "no_cycles"
1322 name = "no import cycles"
1323 """))
1324 result = runner.invoke(cli, ["code", "invariants"])
1325 assert result.exit_code == 0
1326
1327 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1328 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1329 [[rules]]
1330 type = "forbidden_dependency"
1331 name = "billing must not import utils"
1332 source_pattern = "billing.py"
1333 forbidden_pattern = "utils.py"
1334 """))
1335 result = runner.invoke(cli, ["code", "invariants"])
1336 assert result.exit_code == 0
1337
1338 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1339 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1340 [[rules]]
1341 type = "required_test"
1342 name = "billing must have tests"
1343 source_pattern = "billing.py"
1344 test_pattern = "test_billing.py"
1345 """))
1346 result = runner.invoke(cli, ["code", "invariants"])
1347 # May pass or fail depending on whether test_billing.py exists; should not crash.
1348 assert result.exit_code in (0, 1)
1349
1350 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1351 (code_repo / ".muse" / "invariants.toml").write_text("# empty\n")
1352 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1353 assert result.exit_code == 0
1354
1355
1356 # ---------------------------------------------------------------------------
1357 # muse commit — semantic versioning
1358 # ---------------------------------------------------------------------------
1359
1360
1361 class TestSemVerInCommit:
1362 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1363 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1364 commit_id = get_head_commit_id(code_repo, "main")
1365 assert commit_id is not None
1366 commit = read_commit(code_repo, commit_id)
1367 assert commit is not None
1368 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1369
1370 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1371 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1372 commit_id = get_head_commit_id(code_repo, "main")
1373 assert commit_id is not None
1374 commit = read_commit(code_repo, commit_id)
1375 assert commit is not None
1376 assert isinstance(commit.breaking_changes, list)
1377
1378 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1379 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1380 commit_id = get_head_commit_id(code_repo, "main")
1381 assert commit_id is not None
1382 commit = read_commit(code_repo, commit_id)
1383 assert commit is not None
1384 if commit.sem_ver_bump == "major":
1385 result = runner.invoke(cli, ["log"])
1386 assert "MAJOR" in result.output or "major" in result.output.lower()
1387
1388
1389 # ---------------------------------------------------------------------------
1390 # Call-graph tier — muse impact
1391 # ---------------------------------------------------------------------------
1392
1393
1394 class TestImpact:
1395 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1396 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1397 assert result.exit_code == 0, result.output
1398
1399 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1400 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1401 assert result.exit_code == 0
1402 data = json.loads(result.output)
1403 assert isinstance(data, dict)
1404 assert "blast_radius" in data
1405 assert "total" in data
1406 assert "commit_id" in data
1407 assert data["mode"] == "reverse"
1408
1409 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1410 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1411 assert result.exit_code in (0, 1)
1412
1413 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1414 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1415 assert result.exit_code == 0
1416 assert result.output.strip().isdigit()
1417
1418 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1419 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1420 assert result.exit_code == 1
1421
1422 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1423 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1424 assert result.exit_code == 0
1425
1426 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1427 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1428 assert result.exit_code == 0
1429 data = json.loads(result.output)
1430 assert data["mode"] == "forward"
1431 assert "callees" in data
1432 assert "total" in data
1433 assert "commit_id" in data
1434
1435 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1436 result = runner.invoke(cli, [
1437 "code", "impact", "--forward", "--compare", "HEAD",
1438 "--", "billing.py::process_order",
1439 ])
1440 assert result.exit_code == 1
1441
1442 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1443 result = runner.invoke(cli, [
1444 "code", "impact", "--file", "billing.py",
1445 "--", "billing.py::Invoice.compute_invoice_total",
1446 ])
1447 assert result.exit_code == 0
1448
1449 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1450 result = runner.invoke(cli, [
1451 "code", "impact", "--file", "billing.py", "--json",
1452 "--", "billing.py::Invoice.compute_invoice_total",
1453 ])
1454 assert result.exit_code == 0
1455 data = json.loads(result.output)
1456 assert data["file_filter"] == "billing.py"
1457 for depth_addrs in data["blast_radius"].values():
1458 for addr in depth_addrs:
1459 assert addr.startswith("billing.py::")
1460
1461 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1462 result = runner.invoke(cli, [
1463 "code", "impact", "--compare", "HEAD",
1464 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1465 ])
1466 assert result.exit_code == 0
1467 data = json.loads(result.output)
1468 assert "compare_commit_id" in data
1469 assert "added_callers" in data
1470 assert "removed_callers" in data
1471 assert "net_change" in data
1472 assert isinstance(data["added_callers"], list)
1473 assert isinstance(data["removed_callers"], list)
1474
1475 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1476 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1477 assert result.exit_code == 0
1478 assert result.output.strip().isdigit()
1479
1480
1481 # ---------------------------------------------------------------------------
1482 # Call-graph tier — muse dead
1483 # ---------------------------------------------------------------------------
1484
1485
1486 class TestDead:
1487 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1488 result = runner.invoke(cli, ["code", "dead"])
1489 assert result.exit_code == 0, result.output
1490
1491 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1492 result = runner.invoke(cli, ["code", "dead", "--json"])
1493 assert result.exit_code == 0
1494 data = json.loads(result.output)
1495 assert isinstance(data, dict)
1496 assert "results" in data
1497 assert "high_confidence_count" in data
1498 assert "total_files_scanned" in data
1499 assert "duration_ms" in data
1500
1501 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1502 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1503 assert result.exit_code == 0
1504
1505 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1506 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1507 assert result.exit_code == 0
1508
1509 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1510 result = runner.invoke(cli, ["code", "dead", "--count"])
1511 assert result.exit_code == 0
1512 assert result.output.strip().isdigit()
1513
1514 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1515 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1516 assert result.exit_code == 0
1517 data = json.loads(result.output)
1518 assert "compare_commit_id" in data
1519 assert "new_dead" in data
1520 assert "recovered" in data
1521 assert "net_change" in data
1522 assert isinstance(data["new_dead"], list)
1523 assert isinstance(data["recovered"], list)
1524
1525 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1526 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1527 assert result.exit_code == 0
1528
1529 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1530 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1531 assert result.exit_code == 1
1532
1533 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1534 out_file = tmp_path / "allowlist.json"
1535 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1536 assert result.exit_code == 0
1537 if out_file.exists():
1538 data = json.loads(out_file.read_text())
1539 assert isinstance(data, list)
1540 assert all(isinstance(x, str) for x in data)
1541
1542 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1543 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1544 assert result.exit_code == 0
1545 data = json.loads(result.output)
1546 for c in data["results"]:
1547 assert c["confidence"] == "high"
1548
1549 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1550 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1551 assert result.exit_code == 0
1552
1553
1554 # ---------------------------------------------------------------------------
1555 # muse code cat
1556 # ---------------------------------------------------------------------------
1557
1558
1559 class TestCat:
1560 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1561 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1562 assert result.exit_code == 0, result.output
1563 assert "class Invoice" in result.output
1564
1565 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1566 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1567 assert result.exit_code == 0, result.output
1568 assert "def compute_invoice_total" in result.output
1569
1570 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1571 # Invoice is unique — short name should resolve.
1572 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1573 assert result.exit_code == 0
1574
1575 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1576 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1577 assert result.exit_code != 0
1578
1579 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1580 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1581 assert result.exit_code != 0
1582
1583 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1584 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1585 assert result.exit_code != 0
1586
1587 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1588 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1589 assert result.exit_code == 0
1590 # Line numbers prefix lines with digits.
1591 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1592 first_code_line = next((ln for ln in lines if ln.strip()), "")
1593 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1594
1595 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1596 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1597 assert result.exit_code == 0
1598 data = json.loads(result.output)
1599 assert "results" in data
1600 assert "errors" in data
1601 assert "source_ref" in data
1602 assert len(data["results"]) == 1
1603 r = data["results"][0]
1604 assert r["file_path"] == "billing.py"
1605 assert r["kind"] in ("class", "function", "method")
1606 assert isinstance(r["lineno"], int)
1607 assert isinstance(r["end_lineno"], int)
1608 assert "class Invoice" in r["source"]
1609
1610 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1611 result = runner.invoke(
1612 cli,
1613 [
1614 "code", "cat",
1615 "billing.py::Invoice",
1616 "billing.py::Invoice.compute_invoice_total",
1617 "--json",
1618 ],
1619 )
1620 assert result.exit_code == 0, result.output
1621 data = json.loads(result.output)
1622 assert len(data["results"]) == 2
1623
1624 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1625 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1626 assert result.exit_code == 0
1627 assert "Invoice" in result.output
1628
1629 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1630 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1631 assert result.exit_code == 0
1632
1633 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1634 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1635 assert result.exit_code == 0
1636 data = json.loads(result.output)
1637 assert len(data["results"]) > 0
1638 # Every result has required fields.
1639 for r in data["results"]:
1640 assert "address" in r
1641 assert "lineno" in r
1642 assert "source" in r
1643
1644 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1645 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1646 result_ctx = runner.invoke(
1647 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1648 )
1649 assert result_ctx.exit_code == 0
1650 # With context we get at least as many lines.
1651 plain_lines = result_plain.output.count("\n")
1652 ctx_lines = result_ctx.output.count("\n")
1653 assert ctx_lines >= plain_lines
1654
1655 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1656 # In --json mode a missing symbol goes to the errors field, not a crash.
1657 result = runner.invoke(
1658 cli,
1659 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1660 )
1661 # Output must be valid JSON (no stderr bleed into stdout).
1662 data = json.loads(result.output)
1663 assert len(data["results"]) == 1
1664 assert len(data["errors"]) == 1
1665 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1666
1667 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1668 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1669 assert result.exit_code == 0
1670 assert "working tree" in result.output
1671
1672 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1673 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1674 assert result.exit_code == 0
1675 assert "Invoice" in result.output
1676
1677 def test_cat_wrong_file_fallback_finds_symbol(
1678 self, code_repo: pathlib.Path, tmp_path: pathlib.Path
1679 ) -> None:
1680 """FILE::SYMBOL where SYMBOL lives in a different file — should fall back
1681 to a global snapshot search and cat it from its actual location, exit 0."""
1682 # Add a second file with a unique function the billing module doesn't have.
1683 work = pathlib.Path.cwd()
1684 (work / "utils.py").write_text(
1685 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1686 )
1687 runner.invoke(cli, ["commit", "-m", "Add utils"])
1688
1689 # Ask for utils.format_currency but specify the wrong file (billing.py).
1690 result = runner.invoke(
1691 cli, ["code", "cat", "billing.py::format_currency"]
1692 )
1693 assert result.exit_code == 0, result.output
1694 assert "format_currency" in result.output
1695
1696 def test_cat_wrong_file_fallback_json(self, code_repo: pathlib.Path) -> None:
1697 """Same fallback in --json mode: result is in results[], not errors[]."""
1698 work = pathlib.Path.cwd()
1699 (work / "utils.py").write_text(
1700 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1701 )
1702 runner.invoke(cli, ["commit", "-m", "Add utils"])
1703
1704 result = runner.invoke(
1705 cli, ["code", "cat", "billing.py::format_currency", "--json"]
1706 )
1707 assert result.exit_code == 0, result.output
1708 data = json.loads(result.output)
1709 assert len(data["results"]) == 1
1710 assert data["results"][0]["symbol"] == "format_currency"
1711 assert data["results"][0]["file_path"] == "utils.py"
1712
1713 def test_cat_wrong_file_fallback_ambiguous_exits_nonzero(
1714 self, code_repo: pathlib.Path
1715 ) -> None:
1716 """If the symbol exists in multiple files, fallback reports ambiguity and exits 1."""
1717 work = pathlib.Path.cwd()
1718 (work / "utils.py").write_text("def send_email(to): pass\n")
1719 runner.invoke(cli, ["commit", "-m", "Duplicate send_email in utils"])
1720
1721 # billing.py already has send_email; utils.py now also has it.
1722 result = runner.invoke(
1723 cli, ["code", "cat", "nope.py::send_email"]
1724 )
1725 assert result.exit_code != 0
1726
1727 def test_cat_truly_missing_symbol_still_errors(self, code_repo: pathlib.Path) -> None:
1728 """A symbol that doesn't exist anywhere in the snapshot still exits 1."""
1729 result = runner.invoke(cli, ["code", "cat", "billing.py::AbsolutelyNowhere"])
1730 assert result.exit_code != 0
1731
1732
1733 # ---------------------------------------------------------------------------
1734 # Call-graph tier — muse coverage
1735 # ---------------------------------------------------------------------------
1736
1737
1738 class TestCoverage:
1739 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1740 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1741 assert result.exit_code == 0, result.output
1742
1743 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1744 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1745 assert result.exit_code == 0
1746 data = json.loads(result.output)
1747 assert isinstance(data, dict)
1748 assert "methods" in data
1749 assert "total_methods" in data
1750 assert "covered" in data
1751 assert "percent" in data
1752 assert "commit_id" in data
1753 assert "filters" in data
1754 for m in data["methods"]:
1755 assert "address" in m
1756 assert "called" in m
1757 assert "callers" in m
1758
1759 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1760 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1761 assert result.exit_code in (0, 1)
1762
1763 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1764 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1765 assert result.exit_code == 0
1766 # Output should be "n/total" format
1767 assert "/" in result.output.strip()
1768
1769 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1770 result = runner.invoke(cli, [
1771 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1772 ])
1773 assert result.exit_code == 0
1774 data = json.loads(result.output)
1775 assert data["filters"]["exclude_dunder"] is True
1776 for m in data["methods"]:
1777 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1778
1779 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1780 result = runner.invoke(cli, [
1781 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1782 ])
1783 assert result.exit_code == 0
1784 data = json.loads(result.output)
1785 assert data["filters"]["exclude_private"] is True
1786
1787 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1788 result = runner.invoke(cli, [
1789 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1790 ])
1791 assert result.exit_code == 0
1792 data = json.loads(result.output)
1793 assert data["filters"]["min_callers"] == 2
1794
1795 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1796 result = runner.invoke(cli, [
1797 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1798 ])
1799 assert result.exit_code == 0
1800 data = json.loads(result.output)
1801 assert data["filters"]["exclude_self"] is True
1802 # All reported callers should be from a different file
1803 for m in data["methods"]:
1804 for caller in m["callers"]:
1805 assert not caller.startswith("billing.py::")
1806
1807 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1808 result = runner.invoke(cli, [
1809 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1810 ])
1811 assert result.exit_code == 0
1812 data = json.loads(result.output)
1813 assert "compare_commit_id" in data
1814 assert "newly_covered" in data
1815 assert "newly_uncovered" in data
1816 assert "percent_change" in data
1817
1818 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1819 result = runner.invoke(cli, [
1820 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1821 ])
1822 assert result.exit_code == 0
1823
1824 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1825 result = runner.invoke(cli, [
1826 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1827 ])
1828 assert result.exit_code == 0
1829
1830
1831 # ---------------------------------------------------------------------------
1832 # Call-graph tier — muse deps
1833 # ---------------------------------------------------------------------------
1834
1835
1836 class TestDeps:
1837 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1838 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1839 assert result.exit_code == 0, result.output
1840
1841 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1842 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1843 assert result.exit_code == 0
1844
1845 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1846 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1847 assert result.exit_code == 0
1848 data = json.loads(result.output)
1849 assert isinstance(data, dict)
1850
1851 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1852 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1853 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1854
1855 # ── new flags ──────────────────────────────────────────────────────────────
1856
1857 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1858 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1859 assert result.exit_code == 0, result.output
1860 assert result.output.strip().isdigit()
1861
1862 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1863 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1864 assert result.exit_code == 0, result.output
1865 assert result.output.strip().isdigit()
1866
1867 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1868 result = runner.invoke(
1869 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1870 )
1871 assert result.exit_code == 0, result.output
1872
1873 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1874 # --depth > 1 in file mode is fine (just filters imports as before).
1875 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1876 assert result.exit_code == 0, result.output
1877
1878 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1879 result = runner.invoke(
1880 cli,
1881 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1882 )
1883 assert result.exit_code != 0
1884
1885 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1886 result = runner.invoke(
1887 cli,
1888 ["code", "deps", "--reverse", "--depth", "2",
1889 "billing.py::Invoice.compute_invoice_total"],
1890 )
1891 assert result.exit_code == 0, result.output
1892
1893 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1894 result = runner.invoke(
1895 cli,
1896 ["code", "deps", "--transitive",
1897 "billing.py::Invoice.compute_invoice_total"],
1898 )
1899 assert result.exit_code == 0, result.output
1900
1901 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1902 result = runner.invoke(
1903 cli,
1904 ["code", "deps", "--transitive", "--count",
1905 "billing.py::Invoice.compute_invoice_total"],
1906 )
1907 assert result.exit_code == 0
1908 assert result.output.strip().isdigit()
1909
1910 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1911 result = runner.invoke(
1912 cli,
1913 ["code", "deps", "--transitive", "--json",
1914 "billing.py::Invoice.compute_invoice_total"],
1915 )
1916 assert result.exit_code == 0
1917 data = json.loads(result.output)
1918 assert "by_depth" in data
1919 assert data["transitive"] is True
1920
1921 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1922 result = runner.invoke(
1923 cli,
1924 ["code", "deps", "--reverse", "--depth", "2", "--json",
1925 "billing.py::Invoice.compute_invoice_total"],
1926 )
1927 assert result.exit_code == 0
1928 data = json.loads(result.output)
1929 assert "by_depth" in data
1930 assert data["depth"] == 2
1931
1932 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1933 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1934 assert result.exit_code != 0
1935
1936 def test_deps_empty_file_rel_in_symbol_rejected(
1937 self, code_repo: pathlib.Path
1938 ) -> None:
1939 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1940 assert result.exit_code != 0
1941
1942 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1943 result = runner.invoke(
1944 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1945 )
1946 assert result.exit_code == 0
1947 data = json.loads(result.output)
1948 assert "imported_by" in data
1949 assert isinstance(data["imported_by"], list)
1950
1951
1952 # ---------------------------------------------------------------------------
1953 # Call-graph tier — muse find-symbol
1954 # ---------------------------------------------------------------------------
1955
1956
1957 class TestFindSymbol:
1958 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1959 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1960 assert result.exit_code == 0, result.output
1961
1962 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
1963 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
1964 assert result.exit_code == 0
1965 data = json.loads(result.output)
1966 assert isinstance(data, dict)
1967 assert "results" in data
1968 assert "query" in data
1969 assert "total" in data
1970
1971 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
1972 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
1973 assert result.exit_code == 0
1974 assert result.output is not None
1975
1976 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
1977 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
1978 assert result.exit_code == 0
1979 assert "no matching" in result.output
1980
1981 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
1982 result = runner.invoke(cli, ["code", "find-symbol"])
1983 assert result.exit_code == 1
1984
1985 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
1986 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
1987 assert result.exit_code == 0
1988 assert result.output.strip().isdigit()
1989
1990 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1991 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
1992 assert result.exit_code == 1
1993
1994 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
1995 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
1996 assert result.exit_code == 1
1997
1998 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
1999 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
2000 assert result.exit_code == 1
2001
2002 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
2003 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
2004 assert result.exit_code == 1
2005
2006 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
2007 result = runner.invoke(cli, [
2008 "code", "find-symbol", "--name", "process_order",
2009 "--since", "2099-01-01",
2010 ])
2011 assert result.exit_code == 0
2012 assert "no matching" in result.output
2013
2014 def test_find_limit(self, code_repo: pathlib.Path) -> None:
2015 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
2016 assert result.exit_code == 0
2017
2018 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
2019 result = runner.invoke(cli, [
2020 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
2021 ])
2022 assert result.exit_code == 0
2023
2024 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
2025 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
2026 assert result.exit_code == 0
2027 data = json.loads(result.output)
2028 for ap in data["results"]:
2029 assert ap["name"].lower().startswith("process")
2030
2031 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
2032 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2033 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
2034 assert result_all.exit_code == 0
2035 assert result_first.exit_code == 0
2036 count_all = int(result_all.output.strip())
2037 count_first = int(result_first.output.strip())
2038 assert count_first <= count_all
2039
2040 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
2041 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
2042 assert result.exit_code == 0
2043 data = json.loads(result.output)
2044 assert "query" in data
2045 assert "results" in data
2046 assert "total" in data
2047 assert data["total"] == len(data["results"])
2048 if data["results"]:
2049 ap = data["results"][0]
2050 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
2051 assert key in ap
2052
2053
2054 # ---------------------------------------------------------------------------
2055 # Call-graph tier — muse patch
2056 # ---------------------------------------------------------------------------
2057
2058
2059 class TestPatch:
2060 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
2061 new_impl = textwrap.dedent("""\
2062 def send_email(address):
2063 return f"Sending to {address}"
2064 """)
2065 impl_file = code_repo / "send_email_impl.py"
2066 impl_file.write_text(new_impl)
2067 # patch takes ADDRESS SOURCE — put options before address.
2068 result = runner.invoke(cli, [
2069 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
2070 ])
2071 assert result.exit_code in (0, 1, 2)
2072
2073 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
2074 bad_impl = "def broken(\n not valid python at all{"
2075 bad_file = code_repo / "bad.py"
2076 bad_file.write_text(bad_impl)
2077 result = runner.invoke(cli, [
2078 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2079 ])
2080 # Invalid syntax must be rejected or command handles gracefully.
2081 assert result.exit_code in (0, 1, 2)
2082
2083
2084 # ---------------------------------------------------------------------------
2085 # Security — path traversal guards
2086 # ---------------------------------------------------------------------------
2087
2088
2089 class TestPatchPathTraversal:
2090 """patch must reject addresses whose file component escapes the repo root."""
2091
2092 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2093 body = code_repo / "body.py"
2094 body.write_text("def foo(): pass\n")
2095 result = runner.invoke(cli, [
2096 "code", "patch",
2097 "--body", str(body),
2098 "../../etc/passwd::foo",
2099 ])
2100 assert result.exit_code == 1
2101
2102 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2103 body = code_repo / "body.py"
2104 body.write_text("def foo(): pass\n")
2105 result = runner.invoke(cli, [
2106 "code", "patch",
2107 "--body", str(body),
2108 "../../../tmp/evil::foo",
2109 ])
2110 assert result.exit_code == 1
2111
2112 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2113 """--json flag returns parseable JSON on a dry-run."""
2114 body = code_repo / "body.py"
2115 body.write_text("def send_email(address):\n return address\n")
2116 result = runner.invoke(cli, [
2117 "code", "patch",
2118 "--body", str(body),
2119 "--dry-run",
2120 "--json",
2121 "billing.py::send_email",
2122 ])
2123 # Address may or may not exist; if it exits 0 the output must be JSON.
2124 if result.exit_code == 0:
2125 data = json.loads(result.output)
2126 assert data["address"] == "billing.py::send_email"
2127 assert data["dry_run"] is True
2128
2129
2130 class TestCheckoutSymbolPathTraversal:
2131 """checkout-symbol must reject addresses whose file component escapes root."""
2132
2133 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2134 result = runner.invoke(cli, [
2135 "code", "checkout-symbol",
2136 "--commit", "HEAD",
2137 "../../etc/passwd::foo",
2138 ])
2139 assert result.exit_code == 1
2140
2141 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2142 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2143 result = runner.invoke(cli, [
2144 "code", "checkout-symbol",
2145 "--commit", "HEAD",
2146 "--json",
2147 "billing.py::nonexistent_func_xyz",
2148 ])
2149 # Either exits 1 (symbol not found) — but must not crash.
2150 assert result.exit_code in (0, 1)
2151
2152
2153 class TestSemanticCherryPickPathTraversal:
2154 """semantic-cherry-pick must reject addresses that escape the repo root."""
2155
2156 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2157 result = runner.invoke(cli, [
2158 "code", "semantic-cherry-pick",
2159 "--from", "HEAD",
2160 "../../etc/passwd::foo",
2161 ])
2162 # The traversal-rejected symbol is recorded as not_found but the
2163 # command exits 0 (failed symbols don't abort the batch).
2164 # The key invariant is that no file outside the repo is written.
2165 # We assert exit_code is 0 (graceful) and the output does NOT write.
2166 assert result.exit_code in (0, 1)
2167 # No file was created outside the repo.
2168 assert not pathlib.Path("/etc/passwd_copy").exists()
2169
2170 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2171 result = runner.invoke(cli, [
2172 "code", "semantic-cherry-pick",
2173 "--from", "HEAD",
2174 "--json",
2175 "../../etc/passwd::foo",
2176 ])
2177 assert result.exit_code in (0, 1)
2178 if result.exit_code == 0:
2179 data = json.loads(result.output)
2180 assert data["applied"] == 0
2181 # The traversal-escaped address should be marked as not_found
2182 results = data.get("results", [])
2183 assert any(r["status"] == "not_found" for r in results)
2184
2185
2186 # ---------------------------------------------------------------------------
2187 # muse code blame
2188 # ---------------------------------------------------------------------------
2189
2190
2191 @pytest.fixture
2192 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2193 """Repo with four commits: seed → creation → modification → rename.
2194
2195 A seed commit is required so that the billing.py creation commit has
2196 a parent (and therefore a structured_delta with insert ops).
2197
2198 Timeline (oldest → newest):
2199 commit 0: README.md only (seed — gives billing.py commit a parent)
2200 commit 1: billing.py created — defines compute_total + process_order
2201 commit 2: compute_total implementation modified (same name)
2202 commit 3: compute_total renamed to compute_invoice_total
2203 """
2204 work = repo
2205
2206 # Seed commit so billing.py introduction has a parent and structured_delta.
2207 (work / "README.md").write_text("# Billing module\n")
2208 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2209 assert r.exit_code == 0, r.output
2210
2211 (work / "billing.py").write_text(textwrap.dedent("""\
2212 def compute_total(items):
2213 return sum(items)
2214
2215 def process_order(items):
2216 return compute_total(items)
2217 """))
2218 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2219 assert r.exit_code == 0, r.output
2220
2221 (work / "billing.py").write_text(textwrap.dedent("""\
2222 def compute_total(items):
2223 # faster implementation
2224 return sum(x for x in items)
2225
2226 def process_order(items):
2227 return compute_total(items)
2228 """))
2229 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2230 assert r.exit_code == 0, r.output
2231
2232 (work / "billing.py").write_text(textwrap.dedent("""\
2233 def compute_invoice_total(items):
2234 # faster implementation
2235 return sum(x for x in items)
2236
2237 def process_order(items):
2238 return compute_invoice_total(items)
2239 """))
2240 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2241 assert r.exit_code == 0, r.output
2242
2243 return repo
2244
2245
2246 class TestBlame:
2247 """Tests for muse code blame."""
2248
2249 # ── address validation ───────────────────────────────────────────────────
2250
2251 def test_invalid_address_no_separator_exits_error(
2252 self, blame_repo: pathlib.Path
2253 ) -> None:
2254 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2255 assert result.exit_code == 1
2256 assert "Invalid address" in result.output or "::" in result.output
2257
2258 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2259 result = runner.invoke(
2260 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2261 )
2262 assert result.exit_code == 1
2263
2264 # ── basic correctness (no rename involved) ───────────────────────────────
2265
2266 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2267 """A symbol that was never renamed should have created + modified events."""
2268 result = runner.invoke(
2269 cli, ["code", "blame", "billing.py::process_order", "--json"]
2270 )
2271 assert result.exit_code == 0, result.output
2272 data = json.loads(result.output)
2273 kinds = [ev["event"] for ev in data["events"]]
2274 assert "created" in kinds
2275
2276 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2277 result = runner.invoke(
2278 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2279 )
2280 assert result.exit_code == 0
2281 assert "no events found" in result.output
2282
2283 # ── rename tracking — new name (the critical regression) ─────────────────
2284
2285 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2286 """Blaming the POST-rename name must find the rename event."""
2287 result = runner.invoke(
2288 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2289 )
2290 assert result.exit_code == 0, result.output
2291 data = json.loads(result.output)
2292 kinds = [ev["event"] for ev in data["events"]]
2293 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2294
2295 def test_blame_new_name_follows_into_old_history(
2296 self, blame_repo: pathlib.Path
2297 ) -> None:
2298 """After finding the rename, blame must continue tracking the old name.
2299
2300 The symbol was created as compute_total → modified → renamed.
2301 Blaming compute_invoice_total should find ALL three events.
2302 """
2303 result = runner.invoke(
2304 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2305 )
2306 assert result.exit_code == 0, result.output
2307 data = json.loads(result.output)
2308 kinds = [ev["event"] for ev in data["events"]]
2309 assert "created" in kinds, f"Expected created event, got: {kinds}"
2310 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2311
2312 # ── rename tracking — old name ────────────────────────────────────────────
2313
2314 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2315 """Blaming the PRE-rename name must find the creation event."""
2316 result = runner.invoke(
2317 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2318 )
2319 assert result.exit_code == 0, result.output
2320 data = json.loads(result.output)
2321 kinds = [ev["event"] for ev in data["events"]]
2322 assert "created" in kinds, f"Expected created event, got: {kinds}"
2323
2324 def test_blame_old_name_finds_rename_not_lost(
2325 self, blame_repo: pathlib.Path
2326 ) -> None:
2327 """Blaming the old name should also surface the rename event."""
2328 result = runner.invoke(
2329 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2330 )
2331 assert result.exit_code == 0, result.output
2332 data = json.loads(result.output)
2333 kinds = [ev["event"] for ev in data["events"]]
2334 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2335
2336 # ── JSON schema ───────────────────────────────────────────────────────────
2337
2338 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2339 result = runner.invoke(
2340 cli, ["code", "blame", "billing.py::process_order", "--json"]
2341 )
2342 assert result.exit_code == 0, result.output
2343 data = json.loads(result.output)
2344 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2345 assert key in data, f"missing key: {key}"
2346 assert isinstance(data["events"], list)
2347 assert isinstance(data["truncated"], bool)
2348 assert isinstance(data["total_commits_scanned"], int)
2349
2350 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2351 result = runner.invoke(
2352 cli,
2353 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2354 )
2355 assert result.exit_code == 0, result.output
2356 data = json.loads(result.output)
2357 assert data["events"], "expected at least one event"
2358 ev = data["events"][0]
2359 for field in (
2360 "event", "commit_id", "author", "message",
2361 "committed_at", "address", "detail",
2362 ):
2363 assert field in ev, f"missing event field: {field}"
2364
2365 def test_blame_json_address_field_matches_input(
2366 self, blame_repo: pathlib.Path
2367 ) -> None:
2368 addr = "billing.py::process_order"
2369 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2370 data = json.loads(result.output)
2371 assert data["address"] == addr
2372
2373 # ── --max truncation ──────────────────────────────────────────────────────
2374
2375 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2376 result = runner.invoke(
2377 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2378 )
2379 assert result.exit_code == 0, result.output
2380 data = json.loads(result.output)
2381 assert data["total_commits_scanned"] <= 1
2382
2383 def test_blame_truncation_flag_set_when_capped(
2384 self, blame_repo: pathlib.Path
2385 ) -> None:
2386 result = runner.invoke(
2387 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2388 )
2389 data = json.loads(result.output)
2390 assert data["truncated"] is True
2391
2392 def test_blame_truncation_warning_in_human_output(
2393 self, blame_repo: pathlib.Path
2394 ) -> None:
2395 result = runner.invoke(
2396 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2397 )
2398 assert result.exit_code == 0, result.output
2399 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2400
2401 # ── human output ─────────────────────────────────────────────────────────
2402
2403 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2404 result = runner.invoke(
2405 cli, ["code", "blame", "billing.py::process_order"]
2406 )
2407 assert result.exit_code == 0, result.output
2408 assert "last touched:" in result.output
2409
2410 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2411 result_default = runner.invoke(
2412 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2413 )
2414 result_all = runner.invoke(
2415 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2416 )
2417 assert result_all.exit_code == 0, result_all.output
2418 # --all shows at least as many lines as default
2419 assert len(result_all.output) >= len(result_default.output)
2420
2421 # ── BFS follows merge parents ─────────────────────────────────────────────
2422
2423 def test_blame_bfs_follows_merge_parent2(
2424 self, repo: pathlib.Path
2425 ) -> None:
2426 """A symbol introduced on a feature branch is visible after merging."""
2427 # Main: empty billing.py
2428 (repo / "billing.py").write_text("def main_fn(): pass\n")
2429 runner.invoke(cli, ["commit", "-m", "main commit"])
2430
2431 # Feature branch: add feature_fn
2432 runner.invoke(cli, ["branch", "feat/feature"])
2433 runner.invoke(cli, ["checkout", "feat/feature"])
2434 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2435 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2436
2437 # Merge back to main
2438 runner.invoke(cli, ["checkout", "main"])
2439 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2440
2441 # Blame feature_fn — should find 'created' event on the feature branch
2442 result = runner.invoke(
2443 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2444 )
2445 assert result.exit_code == 0, result.output
2446 data = json.loads(result.output)
2447 kinds = [ev["event"] for ev in data["events"]]
2448 assert "created" in kinds, (
2449 f"Expected created event for feature_fn after merge; got: {kinds}"
2450 )
2451
2452
2453 # ---------------------------------------------------------------------------
2454 # Security — ReDoS guard in grep
2455 # ---------------------------------------------------------------------------
2456
2457
2458 class TestGrepReDoS:
2459 """grep must reject patterns longer than 512 characters."""
2460
2461 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2462 long_pattern = "a" * 513
2463 result = runner.invoke(cli, ["code", "grep", long_pattern])
2464 assert result.exit_code == 1
2465 assert "too long" in result.output.lower() or "512" in result.output
2466
2467 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2468 pattern = "a" * 512
2469 result = runner.invoke(cli, ["code", "grep", pattern])
2470 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2471 assert result.exit_code != 1 or "too long" not in result.output.lower()
2472
2473 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2474 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2475 assert result.exit_code == 1
2476
2477
2478 # ---------------------------------------------------------------------------
2479 # JSON output — index status and rebuild
2480 # ---------------------------------------------------------------------------
2481
2482
2483 class TestIndexJsonOutput:
2484 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2485 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2486 assert result.exit_code == 0, result.output
2487 raw = json.loads(result.output)
2488 data = raw["indexes"] if isinstance(raw, dict) else raw
2489 assert isinstance(data, list)
2490 names = [entry["name"] for entry in data]
2491 assert "symbol_history" in names
2492 assert "hash_occurrence" in names
2493 for entry in data:
2494 assert "status" in entry
2495 assert "entries" in entry
2496
2497 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2498 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2499 assert result.exit_code == 0, result.output
2500 data = json.loads(result.output)
2501 assert isinstance(data, dict)
2502 assert "rebuilt" in data
2503 assert isinstance(data["rebuilt"], list)
2504 assert "symbol_history" in data["rebuilt"]
2505 assert "hash_occurrence" in data["rebuilt"]
2506
2507 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2508 result = runner.invoke(cli, [
2509 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2510 ])
2511 assert result.exit_code == 0, result.output
2512 data = json.loads(result.output)
2513 assert "symbol_history" in data.get("rebuilt", [])
2514 assert "symbol_history_addresses" in data
2515
2516
2517 # ---------------------------------------------------------------------------
2518 # Extended — muse code index status
2519 # ---------------------------------------------------------------------------
2520
2521
2522 class TestIndexStatusExtended:
2523 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2524 """-j is equivalent to --json."""
2525 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2526 assert result.exit_code == 0, result.output
2527 _raw = json.loads(result.output.strip())
2528 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2529 assert isinstance(data, list)
2530
2531 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2532 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2533 assert result.exit_code == 0
2534
2535 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2536 """JSON output is compact — single line, no indent=2."""
2537 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2538 assert result.exit_code == 0
2539 lines = [l for l in result.output.splitlines() if l.strip()]
2540 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2541
2542 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
2543 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2544 _raw = json.loads(result.output.strip())
2545 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2546 assert isinstance(data, list)
2547
2548 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2549 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2550 _raw = json.loads(result.output.strip())
2551 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2552 names = [e["name"] for e in data]
2553 assert "symbol_history" in names
2554
2555 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2556 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2557 _raw = json.loads(result.output.strip())
2558 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2559 names = [e["name"] for e in data]
2560 assert "hash_occurrence" in names
2561
2562 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2563 """Every entry has name, status, entries, updated_at."""
2564 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2565 _raw = json.loads(result.output.strip())
2566 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2567 for entry in data:
2568 assert "name" in entry
2569 assert "status" in entry
2570 assert "entries" in entry
2571 assert "updated_at" in entry
2572
2573 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2574 """Freshly initialised repo: both indexes are absent."""
2575 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2576 _raw = json.loads(result.output.strip())
2577 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2578 statuses = {e["name"]: e["status"] for e in data}
2579 assert statuses["symbol_history"] == "absent"
2580 assert statuses["hash_occurrence"] == "absent"
2581
2582 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2583 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2584 _raw = json.loads(result.output.strip())
2585 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2586 for entry in data:
2587 if entry["status"] == "absent":
2588 assert entry["entries"] == 0
2589
2590 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2591 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2592 _raw = json.loads(result.output.strip())
2593 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2594 for entry in data:
2595 if entry["status"] == "absent":
2596 assert entry["updated_at"] is None
2597
2598 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2599 """After rebuild all indexes report present."""
2600 runner.invoke(cli, ["code", "index", "rebuild"])
2601 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2602 _raw = json.loads(result.output.strip())
2603 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2604 for entry in data:
2605 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2606
2607 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2608 """symbol_history should have entries after two commits."""
2609 runner.invoke(cli, ["code", "index", "rebuild"])
2610 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2611 _raw = json.loads(result.output.strip())
2612 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2613 sh = next(e for e in data if e["name"] == "symbol_history")
2614 assert sh["entries"] > 0
2615
2616 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2617 runner.invoke(cli, ["code", "index", "rebuild"])
2618 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2619 _raw = json.loads(result.output.strip())
2620 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2621 for entry in data:
2622 assert entry["updated_at"] is not None
2623
2624 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2625 """A file with bad content is reported as corrupt, not absent."""
2626 idx_dir = code_repo / ".muse" / "indices"
2627 idx_dir.mkdir(parents=True, exist_ok=True)
2628 (idx_dir / "symbol_history.msgpack").write_bytes(b"\xff\xfe")
2629 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2630 assert result.exit_code == 0
2631 _raw = json.loads(result.output.strip())
2632 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2633 sh = next(e for e in data if e["name"] == "symbol_history")
2634 assert sh["status"] == "corrupt"
2635
2636 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2637 idx_dir = code_repo / ".muse" / "indices"
2638 idx_dir.mkdir(parents=True, exist_ok=True)
2639 (idx_dir / "hash_occurrence.msgpack").write_bytes(b"notmsgpack")
2640 result = runner.invoke(cli, ["code", "index", "status"])
2641 assert result.exit_code == 0
2642
2643 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2644 """Text mode suggests rebuild command when index is absent."""
2645 result = runner.invoke(cli, ["code", "index", "status"])
2646 assert "rebuild" in result.output.lower()
2647
2648 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2649 runner.invoke(cli, ["code", "index", "rebuild"])
2650 result = runner.invoke(cli, ["code", "index", "status"])
2651 assert "✅" in result.output
2652
2653 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2654 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2655 assert "Agent quickstart" in result.output
2656
2657 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2658 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2659 assert "JSON output schema" in result.output
2660
2661 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2662 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2663 assert "Exit codes" in result.output
2664
2665
2666 # ---------------------------------------------------------------------------
2667 # Security — muse code index status
2668 # ---------------------------------------------------------------------------
2669
2670
2671 class TestIndexStatusSecurity:
2672 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2673 """A corrupt index file must not surface a traceback."""
2674 idx_dir = code_repo / ".muse" / "indices"
2675 idx_dir.mkdir(parents=True, exist_ok=True)
2676 (idx_dir / "symbol_history.msgpack").write_bytes(b"\x00" * 16)
2677 result = runner.invoke(cli, ["code", "index", "status"])
2678 assert "Traceback" not in result.output
2679
2680 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2681 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2682 from muse.core.indices import KNOWN_INDEX_NAMES
2683 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2684 _raw = json.loads(result.output.strip())
2685 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2686 for entry in data:
2687 assert entry["name"] in KNOWN_INDEX_NAMES
2688
2689 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2690 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2691 assert "\x1b" not in result.output
2692
2693 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2694 """status field is always one of the three allowed values."""
2695 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2696 _raw = json.loads(result.output.strip())
2697 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2698 for entry in data:
2699 assert entry["status"] in ("present", "absent", "corrupt")
2700
2701 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2702 monkeypatch.chdir(tmp_path)
2703 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2704 result = runner.invoke(cli, ["code", "index", "status"])
2705 assert "Traceback" not in result.output
2706 assert result.exit_code != 0
2707
2708 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2709 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2710 _raw = json.loads(result.output.strip())
2711 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2712 for entry in data:
2713 assert isinstance(entry["entries"], int)
2714
2715
2716 # ---------------------------------------------------------------------------
2717 # Stress — muse code index status
2718 # ---------------------------------------------------------------------------
2719
2720
2721 class TestIndexStatusStress:
2722 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2723 """50 sequential status calls all exit 0."""
2724 for i in range(50):
2725 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2726 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2727
2728 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2729 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2730 for i in range(100):
2731 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2732 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2733 data = json.loads(result.output.strip())
2734 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2735 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2736 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2737 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2738 data = json.loads(result.output.strip())
2739 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2740 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2741
2742 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2743 """8 threads reading index status concurrently — all must succeed."""
2744 import argparse
2745 import threading
2746
2747 from muse.cli.commands.index_rebuild import run_status
2748
2749 errors: list[str] = []
2750
2751 def worker(idx: int) -> None:
2752 args = argparse.Namespace(json_out=True)
2753 try:
2754 run_status(args)
2755 except SystemExit as exc:
2756 if exc.code != 0:
2757 errors.append(f"Thread {idx}: exit {exc.code}")
2758 except Exception as exc:
2759 errors.append(f"Thread {idx}: {exc}")
2760
2761 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2762 for t in threads:
2763 t.start()
2764 for t in threads:
2765 t.join()
2766 assert not errors, f"Concurrent failures: {errors}"
2767
2768
2769 # ---------------------------------------------------------------------------
2770 # Extended — muse code index rebuild
2771 # ---------------------------------------------------------------------------
2772
2773
2774 class TestIndexRebuildExtended:
2775 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2776 """-j is equivalent to --json."""
2777 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2778 assert result.exit_code == 0, result.output
2779 data = json.loads(result.output.strip())
2780 assert "rebuilt" in data
2781
2782 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2783 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2784 assert result.exit_code == 0
2785
2786 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2787 """JSON output is a single compact line — no indent=2."""
2788 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2789 assert result.exit_code == 0
2790 lines = [l for l in result.output.splitlines() if l.strip()]
2791 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2792
2793 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2794 """JSON output always has dry_run, rebuilt."""
2795 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2796 data = json.loads(result.output.strip())
2797 assert "dry_run" in data
2798 assert "rebuilt" in data
2799
2800 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2801 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2802 data = json.loads(result.output.strip())
2803 assert "symbol_history" in data["rebuilt"]
2804 assert "hash_occurrence" in data["rebuilt"]
2805
2806 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2807 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2808 data = json.loads(result.output.strip())
2809 assert data["dry_run"] is False
2810
2811 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2812 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2813 assert result.exit_code == 0
2814 data = json.loads(result.output.strip())
2815 assert data["dry_run"] is True
2816
2817 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2818 """--dry-run must not create index files."""
2819 idx_dir = code_repo / ".muse" / "indices"
2820 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2821 assert not (idx_dir / "symbol_history.msgpack").exists()
2822 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2823
2824 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2825 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2826 assert result.exit_code == 0
2827 data = json.loads(result.output.strip())
2828 assert data["rebuilt"] == ["symbol_history"]
2829 assert "symbol_history_addresses" in data
2830 assert "hash_occurrence_clusters" not in data
2831
2832 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2833 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2834 assert result.exit_code == 0
2835 data = json.loads(result.output.strip())
2836 assert data["rebuilt"] == ["hash_occurrence"]
2837 assert "hash_occurrence_clusters" in data
2838 assert "symbol_history_addresses" not in data
2839
2840 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2841 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2842 data = json.loads(result.output.strip())
2843 assert isinstance(data["symbol_history_addresses"], int)
2844 assert isinstance(data["symbol_history_events"], int)
2845
2846 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2847 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2848 data = json.loads(result.output.strip())
2849 assert isinstance(data["hash_occurrence_clusters"], int)
2850 assert isinstance(data["hash_occurrence_addresses"], int)
2851
2852 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2853 runner.invoke(cli, ["code", "index", "rebuild"])
2854 idx_dir = code_repo / ".muse" / "indices"
2855 assert (idx_dir / "symbol_history.msgpack").exists()
2856 assert (idx_dir / "hash_occurrence.msgpack").exists()
2857
2858 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2859 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2860 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2861 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2862 assert r1.exit_code == 0 and r2.exit_code == 0
2863 d1 = json.loads(r1.output.strip())
2864 d2 = json.loads(r2.output.strip())
2865 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2866
2867 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2868 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2869 assert result.exit_code == 0
2870 assert "Building" in result.output
2871
2872 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2873 result = runner.invoke(cli, ["code", "index", "rebuild"])
2874 assert "Rebuilt" in result.output or "index" in result.output.lower()
2875
2876 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2877 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2878 assert "Agent quickstart" in result.output
2879
2880 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2881 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2882 assert "JSON output schema" in result.output
2883
2884 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2885 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2886 assert "Exit codes" in result.output
2887
2888
2889 # ---------------------------------------------------------------------------
2890 # Security — muse code index rebuild
2891 # ---------------------------------------------------------------------------
2892
2893
2894 class TestIndexRebuildSecurity:
2895 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2896 """An unknown --index value must be rejected before run_rebuild is called."""
2897 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "evil_index"])
2898 assert result.exit_code != 0
2899
2900 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2901 idx_dir = code_repo / ".muse" / "indices"
2902 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2903 assert not (idx_dir / "symbol_history.msgpack").exists()
2904 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2905
2906 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2907 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2908 assert "\x1b" not in result.output
2909
2910 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2911 monkeypatch.chdir(tmp_path)
2912 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2913 result = runner.invoke(cli, ["code", "index", "rebuild"])
2914 assert "Traceback" not in result.output
2915 assert result.exit_code != 0
2916
2917 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2918 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2919 from muse.core.indices import KNOWN_INDEX_NAMES
2920 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2921 data = json.loads(result.output.strip())
2922 for name in data["rebuilt"]:
2923 assert name in KNOWN_INDEX_NAMES
2924
2925 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
2926 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2927 data = json.loads(result.output.strip())
2928 assert isinstance(data["muse_version"], str)
2929 assert len(data["muse_version"]) > 0
2930
2931
2932 # ---------------------------------------------------------------------------
2933 # Stress — muse code index rebuild
2934 # ---------------------------------------------------------------------------
2935
2936
2937 class TestIndexRebuildStress:
2938 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2939 """50 sequential rebuilds all exit 0."""
2940 for i in range(50):
2941 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2942 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2943
2944 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2945 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2946 indexes = ["symbol_history", "hash_occurrence"]
2947 for i in range(100):
2948 target = indexes[i % 2]
2949 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2950 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2951 data = json.loads(result.output.strip())
2952 assert target in data["rebuilt"]
2953
2954 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2955 """8 threads rebuilding hash_occurrence concurrently via core function."""
2956 import argparse
2957 import threading
2958
2959 from muse.cli.commands.index_rebuild import run_rebuild
2960
2961 errors: list[str] = []
2962
2963 def worker(idx: int) -> None:
2964 args = argparse.Namespace(
2965 index_name="hash_occurrence",
2966 dry_run=True, # dry_run avoids concurrent write races
2967 verbose=False,
2968 json_out=True,
2969 )
2970 try:
2971 run_rebuild(args)
2972 except SystemExit as exc:
2973 if exc.code != 0:
2974 errors.append(f"Thread {idx}: exit {exc.code}")
2975 except Exception as exc:
2976 errors.append(f"Thread {idx}: {exc}")
2977
2978 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2979 for t in threads:
2980 t.start()
2981 for t in threads:
2982 t.join()
2983 assert not errors, f"Concurrent failures: {errors}"
2984
2985
2986 # ---------------------------------------------------------------------------
2987 # Extended — muse code index purge
2988 # ---------------------------------------------------------------------------
2989
2990
2991 class TestIndexPurgeExtended:
2992 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2993 """-j is equivalent to --json."""
2994 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2995 assert result.exit_code == 0, result.output
2996 data = json.loads(result.output.strip())
2997 assert "purged" in data
2998
2999 def test_help_flag(self, code_repo: pathlib.Path) -> None:
3000 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3001 assert result.exit_code == 0
3002
3003 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
3004 """JSON output is compact — single line, no indent=2."""
3005 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3006 assert result.exit_code == 0
3007 lines = [l for l in result.output.splitlines() if l.strip()]
3008 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
3009
3010 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
3011 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3012 data = json.loads(result.output.strip())
3013 assert "purged" in data
3014 assert "skipped" in data
3015
3016 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
3017 """Purging when indexes are absent — both in skipped, none in purged."""
3018 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3019 data = json.loads(result.output.strip())
3020 assert data["purged"] == []
3021 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
3022
3023 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
3024 """After rebuild, purge reports both as purged."""
3025 runner.invoke(cli, ["code", "index", "rebuild"])
3026 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3027 data = json.loads(result.output.strip())
3028 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
3029 assert data["skipped"] == []
3030
3031 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
3032 runner.invoke(cli, ["code", "index", "rebuild"])
3033 runner.invoke(cli, ["code", "index", "purge"])
3034 idx_dir = code_repo / ".muse" / "indices"
3035 assert not (idx_dir / "symbol_history.msgpack").exists()
3036 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3037
3038 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
3039 runner.invoke(cli, ["code", "index", "rebuild"])
3040 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
3041 assert result.exit_code == 0
3042 data = json.loads(result.output.strip())
3043 assert data["purged"] == ["symbol_history"]
3044 assert data["skipped"] == []
3045 idx_dir = code_repo / ".muse" / "indices"
3046 assert not (idx_dir / "symbol_history.msgpack").exists()
3047 assert (idx_dir / "hash_occurrence.msgpack").exists()
3048
3049 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
3050 runner.invoke(cli, ["code", "index", "rebuild"])
3051 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3052 assert result.exit_code == 0
3053 data = json.loads(result.output.strip())
3054 assert data["purged"] == ["hash_occurrence"]
3055 idx_dir = code_repo / ".muse" / "indices"
3056 assert not (idx_dir / "hash_occurrence.msgpack").exists()
3057 assert (idx_dir / "symbol_history.msgpack").exists()
3058
3059 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
3060 """Purging when nothing is present still exits 0."""
3061 result = runner.invoke(cli, ["code", "index", "purge"])
3062 assert result.exit_code == 0
3063
3064 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
3065 """Purging twice in a row both exit 0."""
3066 runner.invoke(cli, ["code", "index", "rebuild"])
3067 r1 = runner.invoke(cli, ["code", "index", "purge"])
3068 r2 = runner.invoke(cli, ["code", "index", "purge"])
3069 assert r1.exit_code == 0
3070 assert r2.exit_code == 0
3071
3072 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
3073 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3074 data = json.loads(result.output.strip())
3075 assert isinstance(data["muse_version"], str)
3076 assert len(data["muse_version"]) > 0
3077
3078 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
3079 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3080 data = json.loads(result.output.strip())
3081 assert isinstance(data["purged"], list)
3082 assert isinstance(data["skipped"], list)
3083
3084 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
3085 runner.invoke(cli, ["code", "index", "rebuild"])
3086 result = runner.invoke(cli, ["code", "index", "purge"])
3087 assert "deleted" in result.output.lower() or "🗑" in result.output
3088
3089 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3090 result = runner.invoke(cli, ["code", "index", "purge"])
3091 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3092
3093 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3094 runner.invoke(cli, ["code", "index", "rebuild"])
3095 runner.invoke(cli, ["code", "index", "purge"])
3096 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3097 _raw = json.loads(result.output.strip())
3098 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
3099 for entry in data:
3100 assert entry["status"] == "absent"
3101
3102 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3103 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3104 assert "Agent quickstart" in result.output
3105
3106 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3107 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3108 assert "JSON output schema" in result.output
3109
3110 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3111 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3112 assert "Exit codes" in result.output
3113
3114
3115 # ---------------------------------------------------------------------------
3116 # Security — muse code index purge
3117 # ---------------------------------------------------------------------------
3118
3119
3120 class TestIndexPurgeSecurity:
3121 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3122 """Unknown --index value rejected by argparse before run_purge runs."""
3123 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3124 assert result.exit_code != 0
3125
3126 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3127 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3128 assert "\x1b" not in result.output
3129
3130 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3131 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3132 from muse.core.indices import KNOWN_INDEX_NAMES
3133 runner.invoke(cli, ["code", "index", "rebuild"])
3134 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3135 data = json.loads(result.output.strip())
3136 for name in data["purged"] + data["skipped"]:
3137 assert name in KNOWN_INDEX_NAMES
3138
3139 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3140 monkeypatch.chdir(tmp_path)
3141 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3142 result = runner.invoke(cli, ["code", "index", "purge"])
3143 assert "Traceback" not in result.output
3144 assert result.exit_code != 0
3145
3146 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3147 """Purge must not remove anything outside .muse/indices/."""
3148 runner.invoke(cli, ["code", "index", "rebuild"])
3149 repo_json = code_repo / ".muse" / "repo.json"
3150 assert repo_json.exists()
3151 runner.invoke(cli, ["code", "index", "purge"])
3152 assert repo_json.exists(), "repo.json must not be deleted by purge"
3153
3154 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3155 runner.invoke(cli, ["code", "index", "rebuild"])
3156 runner.invoke(cli, ["code", "index", "purge"])
3157 result = runner.invoke(cli, ["code", "index", "purge"])
3158 assert "Traceback" not in result.output
3159
3160
3161 # ---------------------------------------------------------------------------
3162 # Stress — muse code index purge
3163 # ---------------------------------------------------------------------------
3164
3165
3166 class TestIndexPurgeStress:
3167 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3168 """50 sequential purge calls all exit 0 (idempotent)."""
3169 for i in range(50):
3170 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3171 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3172
3173 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3174 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3175 for i in range(100):
3176 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3177 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3178 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3179 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3180 d = json.loads(r2.output.strip())
3181 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3182
3183 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3184 """8 threads purging concurrently via core function — all must exit 0."""
3185 import argparse
3186 import threading
3187
3188 from muse.cli.commands.index_rebuild import run_purge
3189
3190 runner.invoke(cli, ["code", "index", "rebuild"])
3191 errors: list[str] = []
3192
3193 def worker(idx: int) -> None:
3194 args = argparse.Namespace(index_name=None, json_out=True)
3195 try:
3196 run_purge(args)
3197 except SystemExit as exc:
3198 if exc.code != 0:
3199 errors.append(f"Thread {idx}: exit {exc.code}")
3200 except Exception as exc:
3201 errors.append(f"Thread {idx}: {exc}")
3202
3203 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3204 for t in threads:
3205 t.start()
3206 for t in threads:
3207 t.join()
3208 assert not errors, f"Concurrent failures: {errors}"
3209
3210
3211 # ---------------------------------------------------------------------------
3212 # Performance — iterative DFS regression (no RecursionError)
3213 # ---------------------------------------------------------------------------
3214
3215
3216 class TestIterativeDFS:
3217 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3218
3219 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3220 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3221
3222 # Build a linear chain A→B→C→…→Z (depth 600, beyond Python's 1000 default).
3223 depth = 600
3224 nodes = [f"mod_{i}" for i in range(depth)]
3225 imports_out: _ImportsMap = {
3226 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3227 }
3228 imports_out[nodes[-1]] = []
3229
3230 # Must not raise RecursionError.
3231 cycles = codemap_find_cycles(imports_out)
3232 assert isinstance(cycles, list)
3233 assert len(cycles) == 0 # linear chain has no cycles
3234
3235 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3236 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3237
3238 # A→B→C→A is a cycle.
3239 imports_out: _ImportsMap = {
3240 "A": ["B"],
3241 "B": ["C"],
3242 "C": ["A"],
3243 }
3244 cycles = codemap_find_cycles(imports_out)
3245 assert len(cycles) >= 1
3246
3247 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3248 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3249
3250 depth = 600
3251 nodes = [f"file_{i}.py" for i in range(depth)]
3252 imports: _ImportsSetMap = {
3253 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3254 }
3255 imports[nodes[-1]] = set()
3256
3257 cycles = invariants_find_cycles(imports)
3258 assert isinstance(cycles, list)
3259 assert len(cycles) == 0
3260
3261 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3262 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3263
3264 # A module that imports itself.
3265 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3266 cycles = invariants_find_cycles(imports)
3267 assert len(cycles) >= 1
3268
3269
3270 # ---------------------------------------------------------------------------
3271 # muse code symbols
3272 # ---------------------------------------------------------------------------
3273
3274
3275 class TestSymbols:
3276 """Tests for ``muse code symbols``."""
3277
3278 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3279 """Basic invocation lists functions and classes from HEAD snapshot."""
3280 result = runner.invoke(cli, ["code", "symbols"])
3281 assert result.exit_code == 0, result.output
3282 # billing.py contains Invoice class and process_order / send_email functions.
3283 assert "Invoice" in result.output
3284 assert "process_order" in result.output
3285 assert "symbols across" in result.output
3286
3287 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3288 """``--count`` prints a total count and language breakdown, no symbol table."""
3289 result = runner.invoke(cli, ["code", "symbols", "--count"])
3290 assert result.exit_code == 0, result.output
3291 assert "symbols" in result.output
3292 assert "Python" in result.output
3293 # Should NOT print individual symbol lines.
3294 assert "Invoice" not in result.output
3295
3296 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3297 """``--json`` emits a structured envelope with a flat 'results' list."""
3298 result = runner.invoke(cli, ["code", "symbols", "--json"])
3299 assert result.exit_code == 0, result.output
3300 data = json.loads(result.output)
3301 assert isinstance(data, dict)
3302 assert "results" in data
3303 assert "files" not in data
3304 assert isinstance(data["results"], list)
3305 assert any(e.get("address", "").startswith("billing.py") for e in data["results"])
3306 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3307
3308 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3309 """``--kind class`` shows only class-kind symbols."""
3310 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3311 assert result.exit_code == 0, result.output
3312 assert "Invoice" in result.output
3313 assert "process_order" not in result.output
3314
3315 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3316 """``--kind function`` shows only top-level functions, not methods."""
3317 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3318 assert result.exit_code == 0, result.output
3319 assert "process_order" in result.output
3320 assert "send_email" in result.output
3321 assert "Invoice" not in result.output
3322
3323 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3324 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3325 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3326 assert result.exit_code != 0
3327 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3328
3329 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3330 """``--file`` restricts output to a single file."""
3331 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3332 assert result.exit_code == 0, result.output
3333 assert "symbols across" in result.output
3334
3335 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3336 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3337 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3338 assert result.exit_code == 0, result.output
3339 assert "no semantic symbols found" in result.output
3340
3341 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3342 """``--language Python`` includes Python symbols; other languages excluded."""
3343 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3344 assert result.exit_code == 0, result.output
3345 assert "Invoice" in result.output
3346
3347 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3348 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3349 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3350 assert result.exit_code == 0, result.output
3351 assert "no semantic symbols found" in result.output
3352
3353 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3354 """``--hashes`` appends content hash abbreviations to each symbol row."""
3355 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3356 assert result.exit_code == 0, result.output
3357 # Hash suffix is 8 hex chars followed by ".."
3358 assert ".." in result.output
3359
3360 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3361 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3362 default = runner.invoke(cli, ["code", "symbols"])
3363 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3364 assert default.exit_code == 0
3365 assert head.exit_code == 0
3366 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3367 assert "Invoice" in default.output
3368 assert "Invoice" in head.output
3369 assert "symbols across" in default.output
3370 assert "symbols across" in head.output
3371
3372 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3373 """``--count`` and ``--json`` cannot be combined."""
3374 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3375 assert result.exit_code != 0
3376
3377 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3378 """JSON output uses the structured envelope with source_ref and results."""
3379 result = runner.invoke(cli, ["code", "symbols", "--json"])
3380 assert result.exit_code == 0, result.output
3381 data = json.loads(result.output)
3382 assert "source_ref" in data
3383 assert "working_tree" in data
3384 assert "total_symbols" in data
3385 assert "results" in data
3386 assert "files" not in data
3387 assert isinstance(data["working_tree"], bool)
3388 assert isinstance(data["total_symbols"], int)
3389 for entry in data["results"]:
3390 for field in ("address", "kind", "name", "qualified_name",
3391 "lineno", "content_id", "body_hash", "signature_id"):
3392 assert field in entry, f"missing field '{field}' in JSON entry"
3393
3394 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3395 """``--json`` without ``--commit`` reports working_tree=true."""
3396 result = runner.invoke(cli, ["code", "symbols", "--json"])
3397 assert result.exit_code == 0, result.output
3398 data = json.loads(result.output)
3399 assert data["working_tree"] is True
3400 assert data["source_ref"] == "working-tree"
3401
3402 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3403 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3404 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3405 assert result.exit_code == 0, result.output
3406 data = json.loads(result.output)
3407 assert data["working_tree"] is False
3408 assert data["source_ref"] != "working-tree"
3409 # source_ref is a prefixed short commit id (e.g. "sha256:<12hex>")
3410 assert data["source_ref"].startswith("sha256:")
3411
3412 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3413 """Working-tree mode picks up edits made to files after the last commit."""
3414 # Find the billing.py path on disk.
3415 billing = code_repo / "billing.py"
3416 assert billing.exists()
3417 # Append a new function — not yet committed.
3418 billing.write_text(
3419 billing.read_text() + "\ndef newly_added_function():\n pass\n"
3420 )
3421 result = runner.invoke(cli, ["code", "symbols"])
3422 assert result.exit_code == 0, result.output
3423 assert "newly_added_function" in result.output
3424
3425 # Committed snapshot should NOT contain it.
3426 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3427 assert committed.exit_code == 0
3428 assert "newly_added_function" not in committed.output
3429
3430 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3431 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3432 for variant in ("python", "Python", "PYTHON"):
3433 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3434 assert result.exit_code == 0, f"failed for --language {variant!r}"
3435 assert "Invoice" in result.output
3436
3437 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3438 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3439 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3440 assert result.exit_code == 0, result.output
3441 assert "Invoice" in result.output
3442
3443 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3444 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3445 # Write a second file with the same basename in a sub-directory.
3446 sub = code_repo / "sub"
3447 sub.mkdir(exist_ok=True)
3448 (sub / "billing.py").write_text("def sub_func(): pass\n")
3449 # Stage and commit both so the manifest has two paths ending in billing.py.
3450 import subprocess
3451 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3452 subprocess.run(
3453 ["muse", "commit", "-m", "add sub/billing.py"],
3454 cwd=code_repo, check=True,
3455 )
3456 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3457 assert result.exit_code != 0
3458 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3459
3460 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3461 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3462 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3463 assert result.exit_code != 0
3464 assert "not found" in result.output or "not found" in (result.stderr or "")
3465
3466
3467 # ---------------------------------------------------------------------------
3468 # TestSymbolLog
3469 # ---------------------------------------------------------------------------
3470
3471
3472 class TestSymbolLog:
3473 """Tests for ``muse code symbol-log``."""
3474
3475 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3476 """An address not found in any commit produces 'no events found'."""
3477 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3478 assert result.exit_code == 0, result.output
3479 assert "no events found" in result.output
3480
3481 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3482 """An address without '::' exits non-zero with a descriptive error."""
3483 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3484 assert result.exit_code != 0
3485 assert "::" in (result.output + (result.stderr or ""))
3486
3487 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3488 """An empty string as address exits non-zero."""
3489 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3490 # "::" is technically valid syntax; should at least not crash.
3491 assert result.exit_code == 0
3492
3493 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3494 """``--json`` emits the structured envelope with all top-level fields."""
3495 result = runner.invoke(
3496 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3497 )
3498 assert result.exit_code == 0, result.output
3499 data = json.loads(result.output)
3500 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3501 assert field in data, f"missing top-level field '{field}'"
3502 assert data["address"] == "billing.py::Invoice"
3503 assert data["start_ref"] == "HEAD"
3504 assert isinstance(data["total_commits_scanned"], int)
3505 assert isinstance(data["truncated"], bool)
3506 assert isinstance(data["events"], list)
3507
3508 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3509 """Each JSON event has the required fields."""
3510 result = runner.invoke(
3511 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3512 )
3513 assert result.exit_code == 0, result.output
3514 data = json.loads(result.output)
3515 for ev in data["events"]:
3516 for field in ("event", "commit_id", "message", "committed_at",
3517 "address", "detail", "new_address"):
3518 assert field in ev, f"missing event field '{field}'"
3519
3520 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3521 """When --max is hit, a truncation warning appears in human output."""
3522 result = runner.invoke(
3523 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3524 )
3525 assert result.exit_code == 0, result.output
3526 assert "incomplete" in result.output or "limit" in result.output
3527
3528 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3529 """When --max is hit, truncated=true appears in JSON output."""
3530 result = runner.invoke(
3531 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3532 )
3533 assert result.exit_code == 0, result.output
3534 data = json.loads(result.output)
3535 assert data["truncated"] is True
3536 assert data["total_commits_scanned"] == 1
3537
3538 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3539 """--max 0 exits non-zero with a clear error."""
3540 result = runner.invoke(
3541 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3542 )
3543 assert result.exit_code != 0
3544
3545 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3546 """``--from`` with a non-existent ref exits non-zero."""
3547 result = runner.invoke(
3548 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3549 )
3550 assert result.exit_code != 0
3551 assert "not found" in (result.output + (result.stderr or ""))
3552
3553 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3554 """BFS walk finds events on feature branches that were merged in via parent2.
3555
3556 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3557 The feature branch commit has a structured_delta inserting a symbol.
3558 The linear (parent1-only) walk would miss this; BFS must find it.
3559 """
3560 import datetime
3561
3562 root = code_repo
3563 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3564 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3565 from muse.core.snapshot import compute_commit_id
3566 from muse.domain import InsertOp, PatchOp, StructuredDelta
3567 branch = read_current_branch(root)
3568 head_id = get_head_commit_id(root, branch)
3569 assert head_id is not None
3570
3571 feature_snap = "aa" * 32
3572 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3573 feature_id = compute_commit_id(
3574 repo_id=repo_id,
3575 parent_ids=[head_id],
3576 snapshot_id=feature_snap,
3577 message="feat: add merged_fn",
3578 committed_at_iso=feature_at.isoformat(),
3579 author="test",)
3580 write_commit(root, CommitRecord(
3581 commit_id=feature_id,
3582 repo_id=repo_id,
3583 created_on_branch="feat/branch",
3584 snapshot_id=feature_snap,
3585 message="feat: add merged_fn",
3586 committed_at=feature_at,
3587 parent_commit_id=head_id,
3588 author="test",
3589 structured_delta=StructuredDelta(ops=[PatchOp(
3590 op="patch",
3591 address="billing.py",
3592 child_ops=[InsertOp(
3593 op="insert",
3594 address="billing.py::merged_fn",
3595 content_summary="function merged_fn",
3596 )],
3597 )]),
3598 ))
3599
3600 merge_snap = "bb" * 32
3601 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3602 merge_id = compute_commit_id(
3603 repo_id=repo_id,
3604 parent_ids=[head_id, feature_id],
3605 snapshot_id=merge_snap,
3606 message="merge feat/branch",
3607 committed_at_iso=merge_at.isoformat(),
3608 author="test",)
3609 write_commit(root, CommitRecord(
3610 commit_id=merge_id,
3611 repo_id=repo_id,
3612 created_on_branch=branch,
3613 snapshot_id=merge_snap,
3614 message="merge feat/branch",
3615 committed_at=merge_at,
3616 parent_commit_id=head_id,
3617 parent2_commit_id=feature_id,
3618 author="test",
3619 ))
3620
3621 branch_ref = root / ".muse" / "refs" / "heads" / branch
3622 branch_ref.write_text(merge_id)
3623
3624 result = runner.invoke(
3625 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3626 )
3627 assert result.exit_code == 0, result.output
3628 # BFS must find the creation event on the feature branch.
3629 assert "merged_fn" in result.output
3630 assert "created" in result.output
3631
3632 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3633 """Regression guard: verify the BFS result differs from a parent1-only scan.
3634
3635 Directly calls _walk_commits_dag and checks it returns commits from
3636 both parent chains, not just parent1.
3637 """
3638 import datetime
3639
3640 root = code_repo
3641 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3642 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3643 from muse.core.snapshot import compute_commit_id
3644 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3645 branch = read_current_branch(root)
3646 head_id = get_head_commit_id(root, branch)
3647 assert head_id is not None
3648
3649 feature_snap = "dd" * 32
3650 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3651 feature_id = compute_commit_id(
3652 repo_id=repo_id,
3653 parent_ids=[],
3654 snapshot_id=feature_snap,
3655 message="feat on second parent",
3656 committed_at_iso=feature_at.isoformat(),
3657 author="test",)
3658 write_commit(root, CommitRecord(
3659 commit_id=feature_id,
3660 repo_id=repo_id,
3661 created_on_branch="feat/x",
3662 snapshot_id=feature_snap,
3663 message="feat on second parent",
3664 committed_at=feature_at,
3665 author="test",
3666 ))
3667 merge_snap = "ee" * 32
3668 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3669 merge_id = compute_commit_id(
3670 repo_id=repo_id,
3671 parent_ids=[head_id, feature_id],
3672 snapshot_id=merge_snap,
3673 message="merge",
3674 committed_at_iso=merge_at.isoformat(),
3675 author="test",)
3676 write_commit(root, CommitRecord(
3677 commit_id=merge_id,
3678 repo_id=repo_id,
3679 created_on_branch=branch,
3680 snapshot_id=merge_snap,
3681 message="merge",
3682 committed_at=merge_at,
3683 parent_commit_id=head_id,
3684 parent2_commit_id=feature_id,
3685 author="test",
3686 ))
3687
3688 branch_ref = root / ".muse" / "refs" / "heads" / branch
3689 branch_ref.write_text(merge_id)
3690
3691 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3692 commit_ids = {c.commit_id for c in commits}
3693 assert feature_id in commit_ids
3694
3695
3696 # ---------------------------------------------------------------------------
3697 # muse code coupling
3698 # ---------------------------------------------------------------------------
3699
3700
3701 @pytest.fixture
3702 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3703 """Repo with 3 commits where billing.py + models.py co-change twice."""
3704 work = repo
3705
3706 # Commit 1: seed — only billing.py
3707 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3708 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3709 assert r.exit_code == 0, r.output
3710
3711 # Commit 2: billing.py + models.py change together
3712 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3713 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3714 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3715 assert r.exit_code == 0, r.output
3716
3717 # Commit 3: billing.py + models.py change together again
3718 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3719 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3720 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3721 assert r.exit_code == 0, r.output
3722
3723 return repo
3724
3725
3726 class TestCoupling:
3727 """Tests for muse code coupling."""
3728
3729 # ── basic correctness ────────────────────────────────────────────────────
3730
3731 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3732 result = runner.invoke(cli, ["code", "coupling"])
3733 assert result.exit_code == 0, result.output
3734
3735 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3736 """billing.py and models.py co-changed twice — must appear in output."""
3737 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3738 assert result.exit_code == 0, result.output
3739 assert "billing.py" in result.output
3740 assert "models.py" in result.output
3741
3742 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3743 result = runner.invoke(cli, ["code", "coupling"])
3744 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3745 assert "Commits analysed" in result.output
3746
3747 def test_coupling_min_filter_excludes_low_count(
3748 self, coupling_repo: pathlib.Path
3749 ) -> None:
3750 """--min 3 must exclude our pair that co-changed only twice."""
3751 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3752 assert result.exit_code == 0, result.output
3753 assert "billing.py" not in result.output or "no file pairs" in result.output
3754
3755 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3756 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3757 data = json.loads(result.output)
3758 assert len(data["pairs"]) <= 1
3759
3760 # ── --file filter ─────────────────────────────────────────────────────────
3761
3762 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3763 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3764 assert result.exit_code == 0, result.output
3765
3766 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3767 """--file billing.py must surface models.py as its partner."""
3768 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3769 assert result.exit_code == 0, result.output
3770 assert "models.py" in result.output
3771
3772 def test_coupling_file_filter_header_names_file(
3773 self, coupling_repo: pathlib.Path
3774 ) -> None:
3775 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3776 assert "billing.py" in result.output
3777
3778 def test_coupling_file_filter_nonexistent_returns_cleanly(
3779 self, coupling_repo: pathlib.Path
3780 ) -> None:
3781 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3782 assert result.exit_code == 0, result.output
3783
3784 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3785 """Suffix billing.py should match the file even without the full path."""
3786 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3787 assert result.exit_code == 0, result.output
3788 assert "models.py" in result.output
3789
3790 # ── JSON output ───────────────────────────────────────────────────────────
3791
3792 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3793 result = runner.invoke(cli, ["code", "coupling", "--json"])
3794 assert result.exit_code == 0, result.output
3795 data = json.loads(result.output)
3796 assert "from_ref" in data
3797 assert "to_ref" in data
3798 assert "commits_analysed" in data
3799 assert "truncated" in data
3800 assert "filters" in data
3801 assert "pairs" in data
3802 assert isinstance(data["pairs"], list)
3803
3804 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3805 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3806 data = json.loads(result.output)
3807 if data["pairs"]:
3808 pair = data["pairs"][0]
3809 assert "file_a" in pair or "file" in pair
3810 assert "co_changes" in pair
3811 assert isinstance(pair["co_changes"], int)
3812
3813 def test_coupling_json_file_filter_uses_partner_schema(
3814 self, coupling_repo: pathlib.Path
3815 ) -> None:
3816 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3817 result = runner.invoke(
3818 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3819 )
3820 data = json.loads(result.output)
3821 assert data["filters"]["file"] == "billing.py"
3822 if data["pairs"]:
3823 pair = data["pairs"][0]
3824 assert "file" in pair
3825 assert "partner" in pair
3826 assert "co_changes" in pair
3827 assert "file_a" not in pair # partner schema, not pair schema
3828
3829 def test_coupling_json_not_truncated_small_repo(
3830 self, coupling_repo: pathlib.Path
3831 ) -> None:
3832 result = runner.invoke(cli, ["code", "coupling", "--json"])
3833 data = json.loads(result.output)
3834 assert data["truncated"] is False
3835
3836 def test_coupling_json_filters_reflect_args(
3837 self, coupling_repo: pathlib.Path
3838 ) -> None:
3839 result = runner.invoke(
3840 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3841 )
3842 data = json.loads(result.output)
3843 assert data["filters"]["top"] == 5
3844 assert data["filters"]["min_count"] == 2
3845
3846 # ── --max-commits ─────────────────────────────────────────────────────────
3847
3848 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3849 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3850 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3851 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3852 d_cap = json.loads(r_cap.output)
3853 assert d_cap["commits_analysed"] <= 1
3854
3855 def test_coupling_max_commits_truncated_flag(
3856 self, coupling_repo: pathlib.Path
3857 ) -> None:
3858 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3859 data = json.loads(result.output)
3860 # With 3 commits and cap=1, truncated must be True.
3861 assert data["truncated"] is True
3862
3863 def test_coupling_max_commits_one_shows_warning(
3864 self, coupling_repo: pathlib.Path
3865 ) -> None:
3866 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3867 assert result.exit_code == 0, result.output
3868 assert "⚠️" in result.output or "capped" in result.output
3869
3870 # ── validation ────────────────────────────────────────────────────────────
3871
3872 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3873 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3874 assert result.exit_code != 0
3875
3876 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3877 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3878 assert result.exit_code != 0
3879
3880 def test_coupling_max_commits_zero_exits_error(
3881 self, coupling_repo: pathlib.Path
3882 ) -> None:
3883 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3884 assert result.exit_code != 0
3885
3886 def test_coupling_invalid_from_ref_exits_error(
3887 self, coupling_repo: pathlib.Path
3888 ) -> None:
3889 result = runner.invoke(
3890 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3891 )
3892 assert result.exit_code != 0
3893
3894 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3895 """Coupling must count co-changes on feature-branch commits (parent2)."""
3896 import datetime
3897
3898 # Genesis commit
3899 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3900 r = runner.invoke(cli, ["commit", "-m", "seed"])
3901 assert r.exit_code == 0, r.output
3902
3903 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
3904 repo_id = repo_json["repo_id"]
3905 from muse.core.store import read_current_branch, resolve_commit_ref
3906 branch = read_current_branch(repo)
3907 head = resolve_commit_ref(repo, repo_id, branch, None)
3908 assert head is not None
3909
3910 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3911 feature_at = now
3912 merge_at = now + datetime.timedelta(hours=1)
3913
3914 # Feature commit touching billing.py + models.py together.
3915 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3916 from muse.core.snapshot import compute_commit_id
3917 feature_delta = StructuredDelta(
3918 domain="code",
3919 ops=[
3920 PatchOp(
3921 op="patch", address="billing.py",
3922 child_ops=[ReplaceOp(
3923 op="replace", address="billing.py::compute",
3924 old_content_id="a" * 64, new_content_id="b" * 64,
3925 old_summary="function compute",
3926 new_summary="function compute (modified)", position=None,
3927 )],
3928 child_domain="code", child_summary="compute modified",
3929 ),
3930 PatchOp(
3931 op="patch", address="models.py",
3932 child_ops=[InsertOp(
3933 op="insert", address="models.py::Order",
3934 content_id="c" * 64, content_summary="class Order", position=None,
3935 )],
3936 child_domain="code", child_summary="Order added",
3937 ),
3938 ],
3939 summary="co-change",
3940 )
3941 feature_id = compute_commit_id(
3942 [head.commit_id], head.snapshot_id,
3943 "co-change on feature branch", feature_at.isoformat(),
3944 repo_id=repo_id, author="test",
3945 )
3946 merge_id = compute_commit_id(
3947 [head.commit_id, feature_id], head.snapshot_id,
3948 "Merge feature", merge_at.isoformat(),
3949 repo_id=repo_id, author="test",
3950 )
3951 feature_body: CommitDict = {
3952 "commit_id": feature_id,
3953 "repo_id": repo_id,
3954 "branch": "feat/test",
3955 "snapshot_id": head.snapshot_id,
3956 "message": "co-change on feature branch",
3957 "committed_at": feature_at.isoformat(),
3958 "parent_commit_id": head.commit_id,
3959 "parent2_commit_id": None,
3960 "author": "test",
3961 "metadata": {},
3962 "structured_delta": feature_delta,
3963 }
3964 merge_body: CommitDict = {
3965 "commit_id": merge_id,
3966 "repo_id": repo_id,
3967 "branch": branch,
3968 "snapshot_id": head.snapshot_id,
3969 "message": "Merge feature",
3970 "committed_at": merge_at.isoformat(),
3971 "parent_commit_id": head.commit_id,
3972 "parent2_commit_id": feature_id,
3973 "author": "test",
3974 "metadata": {},
3975 "structured_delta": None,
3976 }
3977 from muse.core.store import write_commit, CommitRecord
3978 write_commit(repo, CommitRecord.from_dict(feature_body))
3979 write_commit(repo, CommitRecord.from_dict(merge_body))
3980 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
3981
3982 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3983 assert result.exit_code == 0, result.output
3984 data = json.loads(result.output)
3985 pairs_found = {
3986 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
3987 }
3988 billing_models = any(
3989 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
3990 for a, b in pairs_found
3991 )
3992 assert billing_models, "BFS must find the feature-branch co-change commit"
3993
3994
3995 # ---------------------------------------------------------------------------
3996 # muse code stable
3997 # ---------------------------------------------------------------------------
3998
3999
4000 class TestStable:
4001 """Tests for muse code stable."""
4002
4003 # ── basic correctness ────────────────────────────────────────────────────
4004
4005 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
4006 result = runner.invoke(cli, ["code", "stable"])
4007 assert result.exit_code == 0, result.output
4008
4009 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
4010 result = runner.invoke(cli, ["code", "stable"])
4011 assert result.exit_code == 0, result.output
4012 assert "Symbol stability" in result.output
4013 assert "Commits analysed" in result.output
4014 assert "bedrock" in result.output
4015
4016 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
4017 """Invoice.apply_discount was defined in the genesis commit and never modified."""
4018 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4019 assert result.exit_code == 0, result.output
4020 # apply_discount was never touched in any structured_delta → maximally stable.
4021 assert "apply_discount" in result.output
4022
4023 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
4024 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4025 assert result.exit_code == 0, result.output
4026 assert "since start of range" in result.output
4027
4028 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
4029 """Markdown / TOML / YAML symbols must be absent from default output."""
4030 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4031 assert result.exit_code == 0, result.output
4032 assert ".md::" not in result.output
4033 assert ".toml::" not in result.output
4034
4035 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
4036 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4037 assert result.exit_code == 0, result.output
4038 assert "::import::" not in result.output
4039
4040 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
4041 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
4042 assert result.exit_code == 0, result.output
4043
4044 # ── JSON output ───────────────────────────────────────────────────────────
4045
4046 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
4047 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4048 assert result.exit_code == 0, result.output
4049 data = json.loads(result.output)
4050 assert "from_ref" in data
4051 assert "to_ref" in data
4052 assert "commits_analysed" in data
4053 assert "truncated" in data
4054 assert "filters" in data
4055 assert "stable" in data
4056 assert isinstance(data["stable"], list)
4057
4058 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
4059 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4060 data = json.loads(result.output)
4061 assert len(data["stable"]) > 0
4062 entry = data["stable"][0]
4063 assert "address" in entry
4064 assert "unchanged_for" in entry
4065 assert "since_start_of_range" in entry
4066 assert isinstance(entry["unchanged_for"], int)
4067 assert isinstance(entry["since_start_of_range"], bool)
4068
4069 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
4070 result = runner.invoke(
4071 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
4072 )
4073 data = json.loads(result.output)
4074 assert data["filters"]["top"] == 3
4075 assert data["filters"]["kind"] == "function"
4076 assert data["filters"]["include_imports"] is False
4077 assert data["filters"]["include_docs"] is False
4078
4079 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
4080 result = runner.invoke(cli, ["code", "stable", "--json"])
4081 data = json.loads(result.output)
4082 assert data["truncated"] is False
4083
4084 # ── --language filter ─────────────────────────────────────────────────────
4085
4086 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
4087 """--language python and --language Python must behave identically."""
4088 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4089 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
4090 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
4091 d_lower = json.loads(r_lower.output)
4092 d_upper = json.loads(r_upper.output)
4093 addrs_lower = {e["address"] for e in d_lower["stable"]}
4094 addrs_upper = {e["address"] for e in d_upper["stable"]}
4095 assert addrs_lower == addrs_upper
4096
4097 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
4098 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4099 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4100 d_py = json.loads(r_py.output)
4101 d_all = json.loads(r_all.output)
4102 # Python-filtered results must be a subset of or equal to unfiltered results.
4103 py_addrs = {e["address"] for e in d_py["stable"]}
4104 all_addrs = {e["address"] for e in d_all["stable"]}
4105 assert py_addrs <= all_addrs
4106
4107 # ── --since REF ───────────────────────────────────────────────────────────
4108
4109 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
4110 """--since HEAD restricts the window to 0 commits (stop immediately)."""
4111 # Get the HEAD commit id to use as --since boundary
4112 import json as _json
4113 root = code_repo
4114 repo_id = _json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
4115 from muse.core.store import read_current_branch, resolve_commit_ref
4116 branch = read_current_branch(root)
4117 head = resolve_commit_ref(root, repo_id, branch, None)
4118 assert head is not None
4119
4120 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4121 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4122 assert r_all.exit_code == 0 and r_since.exit_code == 0
4123 d_all = json.loads(r_all.output)
4124 d_since = json.loads(r_since.output)
4125 # Window stops at HEAD itself → at most 1 commit analysed.
4126 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4127
4128 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4129 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4130 assert result.exit_code != 0
4131
4132 # ── --max-commits ─────────────────────────────────────────────────────────
4133
4134 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4135 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4136 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4137 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4138 d_cap = json.loads(r_cap.output)
4139 assert d_cap["commits_analysed"] <= 1
4140
4141 def test_stable_max_commits_one_shows_truncated_warning(
4142 self, code_repo: pathlib.Path
4143 ) -> None:
4144 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4145 assert result.exit_code == 0, result.output
4146 # With 2 commits and cap=1, truncated warning should appear.
4147 assert "capped" in result.output or "⚠️" in result.output
4148
4149 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4150 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4151 assert result.exit_code != 0
4152
4153 # ── --top validation ──────────────────────────────────────────────────────
4154
4155 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4156 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4157 assert result.exit_code != 0
4158
4159 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4160 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4161 data = json.loads(result.output)
4162 assert len(data["stable"]) <= 2
4163
4164 # ── BFS follows merge parents ─────────────────────────────────────────────
4165
4166 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4167 """Symbols touched only on a merged feature branch must be detected as unstable."""
4168 import datetime
4169
4170 # Create a symbol in commit 1 (main).
4171 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4172 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4173 assert r.exit_code == 0, r.output
4174
4175 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
4176 repo_id = repo_json["repo_id"]
4177 from muse.core.store import read_current_branch, resolve_commit_ref
4178 branch = read_current_branch(repo)
4179 head_commit = resolve_commit_ref(repo, repo_id, branch, None)
4180 assert head_commit is not None
4181 head_id = head_commit.commit_id
4182
4183 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4184 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4185
4186 # Feature-branch commit that touched "bedrock" via a structured_delta.
4187 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4188 from muse.core.snapshot import compute_commit_id
4189 bedrock_delta = StructuredDelta(
4190 domain="code",
4191 ops=[PatchOp(
4192 op="patch", address="core.py",
4193 child_ops=[ReplaceOp(
4194 op="replace", address="core.py::bedrock",
4195 old_content_id="a" * 64, new_content_id="b" * 64,
4196 old_summary="function bedrock",
4197 new_summary="function bedrock (modified)", position=None,
4198 )],
4199 child_domain="code", child_summary="bedrock modified",
4200 )],
4201 summary="bedrock modified",
4202 )
4203 feature_id = compute_commit_id(
4204 [head_id], head_commit.snapshot_id,
4205 "Feature: touch bedrock", feature_at.isoformat(),
4206 repo_id=repo_id, author="test",
4207 )
4208 merge_id = compute_commit_id(
4209 [head_id, feature_id], head_commit.snapshot_id,
4210 "Merge feat/touch-bedrock", merge_at.isoformat(),
4211 repo_id=repo_id, author="test",
4212 )
4213 feature_body: CommitDict = {
4214 "commit_id": feature_id,
4215 "repo_id": repo_id,
4216 "branch": "feat/touch-bedrock",
4217 "snapshot_id": head_commit.snapshot_id,
4218 "message": "Feature: touch bedrock",
4219 "committed_at": feature_at.isoformat(),
4220 "parent_commit_id": head_id,
4221 "parent2_commit_id": None,
4222 "author": "test",
4223 "metadata": {},
4224 "structured_delta": bedrock_delta,
4225 }
4226 # Merge commit whose parent2 is the feature commit.
4227 merge_body: CommitDict = {
4228 "commit_id": merge_id,
4229 "repo_id": repo_id,
4230 "branch": branch,
4231 "snapshot_id": head_commit.snapshot_id,
4232 "message": "Merge feat/touch-bedrock",
4233 "committed_at": merge_at.isoformat(),
4234 "parent_commit_id": head_id,
4235 "parent2_commit_id": feature_id,
4236 "author": "test",
4237 "metadata": {},
4238 "structured_delta": None,
4239 }
4240 from muse.core.store import write_commit, CommitRecord
4241 write_commit(repo, CommitRecord.from_dict(feature_body))
4242 write_commit(repo, CommitRecord.from_dict(merge_body))
4243 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
4244
4245 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4246 assert result.exit_code == 0, result.output
4247 data = json.loads(result.output)
4248 # bedrock was touched in the feature-branch commit; BFS must find it.
4249 # It should have unchanged_for < total_commits (not maximally stable).
4250 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4251 if bedrock_entries:
4252 assert not bedrock_entries[0]["since_start_of_range"]
4253
4254
4255 # ---------------------------------------------------------------------------
4256 # muse code compare
4257 # ---------------------------------------------------------------------------
4258
4259
4260 @pytest.fixture
4261 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4262 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4263
4264 Commit A — billing.py with Invoice.compute_total + process_order.
4265 Commit B — compute_total renamed to compute_invoice_total; generate_pdf
4266 and send_email added. Multi-line message to test truncation.
4267 """
4268 (repo / "billing.py").write_text(textwrap.dedent("""\
4269 class Invoice:
4270 def compute_total(self, items):
4271 return sum(items)
4272
4273 def apply_discount(self, total, pct):
4274 return total * (1 - pct)
4275
4276 def process_order(invoice, items):
4277 return invoice.compute_total(items)
4278 """))
4279 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4280 assert r.exit_code == 0, r.output
4281 from muse.core.store import read_current_branch
4282 branch = read_current_branch(repo)
4283 commit_a = get_head_commit_id(repo, branch)
4284
4285 (repo / "billing.py").write_text(textwrap.dedent("""\
4286 class Invoice:
4287 def compute_invoice_total(self, items):
4288 return sum(items)
4289
4290 def apply_discount(self, total, pct):
4291 return total * (1 - pct)
4292
4293 def generate_pdf(self):
4294 return b"pdf"
4295
4296 def process_order(invoice, items):
4297 return invoice.compute_invoice_total(items)
4298
4299 def send_email(address):
4300 pass
4301 """))
4302 # Multi-line message to test first-line truncation.
4303 r = runner.invoke(cli, [
4304 "commit", "-m",
4305 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4306 ])
4307 assert r.exit_code == 0, r.output
4308 commit_b = get_head_commit_id(repo, branch)
4309
4310 assert commit_a is not None
4311 assert commit_b is not None
4312 return repo, commit_a, commit_b
4313
4314
4315 class TestCompare:
4316 """Tests for muse code compare."""
4317
4318 # ── basic correctness ────────────────────────────────────────────────────
4319
4320 def test_compare_exits_zero(
4321 self, compare_repo: tuple[pathlib.Path, str, str]
4322 ) -> None:
4323 _, ref_a, ref_b = compare_repo
4324 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4325 assert result.exit_code == 0, result.output
4326
4327 def test_compare_shows_header(
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])
4332 assert result.exit_code == 0, result.output
4333 assert "Semantic comparison" in result.output
4334 assert "From:" in result.output
4335 assert "To:" in result.output
4336
4337 def test_compare_commit_message_first_line_only(
4338 self, compare_repo: tuple[pathlib.Path, str, str]
4339 ) -> None:
4340 """Multi-line commit messages must be truncated to their first line."""
4341 _, ref_a, ref_b = compare_repo
4342 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4343 assert result.exit_code == 0, result.output
4344 # The body of the second commit must not appear in the header.
4345 assert "This is the extended body" not in result.output
4346
4347 def test_compare_same_ref_no_changes(
4348 self, compare_repo: tuple[pathlib.Path, str, str]
4349 ) -> None:
4350 _, ref_a, _ = compare_repo
4351 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4352 assert result.exit_code == 0, result.output
4353 assert "no semantic changes" in result.output
4354
4355 def test_compare_detects_added_symbols(
4356 self, compare_repo: tuple[pathlib.Path, str, str]
4357 ) -> None:
4358 _, ref_a, ref_b = compare_repo
4359 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4360 assert result.exit_code == 0, result.output
4361 # generate_pdf and send_email were added in commit B.
4362 assert "generate_pdf" in result.output or "send_email" in result.output
4363
4364 def test_compare_invalid_ref_exits_nonzero(
4365 self, compare_repo: tuple[pathlib.Path, str, str]
4366 ) -> None:
4367 _, ref_a, _ = compare_repo
4368 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4369 assert result.exit_code != 0
4370
4371 def test_compare_requires_repo(
4372 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4373 ) -> None:
4374 monkeypatch.chdir(tmp_path)
4375 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4376 assert result.exit_code != 0
4377
4378 # ── JSON schema ──────────────────────────────────────────────────────────
4379
4380 def test_compare_json_schema(
4381 self, compare_repo: tuple[pathlib.Path, str, str]
4382 ) -> None:
4383 _, ref_a, ref_b = compare_repo
4384 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4385 assert result.exit_code == 0, result.output
4386 data = json.loads(result.output)
4387 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4388
4389 def test_compare_json_from_to_schema(
4390 self, compare_repo: tuple[pathlib.Path, str, str]
4391 ) -> None:
4392 _, ref_a, ref_b = compare_repo
4393 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4394 assert result.exit_code == 0, result.output
4395 data = json.loads(result.output)
4396 assert "commit_id" in data["from"]
4397 assert "message" in data["from"]
4398 assert "commit_id" in data["to"]
4399 assert "message" in data["to"]
4400
4401 def test_compare_json_message_first_line_only(
4402 self, compare_repo: tuple[pathlib.Path, str, str]
4403 ) -> None:
4404 _, ref_a, ref_b = compare_repo
4405 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4406 assert result.exit_code == 0, result.output
4407 data = json.loads(result.output)
4408 assert "\n" not in data["to"]["message"]
4409 assert "This is the extended body" not in data["to"]["message"]
4410
4411 def test_compare_json_stat_schema(
4412 self, compare_repo: tuple[pathlib.Path, str, str]
4413 ) -> None:
4414 _, ref_a, ref_b = compare_repo
4415 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4416 assert result.exit_code == 0, result.output
4417 stat = json.loads(result.output)["stat"]
4418 assert set(stat.keys()) >= {
4419 "files_changed", "symbols_added", "symbols_removed",
4420 "symbols_modified", "semver_impact",
4421 }
4422 assert isinstance(stat["files_changed"], int)
4423 assert isinstance(stat["symbols_added"], int)
4424 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4425
4426 def test_compare_json_filters_schema(
4427 self, compare_repo: tuple[pathlib.Path, str, str]
4428 ) -> None:
4429 _, ref_a, ref_b = compare_repo
4430 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4431 assert result.exit_code == 0, result.output
4432 filters = json.loads(result.output)["filters"]
4433 assert set(filters.keys()) >= {"kind", "file", "language"}
4434 # No filters applied — all None.
4435 assert filters["kind"] is None
4436 assert filters["file"] is None
4437 assert filters["language"] is None
4438
4439 def test_compare_json_ops_schema(
4440 self, compare_repo: tuple[pathlib.Path, str, str]
4441 ) -> None:
4442 _, ref_a, ref_b = compare_repo
4443 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4444 assert result.exit_code == 0, result.output
4445 ops = json.loads(result.output)["ops"]
4446 assert isinstance(ops, list)
4447 assert len(ops) > 0
4448 for op in ops:
4449 assert "op" in op
4450 assert "address" in op
4451 assert "detail" in op
4452
4453 def test_compare_same_ref_json_empty_ops(
4454 self, compare_repo: tuple[pathlib.Path, str, str]
4455 ) -> None:
4456 _, ref_a, _ = compare_repo
4457 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--json"])
4458 assert result.exit_code == 0, result.output
4459 data = json.loads(result.output)
4460 assert data["ops"] == []
4461 assert data["stat"]["semver_impact"] == "NONE"
4462
4463 # ── --stat flag ──────────────────────────────────────────────────────────
4464
4465 def test_compare_stat_shows_counts(
4466 self, compare_repo: tuple[pathlib.Path, str, str]
4467 ) -> None:
4468 _, ref_a, ref_b = compare_repo
4469 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4470 assert result.exit_code == 0, result.output
4471 assert "Files changed:" in result.output
4472 assert "Symbols added:" in result.output
4473 assert "Symbols removed:" in result.output
4474 assert "Symbols modified:" in result.output
4475 assert "SemVer impact:" in result.output
4476
4477 def test_compare_stat_no_per_symbol_listing(
4478 self, compare_repo: tuple[pathlib.Path, str, str]
4479 ) -> None:
4480 _, ref_a, ref_b = compare_repo
4481 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4482 assert result.exit_code == 0, result.output
4483 # --stat should not include per-symbol listing lines ("added …", "removed …").
4484 assert " added " not in result.output
4485 assert " removed " not in result.output
4486 assert " modified " not in result.output
4487
4488 def test_compare_stat_same_ref_semver_none(
4489 self, compare_repo: tuple[pathlib.Path, str, str]
4490 ) -> None:
4491 _, ref_a, _ = compare_repo
4492 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--stat"])
4493 assert result.exit_code == 0, result.output
4494 assert "NONE" in result.output
4495
4496 # ── --semver flag ────────────────────────────────────────────────────────
4497
4498 def test_compare_semver_appended_to_full_output(
4499 self, compare_repo: tuple[pathlib.Path, str, str]
4500 ) -> None:
4501 _, ref_a, ref_b = compare_repo
4502 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--semver"])
4503 assert result.exit_code == 0, result.output
4504 assert "SemVer impact:" in result.output
4505
4506 # ── --file filter ────────────────────────────────────────────────────────
4507
4508 def test_compare_file_filter_restricts_output(
4509 self, compare_repo: tuple[pathlib.Path, str, str]
4510 ) -> None:
4511 _, ref_a, ref_b = compare_repo
4512 result = runner.invoke(
4513 cli, ["code", "compare", ref_a, ref_b, "--file", "billing.py"]
4514 )
4515 assert result.exit_code == 0, result.output
4516
4517 def test_compare_file_filter_nonexistent_no_ops(
4518 self, compare_repo: tuple[pathlib.Path, str, str]
4519 ) -> None:
4520 _, ref_a, ref_b = compare_repo
4521 result = runner.invoke(
4522 cli, ["code", "compare", ref_a, ref_b, "--file", "nonexistent.py"]
4523 )
4524 assert result.exit_code == 0, result.output
4525 assert "no semantic changes" in result.output
4526
4527 def test_compare_file_filter_in_json(
4528 self, compare_repo: tuple[pathlib.Path, str, str]
4529 ) -> None:
4530 _, ref_a, ref_b = compare_repo
4531 result = runner.invoke(
4532 cli,
4533 ["code", "compare", ref_a, ref_b, "--file", "billing.py", "--json"],
4534 )
4535 assert result.exit_code == 0, result.output
4536 data = json.loads(result.output)
4537 assert data["filters"]["file"] == "billing.py"
4538
4539 # ── --kind filter ────────────────────────────────────────────────────────
4540
4541 def test_compare_kind_filter_case_insensitive(
4542 self, compare_repo: tuple[pathlib.Path, str, str]
4543 ) -> None:
4544 _, ref_a, ref_b = compare_repo
4545 r_lower = runner.invoke(
4546 cli, ["code", "compare", ref_a, ref_b, "--kind", "function"]
4547 )
4548 r_upper = runner.invoke(
4549 cli, ["code", "compare", ref_a, ref_b, "--kind", "Function"]
4550 )
4551 assert r_lower.exit_code == 0
4552 assert r_upper.exit_code == 0
4553 # Both produce the same ops list.
4554 assert r_lower.output == r_upper.output
4555
4556 def test_compare_kind_filter_in_json(
4557 self, compare_repo: tuple[pathlib.Path, str, str]
4558 ) -> None:
4559 _, ref_a, ref_b = compare_repo
4560 result = runner.invoke(
4561 cli, ["code", "compare", ref_a, ref_b, "--kind", "function", "--json"]
4562 )
4563 assert result.exit_code == 0, result.output
4564 data = json.loads(result.output)
4565 assert data["filters"]["kind"] == "function"
4566
4567 # ── --language filter ────────────────────────────────────────────────────
4568
4569 def test_compare_language_filter_python(
4570 self, compare_repo: tuple[pathlib.Path, str, str]
4571 ) -> None:
4572 _, ref_a, ref_b = compare_repo
4573 result = runner.invoke(
4574 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4575 )
4576 assert result.exit_code == 0, result.output
4577
4578 def test_compare_language_filter_case_insensitive(
4579 self, compare_repo: tuple[pathlib.Path, str, str]
4580 ) -> None:
4581 _, ref_a, ref_b = compare_repo
4582 r_lower = runner.invoke(
4583 cli, ["code", "compare", ref_a, ref_b, "--language", "python"]
4584 )
4585 r_upper = runner.invoke(
4586 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4587 )
4588 assert r_lower.exit_code == 0
4589 assert r_upper.exit_code == 0
4590 assert r_lower.output == r_upper.output
4591
4592 def test_compare_language_filter_in_json(
4593 self, compare_repo: tuple[pathlib.Path, str, str]
4594 ) -> None:
4595 _, ref_a, ref_b = compare_repo
4596 result = runner.invoke(
4597 cli, ["code", "compare", ref_a, ref_b, "--language", "python", "--json"]
4598 )
4599 assert result.exit_code == 0, result.output
4600 data = json.loads(result.output)
4601 assert data["filters"]["language"] == "Python"
4602
4603
4604 # ---------------------------------------------------------------------------
4605 # muse code languages
4606 # ---------------------------------------------------------------------------
4607
4608
4609 @pytest.fixture
4610 def lang_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4611 """Two-commit repo; returns (path, commit_id_a, commit_id_b).
4612
4613 Commit A — billing.py (Python) only.
4614 Commit B — billing.py extended + README.md added.
4615 """
4616 (repo / "billing.py").write_text(textwrap.dedent("""\
4617 import os
4618 import json
4619
4620 class Invoice:
4621 def compute_total(self, items: list[float]) -> float:
4622 return sum(items)
4623
4624 def process_order(invoice: Invoice, items: list[float]) -> float:
4625 return invoice.compute_total(items)
4626 """))
4627 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4628 assert r.exit_code == 0, r.output
4629 from muse.core.store import read_current_branch
4630 branch = read_current_branch(repo)
4631 commit_a = get_head_commit_id(repo, branch)
4632
4633 (repo / "billing.py").write_text(textwrap.dedent("""\
4634 import os
4635 import json
4636
4637 class Invoice:
4638 def compute_total(self, items: list[float]) -> float:
4639 return sum(items)
4640
4641 def generate_pdf(self) -> bytes:
4642 return b"pdf"
4643
4644 def process_order(invoice: Invoice, items: list[float]) -> float:
4645 return invoice.compute_total(items)
4646
4647 def send_email(address: str) -> None:
4648 pass
4649 """))
4650 (repo / "README.md").write_text("# My Project\n\nA billing module.\n")
4651 r = runner.invoke(cli, ["commit", "-m", "Add generate_pdf, send_email, README"])
4652 assert r.exit_code == 0, r.output
4653 commit_b = get_head_commit_id(repo, branch)
4654
4655 assert commit_a is not None
4656 assert commit_b is not None
4657 return repo, commit_a, commit_b
4658
4659
4660 class TestLanguages:
4661 """Tests for muse code languages."""
4662
4663 # ── basic correctness ────────────────────────────────────────────────────
4664
4665 def test_languages_exits_zero(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4666 result = runner.invoke(cli, ["code", "languages"])
4667 assert result.exit_code == 0, result.output
4668
4669 def test_languages_shows_header(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4670 result = runner.invoke(cli, ["code", "languages"])
4671 assert result.exit_code == 0, result.output
4672 assert "Language breakdown" in result.output
4673 assert "Total" in result.output
4674
4675 def test_languages_shows_python(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4676 result = runner.invoke(cli, ["code", "languages"])
4677 assert result.exit_code == 0, result.output
4678 assert "Python" in result.output
4679
4680 def test_languages_shows_markdown(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4681 result = runner.invoke(cli, ["code", "languages"])
4682 assert result.exit_code == 0, result.output
4683 assert "Markdown" in result.output
4684
4685 def test_languages_excludes_imports_by_default(
4686 self, lang_repo: tuple[pathlib.Path, str, str]
4687 ) -> None:
4688 """Import pseudo-symbols must not inflate the count by default."""
4689 r_default = runner.invoke(cli, ["code", "languages", "--json"])
4690 assert r_default.exit_code == 0, r_default.output
4691 r_imports = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4692 assert r_imports.exit_code == 0, r_imports.output
4693
4694 class _LangEntry(TypedDict):
4695 language: str
4696 files: int
4697 symbols: int
4698 kinds: _KindsMap
4699
4700 class _LangsJson(TypedDict):
4701 languages: list[_LangEntry]
4702
4703 data_default: _LangsJson = json.loads(r_default.output)
4704 data_imports: _LangsJson = json.loads(r_imports.output)
4705
4706 def _py_syms(data: _LangsJson) -> int:
4707 for e in data["languages"]:
4708 if e["language"] == "Python":
4709 return e["symbols"]
4710 return 0
4711
4712 syms_default = _py_syms(data_default)
4713 syms_imports = _py_syms(data_imports)
4714 # With imports included the symbol count must be strictly higher.
4715 assert syms_imports > syms_default
4716
4717 def test_languages_requires_repo(
4718 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4719 ) -> None:
4720 monkeypatch.chdir(tmp_path)
4721 result = runner.invoke(cli, ["code", "languages"])
4722 assert result.exit_code != 0
4723
4724 def test_languages_invalid_commit_exits_nonzero(
4725 self, lang_repo: tuple[pathlib.Path, str, str]
4726 ) -> None:
4727 result = runner.invoke(cli, ["code", "languages", "--commit", "deadbeefdeadbeef"])
4728 assert result.exit_code != 0
4729
4730 # ── JSON schema ──────────────────────────────────────────────────────────
4731
4732 def test_languages_json_schema(
4733 self, lang_repo: tuple[pathlib.Path, str, str]
4734 ) -> None:
4735 result = runner.invoke(cli, ["code", "languages", "--json"])
4736 assert result.exit_code == 0, result.output
4737 data = json.loads(result.output)
4738 assert set(data.keys()) >= {"commit", "include_imports", "languages"}
4739
4740 def test_languages_json_commit_block(
4741 self, lang_repo: tuple[pathlib.Path, str, str]
4742 ) -> None:
4743 result = runner.invoke(cli, ["code", "languages", "--json"])
4744 assert result.exit_code == 0, result.output
4745 data = json.loads(result.output)
4746 commit = data["commit"]
4747 assert "commit_id" in commit
4748 assert "message" in commit
4749 # message is first line only — no newlines.
4750 assert "\n" not in commit["message"]
4751
4752 def test_languages_json_entry_schema(
4753 self, lang_repo: tuple[pathlib.Path, str, str]
4754 ) -> None:
4755 result = runner.invoke(cli, ["code", "languages", "--json"])
4756 assert result.exit_code == 0, result.output
4757 langs = json.loads(result.output)["languages"]
4758 assert isinstance(langs, list)
4759 assert len(langs) > 0
4760 for entry in langs:
4761 assert "language" in entry
4762 assert "files" in entry
4763 assert "symbols" in entry
4764 assert "kinds" in entry
4765 assert isinstance(entry["files"], int)
4766 assert isinstance(entry["symbols"], int)
4767 assert isinstance(entry["kinds"], dict)
4768
4769 def test_languages_json_include_imports_flag(
4770 self, lang_repo: tuple[pathlib.Path, str, str]
4771 ) -> None:
4772 result = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4773 assert result.exit_code == 0, result.output
4774 data = json.loads(result.output)
4775 assert data["include_imports"] is True
4776
4777 # ── --sort flag ──────────────────────────────────────────────────────────
4778
4779 def test_languages_sort_name(
4780 self, lang_repo: tuple[pathlib.Path, str, str]
4781 ) -> None:
4782 result = runner.invoke(cli, ["code", "languages", "--sort", "name"])
4783 assert result.exit_code == 0, result.output
4784
4785 def test_languages_sort_symbols(
4786 self, lang_repo: tuple[pathlib.Path, str, str]
4787 ) -> None:
4788 result = runner.invoke(cli, ["code", "languages", "--sort", "symbols"])
4789 assert result.exit_code == 0, result.output
4790 # Python should appear before Markdown when sorted by symbols desc.
4791 lines = result.output.splitlines()
4792 py_line = next((i for i, l in enumerate(lines) if "Python" in l), None)
4793 md_line = next((i for i, l in enumerate(lines) if "Markdown" in l), None)
4794 # Both might not exist if the repo only has Python; at least ensure no crash.
4795 assert py_line is not None
4796
4797 def test_languages_sort_files(
4798 self, lang_repo: tuple[pathlib.Path, str, str]
4799 ) -> None:
4800 result = runner.invoke(cli, ["code", "languages", "--sort", "files"])
4801 assert result.exit_code == 0, result.output
4802
4803 def test_languages_invalid_sort_exits_nonzero(
4804 self, lang_repo: tuple[pathlib.Path, str, str]
4805 ) -> None:
4806 result = runner.invoke(cli, ["code", "languages", "--sort", "bad"])
4807 assert result.exit_code != 0
4808
4809 # ── --diff flag ──────────────────────────────────────────────────────────
4810
4811 def test_languages_diff_exits_zero(
4812 self, lang_repo: tuple[pathlib.Path, str, str]
4813 ) -> None:
4814 _, commit_a, _ = lang_repo
4815 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4816 assert result.exit_code == 0, result.output
4817
4818 def test_languages_diff_shows_header(
4819 self, lang_repo: tuple[pathlib.Path, str, str]
4820 ) -> None:
4821 _, commit_a, _ = lang_repo
4822 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4823 assert result.exit_code == 0, result.output
4824 assert "Language change" in result.output
4825 assert "Net" in result.output
4826
4827 def test_languages_diff_detects_new_symbols(
4828 self, lang_repo: tuple[pathlib.Path, str, str]
4829 ) -> None:
4830 """Commit B added generate_pdf and send_email — Python symbol count must grow."""
4831 _, commit_a, _ = lang_repo
4832 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4833 assert result.exit_code == 0, result.output
4834 # Python line should show a positive delta.
4835 lines = result.output.splitlines()
4836 py_line = next((l for l in lines if "Python" in l), "")
4837 assert "+" in py_line
4838
4839 def test_languages_diff_unchanged_label(
4840 self, lang_repo: tuple[pathlib.Path, str, str]
4841 ) -> None:
4842 """Comparing a commit to itself must show all languages as unchanged."""
4843 _, _, commit_b = lang_repo
4844 result = runner.invoke(cli, ["code", "languages", "--diff", commit_b])
4845 assert result.exit_code == 0, result.output
4846 assert "unchanged" in result.output
4847
4848 def test_languages_diff_invalid_ref_exits_nonzero(
4849 self, lang_repo: tuple[pathlib.Path, str, str]
4850 ) -> None:
4851 result = runner.invoke(cli, ["code", "languages", "--diff", "deadbeefdeadbeef"])
4852 assert result.exit_code != 0
4853
4854 def test_languages_diff_json_schema(
4855 self, lang_repo: tuple[pathlib.Path, str, str]
4856 ) -> None:
4857 _, commit_a, _ = lang_repo
4858 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4859 assert result.exit_code == 0, result.output
4860 data = json.loads(result.output)
4861 assert set(data.keys()) >= {"from_commit", "to_commit", "include_imports", "diff"}
4862 assert "commit_id" in data["from_commit"]
4863 assert "message" in data["to_commit"]
4864
4865 def test_languages_diff_json_entry_schema(
4866 self, lang_repo: tuple[pathlib.Path, str, str]
4867 ) -> None:
4868 _, commit_a, _ = lang_repo
4869 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4870 assert result.exit_code == 0, result.output
4871 diff = json.loads(result.output)["diff"]
4872 assert isinstance(diff, list)
4873 assert len(diff) > 0
4874 for entry in diff:
4875 assert "language" in entry
4876 assert "delta_files" in entry
4877 assert "delta_symbols" in entry
4878 assert "files_before" in entry
4879 assert "files_after" in entry
4880 assert "symbols_before" in entry
4881 assert "symbols_after" in entry
4882 assert "status" in entry
4883 assert entry["status"] in ("added", "removed", "changed", "unchanged")
4884
4885 def test_languages_diff_json_python_delta_positive(
4886 self, lang_repo: tuple[pathlib.Path, str, str]
4887 ) -> None:
4888 _, commit_a, _ = lang_repo
4889 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4890 assert result.exit_code == 0, result.output
4891 diff = json.loads(result.output)["diff"]
4892 py = next((e for e in diff if e["language"] == "Python"), None)
4893 assert py is not None
4894 assert py["delta_symbols"] > 0
4895 assert py["status"] == "changed"
4896
4897 def test_languages_diff_json_markdown_added(
4898 self, lang_repo: tuple[pathlib.Path, str, str]
4899 ) -> None:
4900 """README.md was added in commit B — Markdown status should be 'added'."""
4901 _, commit_a, _ = lang_repo
4902 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4903 assert result.exit_code == 0, result.output
4904 diff = json.loads(result.output)["diff"]
4905 md = next((e for e in diff if e["language"] == "Markdown"), None)
4906 assert md is not None
4907 assert md["status"] == "added"
4908 assert md["files_before"] == 0
4909 assert md["files_after"] == 1
4910
4911
4912 # ---------------------------------------------------------------------------
4913 # muse code rename
4914 # ---------------------------------------------------------------------------
4915
4916
4917 @pytest.fixture
4918 def rename_repo(repo: pathlib.Path) -> pathlib.Path:
4919 """Repo with billing.py and a test file that imports and calls its symbols."""
4920 (repo / "billing.py").write_text(textwrap.dedent("""\
4921 import os
4922
4923 class Invoice:
4924 def compute_total(self, items):
4925 return sum(items)
4926
4927 def apply_discount(self, total, pct):
4928 return total * (1 - pct)
4929
4930 def process_order(invoice, items):
4931 total = compute_total(items)
4932 return total
4933 """))
4934 (repo / "test_billing.py").write_text(textwrap.dedent("""\
4935 from billing import compute_total, Invoice
4936
4937 def test_compute_total():
4938 inv = Invoice()
4939 result = inv.compute_total([1, 2, 3])
4940 assert compute_total([1, 2, 3]) == 6
4941 """))
4942 r = runner.invoke(cli, ["commit", "-m", "Initial billing + tests"])
4943 assert r.exit_code == 0, r.output
4944 return repo
4945
4946
4947 class TestRename:
4948 """Tests for muse code rename."""
4949
4950 # ── basic correctness ────────────────────────────────────────────────────
4951
4952 def test_rename_dry_run_exits_zero(self, rename_repo: pathlib.Path) -> None:
4953 result = runner.invoke(
4954 cli,
4955 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4956 )
4957 assert result.exit_code == 0, result.output
4958
4959 def test_rename_dry_run_shows_preview(self, rename_repo: pathlib.Path) -> None:
4960 result = runner.invoke(
4961 cli,
4962 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4963 )
4964 assert result.exit_code == 0, result.output
4965 assert "Renaming" in result.output
4966 assert "process_order" in result.output
4967 assert "handle_order" in result.output
4968
4969 def test_rename_dry_run_does_not_write(self, rename_repo: pathlib.Path) -> None:
4970 before = (rename_repo / "billing.py").read_text()
4971 runner.invoke(
4972 cli,
4973 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4974 )
4975 assert (rename_repo / "billing.py").read_text() == before
4976
4977 def test_rename_applies_definition(self, rename_repo: pathlib.Path) -> None:
4978 result = runner.invoke(
4979 cli,
4980 ["code", "rename", "billing.py::process_order", "handle_order",
4981 "--scope", "definition", "--yes"],
4982 )
4983 assert result.exit_code == 0, result.output
4984 content = (rename_repo / "billing.py").read_text()
4985 assert "def handle_order(" in content
4986 assert "def process_order(" not in content
4987
4988 def test_rename_only_def_token_not_string_literal(
4989 self, rename_repo: pathlib.Path
4990 ) -> None:
4991 """The rename must not touch string literals containing the old name."""
4992 # Add a docstring with the old name.
4993 billing = (rename_repo / "billing.py").read_text()
4994 billing += '\nDOC = "compute_total is a function"\n'
4995 (rename_repo / "billing.py").write_text(billing)
4996 runner.invoke(cli, ["commit", "-m", "add docstring"])
4997
4998 runner.invoke(
4999 cli,
5000 ["code", "rename", "billing.py::Invoice.compute_total",
5001 "compute_invoice_total", "--scope", "definition", "--yes"],
5002 )
5003 content = (rename_repo / "billing.py").read_text()
5004 # The string literal must be untouched.
5005 assert '"compute_total is a function"' in content
5006
5007 def test_rename_method_definition_scoped_to_class(
5008 self, rename_repo: pathlib.Path
5009 ) -> None:
5010 """billing.py::Invoice.compute_total must rename the method inside Invoice."""
5011 result = runner.invoke(
5012 cli,
5013 ["code", "rename", "billing.py::Invoice.compute_total",
5014 "compute_invoice_total", "--scope", "definition", "--yes"],
5015 )
5016 assert result.exit_code == 0, result.output
5017 content = (rename_repo / "billing.py").read_text()
5018 assert "def compute_invoice_total(self" in content
5019 # The module-level bare call in process_order stays unchanged.
5020 assert "compute_total(items)" in content
5021
5022 def test_rename_updates_import_sites(self, rename_repo: pathlib.Path) -> None:
5023 result = runner.invoke(
5024 cli,
5025 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5026 "--scope", "imports", "--yes"],
5027 )
5028 assert result.exit_code == 0, result.output
5029 content = (rename_repo / "test_billing.py").read_text()
5030 assert "compute_invoice_total" in content
5031 assert "from billing import" in content
5032
5033 def test_rename_requires_repo(
5034 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5035 ) -> None:
5036 monkeypatch.chdir(tmp_path)
5037 result = runner.invoke(
5038 cli, ["code", "rename", "billing.py::foo", "bar", "--yes"]
5039 )
5040 assert result.exit_code != 0
5041
5042 def test_rename_rejects_same_name(self, rename_repo: pathlib.Path) -> None:
5043 result = runner.invoke(
5044 cli,
5045 ["code", "rename", "billing.py::process_order", "process_order", "--yes"],
5046 )
5047 assert result.exit_code != 0
5048
5049 def test_rename_rejects_invalid_identifier(self, rename_repo: pathlib.Path) -> None:
5050 result = runner.invoke(
5051 cli,
5052 ["code", "rename", "billing.py::process_order", "123invalid", "--yes"],
5053 )
5054 assert result.exit_code != 0
5055
5056 def test_rename_rejects_address_without_double_colon(
5057 self, rename_repo: pathlib.Path
5058 ) -> None:
5059 result = runner.invoke(
5060 cli, ["code", "rename", "billing.py", "new_name", "--yes"]
5061 )
5062 assert result.exit_code != 0
5063
5064 def test_rename_rejects_nonexistent_symbol(self, rename_repo: pathlib.Path) -> None:
5065 result = runner.invoke(
5066 cli,
5067 ["code", "rename", "billing.py::nonexistent_func", "new_name",
5068 "--scope", "definition", "--yes"],
5069 )
5070 assert result.exit_code != 0
5071
5072 def test_rename_rejects_path_traversal(self, rename_repo: pathlib.Path) -> None:
5073 result = runner.invoke(
5074 cli,
5075 ["code", "rename", "../../etc/passwd::foo", "bar", "--yes"],
5076 )
5077 assert result.exit_code != 0
5078
5079 def test_rename_rejects_dunder_without_force(self, rename_repo: pathlib.Path) -> None:
5080 result = runner.invoke(
5081 cli,
5082 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__", "--yes"],
5083 )
5084 assert result.exit_code != 0
5085
5086 def test_rename_allows_dunder_with_force(self, rename_repo: pathlib.Path) -> None:
5087 result = runner.invoke(
5088 cli,
5089 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__",
5090 "--scope", "definition", "--yes", "--force"],
5091 )
5092 assert result.exit_code == 0, result.output
5093 content = (rename_repo / "billing.py").read_text()
5094 assert "def __compute__(self" in content
5095
5096 # ── JSON output ──────────────────────────────────────────────────────────
5097
5098 def test_rename_json_schema(self, rename_repo: pathlib.Path) -> None:
5099 result = runner.invoke(
5100 cli,
5101 ["code", "rename", "billing.py::process_order", "handle_order",
5102 "--dry-run", "--json"],
5103 )
5104 assert result.exit_code == 0, result.output
5105 data = json.loads(result.output)
5106 assert set(data.keys()) >= {
5107 "from_address", "to_address", "from_name", "to_name",
5108 "scope", "dry_run", "files_to_modify", "total_edit_sites", "edit_sites",
5109 }
5110
5111 def test_rename_json_dry_run_empty_files_to_modify(
5112 self, rename_repo: pathlib.Path
5113 ) -> None:
5114 result = runner.invoke(
5115 cli,
5116 ["code", "rename", "billing.py::process_order", "handle_order",
5117 "--dry-run", "--json"],
5118 )
5119 assert result.exit_code == 0, result.output
5120 data = json.loads(result.output)
5121 assert data["dry_run"] is True
5122 assert data["files_to_modify"] == []
5123
5124 def test_rename_json_edit_site_schema(self, rename_repo: pathlib.Path) -> None:
5125 result = runner.invoke(
5126 cli,
5127 ["code", "rename", "billing.py::process_order", "handle_order",
5128 "--dry-run", "--json"],
5129 )
5130 assert result.exit_code == 0, result.output
5131 sites = json.loads(result.output)["edit_sites"]
5132 assert isinstance(sites, list)
5133 assert len(sites) > 0
5134 for site in sites:
5135 assert "file" in site
5136 assert "line" in site
5137 assert "col_start" in site
5138 assert "col_end" in site
5139 assert "kind" in site
5140 assert "context" in site
5141 assert site["kind"] in ("definition", "import", "reference")
5142 assert site["col_start"] < site["col_end"]
5143
5144 def test_rename_json_definition_site_present(self, rename_repo: pathlib.Path) -> None:
5145 result = runner.invoke(
5146 cli,
5147 ["code", "rename", "billing.py::process_order", "handle_order",
5148 "--dry-run", "--json"],
5149 )
5150 assert result.exit_code == 0, result.output
5151 sites = json.loads(result.output)["edit_sites"]
5152 def_sites = [s for s in sites if s["kind"] == "definition"]
5153 assert len(def_sites) == 1
5154 assert def_sites[0]["file"] == "billing.py"
5155 assert "process_order" in def_sites[0]["context"]
5156
5157 def test_rename_json_apply_writes_files(self, rename_repo: pathlib.Path) -> None:
5158 result = runner.invoke(
5159 cli,
5160 ["code", "rename", "billing.py::process_order", "handle_order",
5161 "--yes", "--json", "--scope", "definition"],
5162 )
5163 assert result.exit_code == 0, result.output
5164 content = (rename_repo / "billing.py").read_text()
5165 assert "def handle_order(" in content
5166
5167 # ── --scope flag ─────────────────────────────────────────────────────────
5168
5169 def test_rename_scope_definition_only(self, rename_repo: pathlib.Path) -> None:
5170 """--scope definition should only touch the def token."""
5171 runner.invoke(
5172 cli,
5173 ["code", "rename", "billing.py::process_order", "handle_order",
5174 "--scope", "definition", "--yes"],
5175 )
5176 billing = (rename_repo / "billing.py").read_text()
5177 test = (rename_repo / "test_billing.py").read_text()
5178 assert "def handle_order(" in billing
5179 # The import in test_billing.py must be untouched.
5180 assert "process_order" not in test or "import" in test
5181
5182 def test_rename_scope_imports_only(self, rename_repo: pathlib.Path) -> None:
5183 runner.invoke(
5184 cli,
5185 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5186 "--scope", "imports", "--yes"],
5187 )
5188 billing = (rename_repo / "billing.py").read_text()
5189 # The definition in billing.py must be untouched.
5190 assert "def compute_total(" in billing
5191
5192 def test_rename_json_scope_reflected(self, rename_repo: pathlib.Path) -> None:
5193 result = runner.invoke(
5194 cli,
5195 ["code", "rename", "billing.py::process_order", "handle_order",
5196 "--scope", "definition", "--dry-run", "--json"],
5197 )
5198 assert result.exit_code == 0, result.output
5199 assert json.loads(result.output)["scope"] == "definition"
5200
5201 # ── --max-files guard ────────────────────────────────────────────────────
5202
5203 def test_rename_max_files_validation(self, rename_repo: pathlib.Path) -> None:
5204 result = runner.invoke(
5205 cli,
5206 ["code", "rename", "billing.py::process_order", "handle_order",
5207 "--max-files", "0", "--dry-run"],
5208 )
5209 assert result.exit_code != 0
5210
5211 # ── edit precision ───────────────────────────────────────────────────────
5212
5213 def test_rename_preserves_surrounding_code(self, rename_repo: pathlib.Path) -> None:
5214 """Renaming process_order must not touch apply_discount or compute_total."""
5215 runner.invoke(
5216 cli,
5217 ["code", "rename", "billing.py::process_order", "handle_order",
5218 "--scope", "definition", "--yes"],
5219 )
5220 content = (rename_repo / "billing.py").read_text()
5221 assert "def apply_discount(" in content
5222 assert "def compute_total(" in content
5223
5224 def test_rename_col_precision_correct(self, rename_repo: pathlib.Path) -> None:
5225 """The definition rename must produce syntactically valid Python."""
5226 runner.invoke(
5227 cli,
5228 ["code", "rename", "billing.py::process_order", "handle_order",
5229 "--scope", "definition", "--yes"],
5230 )
5231 import ast as _ast
5232 content = (rename_repo / "billing.py").read_text()
5233 # Must parse without SyntaxError.
5234 try:
5235 _ast.parse(content)
5236 except SyntaxError as e:
5237 pytest.fail(f"Renamed file has a syntax error: {e}")
5238
5239
5240 # ---------------------------------------------------------------------------
5241 # blast-risk
5242 # ---------------------------------------------------------------------------
5243
5244
5245 @pytest.fixture
5246 def blast_repo(repo: pathlib.Path) -> pathlib.Path:
5247 """Repo with two commits: a production module and a test file.
5248
5249 billing.py defines Invoice.compute_total and process_order.
5250 test_billing.py imports and calls both — so they have at least one
5251 test caller. A second commit modifies compute_total so churn > 0.
5252 """
5253 (repo / "billing.py").write_text(textwrap.dedent("""\
5254 class Invoice:
5255 def compute_total(self, items):
5256 return sum(items)
5257
5258 def apply_discount(self, total, pct):
5259 return total * (1 - pct)
5260
5261 def process_order(invoice, items):
5262 return invoice.compute_total(items)
5263 """))
5264 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5265 from billing import Invoice, process_order
5266
5267 def test_compute_total():
5268 inv = Invoice()
5269 assert inv.compute_total([1, 2, 3]) == 6
5270
5271 def test_process_order():
5272 inv = Invoice()
5273 assert process_order(inv, [10]) == 10
5274 """))
5275 r = runner.invoke(cli, ["commit", "-m", "Add billing module and tests"])
5276 assert r.exit_code == 0, r.output
5277
5278 # Second commit: modify compute_total so churn count > 0.
5279 (repo / "billing.py").write_text(textwrap.dedent("""\
5280 class Invoice:
5281 def compute_total(self, items):
5282 # round to two decimal places
5283 return round(sum(items), 2)
5284
5285 def apply_discount(self, total, pct):
5286 return total * (1 - pct)
5287
5288 def process_order(invoice, items):
5289 return invoice.compute_total(items)
5290 """))
5291 r2 = runner.invoke(cli, ["commit", "-m", "Round compute_total result"])
5292 assert r2.exit_code == 0, r2.output
5293
5294 return repo
5295
5296
5297 class TestBlastRisk:
5298 """Tests for muse code blast-risk."""
5299
5300 # ── basic correctness ────────────────────────────────────────────────────
5301
5302 def test_blast_risk_exits_zero(self, blast_repo: pathlib.Path) -> None:
5303 result = runner.invoke(cli, ["code", "blast-risk"])
5304 assert result.exit_code == 0, result.output
5305
5306 def test_blast_risk_shows_header(self, blast_repo: pathlib.Path) -> None:
5307 result = runner.invoke(cli, ["code", "blast-risk"])
5308 assert result.exit_code == 0
5309 assert "blast-risk" in result.output
5310 assert "commits" in result.output
5311
5312 def test_blast_risk_shows_scoring_line(self, blast_repo: pathlib.Path) -> None:
5313 result = runner.invoke(cli, ["code", "blast-risk"])
5314 assert result.exit_code == 0
5315 assert "Scoring:" in result.output
5316 assert "impact" in result.output
5317 assert "churn" in result.output
5318 assert "test-gap" in result.output
5319 assert "coupling" in result.output
5320
5321 def test_blast_risk_shows_table_columns(self, blast_repo: pathlib.Path) -> None:
5322 result = runner.invoke(cli, ["code", "blast-risk"])
5323 assert result.exit_code == 0
5324 assert "RISK" in result.output
5325 assert "IMPACT" in result.output
5326 assert "CHURN" in result.output
5327 assert "TEST-GAP" in result.output
5328
5329 def test_blast_risk_lists_symbols(self, blast_repo: pathlib.Path) -> None:
5330 result = runner.invoke(cli, ["code", "blast-risk"])
5331 assert result.exit_code == 0
5332 # At least one symbol from billing.py should appear.
5333 assert "billing.py" in result.output
5334
5335 def test_blast_risk_risk_scores_in_range(self, blast_repo: pathlib.Path) -> None:
5336 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5337 assert result.exit_code == 0, result.output
5338 data = json.loads(result.output)
5339 for sym in data["symbols"]:
5340 assert 0 <= sym["risk"] <= 100
5341 assert 0 <= sym["impact_score"] <= 100
5342 assert 0 <= sym["churn_score"] <= 100
5343 assert 0 <= sym["test_gap_score"] <= 100
5344 assert 0 <= sym["coupling_score"] <= 100
5345
5346 # ── JSON schema ──────────────────────────────────────────────────────────
5347
5348 def test_blast_risk_json_top_level_keys(self, blast_repo: pathlib.Path) -> None:
5349 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5350 assert result.exit_code == 0, result.output
5351 data = json.loads(result.output)
5352 assert "ref" in data
5353 assert "commits_analysed" in data
5354 assert "truncated" in data
5355 assert "filters" in data
5356 assert "weights" in data
5357 assert "symbols" in data
5358
5359 def test_blast_risk_json_weights_sum_to_one(self, blast_repo: pathlib.Path) -> None:
5360 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5361 data = json.loads(result.output)
5362 total = sum(data["weights"].values())
5363 assert abs(total - 1.0) < 1e-6
5364
5365 def test_blast_risk_json_symbol_schema(self, blast_repo: pathlib.Path) -> None:
5366 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5367 data = json.loads(result.output)
5368 assert len(data["symbols"]) > 0
5369 sym = data["symbols"][0]
5370 for key in ("address", "kind", "file", "risk",
5371 "impact_raw", "churn_raw", "test_gap_raw",
5372 "coupling_raw", "impact_score", "churn_score",
5373 "test_gap_score", "coupling_score"):
5374 assert key in sym, f"missing key: {key}"
5375
5376 def test_blast_risk_json_sorted_by_risk_desc(self, blast_repo: pathlib.Path) -> None:
5377 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5378 data = json.loads(result.output)
5379 risks = [s["risk"] for s in data["symbols"]]
5380 assert risks == sorted(risks, reverse=True)
5381
5382 def test_blast_risk_json_no_import_pseudosymbols(self, blast_repo: pathlib.Path) -> None:
5383 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5384 data = json.loads(result.output)
5385 for sym in data["symbols"]:
5386 assert "::import::" not in sym["address"]
5387
5388 def test_blast_risk_json_filters_reflected(self, blast_repo: pathlib.Path) -> None:
5389 result = runner.invoke(
5390 cli, ["code", "blast-risk", "--json", "--kind", "function", "--min-risk", "10"]
5391 )
5392 data = json.loads(result.output)
5393 assert data["filters"]["kind"] == "function"
5394 assert data["filters"]["min_risk"] == 10
5395
5396 # ── --top flag ───────────────────────────────────────────────────────────
5397
5398 def test_blast_risk_top_limits_output(self, blast_repo: pathlib.Path) -> None:
5399 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--top", "2"])
5400 data = json.loads(result.output)
5401 assert len(data["symbols"]) <= 2
5402
5403 def test_blast_risk_top_validation(self, blast_repo: pathlib.Path) -> None:
5404 result = runner.invoke(cli, ["code", "blast-risk", "--top", "0"])
5405 assert result.exit_code != 0
5406
5407 # ── --kind filter ────────────────────────────────────────────────────────
5408
5409 def test_blast_risk_kind_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5410 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "class"])
5411 data = json.loads(result.output)
5412 for sym in data["symbols"]:
5413 assert sym["kind"] == "class"
5414
5415 def test_blast_risk_kind_filter_function(self, blast_repo: pathlib.Path) -> None:
5416 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "function"])
5417 assert result.exit_code == 0
5418 data = json.loads(result.output)
5419 for sym in data["symbols"]:
5420 assert sym["kind"] in ("function", "method")
5421
5422 # ── --file filter ────────────────────────────────────────────────────────
5423
5424 def test_blast_risk_file_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5425 result = runner.invoke(
5426 cli, ["code", "blast-risk", "--json", "--file", "billing.py"]
5427 )
5428 data = json.loads(result.output)
5429 for sym in data["symbols"]:
5430 assert "billing.py" in sym["file"]
5431
5432 def test_blast_risk_file_filter_nonexistent_returns_empty(
5433 self, blast_repo: pathlib.Path
5434 ) -> None:
5435 result = runner.invoke(
5436 cli, ["code", "blast-risk", "--json", "--file", "no_such_file.py"]
5437 )
5438 assert result.exit_code == 0
5439 data = json.loads(result.output)
5440 assert data["symbols"] == []
5441
5442 # ── --min-risk filter ────────────────────────────────────────────────────
5443
5444 def test_blast_risk_min_risk_filters(self, blast_repo: pathlib.Path) -> None:
5445 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "80"])
5446 data = json.loads(result.output)
5447 for sym in data["symbols"]:
5448 assert sym["risk"] >= 80
5449
5450 def test_blast_risk_min_risk_100_all_excluded(self, blast_repo: pathlib.Path) -> None:
5451 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "100"])
5452 assert result.exit_code == 0
5453
5454 def test_blast_risk_min_risk_validation(self, blast_repo: pathlib.Path) -> None:
5455 result = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "101"])
5456 assert result.exit_code != 0
5457 result2 = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "-1"])
5458 assert result2.exit_code != 0
5459
5460 # ── --explain flag ───────────────────────────────────────────────────────
5461
5462 def test_blast_risk_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
5463 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5464 data = json.loads(result.output)
5465 if not data["symbols"]:
5466 pytest.skip("no symbols")
5467 addr = data["symbols"][0]["address"]
5468 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5469 assert result2.exit_code == 0, result2.output
5470
5471 def test_blast_risk_explain_shows_breakdown(self, blast_repo: pathlib.Path) -> None:
5472 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5473 data = json.loads(result.output)
5474 if not data["symbols"]:
5475 pytest.skip("no symbols")
5476 addr = data["symbols"][0]["address"]
5477 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5478 assert "Risk score:" in result2.output
5479 assert "Impact" in result2.output
5480 assert "Churn" in result2.output
5481 assert "Test gap" in result2.output
5482 assert "Coupling" in result2.output
5483
5484 def test_blast_risk_explain_nonexistent_errors(self, blast_repo: pathlib.Path) -> None:
5485 result = runner.invoke(
5486 cli, ["code", "blast-risk", "--explain", "no_file.py::no_symbol"]
5487 )
5488 assert result.exit_code != 0
5489
5490 def test_blast_risk_explain_json(self, blast_repo: pathlib.Path) -> None:
5491 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5492 data = json.loads(result.output)
5493 if not data["symbols"]:
5494 pytest.skip("no symbols")
5495 addr = data["symbols"][0]["address"]
5496 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr, "--json"])
5497 assert result2.exit_code == 0, result2.output
5498 detail = json.loads(result2.output)
5499 assert detail["address"] == addr
5500 assert "risk" in detail
5501
5502 # ── --max-commits ────────────────────────────────────────────────────────
5503
5504 def test_blast_risk_max_commits_validation(self, blast_repo: pathlib.Path) -> None:
5505 result = runner.invoke(cli, ["code", "blast-risk", "--max-commits", "0"])
5506 assert result.exit_code != 0
5507
5508 def test_blast_risk_max_commits_respected(self, blast_repo: pathlib.Path) -> None:
5509 # With max-commits=1, commits_analysed <= 1.
5510 result = runner.invoke(
5511 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5512 )
5513 assert result.exit_code == 0
5514 data = json.loads(result.output)
5515 assert data["commits_analysed"] <= 1
5516
5517 def test_blast_risk_max_commits_truncated_flag(self, blast_repo: pathlib.Path) -> None:
5518 result = runner.invoke(
5519 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5520 )
5521 data = json.loads(result.output)
5522 # Two commits exist so truncated should be True with cap=1.
5523 assert isinstance(data["truncated"], bool)
5524
5525 # ── --since ──────────────────────────────────────────────────────────────
5526
5527 def test_blast_risk_since_invalid_ref(self, blast_repo: pathlib.Path) -> None:
5528 result = runner.invoke(cli, ["code", "blast-risk", "--since", "nonexistent_ref"])
5529 assert result.exit_code != 0
5530
5531 # ── requires repo ────────────────────────────────────────────────────────
5532
5533 def test_blast_risk_requires_repo(self, tmp_path: pathlib.Path) -> None:
5534 import os
5535 old = os.getcwd()
5536 try:
5537 os.chdir(tmp_path)
5538 result = runner.invoke(cli, ["code", "blast-risk"])
5539 assert result.exit_code != 0
5540 finally:
5541 os.chdir(old)
5542
5543
5544 # ---------------------------------------------------------------------------
5545 # velocity
5546 # ---------------------------------------------------------------------------
5547
5548
5549 @pytest.fixture
5550 def velocity_repo(repo: pathlib.Path) -> pathlib.Path:
5551 """Repo with two modules across several commits to exercise velocity metrics.
5552
5553 Module layout:
5554 core/store.py — grows across commits (inserts)
5555 shrink/util.py — has a delete later (net negative at some point)
5556
5557 Commit structure (window=2):
5558 1: create core/store.py with 2 functions
5559 2: add a third function to core/store.py → current window: +3 added
5560 3: add shrink/util.py with one function → also in current window
5561 4: delete the function in shrink/util.py → shrink net = 0 (1 added, 1 deleted)
5562 """
5563 (repo / "core").mkdir(exist_ok=True)
5564 (repo / "shrink").mkdir(exist_ok=True)
5565
5566 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5567 def read_object(path):
5568 return path.read_bytes()
5569
5570 def write_object(path, data):
5571 path.write_bytes(data)
5572 """))
5573 r = runner.invoke(cli, ["commit", "-m", "core: initial store"])
5574 assert r.exit_code == 0, r.output
5575
5576 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5577 def read_object(path):
5578 return path.read_bytes()
5579
5580 def write_object(path, data):
5581 path.write_bytes(data)
5582
5583 def delete_object(path):
5584 path.unlink()
5585 """))
5586 r2 = runner.invoke(cli, ["commit", "-m", "core: add delete_object"])
5587 assert r2.exit_code == 0, r2.output
5588
5589 (repo / "shrink" / "util.py").write_text(textwrap.dedent("""\
5590 def helper():
5591 return True
5592 """))
5593 r3 = runner.invoke(cli, ["commit", "-m", "shrink: add helper"])
5594 assert r3.exit_code == 0, r3.output
5595
5596 return repo
5597
5598
5599 class TestVelocity:
5600 """Tests for muse code velocity."""
5601
5602 # ── basic correctness ────────────────────────────────────────────────────
5603
5604 def test_velocity_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5605 result = runner.invoke(cli, ["code", "velocity"])
5606 assert result.exit_code == 0, result.output
5607
5608 def test_velocity_shows_header(self, velocity_repo: pathlib.Path) -> None:
5609 result = runner.invoke(cli, ["code", "velocity"])
5610 assert "velocity" in result.output.lower()
5611
5612 def test_velocity_shows_column_headers(self, velocity_repo: pathlib.Path) -> None:
5613 result = runner.invoke(cli, ["code", "velocity"])
5614 assert "ADD" in result.output
5615 assert "NET" in result.output
5616
5617 def test_velocity_shows_modules(self, velocity_repo: pathlib.Path) -> None:
5618 result = runner.invoke(cli, ["code", "velocity"])
5619 # Both modules should appear.
5620 assert "core/" in result.output or "store" in result.output
5621
5622 # ── JSON schema ──────────────────────────────────────────────────────────
5623
5624 def test_velocity_json_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5625 result = runner.invoke(cli, ["code", "velocity", "--json"])
5626 assert result.exit_code == 0, result.output
5627 json.loads(result.output)
5628
5629 def test_velocity_json_top_level_keys(self, velocity_repo: pathlib.Path) -> None:
5630 result = runner.invoke(cli, ["code", "velocity", "--json"])
5631 data = json.loads(result.output)
5632 for key in (
5633 "ref", "window_size", "commits_analysed", "truncated",
5634 "filters", "modules", "predictions",
5635 ):
5636 assert key in data, f"missing key: {key}"
5637
5638 def test_velocity_json_module_schema(self, velocity_repo: pathlib.Path) -> None:
5639 result = runner.invoke(cli, ["code", "velocity", "--json"])
5640 data = json.loads(result.output)
5641 if not data["modules"]:
5642 pytest.skip("no modules")
5643 mod = data["modules"][0]
5644 for key in ("module", "current", "prior", "acceleration", "stagnant_commits"):
5645 assert key in mod, f"missing key: {key}"
5646 for key in ("added", "removed", "net", "modified", "active_commits"):
5647 assert key in mod["current"], f"missing current key: {key}"
5648 assert key in mod["prior"], f"missing prior key: {key}"
5649
5650 def test_velocity_json_acceleration_is_net_delta(
5651 self, velocity_repo: pathlib.Path
5652 ) -> None:
5653 result = runner.invoke(cli, ["code", "velocity", "--json"])
5654 data = json.loads(result.output)
5655 for mod in data["modules"]:
5656 expected = mod["current"]["net"] - mod["prior"]["net"]
5657 assert mod["acceleration"] == expected
5658
5659 def test_velocity_json_filters_reflected(self, velocity_repo: pathlib.Path) -> None:
5660 result = runner.invoke(
5661 cli, ["code", "velocity", "--json", "--window", "5", "--top", "3"]
5662 )
5663 data = json.loads(result.output)
5664 assert data["window_size"] == 5
5665 assert data["filters"]["top"] == 3
5666
5667 def test_velocity_json_no_import_pseudosymbols_in_counts(
5668 self, velocity_repo: pathlib.Path
5669 ) -> None:
5670 # Modules should not be "(root)" due to import pseudo-symbols
5671 # (import:: addresses should be filtered out).
5672 result = runner.invoke(cli, ["code", "velocity", "--json"])
5673 data = json.loads(result.output)
5674 # We can't assert 0 imports in the module list, but we can assert
5675 # that '::import::' doesn't appear as a module name.
5676 for mod in data["modules"]:
5677 assert "import" not in mod["module"].lower() or "/" in mod["module"]
5678
5679 # ── --window ─────────────────────────────────────────────────────────────
5680
5681 def test_velocity_window_1_runs(self, velocity_repo: pathlib.Path) -> None:
5682 result = runner.invoke(cli, ["code", "velocity", "--window", "1"])
5683 assert result.exit_code == 0, result.output
5684
5685 def test_velocity_window_validation(self, velocity_repo: pathlib.Path) -> None:
5686 result = runner.invoke(cli, ["code", "velocity", "--window", "0"])
5687 assert result.exit_code != 0
5688
5689 def test_velocity_window_reflected_in_json(
5690 self, velocity_repo: pathlib.Path
5691 ) -> None:
5692 result = runner.invoke(cli, ["code", "velocity", "--json", "--window", "1"])
5693 data = json.loads(result.output)
5694 assert data["window_size"] == 1
5695
5696 # ── --top ─────────────────────────────────────────────────────────────────
5697
5698 def test_velocity_top_limits(self, velocity_repo: pathlib.Path) -> None:
5699 result = runner.invoke(cli, ["code", "velocity", "--json", "--top", "1"])
5700 data = json.loads(result.output)
5701 assert len(data["modules"]) <= 1
5702
5703 def test_velocity_top_validation(self, velocity_repo: pathlib.Path) -> None:
5704 result = runner.invoke(cli, ["code", "velocity", "--top", "0"])
5705 assert result.exit_code != 0
5706
5707 # ── --predict ─────────────────────────────────────────────────────────────
5708
5709 def test_velocity_predict_0_empty(self, velocity_repo: pathlib.Path) -> None:
5710 result = runner.invoke(cli, ["code", "velocity", "--json", "--predict", "0"])
5711 data = json.loads(result.output)
5712 assert data["predictions"] == []
5713
5714 def test_velocity_predict_returns_results(self, velocity_repo: pathlib.Path) -> None:
5715 result = runner.invoke(
5716 cli, ["code", "velocity", "--json", "--predict", "5"]
5717 )
5718 data = json.loads(result.output)
5719 # There are symbols in the window so predictions should be non-empty.
5720 assert isinstance(data["predictions"], list)
5721 if data["predictions"]:
5722 pred = data["predictions"][0]
5723 for key in ("address", "module", "score", "frequency", "last_commit_rank"):
5724 assert key in pred, f"missing key: {key}"
5725
5726 def test_velocity_predict_scores_descending(
5727 self, velocity_repo: pathlib.Path
5728 ) -> None:
5729 result = runner.invoke(
5730 cli, ["code", "velocity", "--json", "--predict", "10"]
5731 )
5732 data = json.loads(result.output)
5733 scores = [p["score"] for p in data["predictions"]]
5734 assert scores == sorted(scores, reverse=True)
5735
5736 def test_velocity_predict_validation(self, velocity_repo: pathlib.Path) -> None:
5737 result = runner.invoke(cli, ["code", "velocity", "--predict", "-1"])
5738 assert result.exit_code != 0
5739
5740 def test_velocity_predict_shown_in_human_output(
5741 self, velocity_repo: pathlib.Path
5742 ) -> None:
5743 result = runner.invoke(
5744 cli, ["code", "velocity", "--predict", "3"]
5745 )
5746 assert result.exit_code == 0
5747 if "predictions" in result.output.lower() or "score" in result.output:
5748 # Just check it doesn't crash.
5749 pass
5750
5751 # ── --max-commits ─────────────────────────────────────────────────────────
5752
5753 def test_velocity_max_commits_validation(self, velocity_repo: pathlib.Path) -> None:
5754 result = runner.invoke(cli, ["code", "velocity", "--max-commits", "0"])
5755 assert result.exit_code != 0
5756
5757 def test_velocity_max_commits_respected(self, velocity_repo: pathlib.Path) -> None:
5758 # With --window 1 and --max-commits 1, effective_max = max(1, 1*2) = 2.
5759 # The 3-commit repo should be capped at 2 commits analysed.
5760 result = runner.invoke(
5761 cli, ["code", "velocity", "--json", "--window", "1", "--max-commits", "1"]
5762 )
5763 assert result.exit_code == 0
5764 data = json.loads(result.output)
5765 assert data["commits_analysed"] <= 2
5766
5767 # ── --since ───────────────────────────────────────────────────────────────
5768
5769 def test_velocity_since_invalid_ref(self, velocity_repo: pathlib.Path) -> None:
5770 result = runner.invoke(cli, ["code", "velocity", "--since", "bad_ref"])
5771 assert result.exit_code != 0
5772
5773 # ── stagnation detection ──────────────────────────────────────────────────
5774
5775 def test_velocity_stagnant_commits_non_negative(
5776 self, velocity_repo: pathlib.Path
5777 ) -> None:
5778 result = runner.invoke(cli, ["code", "velocity", "--json"])
5779 data = json.loads(result.output)
5780 for mod in data["modules"]:
5781 assert mod["stagnant_commits"] >= 0
5782
5783 # ── net counts are consistent ─────────────────────────────────────────────
5784
5785 def test_velocity_net_equals_added_minus_removed(
5786 self, velocity_repo: pathlib.Path
5787 ) -> None:
5788 result = runner.invoke(cli, ["code", "velocity", "--json"])
5789 data = json.loads(result.output)
5790 for mod in data["modules"]:
5791 assert mod["current"]["net"] == (
5792 mod["current"]["added"] - mod["current"]["removed"]
5793 )
5794 assert mod["prior"]["net"] == (
5795 mod["prior"]["added"] - mod["prior"]["removed"]
5796 )
5797
5798 # ── requires repo ─────────────────────────────────────────────────────────
5799
5800 def test_velocity_requires_repo(self, tmp_path: pathlib.Path) -> None:
5801 import os
5802 old = os.getcwd()
5803 try:
5804 os.chdir(tmp_path)
5805 result = runner.invoke(cli, ["code", "velocity"])
5806 assert result.exit_code != 0
5807 finally:
5808 os.chdir(old)
5809
5810
5811 # ---------------------------------------------------------------------------
5812 # age
5813 # ---------------------------------------------------------------------------
5814
5815
5816 @pytest.fixture
5817 def age_repo(repo: pathlib.Path) -> pathlib.Path:
5818 """Repo with several commits to exercise evolutionary-age metrics.
5819
5820 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
5821 Commit 2: modify compute_total body → 1 impl change
5822 Commit 3: modify compute_total body → 2 impl changes
5823 Commit 4: modify compute_total signature only (add type hint)
5824
5825 stable_fn is created in commit 1 and never touched again.
5826 """
5827 (repo / "billing.py").write_text(textwrap.dedent("""\
5828 class Invoice:
5829 def compute_total(self, items):
5830 return sum(items)
5831
5832 def stable_fn():
5833 return 42
5834 """))
5835 r = runner.invoke(cli, ["commit", "-m", "initial billing"])
5836 assert r.exit_code == 0, r.output
5837
5838 # Commit 2: impl change to compute_total
5839 (repo / "billing.py").write_text(textwrap.dedent("""\
5840 class Invoice:
5841 def compute_total(self, items):
5842 return round(sum(items), 2)
5843
5844 def stable_fn():
5845 return 42
5846 """))
5847 r2 = runner.invoke(cli, ["commit", "-m", "round result"])
5848 assert r2.exit_code == 0, r2.output
5849
5850 # Commit 3: second impl change to compute_total
5851 (repo / "billing.py").write_text(textwrap.dedent("""\
5852 class Invoice:
5853 def compute_total(self, items):
5854 total = sum(items)
5855 return round(total, 4)
5856
5857 def stable_fn():
5858 return 42
5859 """))
5860 r3 = runner.invoke(cli, ["commit", "-m", "higher precision"])
5861 assert r3.exit_code == 0, r3.output
5862
5863 return repo
5864
5865
5866 class TestAge:
5867 """Tests for muse code age."""
5868
5869 # ── basic correctness ────────────────────────────────────────────────────
5870
5871 def test_age_exits_zero(self, age_repo: pathlib.Path) -> None:
5872 result = runner.invoke(cli, ["code", "age"])
5873 assert result.exit_code == 0, result.output
5874
5875 def test_age_shows_header(self, age_repo: pathlib.Path) -> None:
5876 result = runner.invoke(cli, ["code", "age"])
5877 assert "evolutionary age" in result.output.lower()
5878
5879 def test_age_shows_sort_line(self, age_repo: pathlib.Path) -> None:
5880 result = runner.invoke(cli, ["code", "age"])
5881 assert "Sorted by" in result.output
5882
5883 def test_age_shows_table_columns(self, age_repo: pathlib.Path) -> None:
5884 result = runner.invoke(cli, ["code", "age"])
5885 assert "BORN" in result.output
5886 assert "REWRITES" in result.output
5887 assert "GENETIC" in result.output
5888
5889 def test_age_lists_symbols(self, age_repo: pathlib.Path) -> None:
5890 result = runner.invoke(cli, ["code", "age"])
5891 assert "billing.py" in result.output
5892
5893 # ── JSON schema ──────────────────────────────────────────────────────────
5894
5895 def test_age_json_exits_zero(self, age_repo: pathlib.Path) -> None:
5896 result = runner.invoke(cli, ["code", "age", "--json"])
5897 assert result.exit_code == 0, result.output
5898 json.loads(result.output)
5899
5900 def test_age_json_top_level_keys(self, age_repo: pathlib.Path) -> None:
5901 result = runner.invoke(cli, ["code", "age", "--json"])
5902 data = json.loads(result.output)
5903 for key in ("ref", "as_of", "commits_analysed", "truncated", "filters", "symbols"):
5904 assert key in data, f"missing key: {key}"
5905
5906 def test_age_json_symbol_schema(self, age_repo: pathlib.Path) -> None:
5907 result = runner.invoke(cli, ["code", "age", "--json"])
5908 data = json.loads(result.output)
5909 if not data["symbols"]:
5910 pytest.skip("no symbols with history")
5911 sym = data["symbols"][0]
5912 for key in (
5913 "address", "kind", "file",
5914 "born_commit", "born_date",
5915 "last_impl_commit", "last_impl_date",
5916 "last_change_commit", "last_change_date",
5917 "calendar_age_days", "genetic_age_days",
5918 "impl_changes", "sig_changes", "renames", "est_survival_pct",
5919 ):
5920 assert key in sym, f"missing key: {key}"
5921
5922 def test_age_json_survival_pct_in_range(self, age_repo: pathlib.Path) -> None:
5923 result = runner.invoke(cli, ["code", "age", "--json"])
5924 data = json.loads(result.output)
5925 for sym in data["symbols"]:
5926 assert 0 <= sym["est_survival_pct"] <= 100
5927
5928 def test_age_json_filters_reflected(self, age_repo: pathlib.Path) -> None:
5929 result = runner.invoke(
5930 cli, ["code", "age", "--json", "--sort", "calendar", "--kind", "function"]
5931 )
5932 data = json.loads(result.output)
5933 assert data["filters"]["sort"] == "calendar"
5934 assert data["filters"]["kind"] == "function"
5935
5936 def test_age_json_no_import_pseudosymbols(self, age_repo: pathlib.Path) -> None:
5937 result = runner.invoke(cli, ["code", "age", "--json"])
5938 data = json.loads(result.output)
5939 for sym in data["symbols"]:
5940 assert "::import::" not in sym["address"]
5941
5942 # ── impl_changes recorded correctly ─────────────────────────────────────
5943
5944 def test_age_compute_total_has_impl_changes(self, age_repo: pathlib.Path) -> None:
5945 """compute_total was modified twice — should have impl_changes >= 1."""
5946 result = runner.invoke(cli, ["code", "age", "--json"])
5947 data = json.loads(result.output)
5948 totals = [
5949 s for s in data["symbols"]
5950 if "compute_total" in s["address"]
5951 ]
5952 # If history was recorded, impl_changes should be positive.
5953 if totals:
5954 assert totals[0]["impl_changes"] >= 0 # at least recorded
5955
5956 def test_age_stable_fn_lower_impl_changes(self, age_repo: pathlib.Path) -> None:
5957 """stable_fn was never modified — should have 0 impl_changes."""
5958 result = runner.invoke(cli, ["code", "age", "--json"])
5959 data = json.loads(result.output)
5960 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5961 if stables:
5962 assert stables[0]["impl_changes"] == 0
5963
5964 def test_age_stable_fn_100pct_survival(self, age_repo: pathlib.Path) -> None:
5965 result = runner.invoke(cli, ["code", "age", "--json"])
5966 data = json.loads(result.output)
5967 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5968 if stables:
5969 assert stables[0]["est_survival_pct"] == 100
5970
5971 # ── --top ────────────────────────────────────────────────────────────────
5972
5973 def test_age_top_limits(self, age_repo: pathlib.Path) -> None:
5974 result = runner.invoke(cli, ["code", "age", "--json", "--top", "1"])
5975 data = json.loads(result.output)
5976 assert len(data["symbols"]) <= 1
5977
5978 def test_age_top_validation(self, age_repo: pathlib.Path) -> None:
5979 result = runner.invoke(cli, ["code", "age", "--top", "0"])
5980 assert result.exit_code != 0
5981
5982 # ── --sort ───────────────────────────────────────────────────────────────
5983
5984 def test_age_sort_rewrites(self, age_repo: pathlib.Path) -> None:
5985 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "rewrites"])
5986 assert result.exit_code == 0, result.output
5987 data = json.loads(result.output)
5988 impl_counts = [s["impl_changes"] for s in data["symbols"]]
5989 assert impl_counts == sorted(impl_counts, reverse=True)
5990
5991 def test_age_sort_calendar(self, age_repo: pathlib.Path) -> None:
5992 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "calendar"])
5993 assert result.exit_code == 0, result.output
5994 data = json.loads(result.output)
5995 ages = [s["calendar_age_days"] for s in data["symbols"]]
5996 assert ages == sorted(ages, reverse=True)
5997
5998 def test_age_sort_genetic(self, age_repo: pathlib.Path) -> None:
5999 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "genetic"])
6000 assert result.exit_code == 0, result.output
6001 data = json.loads(result.output)
6002 ages = [s["genetic_age_days"] for s in data["symbols"]]
6003 assert ages == sorted(ages, reverse=True)
6004
6005 def test_age_sort_survival(self, age_repo: pathlib.Path) -> None:
6006 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "survival"])
6007 assert result.exit_code == 0, result.output
6008 data = json.loads(result.output)
6009 survivals = [s["est_survival_pct"] for s in data["symbols"]]
6010 assert survivals == sorted(survivals)
6011
6012 def test_age_sort_invalid(self, age_repo: pathlib.Path) -> None:
6013 result = runner.invoke(cli, ["code", "age", "--sort", "bogus"])
6014 assert result.exit_code != 0
6015
6016 # ── --kind filter ────────────────────────────────────────────────────────
6017
6018 def test_age_kind_filter(self, age_repo: pathlib.Path) -> None:
6019 result = runner.invoke(cli, ["code", "age", "--json", "--kind", "function"])
6020 data = json.loads(result.output)
6021 for sym in data["symbols"]:
6022 assert sym["kind"] in ("function", "method")
6023
6024 # ── --file filter ─────────────────────────────────────────────────────────
6025
6026 def test_age_file_filter(self, age_repo: pathlib.Path) -> None:
6027 result = runner.invoke(
6028 cli, ["code", "age", "--json", "--file", "billing.py"]
6029 )
6030 data = json.loads(result.output)
6031 for sym in data["symbols"]:
6032 assert "billing.py" in sym["file"]
6033
6034 def test_age_file_filter_nonexistent(self, age_repo: pathlib.Path) -> None:
6035 result = runner.invoke(
6036 cli, ["code", "age", "--json", "--file", "no_such_file.py"]
6037 )
6038 assert result.exit_code == 0
6039 data = json.loads(result.output)
6040 assert data["symbols"] == []
6041
6042 # ── --explain ─────────────────────────────────────────────────────────────
6043
6044 def test_age_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
6045 result = runner.invoke(cli, ["code", "age", "--json"])
6046 data = json.loads(result.output)
6047 if not data["symbols"]:
6048 pytest.skip("no symbols")
6049 addr = data["symbols"][0]["address"]
6050 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6051 assert r2.exit_code == 0, r2.output
6052
6053 def test_age_explain_shows_breakdown(self, age_repo: pathlib.Path) -> None:
6054 result = runner.invoke(cli, ["code", "age", "--json"])
6055 data = json.loads(result.output)
6056 if not data["symbols"]:
6057 pytest.skip("no symbols")
6058 addr = data["symbols"][0]["address"]
6059 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6060 assert "Implementation changes" in r2.output
6061 assert "Signature changes" in r2.output
6062 assert "Est. survival" in r2.output
6063
6064 def test_age_explain_requires_double_colon(self, age_repo: pathlib.Path) -> None:
6065 result = runner.invoke(cli, ["code", "age", "--explain", "billing.py"])
6066 assert result.exit_code != 0
6067
6068 def test_age_explain_nonexistent_errors(self, age_repo: pathlib.Path) -> None:
6069 result = runner.invoke(cli, ["code", "age", "--explain", "no.py::nonexistent"])
6070 assert result.exit_code != 0
6071
6072 def test_age_explain_json(self, age_repo: pathlib.Path) -> None:
6073 result = runner.invoke(cli, ["code", "age", "--json"])
6074 data = json.loads(result.output)
6075 if not data["symbols"]:
6076 pytest.skip("no symbols")
6077 addr = data["symbols"][0]["address"]
6078 r2 = runner.invoke(cli, ["code", "age", "--explain", addr, "--json"])
6079 assert r2.exit_code == 0, r2.output
6080 detail = json.loads(r2.output)
6081 assert detail["address"] == addr
6082 assert "events" in detail
6083
6084 # ── --max-commits ─────────────────────────────────────────────────────────
6085
6086 def test_age_max_commits_validation(self, age_repo: pathlib.Path) -> None:
6087 result = runner.invoke(cli, ["code", "age", "--max-commits", "0"])
6088 assert result.exit_code != 0
6089
6090 def test_age_max_commits_respected(self, age_repo: pathlib.Path) -> None:
6091 result = runner.invoke(cli, ["code", "age", "--json", "--max-commits", "1"])
6092 assert result.exit_code == 0
6093 data = json.loads(result.output)
6094 assert data["commits_analysed"] <= 1
6095
6096 # ── --since ───────────────────────────────────────────────────────────────
6097
6098 def test_age_since_invalid_ref(self, age_repo: pathlib.Path) -> None:
6099 result = runner.invoke(cli, ["code", "age", "--since", "bad_ref"])
6100 assert result.exit_code != 0
6101
6102 # ── requires repo ─────────────────────────────────────────────────────────
6103
6104 def test_age_requires_repo(self, tmp_path: pathlib.Path) -> None:
6105 import os
6106 old = os.getcwd()
6107 try:
6108 os.chdir(tmp_path)
6109 result = runner.invoke(cli, ["code", "age"])
6110 assert result.exit_code != 0
6111 finally:
6112 os.chdir(old)
6113
6114
6115 # ---------------------------------------------------------------------------
6116 # entangle
6117 # ---------------------------------------------------------------------------
6118
6119
6120 @pytest.fixture
6121 def entangle_repo(repo: pathlib.Path) -> pathlib.Path:
6122 """Repo that has two files with no import link but symbols that co-change.
6123
6124 Commit 1: create billing.py (Invoice class) and serializers.py (to_json).
6125 Commit 2: modify Invoice.compute_total AND to_json together — they
6126 co-change with no import link.
6127 Commit 3: same again — both change again.
6128
6129 billing.py does NOT import serializers.py, so the pair should be
6130 flagged as entangled.
6131 """
6132 (repo / "billing.py").write_text(textwrap.dedent("""\
6133 class Invoice:
6134 def compute_total(self, items):
6135 return sum(items)
6136 """))
6137 (repo / "serializers.py").write_text(textwrap.dedent("""\
6138 def to_json(obj):
6139 return str(obj)
6140 """))
6141 r = runner.invoke(cli, ["commit", "-m", "initial"])
6142 assert r.exit_code == 0, r.output
6143
6144 # Commit 2: both change.
6145 (repo / "billing.py").write_text(textwrap.dedent("""\
6146 class Invoice:
6147 def compute_total(self, items):
6148 return round(sum(items), 2)
6149 """))
6150 (repo / "serializers.py").write_text(textwrap.dedent("""\
6151 def to_json(obj):
6152 import json
6153 return json.dumps(obj)
6154 """))
6155 r2 = runner.invoke(cli, ["commit", "-m", "update both"])
6156 assert r2.exit_code == 0, r2.output
6157
6158 # Commit 3: both change again.
6159 (repo / "billing.py").write_text(textwrap.dedent("""\
6160 class Invoice:
6161 def compute_total(self, items):
6162 return round(sum(items), 4)
6163 """))
6164 (repo / "serializers.py").write_text(textwrap.dedent("""\
6165 def to_json(obj):
6166 import json
6167 return json.dumps(obj, indent=2)
6168 """))
6169 r3 = runner.invoke(cli, ["commit", "-m", "tweak both again"])
6170 assert r3.exit_code == 0, r3.output
6171
6172 return repo
6173
6174
6175 class TestEntangle:
6176 """Tests for muse code entangle."""
6177
6178 # ── basic correctness ────────────────────────────────────────────────────
6179
6180 def test_entangle_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6181 result = runner.invoke(cli, ["code", "entangle"])
6182 assert result.exit_code == 0, result.output
6183
6184 def test_entangle_shows_header(self, entangle_repo: pathlib.Path) -> None:
6185 result = runner.invoke(cli, ["code", "entangle"])
6186 assert result.exit_code == 0
6187 assert "entanglement" in result.output.lower()
6188
6189 def test_entangle_detects_unlinked_pair(self, entangle_repo: pathlib.Path) -> None:
6190 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6191 assert result.exit_code == 0
6192 # Both files should appear in the output.
6193 assert "billing.py" in result.output or "serializers.py" in result.output
6194
6195 def test_entangle_shows_rate(self, entangle_repo: pathlib.Path) -> None:
6196 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6197 assert result.exit_code == 0
6198 # Rate column should show a percentage.
6199 assert "%" in result.output
6200
6201 # ── JSON schema ──────────────────────────────────────────────────────────
6202
6203 def test_entangle_json_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6204 result = runner.invoke(cli, ["code", "entangle", "--json"])
6205 assert result.exit_code == 0, result.output
6206 json.loads(result.output) # must be valid JSON
6207
6208 def test_entangle_json_top_level_keys(self, entangle_repo: pathlib.Path) -> None:
6209 result = runner.invoke(cli, ["code", "entangle", "--json"])
6210 data = json.loads(result.output)
6211 for key in ("ref", "commits_analysed", "truncated", "filters", "pairs"):
6212 assert key in data, f"missing key: {key}"
6213
6214 def test_entangle_json_pair_schema(self, entangle_repo: pathlib.Path) -> None:
6215 result = runner.invoke(
6216 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6217 )
6218 data = json.loads(result.output)
6219 if not data["pairs"]:
6220 pytest.skip("no pairs detected")
6221 pair = data["pairs"][0]
6222 for key in (
6223 "symbol_a", "symbol_b", "file_a", "file_b", "same_file",
6224 "structurally_linked", "co_changes", "commits_both_active",
6225 "co_change_rate", "a_in_test", "b_in_test",
6226 ):
6227 assert key in pair, f"missing key: {key}"
6228
6229 def test_entangle_json_co_change_rate_in_range(
6230 self, entangle_repo: pathlib.Path
6231 ) -> None:
6232 result = runner.invoke(
6233 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6234 )
6235 data = json.loads(result.output)
6236 for pair in data["pairs"]:
6237 assert 0.0 <= pair["co_change_rate"] <= 1.0
6238
6239 def test_entangle_json_filters_reflected(
6240 self, entangle_repo: pathlib.Path
6241 ) -> None:
6242 result = runner.invoke(
6243 cli, ["code", "entangle", "--json", "--min-co-changes", "3", "--min-rate", "0.5"]
6244 )
6245 data = json.loads(result.output)
6246 assert data["filters"]["min_co_changes"] == 3
6247 assert data["filters"]["min_rate"] == 0.5
6248
6249 def test_entangle_json_sorted_by_rate_desc(
6250 self, entangle_repo: pathlib.Path
6251 ) -> None:
6252 result = runner.invoke(
6253 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6254 )
6255 data = json.loads(result.output)
6256 rates = [p["co_change_rate"] for p in data["pairs"]]
6257 assert rates == sorted(rates, reverse=True)
6258
6259 # ── --top ────────────────────────────────────────────────────────────────
6260
6261 def test_entangle_top_limits(self, entangle_repo: pathlib.Path) -> None:
6262 result = runner.invoke(
6263 cli, ["code", "entangle", "--json", "--top", "1", "--min-co-changes", "1"]
6264 )
6265 data = json.loads(result.output)
6266 assert len(data["pairs"]) <= 1
6267
6268 def test_entangle_top_validation(self, entangle_repo: pathlib.Path) -> None:
6269 result = runner.invoke(cli, ["code", "entangle", "--top", "0"])
6270 assert result.exit_code != 0
6271
6272 # ── --min-co-changes ─────────────────────────────────────────────────────
6273
6274 def test_entangle_min_co_changes_filters(
6275 self, entangle_repo: pathlib.Path
6276 ) -> None:
6277 result = runner.invoke(
6278 cli, ["code", "entangle", "--json", "--min-co-changes", "100"]
6279 )
6280 data = json.loads(result.output)
6281 # No pair can have co-changed 100 times in a 3-commit repo.
6282 assert data["pairs"] == []
6283
6284 def test_entangle_min_co_changes_validation(
6285 self, entangle_repo: pathlib.Path
6286 ) -> None:
6287 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "0"])
6288 assert result.exit_code != 0
6289
6290 # ── --min-rate ───────────────────────────────────────────────────────────
6291
6292 def test_entangle_min_rate_1_may_return_results(
6293 self, entangle_repo: pathlib.Path
6294 ) -> None:
6295 result = runner.invoke(
6296 cli, ["code", "entangle", "--json", "--min-rate", "1.0", "--min-co-changes", "1"]
6297 )
6298 assert result.exit_code == 0
6299 data = json.loads(result.output)
6300 for pair in data["pairs"]:
6301 assert pair["co_change_rate"] == 1.0
6302
6303 def test_entangle_min_rate_validation(self, entangle_repo: pathlib.Path) -> None:
6304 result = runner.invoke(cli, ["code", "entangle", "--min-rate", "1.5"])
6305 assert result.exit_code != 0
6306 result2 = runner.invoke(cli, ["code", "entangle", "--min-rate", "-0.1"])
6307 assert result2.exit_code != 0
6308
6309 # ── --symbol filter ──────────────────────────────────────────────────────
6310
6311 def test_entangle_symbol_requires_double_colon(
6312 self, entangle_repo: pathlib.Path
6313 ) -> None:
6314 result = runner.invoke(cli, ["code", "entangle", "--symbol", "billing.py"])
6315 assert result.exit_code != 0
6316
6317 def test_entangle_symbol_exits_zero_valid(
6318 self, entangle_repo: pathlib.Path
6319 ) -> None:
6320 result = runner.invoke(
6321 cli,
6322 ["code", "entangle", "--symbol", "billing.py::Invoice",
6323 "--min-co-changes", "1"],
6324 )
6325 assert result.exit_code == 0, result.output
6326
6327 def test_entangle_symbol_filters_pairs(
6328 self, entangle_repo: pathlib.Path
6329 ) -> None:
6330 result = runner.invoke(
6331 cli,
6332 ["code", "entangle", "--json", "--symbol", "billing.py::Invoice",
6333 "--min-co-changes", "1"],
6334 )
6335 data = json.loads(result.output)
6336 for pair in data["pairs"]:
6337 assert (
6338 "billing.py" in pair["symbol_a"]
6339 or "billing.py" in pair["symbol_b"]
6340 )
6341
6342 # ── --include-same-file ──────────────────────────────────────────────────
6343
6344 def test_entangle_include_same_file_flag(
6345 self, entangle_repo: pathlib.Path
6346 ) -> None:
6347 # Should not crash, and may return same-file pairs.
6348 result = runner.invoke(
6349 cli,
6350 ["code", "entangle", "--json", "--include-same-file",
6351 "--min-co-changes", "1"],
6352 )
6353 assert result.exit_code == 0, result.output
6354 data = json.loads(result.output)
6355 assert data["filters"]["include_same_file"] is True
6356
6357 # ── --max-commits ─────────────────────────────────────────────────────────
6358
6359 def test_entangle_max_commits_validation(
6360 self, entangle_repo: pathlib.Path
6361 ) -> None:
6362 result = runner.invoke(cli, ["code", "entangle", "--max-commits", "0"])
6363 assert result.exit_code != 0
6364
6365 def test_entangle_max_commits_respected(
6366 self, entangle_repo: pathlib.Path
6367 ) -> None:
6368 result = runner.invoke(
6369 cli, ["code", "entangle", "--json", "--max-commits", "1"]
6370 )
6371 assert result.exit_code == 0
6372 data = json.loads(result.output)
6373 assert data["commits_analysed"] <= 1
6374
6375 # ── --since ───────────────────────────────────────────────────────────────
6376
6377 def test_entangle_since_invalid_ref(self, entangle_repo: pathlib.Path) -> None:
6378 result = runner.invoke(cli, ["code", "entangle", "--since", "no_such_ref"])
6379 assert result.exit_code != 0
6380
6381 # ── requires repo ─────────────────────────────────────────────────────────
6382
6383 def test_entangle_requires_repo(self, tmp_path: pathlib.Path) -> None:
6384 import os
6385 old = os.getcwd()
6386 try:
6387 os.chdir(tmp_path)
6388 result = runner.invoke(cli, ["code", "entangle"])
6389 assert result.exit_code != 0
6390 finally:
6391 os.chdir(old)
6392
6393
6394 # ---------------------------------------------------------------------------
6395 # muse code semantic-test-coverage
6396 # ---------------------------------------------------------------------------
6397
6398
6399 @pytest.fixture
6400 def stc_repo(repo: pathlib.Path) -> pathlib.Path:
6401 """Repo with production code and a test file for semantic-test-coverage.
6402
6403 Layout::
6404
6405 billing.py — compute_total (function), Invoice (class),
6406 Invoice.apply_discount (method),
6407 Invoice.generate_pdf (method) ← never called by tests
6408 services.py — process_order (calls compute_total transitively)
6409 tests/test_billing.py — test_compute_total, test_apply_discount,
6410 test_process_order (direct calls)
6411
6412 Direct coverage expected:
6413 compute_total ← test_compute_total, test_process_order (via bare name)
6414 Invoice ← test_compute_total (instantiation)
6415 apply_discount ← test_apply_discount
6416 generate_pdf ← NOT covered
6417 process_order ← test_process_order
6418
6419 Transitive (depth 2) additionally covers:
6420 compute_total ← test_process_order (because process_order calls it)
6421 """
6422 (repo / "tests").mkdir(exist_ok=True)
6423
6424 (repo / "billing.py").write_text(textwrap.dedent("""\
6425 class Invoice:
6426 def apply_discount(self, rate):
6427 return self.total * (1 - rate)
6428
6429 def generate_pdf(self):
6430 return b"PDF"
6431
6432 def compute_total(items):
6433 return sum(i["price"] for i in items)
6434 """))
6435
6436 (repo / "services.py").write_text(textwrap.dedent("""\
6437 from billing import compute_total
6438
6439 def process_order(order):
6440 return compute_total(order["items"])
6441 """))
6442
6443 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
6444 from billing import compute_total, Invoice
6445 from services import process_order
6446
6447 def test_compute_total():
6448 inv = Invoice()
6449 assert compute_total([{"price": 10}]) == 10
6450
6451 def test_apply_discount():
6452 inv = Invoice()
6453 inv.total = 100
6454 assert inv.apply_discount(0.1) == 90
6455
6456 def test_process_order():
6457 result = process_order({"items": [{"price": 5}]})
6458 assert result == 5
6459 """))
6460
6461 r = runner.invoke(cli, ["commit", "-m", "stc: initial repo"])
6462 assert r.exit_code == 0, r.output
6463 return repo
6464
6465
6466 class TestSemanticTestCoverage:
6467 """Tests for ``muse code semantic-test-coverage``."""
6468
6469 CMD = ["code", "semantic-test-coverage"]
6470
6471 # ── basic correctness ────────────────────────────────────────────────────
6472
6473 def test_stc_exits_zero(self, stc_repo: pathlib.Path) -> None:
6474 result = runner.invoke(cli, self.CMD)
6475 assert result.exit_code == 0, result.output
6476
6477 def test_stc_shows_header(self, stc_repo: pathlib.Path) -> None:
6478 result = runner.invoke(cli, self.CMD)
6479 assert "Semantic test coverage" in result.output
6480 assert "HEAD" in result.output
6481
6482 def test_stc_shows_test_function_count(self, stc_repo: pathlib.Path) -> None:
6483 result = runner.invoke(cli, self.CMD)
6484 # 3 test functions in the repo
6485 assert "test functions" in result.output
6486
6487 def test_stc_shows_total_line(self, stc_repo: pathlib.Path) -> None:
6488 result = runner.invoke(cli, self.CMD)
6489 assert "TOTAL:" in result.output
6490
6491 def test_stc_covered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6492 result = runner.invoke(cli, self.CMD)
6493 assert "compute_total" in result.output
6494
6495 def test_stc_uncovered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6496 result = runner.invoke(cli, self.CMD)
6497 assert "generate_pdf" in result.output
6498
6499 def test_stc_covered_has_check_icon(self, stc_repo: pathlib.Path) -> None:
6500 result = runner.invoke(cli, self.CMD)
6501 assert "✅" in result.output
6502
6503 def test_stc_uncovered_has_cross_icon(self, stc_repo: pathlib.Path) -> None:
6504 result = runner.invoke(cli, self.CMD)
6505 assert "❌" in result.output
6506
6507 # ── JSON output ──────────────────────────────────────────────────────────
6508
6509 def test_stc_json_exits_zero(self, stc_repo: pathlib.Path) -> None:
6510 result = runner.invoke(cli, self.CMD + ["--json"])
6511 assert result.exit_code == 0, result.output
6512
6513 def test_stc_json_is_valid(self, stc_repo: pathlib.Path) -> None:
6514 result = runner.invoke(cli, self.CMD + ["--json"])
6515 data = json.loads(result.output)
6516 assert isinstance(data, dict)
6517
6518 def test_stc_json_top_level_keys(self, stc_repo: pathlib.Path) -> None:
6519 result = runner.invoke(cli, self.CMD + ["--json"])
6520 data = json.loads(result.output)
6521 for key in ("ref", "snapshot_id", "depth", "transitive", "filters",
6522 "summary", "files"):
6523 assert key in data, f"missing key: {key}"
6524
6525 def test_stc_json_ref_is_head(self, stc_repo: pathlib.Path) -> None:
6526 result = runner.invoke(cli, self.CMD + ["--json"])
6527 data = json.loads(result.output)
6528 assert data["ref"] == "HEAD"
6529
6530 def test_stc_json_depth_default(self, stc_repo: pathlib.Path) -> None:
6531 result = runner.invoke(cli, self.CMD + ["--json"])
6532 data = json.loads(result.output)
6533 assert data["depth"] == 1
6534
6535 def test_stc_json_transitive_default_false(self, stc_repo: pathlib.Path) -> None:
6536 result = runner.invoke(cli, self.CMD + ["--json"])
6537 data = json.loads(result.output)
6538 assert data["transitive"] is False
6539
6540 def test_stc_json_summary_schema(self, stc_repo: pathlib.Path) -> None:
6541 result = runner.invoke(cli, self.CMD + ["--json"])
6542 data = json.loads(result.output)
6543 summary = data["summary"]
6544 for key in ("total_symbols", "covered_symbols", "uncovered_symbols",
6545 "coverage_pct", "total_test_functions", "total_production_files"):
6546 assert key in summary, f"summary missing: {key}"
6547
6548 def test_stc_json_summary_counts_consistent(self, stc_repo: pathlib.Path) -> None:
6549 result = runner.invoke(cli, self.CMD + ["--json"])
6550 data = json.loads(result.output)
6551 s = data["summary"]
6552 assert s["covered_symbols"] + s["uncovered_symbols"] == s["total_symbols"]
6553
6554 def test_stc_json_summary_test_fn_count(self, stc_repo: pathlib.Path) -> None:
6555 result = runner.invoke(cli, self.CMD + ["--json"])
6556 data = json.loads(result.output)
6557 # 3 test functions: test_compute_total, test_apply_discount, test_process_order
6558 assert data["summary"]["total_test_functions"] >= 3
6559
6560 def test_stc_json_file_schema(self, stc_repo: pathlib.Path) -> None:
6561 result = runner.invoke(cli, self.CMD + ["--json"])
6562 data = json.loads(result.output)
6563 assert len(data["files"]) > 0
6564 fc = data["files"][0]
6565 for key in ("file", "total_symbols", "covered_symbols",
6566 "uncovered_symbols", "coverage_pct", "symbols"):
6567 assert key in fc, f"file record missing: {key}"
6568
6569 def test_stc_json_symbol_schema(self, stc_repo: pathlib.Path) -> None:
6570 result = runner.invoke(cli, self.CMD + ["--json"])
6571 data = json.loads(result.output)
6572 # Find a file with at least one symbol
6573 sym = data["files"][0]["symbols"][0]
6574 for key in ("address", "name", "kind", "covered", "test_functions"):
6575 assert key in sym, f"symbol record missing: {key}"
6576
6577 def test_stc_json_covered_symbol_has_test_functions(
6578 self, stc_repo: pathlib.Path
6579 ) -> None:
6580 result = runner.invoke(cli, self.CMD + ["--json"])
6581 data = json.loads(result.output)
6582 covered = [
6583 sym
6584 for fc in data["files"]
6585 for sym in fc["symbols"]
6586 if sym["covered"]
6587 ]
6588 assert covered, "expected at least one covered symbol"
6589 assert any(len(sym["test_functions"]) > 0 for sym in covered)
6590
6591 def test_stc_json_uncovered_symbol_empty_test_fns(
6592 self, stc_repo: pathlib.Path
6593 ) -> None:
6594 result = runner.invoke(cli, self.CMD + ["--json"])
6595 data = json.loads(result.output)
6596 uncovered = [
6597 sym
6598 for fc in data["files"]
6599 for sym in fc["symbols"]
6600 if not sym["covered"]
6601 ]
6602 assert uncovered, "expected generate_pdf to be uncovered"
6603 assert all(sym["test_functions"] == [] for sym in uncovered)
6604
6605 def test_stc_json_generate_pdf_uncovered(self, stc_repo: pathlib.Path) -> None:
6606 result = runner.invoke(cli, self.CMD + ["--json"])
6607 data = json.loads(result.output)
6608 found = next(
6609 (
6610 sym
6611 for fc in data["files"]
6612 for sym in fc["symbols"]
6613 if sym["name"] == "generate_pdf"
6614 ),
6615 None,
6616 )
6617 assert found is not None, "generate_pdf symbol not found"
6618 assert found["covered"] is False
6619
6620 def test_stc_json_compute_total_covered(self, stc_repo: pathlib.Path) -> None:
6621 result = runner.invoke(cli, self.CMD + ["--json"])
6622 data = json.loads(result.output)
6623 found = next(
6624 (
6625 sym
6626 for fc in data["files"]
6627 for sym in fc["symbols"]
6628 if sym["name"] == "compute_total"
6629 ),
6630 None,
6631 )
6632 assert found is not None
6633 assert found["covered"] is True
6634
6635 def test_stc_json_coverage_pct_between_0_and_100(
6636 self, stc_repo: pathlib.Path
6637 ) -> None:
6638 result = runner.invoke(cli, self.CMD + ["--json"])
6639 data = json.loads(result.output)
6640 for fc in data["files"]:
6641 assert 0.0 <= fc["coverage_pct"] <= 100.0
6642
6643 def test_stc_json_filter_reflected(self, stc_repo: pathlib.Path) -> None:
6644 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6645 data = json.loads(result.output)
6646 assert data["filters"]["kind"] == "method"
6647
6648 def test_stc_json_no_import_pseudosymbols(self, stc_repo: pathlib.Path) -> None:
6649 result = runner.invoke(cli, self.CMD + ["--json"])
6650 data = json.loads(result.output)
6651 for fc in data["files"]:
6652 for sym in fc["symbols"]:
6653 assert sym["kind"] != "import"
6654
6655 # ── --file filter ────────────────────────────────────────────────────────
6656
6657 def test_stc_file_filter_scopes_output(self, stc_repo: pathlib.Path) -> None:
6658 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6659 data = json.loads(result.output)
6660 for fc in data["files"]:
6661 assert "billing.py" in fc["file"]
6662
6663 def test_stc_file_filter_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6664 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6665 data = json.loads(result.output)
6666 assert data["filters"]["file"] == "billing.py"
6667
6668 def test_stc_file_filter_billing_has_generate_pdf(
6669 self, stc_repo: pathlib.Path
6670 ) -> None:
6671 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6672 data = json.loads(result.output)
6673 names = [
6674 sym["name"] for fc in data["files"] for sym in fc["symbols"]
6675 ]
6676 assert "generate_pdf" in names
6677
6678 # ── --kind filter ────────────────────────────────────────────────────────
6679
6680 def test_stc_kind_method_only_methods(self, stc_repo: pathlib.Path) -> None:
6681 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6682 data = json.loads(result.output)
6683 for fc in data["files"]:
6684 for sym in fc["symbols"]:
6685 assert sym["kind"] == "method"
6686
6687 def test_stc_kind_function_only_functions(self, stc_repo: pathlib.Path) -> None:
6688 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function"])
6689 data = json.loads(result.output)
6690 for fc in data["files"]:
6691 for sym in fc["symbols"]:
6692 assert sym["kind"] == "function"
6693
6694 def test_stc_kind_invalid_rejected(self, stc_repo: pathlib.Path) -> None:
6695 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6696 assert result.exit_code != 0
6697
6698 # ── --uncovered-only ─────────────────────────────────────────────────────
6699
6700 def test_stc_uncovered_only_exits_zero(self, stc_repo: pathlib.Path) -> None:
6701 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6702 assert result.exit_code == 0, result.output
6703
6704 def test_stc_uncovered_only_hides_covered(self, stc_repo: pathlib.Path) -> None:
6705 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6706 # generate_pdf should appear; compute_total should not appear
6707 assert "generate_pdf" in result.output
6708
6709 def test_stc_uncovered_only_json_symbols_all_uncovered(
6710 self, stc_repo: pathlib.Path
6711 ) -> None:
6712 result = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6713 data = json.loads(result.output)
6714 for fc in data["files"]:
6715 for sym in fc["symbols"]:
6716 assert sym["covered"] is False
6717
6718 def test_stc_uncovered_only_json_stats_still_full(
6719 self, stc_repo: pathlib.Path
6720 ) -> None:
6721 result_all = runner.invoke(cli, self.CMD + ["--json"])
6722 result_uncov = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6723 data_all = json.loads(result_all.output)
6724 data_uncov = json.loads(result_uncov.output)
6725 # Total symbol count should be the same (stats reflect full picture)
6726 assert (
6727 data_all["summary"]["total_symbols"]
6728 == data_uncov["summary"]["total_symbols"]
6729 )
6730
6731 # ── --show-tests ─────────────────────────────────────────────────────────
6732
6733 def test_stc_show_tests_exits_zero(self, stc_repo: pathlib.Path) -> None:
6734 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6735 assert result.exit_code == 0, result.output
6736
6737 def test_stc_show_tests_lists_test_addr(self, stc_repo: pathlib.Path) -> None:
6738 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6739 # Should include a ← prefix followed by a test address
6740 assert "←" in result.output
6741
6742 def test_stc_show_tests_references_test_file(
6743 self, stc_repo: pathlib.Path
6744 ) -> None:
6745 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6746 assert "test_billing" in result.output
6747
6748 # ── --transitive / --depth ───────────────────────────────────────────────
6749
6750 def test_stc_transitive_exits_zero(self, stc_repo: pathlib.Path) -> None:
6751 result = runner.invoke(cli, self.CMD + ["--transitive"])
6752 assert result.exit_code == 0, result.output
6753
6754 def test_stc_transitive_json_flag_true(self, stc_repo: pathlib.Path) -> None:
6755 result = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6756 data = json.loads(result.output)
6757 assert data["transitive"] is True
6758
6759 def test_stc_depth_2_implies_transitive(self, stc_repo: pathlib.Path) -> None:
6760 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
6761 data = json.loads(result.output)
6762 assert data["transitive"] is True
6763 assert data["depth"] == 2
6764
6765 def test_stc_depth_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6766 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "3"])
6767 data = json.loads(result.output)
6768 assert data["depth"] == 3
6769
6770 def test_stc_transitive_does_not_reduce_coverage(
6771 self, stc_repo: pathlib.Path
6772 ) -> None:
6773 result_direct = runner.invoke(cli, self.CMD + ["--json"])
6774 result_trans = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6775 data_direct = json.loads(result_direct.output)
6776 data_trans = json.loads(result_trans.output)
6777 # Transitive coverage must be >= direct coverage
6778 assert (
6779 data_trans["summary"]["covered_symbols"]
6780 >= data_direct["summary"]["covered_symbols"]
6781 )
6782
6783 def test_stc_depth_0_invalid(self, stc_repo: pathlib.Path) -> None:
6784 result = runner.invoke(cli, self.CMD + ["--depth", "0"])
6785 assert result.exit_code != 0
6786
6787 def test_stc_depth_exceeds_max_invalid(self, stc_repo: pathlib.Path) -> None:
6788 result = runner.invoke(cli, self.CMD + ["--depth", "11"])
6789 assert result.exit_code != 0
6790
6791 # ── --min-coverage ───────────────────────────────────────────────────────
6792
6793 def test_stc_min_coverage_0_exits_zero(self, stc_repo: pathlib.Path) -> None:
6794 result = runner.invoke(cli, self.CMD + ["--min-coverage", "0"])
6795 assert result.exit_code == 0, result.output
6796
6797 def test_stc_min_coverage_100_exits_nonzero(self, stc_repo: pathlib.Path) -> None:
6798 # generate_pdf is never covered, so 100% is unachievable.
6799 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6800 assert result.exit_code != 0
6801
6802 def test_stc_min_coverage_shows_warning(self, stc_repo: pathlib.Path) -> None:
6803 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6804 assert "⚠️" in result.output or "below" in result.output.lower()
6805
6806 def test_stc_min_coverage_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6807 result = runner.invoke(cli, self.CMD + ["--json", "--min-coverage", "80"])
6808 data = json.loads(result.output)
6809 assert data["filters"]["min_coverage"] == 80
6810
6811 def test_stc_min_coverage_none_when_0(self, stc_repo: pathlib.Path) -> None:
6812 result = runner.invoke(cli, self.CMD + ["--json"])
6813 data = json.loads(result.output)
6814 assert data["filters"]["min_coverage"] is None
6815
6816 def test_stc_min_coverage_invalid_over_100(self, stc_repo: pathlib.Path) -> None:
6817 result = runner.invoke(cli, self.CMD + ["--min-coverage", "101"])
6818 assert result.exit_code != 0
6819
6820 def test_stc_min_coverage_invalid_negative(self, stc_repo: pathlib.Path) -> None:
6821 result = runner.invoke(cli, self.CMD + ["--min-coverage", "-1"])
6822 assert result.exit_code != 0
6823
6824 # ── test-file exclusion ──────────────────────────────────────────────────
6825
6826 def test_stc_test_files_not_in_production_symbols(
6827 self, stc_repo: pathlib.Path
6828 ) -> None:
6829 result = runner.invoke(cli, self.CMD + ["--json"])
6830 data = json.loads(result.output)
6831 for fc in data["files"]:
6832 assert "test_" not in pathlib.PurePosixPath(fc["file"]).name.split(".")[0][:5] or \
6833 not fc["file"].startswith("tests/"), \
6834 f"test file appeared in production symbols: {fc['file']}"
6835
6836 def test_stc_no_test_file_in_prod_files(self, stc_repo: pathlib.Path) -> None:
6837 result = runner.invoke(cli, self.CMD + ["--json"])
6838 data = json.loads(result.output)
6839 for fc in data["files"]:
6840 assert "tests/" not in fc["file"] or fc["file"].startswith("tests/") is False, \
6841 fc["file"]
6842
6843 # ── requires repo ────────────────────────────────────────────────────────
6844
6845 def test_stc_requires_repo(self, tmp_path: pathlib.Path) -> None:
6846 import os
6847 old = os.getcwd()
6848 try:
6849 os.chdir(tmp_path)
6850 result = runner.invoke(cli, self.CMD)
6851 assert result.exit_code != 0
6852 finally:
6853 os.chdir(old)
6854
6855 # ── empty repo ───────────────────────────────────────────────────────────
6856
6857 def test_stc_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
6858 """An empty repo (no commits yet) should not crash."""
6859 # The base repo fixture has no commits — must handle gracefully.
6860 # First commit something minimal so HEAD exists.
6861 (repo / "empty.py").write_text("")
6862 r = runner.invoke(cli, ["commit", "-m", "seed"])
6863 if r.exit_code != 0:
6864 pytest.skip("could not create initial commit")
6865 result = runner.invoke(cli, self.CMD)
6866 assert result.exit_code == 0, result.output
6867
6868
6869 # ---------------------------------------------------------------------------
6870 # muse code gravity
6871 # ---------------------------------------------------------------------------
6872
6873
6874 @pytest.fixture
6875 def gravity_repo(repo: pathlib.Path) -> pathlib.Path:
6876 """Repo whose call graph creates a clear gravity hierarchy.
6877
6878 Layout::
6879
6880 core.py — read_object (called by everything)
6881 mid.py — process (calls read_object)
6882 top.py — handle (calls process, which calls read_object)
6883 leaf.py — leaf_fn (calls handle)
6884
6885 Expected gravity (transitive dependents):
6886 read_object: 3 (process, handle, leaf_fn) → high gravity
6887 process: 2 (handle, leaf_fn)
6888 handle: 1 (leaf_fn)
6889 leaf_fn: 0 → lowest gravity
6890 """
6891 (repo / "core.py").write_text(textwrap.dedent("""\
6892 def read_object(path):
6893 return path.read_bytes()
6894 """))
6895 r1 = runner.invoke(cli, ["commit", "-m", "core: add read_object"])
6896 assert r1.exit_code == 0, r1.output
6897
6898 (repo / "mid.py").write_text(textwrap.dedent("""\
6899 from core import read_object
6900
6901 def process(path):
6902 return read_object(path)
6903 """))
6904 r2 = runner.invoke(cli, ["commit", "-m", "mid: add process"])
6905 assert r2.exit_code == 0, r2.output
6906
6907 (repo / "top.py").write_text(textwrap.dedent("""\
6908 from mid import process
6909
6910 def handle(path):
6911 return process(path)
6912 """))
6913 r3 = runner.invoke(cli, ["commit", "-m", "top: add handle"])
6914 assert r3.exit_code == 0, r3.output
6915
6916 (repo / "leaf.py").write_text(textwrap.dedent("""\
6917 from top import handle
6918
6919 def leaf_fn(path):
6920 return handle(path)
6921 """))
6922 r4 = runner.invoke(cli, ["commit", "-m", "leaf: add leaf_fn"])
6923 assert r4.exit_code == 0, r4.output
6924
6925 return repo
6926
6927
6928 class TestGravity:
6929 """Tests for ``muse code gravity``."""
6930
6931 CMD = ["code", "gravity"]
6932
6933 # ── basic correctness ─────────────────────────────────────────────────────
6934
6935 def test_gravity_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6936 result = runner.invoke(cli, self.CMD)
6937 assert result.exit_code == 0, result.output
6938
6939 def test_gravity_shows_header(self, gravity_repo: pathlib.Path) -> None:
6940 result = runner.invoke(cli, self.CMD)
6941 assert "Symbol gravity" in result.output
6942
6943 def test_gravity_shows_head(self, gravity_repo: pathlib.Path) -> None:
6944 result = runner.invoke(cli, self.CMD)
6945 assert "HEAD" in result.output
6946
6947 def test_gravity_shows_column_headers(self, gravity_repo: pathlib.Path) -> None:
6948 result = runner.invoke(cli, self.CMD)
6949 assert "GRAVITY" in result.output
6950 assert "DIRECT" in result.output
6951 assert "DEPTH" in result.output
6952
6953 def test_gravity_shows_symbols(self, gravity_repo: pathlib.Path) -> None:
6954 result = runner.invoke(cli, self.CMD)
6955 # At least one symbol should appear.
6956 assert "read_object" in result.output or "process" in result.output
6957
6958 def test_gravity_shows_percentage(self, gravity_repo: pathlib.Path) -> None:
6959 result = runner.invoke(cli, self.CMD)
6960 assert "%" in result.output
6961
6962 # ── --top ─────────────────────────────────────────────────────────────────
6963
6964 def test_gravity_top_limits_output(self, gravity_repo: pathlib.Path) -> None:
6965 result1 = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6966 result3 = runner.invoke(cli, self.CMD + ["--json", "--top", "3"])
6967 data1 = json.loads(result1.output)
6968 data3 = json.loads(result3.output)
6969 assert len(data1["symbols"]) <= 1
6970 assert len(data3["symbols"]) <= 3
6971
6972 def test_gravity_top_0_returns_all(self, gravity_repo: pathlib.Path) -> None:
6973 result_all = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6974 result_lim = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6975 data_all = json.loads(result_all.output)
6976 data_lim = json.loads(result_lim.output)
6977 assert len(data_all["symbols"]) >= len(data_lim["symbols"])
6978
6979 def test_gravity_top_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6980 result = runner.invoke(cli, self.CMD + ["--top", "-1"])
6981 assert result.exit_code != 0
6982
6983 # ── --sort ────────────────────────────────────────────────────────────────
6984
6985 def test_gravity_sort_gravity_default(self, gravity_repo: pathlib.Path) -> None:
6986 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6987 data = json.loads(result.output)
6988 if len(data["symbols"]) >= 2:
6989 pcts = [s["gravity_pct"] for s in data["symbols"]]
6990 assert pcts == sorted(pcts, reverse=True)
6991
6992 def test_gravity_sort_direct(self, gravity_repo: pathlib.Path) -> None:
6993 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "direct", "--top", "0"])
6994 data = json.loads(result.output)
6995 assert result.exit_code == 0
6996 if len(data["symbols"]) >= 2:
6997 directs = [s["direct_dependents"] for s in data["symbols"]]
6998 assert directs == sorted(directs, reverse=True)
6999
7000 def test_gravity_sort_depth(self, gravity_repo: pathlib.Path) -> None:
7001 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "depth", "--top", "0"])
7002 data = json.loads(result.output)
7003 assert result.exit_code == 0
7004 if len(data["symbols"]) >= 2:
7005 depths = [s["max_depth"] for s in data["symbols"]]
7006 assert depths == sorted(depths, reverse=True)
7007
7008 def test_gravity_sort_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7009 result = runner.invoke(cli, self.CMD + ["--sort", "invalid"])
7010 assert result.exit_code != 0
7011
7012 # ── --depth cap ───────────────────────────────────────────────────────────
7013
7014 def test_gravity_depth_0_unlimited(self, gravity_repo: pathlib.Path) -> None:
7015 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "0"])
7016 data = json.loads(result.output)
7017 assert result.exit_code == 0
7018 assert data["max_depth"] == 0
7019
7020 def test_gravity_depth_1_direct_only(self, gravity_repo: pathlib.Path) -> None:
7021 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "1", "--top", "0"])
7022 data = json.loads(result.output)
7023 assert result.exit_code == 0
7024 # With depth=1, max_depth for any symbol should be at most 1.
7025 for sym in data["symbols"]:
7026 assert sym["max_depth"] <= 1
7027
7028 def test_gravity_depth_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7029 result = runner.invoke(cli, self.CMD + ["--depth", "-1"])
7030 assert result.exit_code != 0
7031
7032 # ── --kind filter ─────────────────────────────────────────────────────────
7033
7034 def test_gravity_kind_function_only(self, gravity_repo: pathlib.Path) -> None:
7035 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function", "--top", "0"])
7036 data = json.loads(result.output)
7037 assert result.exit_code == 0
7038 for sym in data["symbols"]:
7039 assert sym["kind"] == "function"
7040
7041 def test_gravity_kind_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7042 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
7043 assert result.exit_code != 0
7044
7045 # ── --file filter ─────────────────────────────────────────────────────────
7046
7047 def test_gravity_file_filter_scopes(self, gravity_repo: pathlib.Path) -> None:
7048 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py", "--top", "0"])
7049 data = json.loads(result.output)
7050 assert result.exit_code == 0
7051 for sym in data["symbols"]:
7052 assert "core.py" in sym["file"]
7053
7054 def test_gravity_file_filter_reflected_in_json(self, gravity_repo: pathlib.Path) -> None:
7055 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py"])
7056 data = json.loads(result.output)
7057 assert data["filters"]["file"] == "core.py"
7058
7059 # ── --min-gravity ─────────────────────────────────────────────────────────
7060
7061 def test_gravity_min_gravity_filters_low(self, gravity_repo: pathlib.Path) -> None:
7062 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "50.0", "--top", "0"])
7063 data = json.loads(result.output)
7064 for sym in data["symbols"]:
7065 assert sym["gravity_pct"] >= 50.0
7066
7067 def test_gravity_min_gravity_100_returns_few(self, gravity_repo: pathlib.Path) -> None:
7068 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "100.0"])
7069 assert result.exit_code == 0
7070
7071 def test_gravity_min_gravity_invalid_over_100(self, gravity_repo: pathlib.Path) -> None:
7072 result = runner.invoke(cli, self.CMD + ["--min-gravity", "101.0"])
7073 assert result.exit_code != 0
7074
7075 def test_gravity_min_gravity_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7076 result = runner.invoke(cli, self.CMD + ["--min-gravity", "-1.0"])
7077 assert result.exit_code != 0
7078
7079 # ── --explain ─────────────────────────────────────────────────────────────
7080
7081 def test_gravity_explain_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7082 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7083 assert result.exit_code == 0, result.output
7084
7085 def test_gravity_explain_shows_breakdown(self, gravity_repo: pathlib.Path) -> None:
7086 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7087 assert "Gravity breakdown" in result.output
7088
7089 def test_gravity_explain_shows_depth_distribution(
7090 self, gravity_repo: pathlib.Path
7091 ) -> None:
7092 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7093 assert "Depth distribution" in result.output
7094
7095 def test_gravity_explain_shows_deepest_callers(
7096 self, gravity_repo: pathlib.Path
7097 ) -> None:
7098 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7099 assert "Deepest callers" in result.output
7100
7101 def test_gravity_explain_missing_address_format(
7102 self, gravity_repo: pathlib.Path
7103 ) -> None:
7104 result = runner.invoke(cli, self.CMD + ["--explain", "no_double_colon"])
7105 assert result.exit_code != 0
7106
7107 def test_gravity_explain_unknown_symbol_exits_nonzero(
7108 self, gravity_repo: pathlib.Path
7109 ) -> None:
7110 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::no_such_fn"])
7111 assert result.exit_code != 0
7112
7113 def test_gravity_explain_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7114 result = runner.invoke(
7115 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7116 )
7117 assert result.exit_code == 0, result.output
7118
7119 def test_gravity_explain_json_schema(self, gravity_repo: pathlib.Path) -> None:
7120 result = runner.invoke(
7121 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7122 )
7123 data = json.loads(result.output)
7124 for key in (
7125 "address", "name", "kind", "file",
7126 "gravity_pct", "direct_dependents", "transitive_dependents",
7127 "max_depth", "depth_distribution",
7128 ):
7129 assert key in data, f"missing key: {key}"
7130
7131 # ── JSON leaderboard ──────────────────────────────────────────────────────
7132
7133 def test_gravity_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7134 result = runner.invoke(cli, self.CMD + ["--json"])
7135 assert result.exit_code == 0, result.output
7136
7137 def test_gravity_json_top_level_keys(self, gravity_repo: pathlib.Path) -> None:
7138 result = runner.invoke(cli, self.CMD + ["--json"])
7139 data = json.loads(result.output)
7140 for key in (
7141 "ref", "snapshot_id", "total_production_symbols",
7142 "max_depth", "include_tests", "filters", "symbols",
7143 ):
7144 assert key in data, f"missing key: {key}"
7145
7146 def test_gravity_json_symbol_schema(self, gravity_repo: pathlib.Path) -> None:
7147 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7148 data = json.loads(result.output)
7149 if data["symbols"]:
7150 sym = data["symbols"][0]
7151 for key in (
7152 "address", "name", "kind", "file",
7153 "gravity_pct", "direct_dependents",
7154 "transitive_dependents", "max_depth", "depth_distribution",
7155 ):
7156 assert key in sym, f"symbol missing key: {key}"
7157
7158 def test_gravity_json_gravity_pct_range(self, gravity_repo: pathlib.Path) -> None:
7159 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7160 data = json.loads(result.output)
7161 for sym in data["symbols"]:
7162 assert 0.0 <= sym["gravity_pct"] <= 100.0
7163
7164 def test_gravity_json_read_object_has_highest_gravity(
7165 self, gravity_repo: pathlib.Path
7166 ) -> None:
7167 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7168 data = json.loads(result.output)
7169 # read_object is called transitively by everything — should be near top.
7170 names = [s["name"] for s in data["symbols"]]
7171 if "read_object" in names and len(names) > 1:
7172 ro_idx = names.index("read_object")
7173 # read_object should be in the top half.
7174 assert ro_idx <= len(names) // 2 + 1
7175
7176 def test_gravity_json_leaf_fn_lower_gravity(
7177 self, gravity_repo: pathlib.Path
7178 ) -> None:
7179 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7180 data = json.loads(result.output)
7181 syms = {s["name"]: s for s in data["symbols"]}
7182 if "leaf_fn" in syms and "read_object" in syms:
7183 assert syms["leaf_fn"]["gravity_pct"] <= syms["read_object"]["gravity_pct"]
7184
7185 def test_gravity_json_include_tests_flag(self, gravity_repo: pathlib.Path) -> None:
7186 result = runner.invoke(cli, self.CMD + ["--json", "--include-tests"])
7187 data = json.loads(result.output)
7188 assert data["include_tests"] is True
7189
7190 def test_gravity_json_depth_reflected(self, gravity_repo: pathlib.Path) -> None:
7191 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
7192 data = json.loads(result.output)
7193 assert data["max_depth"] == 2
7194
7195 def test_gravity_json_filters_reflected(self, gravity_repo: pathlib.Path) -> None:
7196 result = runner.invoke(
7197 cli,
7198 self.CMD + ["--json", "--kind", "function", "--min-gravity", "5.0", "--top", "10"],
7199 )
7200 data = json.loads(result.output)
7201 assert data["filters"]["kind"] == "function"
7202 assert data["filters"]["min_gravity"] == 5.0
7203 assert data["filters"]["top"] == 10
7204
7205 def test_gravity_json_depth_distribution_is_dict(
7206 self, gravity_repo: pathlib.Path
7207 ) -> None:
7208 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7209 data = json.loads(result.output)
7210 for sym in data["symbols"]:
7211 assert isinstance(sym["depth_distribution"], dict)
7212
7213 # ── requires repo ─────────────────────────────────────────────────────────
7214
7215 def test_gravity_requires_repo(self, tmp_path: pathlib.Path) -> None:
7216 import os
7217 old = os.getcwd()
7218 try:
7219 os.chdir(tmp_path)
7220 result = runner.invoke(cli, self.CMD)
7221 assert result.exit_code != 0
7222 finally:
7223 os.chdir(old)
7224
7225
7226 # ---------------------------------------------------------------------------
7227 # muse code narrative
7228 # ---------------------------------------------------------------------------
7229
7230
7231 @pytest.fixture
7232 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
7233 """Repo with a symbol that has a rich multi-event history.
7234
7235 billing.py::compute_total goes through:
7236 commit 1: seed commit (different file — gives billing.py a parent context)
7237 commit 2: created (insert — billing.py added, compute_total appears as new symbol)
7238 commit 3: body rewritten (replace with impl keywords)
7239 commit 4: signature changed (replace with signature keywords)
7240 """
7241 # Commit 1 — seed so billing.py's creation is a delta, not the initial commit.
7242 (repo / "readme.txt").write_text("MuseHub billing module\n")
7243 r0 = runner.invoke(cli, ["commit", "-m", "chore: initial seed"])
7244 assert r0.exit_code == 0, r0.output
7245
7246 # Commit 2 — create billing.py (compute_total becomes a new symbol in delta).
7247 (repo / "billing.py").write_text(textwrap.dedent("""\
7248 def compute_total(items):
7249 total = 0
7250 for item in items:
7251 total += item["price"]
7252 return total
7253 """))
7254 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7255 assert r1.exit_code == 0, r1.output
7256
7257 # Commit 3 — body rewrite: implementation changed.
7258 (repo / "billing.py").write_text(textwrap.dedent("""\
7259 def compute_total(items):
7260 return sum(i["price"] for i in items)
7261 """))
7262 r2 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
7263 assert r2.exit_code == 0, r2.output
7264
7265 # Commit 4 — signature change.
7266 (repo / "billing.py").write_text(textwrap.dedent("""\
7267 def compute_total(items, currency="USD"):
7268 return sum(i["price"] for i in items)
7269 """))
7270 r3 = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
7271 assert r3.exit_code == 0, r3.output
7272
7273 return repo
7274
7275
7276 class TestNarrative:
7277 """Tests for ``muse code narrative``."""
7278
7279 CMD = ["code", "narrative"]
7280 ADDR = "billing.py::compute_total"
7281
7282 # ── basic correctness ─────────────────────────────────────────────────────
7283
7284 def test_narrative_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7285 result = runner.invoke(cli, self.CMD + [self.ADDR])
7286 assert result.exit_code == 0, result.output
7287
7288 def test_narrative_shows_symbol_name(self, narrative_repo: pathlib.Path) -> None:
7289 result = runner.invoke(cli, self.CMD + [self.ADDR])
7290 assert "compute_total" in result.output
7291
7292 def test_narrative_shows_file(self, narrative_repo: pathlib.Path) -> None:
7293 result = runner.invoke(cli, self.CMD + [self.ADDR])
7294 assert "billing.py" in result.output
7295
7296 def test_narrative_shows_born_event(self, narrative_repo: pathlib.Path) -> None:
7297 result = runner.invoke(cli, self.CMD + [self.ADDR])
7298 assert "Born" in result.output or "born" in result.output
7299
7300 def test_narrative_shows_life_summary(self, narrative_repo: pathlib.Path) -> None:
7301 result = runner.invoke(cli, self.CMD + [self.ADDR])
7302 assert "Life summary" in result.output or "Survival" in result.output
7303
7304 def test_narrative_shows_commit_id(self, narrative_repo: pathlib.Path) -> None:
7305 result = runner.invoke(cli, self.CMD + [self.ADDR])
7306 assert "commit" in result.output
7307
7308 def test_narrative_shows_survival(self, narrative_repo: pathlib.Path) -> None:
7309 result = runner.invoke(cli, self.CMD + [self.ADDR])
7310 assert "%" in result.output
7311
7312 # ── missing symbol ────────────────────────────────────────────────────────
7313
7314 def test_narrative_missing_symbol_exits_nonzero(
7315 self, narrative_repo: pathlib.Path
7316 ) -> None:
7317 result = runner.invoke(
7318 cli, self.CMD + ["billing.py::does_not_exist"]
7319 )
7320 assert result.exit_code != 0
7321
7322 def test_narrative_bad_address_no_colons_exits_nonzero(
7323 self, narrative_repo: pathlib.Path
7324 ) -> None:
7325 result = runner.invoke(cli, self.CMD + ["no_double_colon"])
7326 assert result.exit_code != 0
7327
7328 # ── --format prose ────────────────────────────────────────────────────────
7329
7330 def test_narrative_prose_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7331 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7332 assert result.exit_code == 0, result.output
7333
7334 def test_narrative_prose_contains_name(self, narrative_repo: pathlib.Path) -> None:
7335 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7336 assert "compute_total" in result.output
7337
7338 def test_narrative_prose_contains_content(self, narrative_repo: pathlib.Path) -> None:
7339 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7340 # Symbol name or some indication of the symbol's life should appear.
7341 assert "compute_total" in result.output or "rewritten" in result.output or "born" in result.output.lower()
7342
7343 def test_narrative_prose_no_timeline_label(
7344 self, narrative_repo: pathlib.Path
7345 ) -> None:
7346 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7347 # Timeline labels like "Born " should not appear in prose.
7348 assert "Life summary" not in result.output
7349
7350 def test_narrative_format_invalid_rejected(
7351 self, narrative_repo: pathlib.Path
7352 ) -> None:
7353 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "invalid"])
7354 assert result.exit_code != 0
7355
7356 # ── --json ────────────────────────────────────────────────────────────────
7357
7358 def test_narrative_json_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7359 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7360 assert result.exit_code == 0, result.output
7361
7362 def test_narrative_json_is_valid(self, narrative_repo: pathlib.Path) -> None:
7363 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7364 data = json.loads(result.output)
7365 assert isinstance(data, dict)
7366
7367 def test_narrative_json_top_level_keys(self, narrative_repo: pathlib.Path) -> None:
7368 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7369 data = json.loads(result.output)
7370 for key in (
7371 "address", "name", "kind", "file", "status",
7372 "born_date", "born_commit", "last_change_date", "last_change_commit",
7373 "calendar_age_days", "genetic_age_days",
7374 "impl_changes", "sig_changes", "renames",
7375 "est_survival_pct", "commits_analysed", "truncated", "events",
7376 ):
7377 assert key in data, f"missing key: {key}"
7378
7379 def test_narrative_json_address_matches(self, narrative_repo: pathlib.Path) -> None:
7380 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7381 data = json.loads(result.output)
7382 assert data["address"] == self.ADDR
7383
7384 def test_narrative_json_name_is_bare(self, narrative_repo: pathlib.Path) -> None:
7385 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7386 data = json.loads(result.output)
7387 assert data["name"] == "compute_total"
7388
7389 def test_narrative_json_file_is_file_part(self, narrative_repo: pathlib.Path) -> None:
7390 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7391 data = json.loads(result.output)
7392 assert data["file"] == "billing.py"
7393
7394 def test_narrative_json_status_alive(self, narrative_repo: pathlib.Path) -> None:
7395 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7396 data = json.loads(result.output)
7397 assert data["status"] == "alive"
7398
7399 def test_narrative_json_events_list(self, narrative_repo: pathlib.Path) -> None:
7400 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7401 data = json.loads(result.output)
7402 assert isinstance(data["events"], list)
7403 assert len(data["events"]) >= 1
7404
7405 def test_narrative_json_event_schema(self, narrative_repo: pathlib.Path) -> None:
7406 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7407 data = json.loads(result.output)
7408 ev = data["events"][0]
7409 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
7410 assert key in ev, f"event missing key: {key}"
7411
7412 def test_narrative_json_born_commit_set(self, narrative_repo: pathlib.Path) -> None:
7413 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7414 data = json.loads(result.output)
7415 assert data["born_commit"] != ""
7416
7417 def test_narrative_json_born_date_format(self, narrative_repo: pathlib.Path) -> None:
7418 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7419 data = json.loads(result.output)
7420 import re
7421 assert re.match(r"\d{4}-\d{2}-\d{2}", data["born_date"])
7422
7423 def test_narrative_json_impl_changes_at_least_one(
7424 self, narrative_repo: pathlib.Path
7425 ) -> None:
7426 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7427 data = json.loads(result.output)
7428 # We made at least one body rewrite commit.
7429 assert data["impl_changes"] >= 1
7430
7431 def test_narrative_json_commits_analysed_positive(
7432 self, narrative_repo: pathlib.Path
7433 ) -> None:
7434 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7435 data = json.loads(result.output)
7436 assert data["commits_analysed"] > 0
7437
7438 def test_narrative_json_survival_between_0_and_100(
7439 self, narrative_repo: pathlib.Path
7440 ) -> None:
7441 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7442 data = json.loads(result.output)
7443 assert 0 <= data["est_survival_pct"] <= 100
7444
7445 def test_narrative_json_calendar_age_nonnegative(
7446 self, narrative_repo: pathlib.Path
7447 ) -> None:
7448 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7449 data = json.loads(result.output)
7450 assert data["calendar_age_days"] >= 0
7451
7452 def test_narrative_json_events_oldest_first(
7453 self, narrative_repo: pathlib.Path
7454 ) -> None:
7455 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7456 data = json.loads(result.output)
7457 dates = [ev["date"] for ev in data["events"]]
7458 assert dates == sorted(dates)
7459
7460 def test_narrative_json_create_event_present(
7461 self, narrative_repo: pathlib.Path
7462 ) -> None:
7463 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7464 data = json.loads(result.output)
7465 types = [ev["event_type"] for ev in data["events"]]
7466 assert "create" in types
7467
7468 # ── --since ───────────────────────────────────────────────────────────────
7469
7470 def test_narrative_since_invalid_ref_exits_nonzero(
7471 self, narrative_repo: pathlib.Path
7472 ) -> None:
7473 result = runner.invoke(
7474 cli, self.CMD + [self.ADDR, "--since", "no_such_ref_xyz"]
7475 )
7476 assert result.exit_code != 0
7477
7478 # ── --max-commits ─────────────────────────────────────────────────────────
7479
7480 def test_narrative_max_commits_validation(
7481 self, narrative_repo: pathlib.Path
7482 ) -> None:
7483 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7484 assert result.exit_code != 0
7485
7486 def test_narrative_max_commits_1_finds_head_event(
7487 self, narrative_repo: pathlib.Path
7488 ) -> None:
7489 result = runner.invoke(
7490 cli, self.CMD + [self.ADDR, "--json", "--max-commits", "1"]
7491 )
7492 # With max-commits=1 we only see the HEAD commit; it must still succeed
7493 # if the HEAD commit touched our symbol, or fail gracefully if not.
7494 assert result.exit_code in (0, 1)
7495
7496 # ── --show-source ─────────────────────────────────────────────────────────
7497
7498 def test_narrative_show_source_exits_zero(
7499 self, narrative_repo: pathlib.Path
7500 ) -> None:
7501 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7502 assert result.exit_code == 0, result.output
7503
7504 def test_narrative_show_source_contains_def(
7505 self, narrative_repo: pathlib.Path
7506 ) -> None:
7507 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7508 # HEAD source should contain the function definition.
7509 assert "def compute_total" in result.output
7510
7511 # ── requires repo ─────────────────────────────────────────────────────────
7512
7513 def test_narrative_requires_repo(self, tmp_path: pathlib.Path) -> None:
7514 import os
7515 old = os.getcwd()
7516 try:
7517 os.chdir(tmp_path)
7518 result = runner.invoke(cli, self.CMD + [self.ADDR])
7519 assert result.exit_code != 0
7520 finally:
7521 os.chdir(old)
7522
7523
7524 # ---------------------------------------------------------------------------
7525 # contract
7526 # ---------------------------------------------------------------------------
7527
7528
7529 @pytest.fixture()
7530 def contract_repo(repo: pathlib.Path) -> pathlib.Path:
7531 """Repo designed to exercise every dimension of ``muse code contract``.
7532
7533 Layout::
7534
7535 billing.py — compute_total(items, currency="USD") → float
7536 services.py — place_order() calls compute_total with currency="EUR" → stored
7537 report.py — generate_report() calls compute_total(items) → stored (omits currency)
7538 audit.py — run_audit() calls compute_total(items) → discarded (bad caller)
7539 tests/test_billing.py — tests with assertions about compute_total
7540
7541 Commit history::
7542
7543 1. seed commit — readme.txt so symbol events are real insert ops
7544 2. billing.py added — compute_total created
7545 3. services.py, report.py, audit.py, tests/ added — callers in place
7546 4. billing.py updated — body rewrite (PATCH)
7547 5. billing.py updated — add currency param (MINOR)
7548 """
7549 import os
7550
7551 (repo / "readme.txt").write_text("# contract test repo\n")
7552 r0 = runner.invoke(cli, ["commit", "-m", "seed: initial readme"])
7553 assert r0.exit_code == 0, r0.output
7554
7555 (repo / "billing.py").write_text(textwrap.dedent("""\
7556 def compute_total(items):
7557 return sum(i["price"] for i in items)
7558 """))
7559 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7560 assert r1.exit_code == 0, r1.output
7561
7562 os.makedirs(repo / "tests", exist_ok=True)
7563 (repo / "services.py").write_text(textwrap.dedent("""\
7564 from billing import compute_total
7565
7566 def place_order(items):
7567 total = compute_total(items, currency="EUR")
7568 return total
7569 """))
7570 (repo / "report.py").write_text(textwrap.dedent("""\
7571 from billing import compute_total
7572
7573 def generate_report(items):
7574 result = compute_total(items)
7575 return result
7576 """))
7577 (repo / "audit.py").write_text(textwrap.dedent("""\
7578 from billing import compute_total
7579
7580 def run_audit(items):
7581 compute_total(items)
7582 """))
7583 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
7584 from billing import compute_total
7585
7586 def test_compute_total_basic():
7587 result = compute_total([{"price": 10}, {"price": 5}])
7588 assert result == 15
7589 assert result > 0
7590 assert isinstance(result, (int, float))
7591
7592 def test_compute_total_empty():
7593 result = compute_total([])
7594 assert result == 0
7595 """))
7596 r2 = runner.invoke(cli, ["commit", "-m", "feat: add callers and tests"])
7597 assert r2.exit_code == 0, r2.output
7598
7599 # body rewrite — PATCH
7600 (repo / "billing.py").write_text(textwrap.dedent("""\
7601 def compute_total(items):
7602 total = 0.0
7603 for item in items:
7604 total += float(item["price"])
7605 return total
7606 """))
7607 r3 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total"])
7608 assert r3.exit_code == 0, r3.output
7609
7610 # add currency param — MINOR
7611 (repo / "billing.py").write_text(textwrap.dedent("""\
7612 def compute_total(items, currency="USD"):
7613 total = 0.0
7614 for item in items:
7615 total += float(item["price"])
7616 return total
7617 """))
7618 r4 = runner.invoke(cli, ["commit", "-m", "feat: add optional currency param"])
7619 assert r4.exit_code == 0, r4.output
7620
7621 return repo
7622
7623
7624 class TestContract:
7625 """Tests for ``muse code contract``."""
7626
7627 CMD = ["code", "contract"]
7628 ADDR = "billing.py::compute_total"
7629
7630 # ── basic correctness ─────────────────────────────────────────────────────
7631
7632 def test_contract_exits_zero(self, contract_repo: pathlib.Path) -> None:
7633 result = runner.invoke(cli, self.CMD + [self.ADDR])
7634 assert result.exit_code == 0, result.output
7635
7636 def test_contract_shows_address(self, contract_repo: pathlib.Path) -> None:
7637 result = runner.invoke(cli, self.CMD + [self.ADDR])
7638 assert "compute_total" in result.output
7639
7640 def test_contract_shows_signature_section(self, contract_repo: pathlib.Path) -> None:
7641 result = runner.invoke(cli, self.CMD + [self.ADDR])
7642 assert "Signature" in result.output
7643
7644 def test_contract_shows_def_keyword(self, contract_repo: pathlib.Path) -> None:
7645 result = runner.invoke(cli, self.CMD + [self.ADDR])
7646 assert "def compute_total" in result.output
7647
7648 def test_contract_shows_stability_section(self, contract_repo: pathlib.Path) -> None:
7649 result = runner.invoke(cli, self.CMD + [self.ADDR])
7650 assert "Stability" in result.output
7651
7652 def test_contract_shows_commits_analysed(self, contract_repo: pathlib.Path) -> None:
7653 result = runner.invoke(cli, self.CMD + [self.ADDR])
7654 assert "commits" in result.output
7655
7656 def test_contract_shows_assessment(self, contract_repo: pathlib.Path) -> None:
7657 result = runner.invoke(cli, self.CMD + [self.ADDR])
7658 assert "Assessment" in result.output
7659
7660 def test_contract_shows_return_section(self, contract_repo: pathlib.Path) -> None:
7661 result = runner.invoke(cli, self.CMD + [self.ADDR])
7662 assert "Return value" in result.output
7663
7664 def test_contract_shows_parameters_section(self, contract_repo: pathlib.Path) -> None:
7665 result = runner.invoke(cli, self.CMD + [self.ADDR])
7666 assert "Parameters" in result.output
7667
7668 # ── call-site disposition detection ──────────────────────────────────────
7669
7670 def test_contract_detects_stored(self, contract_repo: pathlib.Path) -> None:
7671 result = runner.invoke(cli, self.CMD + [self.ADDR])
7672 assert "stored" in result.output
7673
7674 def test_contract_detects_discarded(self, contract_repo: pathlib.Path) -> None:
7675 result = runner.invoke(cli, self.CMD + [self.ADDR])
7676 assert "discarded" in result.output
7677
7678 def test_contract_warns_on_discarded(self, contract_repo: pathlib.Path) -> None:
7679 result = runner.invoke(cli, self.CMD + [self.ADDR])
7680 # audit.py discards the return — should surface a warning.
7681 assert "⚠" in result.output
7682
7683 # ── test assertions ───────────────────────────────────────────────────────
7684
7685 def test_contract_shows_test_assertions(self, contract_repo: pathlib.Path) -> None:
7686 result = runner.invoke(cli, self.CMD + [self.ADDR])
7687 assert "assert" in result.output.lower()
7688
7689 def test_contract_shows_assert_result_positive(
7690 self, contract_repo: pathlib.Path
7691 ) -> None:
7692 result = runner.invoke(cli, self.CMD + [self.ADDR])
7693 assert "result > 0" in result.output or "assert" in result.output
7694
7695 # ── --json ────────────────────────────────────────────────────────────────
7696
7697 def test_contract_json_exits_zero(self, contract_repo: pathlib.Path) -> None:
7698 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7699 assert result.exit_code == 0, result.output
7700
7701 def test_contract_json_is_valid(self, contract_repo: pathlib.Path) -> None:
7702 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7703 data = json.loads(result.output)
7704 assert isinstance(data, dict)
7705
7706 def test_contract_json_top_level_keys(self, contract_repo: pathlib.Path) -> None:
7707 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7708 data = json.loads(result.output)
7709 for key in (
7710 "address", "name", "kind", "signature", "parameters",
7711 "return_annotation", "call_sites", "caller_files",
7712 "return_dispositions", "arg_observations",
7713 "test_assertions", "commit_signals", "history",
7714 "preconditions", "postconditions", "warnings", "stability",
7715 ):
7716 assert key in data, f"missing top-level key: {key}"
7717
7718 def test_contract_json_address_matches(self, contract_repo: pathlib.Path) -> None:
7719 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7720 data = json.loads(result.output)
7721 assert data["address"] == self.ADDR
7722
7723 def test_contract_json_name_is_bare(self, contract_repo: pathlib.Path) -> None:
7724 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7725 data = json.loads(result.output)
7726 assert data["name"] == "compute_total"
7727
7728 def test_contract_json_kind_is_function(self, contract_repo: pathlib.Path) -> None:
7729 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7730 data = json.loads(result.output)
7731 assert data["kind"] in {"function", "async_function", "method", "async_method"}
7732
7733 def test_contract_json_signature_contains_def(
7734 self, contract_repo: pathlib.Path
7735 ) -> None:
7736 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7737 data = json.loads(result.output)
7738 assert "def compute_total" in data["signature"]
7739
7740 def test_contract_json_parameters_is_list(self, contract_repo: pathlib.Path) -> None:
7741 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7742 data = json.loads(result.output)
7743 assert isinstance(data["parameters"], list)
7744
7745 def test_contract_json_parameters_not_empty(self, contract_repo: pathlib.Path) -> None:
7746 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7747 data = json.loads(result.output)
7748 assert len(data["parameters"]) >= 1
7749
7750 def test_contract_json_parameters_schema(self, contract_repo: pathlib.Path) -> None:
7751 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7752 data = json.loads(result.output)
7753 p = data["parameters"][0]
7754 for key in ("name", "annotation", "has_default", "default_str"):
7755 assert key in p, f"parameter missing key: {key}"
7756
7757 def test_contract_json_items_param_present(self, contract_repo: pathlib.Path) -> None:
7758 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7759 data = json.loads(result.output)
7760 names = [p["name"] for p in data["parameters"]]
7761 assert "items" in names
7762
7763 def test_contract_json_currency_param_present(
7764 self, contract_repo: pathlib.Path
7765 ) -> None:
7766 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7767 data = json.loads(result.output)
7768 names = [p["name"] for p in data["parameters"]]
7769 assert "currency" in names
7770
7771 def test_contract_json_currency_has_default(self, contract_repo: pathlib.Path) -> None:
7772 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7773 data = json.loads(result.output)
7774 params = {p["name"]: p for p in data["parameters"]}
7775 assert params["currency"]["has_default"] is True
7776
7777 def test_contract_json_currency_default_str(self, contract_repo: pathlib.Path) -> None:
7778 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7779 data = json.loads(result.output)
7780 params = {p["name"]: p for p in data["parameters"]}
7781 assert params["currency"]["default_str"] == "'USD'"
7782
7783 def test_contract_json_call_sites_positive(self, contract_repo: pathlib.Path) -> None:
7784 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7785 data = json.loads(result.output)
7786 assert data["call_sites"] >= 1
7787
7788 def test_contract_json_caller_files_positive(self, contract_repo: pathlib.Path) -> None:
7789 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7790 data = json.loads(result.output)
7791 assert data["caller_files"] >= 1
7792
7793 def test_contract_json_return_dispositions_is_dict(
7794 self, contract_repo: pathlib.Path
7795 ) -> None:
7796 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7797 data = json.loads(result.output)
7798 assert isinstance(data["return_dispositions"], dict)
7799
7800 def test_contract_json_return_dispositions_keys(
7801 self, contract_repo: pathlib.Path
7802 ) -> None:
7803 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7804 data = json.loads(result.output)
7805 rd = data["return_dispositions"]
7806 for key in ("stored", "discarded", "returned", "asserted", "compared"):
7807 assert key in rd, f"return_dispositions missing: {key}"
7808
7809 def test_contract_json_discarded_count_at_least_one(
7810 self, contract_repo: pathlib.Path
7811 ) -> None:
7812 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7813 data = json.loads(result.output)
7814 # audit.py discards the return value
7815 assert data["return_dispositions"].get("discarded", 0) >= 1
7816
7817 def test_contract_json_stored_count_at_least_one(
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 data["return_dispositions"].get("stored", 0) >= 1
7823
7824 def test_contract_json_test_assertions_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["test_assertions"], list)
7830
7831 def test_contract_json_test_assertions_not_empty(
7832 self, contract_repo: pathlib.Path
7833 ) -> None:
7834 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7835 data = json.loads(result.output)
7836 assert len(data["test_assertions"]) >= 1
7837
7838 def test_contract_json_test_assertions_are_strings(
7839 self, contract_repo: pathlib.Path
7840 ) -> None:
7841 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7842 data = json.loads(result.output)
7843 for assertion in data["test_assertions"]:
7844 assert isinstance(assertion, str)
7845
7846 def test_contract_json_history_schema(self, contract_repo: pathlib.Path) -> None:
7847 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7848 data = json.loads(result.output)
7849 h = data["history"]
7850 for key in (
7851 "commits_analysed", "truncated", "major_bumps",
7852 "minor_bumps", "patch_bumps", "sig_changes",
7853 "impl_changes", "est_survival_pct",
7854 ):
7855 assert key in h, f"history missing key: {key}"
7856
7857 def test_contract_json_history_commits_positive(
7858 self, contract_repo: pathlib.Path
7859 ) -> None:
7860 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7861 data = json.loads(result.output)
7862 assert data["history"]["commits_analysed"] > 0
7863
7864 def test_contract_json_history_survival_0_to_100(
7865 self, contract_repo: pathlib.Path
7866 ) -> None:
7867 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7868 data = json.loads(result.output)
7869 pct = data["history"]["est_survival_pct"]
7870 assert 0 <= pct <= 100
7871
7872 def test_contract_json_commit_signals_is_list(
7873 self, contract_repo: pathlib.Path
7874 ) -> None:
7875 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7876 data = json.loads(result.output)
7877 assert isinstance(data["commit_signals"], list)
7878
7879 def test_contract_json_preconditions_is_list(
7880 self, contract_repo: pathlib.Path
7881 ) -> None:
7882 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7883 data = json.loads(result.output)
7884 assert isinstance(data["preconditions"], list)
7885
7886 def test_contract_json_postconditions_is_list(
7887 self, contract_repo: pathlib.Path
7888 ) -> None:
7889 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7890 data = json.loads(result.output)
7891 assert isinstance(data["postconditions"], list)
7892
7893 def test_contract_json_warnings_is_list(self, contract_repo: pathlib.Path) -> None:
7894 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7895 data = json.loads(result.output)
7896 assert isinstance(data["warnings"], list)
7897
7898 def test_contract_json_stability_valid_value(
7899 self, contract_repo: pathlib.Path
7900 ) -> None:
7901 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7902 data = json.loads(result.output)
7903 assert data["stability"] in {"stable", "evolving", "volatile", "dormant"}
7904
7905 def test_contract_json_arg_observations_is_list(
7906 self, contract_repo: pathlib.Path
7907 ) -> None:
7908 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7909 data = json.loads(result.output)
7910 assert isinstance(data["arg_observations"], list)
7911
7912 # ── input validation ──────────────────────────────────────────────────────
7913
7914 def test_contract_missing_address_exits_nonzero(
7915 self, contract_repo: pathlib.Path
7916 ) -> None:
7917 result = runner.invoke(cli, self.CMD)
7918 assert result.exit_code != 0
7919
7920 def test_contract_bad_address_format_exits_nonzero(
7921 self, contract_repo: pathlib.Path
7922 ) -> None:
7923 result = runner.invoke(cli, self.CMD + ["billing_no_colon"])
7924 assert result.exit_code != 0
7925
7926 def test_contract_unknown_address_exits_nonzero(
7927 self, contract_repo: pathlib.Path
7928 ) -> None:
7929 result = runner.invoke(cli, self.CMD + ["billing.py::nonexistent_fn_xyz"])
7930 assert result.exit_code != 0
7931
7932 def test_contract_max_commits_zero_rejected(
7933 self, contract_repo: pathlib.Path
7934 ) -> None:
7935 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7936 assert result.exit_code != 0
7937
7938 def test_contract_max_commits_one_succeeds(self, contract_repo: pathlib.Path) -> None:
7939 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "1"])
7940 assert result.exit_code == 0, result.output
7941
7942 # ── requires repo ─────────────────────────────────────────────────────────
7943
7944 def test_contract_requires_repo(self, tmp_path: pathlib.Path) -> None:
7945 import os
7946
7947 old = os.getcwd()
7948 try:
7949 os.chdir(tmp_path)
7950 result = runner.invoke(cli, self.CMD + [self.ADDR])
7951 assert result.exit_code != 0
7952 finally:
7953 os.chdir(old)
7954
7955 # ── history accuracy ──────────────────────────────────────────────────────
7956
7957 def test_contract_json_impl_changes_nonzero(
7958 self, contract_repo: pathlib.Path
7959 ) -> None:
7960 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7961 data = json.loads(result.output)
7962 # We made 2 body changes (perf rewrite + currency add).
7963 assert data["history"]["impl_changes"] >= 1
7964
7965 def test_contract_json_truncated_false_small_repo(
7966 self, contract_repo: pathlib.Path
7967 ) -> None:
7968 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7969 data = json.loads(result.output)
7970 assert data["history"]["truncated"] is False
7971
7972 def test_contract_json_postconditions_nonempty(
7973 self, contract_repo: pathlib.Path
7974 ) -> None:
7975 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7976 data = json.loads(result.output)
7977 # Should infer at least one postcondition (return value is stored).
7978 assert len(data["postconditions"]) >= 1
7979
7980 def test_contract_json_warnings_nonempty(self, contract_repo: pathlib.Path) -> None:
7981 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7982 data = json.loads(result.output)
7983 # Missing type annotations + discarded return should generate warnings.
7984 assert len(data["warnings"]) >= 1
7985
7986
7987 # ---------------------------------------------------------------------------
7988 # predict
7989 # ---------------------------------------------------------------------------
7990
7991
7992 @pytest.fixture()
7993 def predict_repo(repo: pathlib.Path) -> pathlib.Path:
7994 """Repo with commit history that produces clear prediction signals.
7995
7996 billing.py::compute_total — changed in every commit (high frequency)
7997 billing.py::apply_discount — always co-changes with compute_total (entanglement)
7998 services.py::place_order — changed only once (low confidence)
7999
8000 5 commits are made so that recency, frequency, and co-change signals
8001 are all detectable within the default horizon.
8002 """
8003 # Commit 1 — establish both symbols
8004 (repo / "billing.py").write_text(textwrap.dedent("""\
8005 def compute_total(items):
8006 return sum(i["price"] for i in items)
8007
8008 def apply_discount(total, rate):
8009 return total * (1 - rate)
8010 """))
8011 (repo / "services.py").write_text(textwrap.dedent("""\
8012 def place_order(items):
8013 return True
8014 """))
8015 r1 = runner.invoke(cli, ["commit", "-m", "feat: initial billing"])
8016 assert r1.exit_code == 0, r1.output
8017
8018 # Commits 2-5 — co-evolve compute_total and apply_discount together
8019 for i in range(2, 6):
8020 (repo / "billing.py").write_text(textwrap.dedent(f"""\
8021 def compute_total(items, rev={i}):
8022 total = 0.0
8023 for item in items:
8024 total += float(item["price"])
8025 return total
8026
8027 def apply_discount(total, rate, rev={i}):
8028 return max(0.0, total * (1 - rate))
8029 """))
8030 r = runner.invoke(cli, ["commit", "-m", f"refactor: billing revision {i}"])
8031 assert r.exit_code == 0, r.output
8032
8033 return repo
8034
8035
8036 class TestPredict:
8037 """Tests for ``muse code predict``."""
8038
8039 CMD = ["code", "predict"]
8040
8041 # ── basic correctness ─────────────────────────────────────────────────────
8042
8043 def test_predict_exits_zero(self, predict_repo: pathlib.Path) -> None:
8044 result = runner.invoke(cli, self.CMD)
8045 assert result.exit_code == 0, result.output
8046
8047 def test_predict_shows_header(self, predict_repo: pathlib.Path) -> None:
8048 result = runner.invoke(cli, self.CMD)
8049 assert "Predicted changes" in result.output
8050
8051 def test_predict_shows_horizon(self, predict_repo: pathlib.Path) -> None:
8052 result = runner.invoke(cli, self.CMD)
8053 assert "horizon:" in result.output
8054
8055 def test_predict_shows_commits_analysed(self, predict_repo: pathlib.Path) -> None:
8056 result = runner.invoke(cli, self.CMD)
8057 assert "analysed" in result.output
8058
8059 def test_predict_shows_compute_total(self, predict_repo: pathlib.Path) -> None:
8060 result = runner.invoke(cli, self.CMD)
8061 assert "compute_total" in result.output
8062
8063 def test_predict_shows_apply_discount(self, predict_repo: pathlib.Path) -> None:
8064 result = runner.invoke(cli, self.CMD)
8065 assert "apply_discount" in result.output
8066
8067 def test_predict_shows_score(self, predict_repo: pathlib.Path) -> None:
8068 result = runner.invoke(cli, self.CMD)
8069 # Scores are in N.NN format at the start of each prediction line.
8070 import re
8071 assert re.search(r"0\.\d{2}", result.output)
8072
8073 def test_predict_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8074 result = runner.invoke(cli, self.CMD)
8075 assert "↳" in result.output
8076
8077 def test_predict_high_confidence_band_present(
8078 self, predict_repo: pathlib.Path
8079 ) -> None:
8080 result = runner.invoke(cli, self.CMD)
8081 # compute_total changed 4/5 commits — should be HIGH or MEDIUM.
8082 assert "CONFIDENCE" in result.output
8083
8084 def test_predict_entanglement_signal(self, predict_repo: pathlib.Path) -> None:
8085 result = runner.invoke(cli, self.CMD + ["--horizon", "10"])
8086 # compute_total and apply_discount co-change → entanglement reason expected.
8087 assert "entangled" in result.output or "co-change" in result.output
8088
8089 # ── --top ─────────────────────────────────────────────────────────────────
8090
8091 def test_predict_top_1_shows_one_prediction(
8092 self, predict_repo: pathlib.Path
8093 ) -> None:
8094 result = runner.invoke(cli, self.CMD + ["--top", "1"])
8095 assert result.exit_code == 0, result.output
8096 # With --top 1 there is exactly one score line.
8097 import re
8098 scores = re.findall(r"^\s+0\.\d{2}\s+", result.output, re.MULTILINE)
8099 assert len(scores) == 1
8100
8101 def test_predict_top_0_shows_all(self, predict_repo: pathlib.Path) -> None:
8102 result = runner.invoke(cli, self.CMD + ["--top", "0"])
8103 assert result.exit_code == 0, result.output
8104 assert "compute_total" in result.output
8105
8106 # ── --min-confidence ──────────────────────────────────────────────────────
8107
8108 def test_predict_min_confidence_1_empty(self, predict_repo: pathlib.Path) -> None:
8109 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.0"])
8110 assert result.exit_code == 0, result.output
8111 # Nothing should reach score 1.0 exactly.
8112 assert "No predictions" in result.output or "compute_total" not in result.output
8113
8114 def test_predict_min_confidence_invalid_rejected(
8115 self, predict_repo: pathlib.Path
8116 ) -> None:
8117 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.5"])
8118 assert result.exit_code != 0
8119
8120 def test_predict_min_confidence_zero_shows_all(
8121 self, predict_repo: pathlib.Path
8122 ) -> None:
8123 result = runner.invoke(cli, self.CMD + ["--min-confidence", "0.0"])
8124 assert result.exit_code == 0, result.output
8125 assert "compute_total" in result.output
8126
8127 # ── --horizon ─────────────────────────────────────────────────────────────
8128
8129 def test_predict_horizon_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8130 result = runner.invoke(cli, self.CMD + ["--horizon", "1"])
8131 assert result.exit_code == 0, result.output
8132
8133 def test_predict_horizon_invalid_rejected(self, predict_repo: pathlib.Path) -> None:
8134 result = runner.invoke(cli, self.CMD + ["--horizon", "0"])
8135 assert result.exit_code != 0
8136
8137 def test_predict_max_commits_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8138 result = runner.invoke(cli, self.CMD + ["--max-commits", "1"])
8139 assert result.exit_code == 0, result.output
8140
8141 def test_predict_max_commits_invalid_rejected(
8142 self, predict_repo: pathlib.Path
8143 ) -> None:
8144 result = runner.invoke(cli, self.CMD + ["--max-commits", "0"])
8145 assert result.exit_code != 0
8146
8147 # ── --file ────────────────────────────────────────────────────────────────
8148
8149 def test_predict_file_filter_billing(self, predict_repo: pathlib.Path) -> None:
8150 result = runner.invoke(cli, self.CMD + ["--file", "billing.py"])
8151 assert result.exit_code == 0, result.output
8152 # Should show billing symbols.
8153 if "compute_total" in result.output or "apply_discount" in result.output:
8154 pass # expected
8155 # Should NOT show services.py symbols.
8156 assert "place_order" not in result.output
8157
8158 def test_predict_file_filter_nonexistent_empty(
8159 self, predict_repo: pathlib.Path
8160 ) -> None:
8161 result = runner.invoke(cli, self.CMD + ["--file", "nonexistent_xyz.py"])
8162 assert result.exit_code == 0, result.output
8163 assert "No predictions" in result.output
8164
8165 # ── --explain ─────────────────────────────────────────────────────────────
8166
8167 def test_predict_explain_exits_zero(self, predict_repo: pathlib.Path) -> None:
8168 result = runner.invoke(
8169 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8170 )
8171 assert result.exit_code == 0, result.output
8172
8173 def test_predict_explain_shows_signal_breakdown(
8174 self, predict_repo: pathlib.Path
8175 ) -> None:
8176 result = runner.invoke(
8177 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8178 )
8179 assert "signal breakdown" in result.output
8180
8181 def test_predict_explain_shows_all_signals(
8182 self, predict_repo: pathlib.Path
8183 ) -> None:
8184 result = runner.invoke(
8185 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8186 )
8187 for signal in ("recency", "frequency", "co_change", "sig_instability",
8188 "module_velocity"):
8189 assert signal in result.output, f"missing signal: {signal}"
8190
8191 def test_predict_explain_shows_bar(self, predict_repo: pathlib.Path) -> None:
8192 result = runner.invoke(
8193 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8194 )
8195 assert "█" in result.output or "░" in result.output
8196
8197 def test_predict_explain_shows_score(self, predict_repo: pathlib.Path) -> None:
8198 result = runner.invoke(
8199 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8200 )
8201 assert "Score:" in result.output
8202
8203 def test_predict_explain_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8204 result = runner.invoke(
8205 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8206 )
8207 assert "Reasons" in result.output
8208
8209 def test_predict_explain_bad_format_rejected(
8210 self, predict_repo: pathlib.Path
8211 ) -> None:
8212 result = runner.invoke(cli, self.CMD + ["--explain", "no_colon_here"])
8213 assert result.exit_code != 0
8214
8215 def test_predict_explain_unknown_addr_rejected(
8216 self, predict_repo: pathlib.Path
8217 ) -> None:
8218 result = runner.invoke(
8219 cli, self.CMD + ["--explain", "billing.py::nonexistent_fn_xyz"]
8220 )
8221 assert result.exit_code != 0
8222
8223 # ── --json ────────────────────────────────────────────────────────────────
8224
8225 def test_predict_json_exits_zero(self, predict_repo: pathlib.Path) -> None:
8226 result = runner.invoke(cli, self.CMD + ["--json"])
8227 assert result.exit_code == 0, result.output
8228
8229 def test_predict_json_is_valid(self, predict_repo: pathlib.Path) -> None:
8230 result = runner.invoke(cli, self.CMD + ["--json"])
8231 data = json.loads(result.output)
8232 assert isinstance(data, dict)
8233
8234 def test_predict_json_top_level_keys(self, predict_repo: pathlib.Path) -> None:
8235 result = runner.invoke(cli, self.CMD + ["--json"])
8236 data = json.loads(result.output)
8237 for key in (
8238 "generated_at", "horizon_commits", "max_commits",
8239 "commits_analysed", "truncated", "predictions",
8240 ):
8241 assert key in data, f"missing key: {key}"
8242
8243 def test_predict_json_predictions_is_list(self, predict_repo: pathlib.Path) -> None:
8244 result = runner.invoke(cli, self.CMD + ["--json"])
8245 data = json.loads(result.output)
8246 assert isinstance(data["predictions"], list)
8247
8248 def test_predict_json_predictions_not_empty(
8249 self, predict_repo: pathlib.Path
8250 ) -> None:
8251 result = runner.invoke(cli, self.CMD + ["--json"])
8252 data = json.loads(result.output)
8253 assert len(data["predictions"]) >= 1
8254
8255 def test_predict_json_prediction_schema(self, predict_repo: pathlib.Path) -> None:
8256 result = runner.invoke(cli, self.CMD + ["--json"])
8257 data = json.loads(result.output)
8258 pred = data["predictions"][0]
8259 for key in (
8260 "address", "name", "kind", "file", "score", "confidence",
8261 "reasons", "signals", "last_changed_commit", "last_changed_date",
8262 "top_partners",
8263 ):
8264 assert key in pred, f"prediction missing key: {key}"
8265
8266 def test_predict_json_signals_schema(self, predict_repo: pathlib.Path) -> None:
8267 result = runner.invoke(cli, self.CMD + ["--json"])
8268 data = json.loads(result.output)
8269 signals = data["predictions"][0]["signals"]
8270 for key in ("recency", "frequency", "co_change", "sig_instability",
8271 "module_velocity"):
8272 assert key in signals, f"signals missing key: {key}"
8273
8274 def test_predict_json_score_is_float(self, predict_repo: pathlib.Path) -> None:
8275 result = runner.invoke(cli, self.CMD + ["--json"])
8276 data = json.loads(result.output)
8277 assert isinstance(data["predictions"][0]["score"], float)
8278
8279 def test_predict_json_score_in_range(self, predict_repo: pathlib.Path) -> None:
8280 result = runner.invoke(cli, self.CMD + ["--json"])
8281 data = json.loads(result.output)
8282 for pred in data["predictions"]:
8283 assert 0.0 <= pred["score"] <= 1.0, (
8284 f"score out of range: {pred['score']}"
8285 )
8286
8287 def test_predict_json_confidence_valid(self, predict_repo: pathlib.Path) -> None:
8288 result = runner.invoke(cli, self.CMD + ["--json"])
8289 data = json.loads(result.output)
8290 for pred in data["predictions"]:
8291 assert pred["confidence"] in {"high", "medium", "low"}, (
8292 f"invalid confidence: {pred['confidence']}"
8293 )
8294
8295 def test_predict_json_sorted_by_score_desc(self, predict_repo: pathlib.Path) -> None:
8296 result = runner.invoke(cli, self.CMD + ["--json"])
8297 data = json.loads(result.output)
8298 scores = [p["score"] for p in data["predictions"]]
8299 assert scores == sorted(scores, reverse=True)
8300
8301 def test_predict_json_commits_analysed_positive(
8302 self, predict_repo: pathlib.Path
8303 ) -> None:
8304 result = runner.invoke(cli, self.CMD + ["--json"])
8305 data = json.loads(result.output)
8306 assert data["commits_analysed"] > 0
8307
8308 def test_predict_json_truncated_false_small_repo(
8309 self, predict_repo: pathlib.Path
8310 ) -> None:
8311 result = runner.invoke(cli, self.CMD + ["--json"])
8312 data = json.loads(result.output)
8313 assert data["truncated"] is False
8314
8315 def test_predict_json_top_partners_is_list(self, predict_repo: pathlib.Path) -> None:
8316 result = runner.invoke(cli, self.CMD + ["--json"])
8317 data = json.loads(result.output)
8318 for pred in data["predictions"]:
8319 assert isinstance(pred["top_partners"], list)
8320
8321 def test_predict_json_partner_schema(self, predict_repo: pathlib.Path) -> None:
8322 result = runner.invoke(cli, self.CMD + ["--json"])
8323 data = json.loads(result.output)
8324 # Find a prediction that has partners.
8325 for pred in data["predictions"]:
8326 if pred["top_partners"]:
8327 p = pred["top_partners"][0]
8328 for key in ("address", "co_change_rate", "co_change_commits"):
8329 assert key in p, f"partner missing key: {key}"
8330 break
8331
8332 def test_predict_json_co_change_rate_in_range(
8333 self, predict_repo: pathlib.Path
8334 ) -> None:
8335 result = runner.invoke(cli, self.CMD + ["--json"])
8336 data = json.loads(result.output)
8337 for pred in data["predictions"]:
8338 for p in pred["top_partners"]:
8339 assert 0.0 <= p["co_change_rate"] <= 1.0
8340
8341 def test_predict_json_top_1_returns_one(self, predict_repo: pathlib.Path) -> None:
8342 result = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
8343 data = json.loads(result.output)
8344 assert len(data["predictions"]) == 1
8345
8346 def test_predict_json_horizon_matches_arg(self, predict_repo: pathlib.Path) -> None:
8347 result = runner.invoke(cli, self.CMD + ["--json", "--horizon", "3"])
8348 data = json.loads(result.output)
8349 assert data["horizon_commits"] == 3
8350
8351 def test_predict_json_reasons_is_list(self, predict_repo: pathlib.Path) -> None:
8352 result = runner.invoke(cli, self.CMD + ["--json"])
8353 data = json.loads(result.output)
8354 for pred in data["predictions"]:
8355 assert isinstance(pred["reasons"], list)
8356 assert all(isinstance(r, str) for r in pred["reasons"])
8357
8358 # ── requires repo ─────────────────────────────────────────────────────────
8359
8360 def test_predict_requires_repo(self, tmp_path: pathlib.Path) -> None:
8361 import os
8362
8363 old = os.getcwd()
8364 try:
8365 os.chdir(tmp_path)
8366 result = runner.invoke(cli, self.CMD)
8367 assert result.exit_code != 0
8368 finally:
8369 os.chdir(old)
8370
8371
8372 # ---------------------------------------------------------------------------
8373 # Helpers
8374 # ---------------------------------------------------------------------------
8375
8376
8377 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
8378 """Return all commit IDs from the store, newest-first (by log order)."""
8379 from muse.core.store import get_all_commits
8380 commits = get_all_commits(repo)
8381 return [c.commit_id for c in commits]
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago