gabriel / muse public
test_code_commands.py python
8,479 lines 372.1 KB
Raw
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 3 days ago
1 """Integration tests for code-domain CLI commands.
2
3 Uses a real Muse repository initialised in tmp_path.
4
5 Coverage
6 --------
7 Provenance & Topology
8 muse lineage ADDRESS [--json]
9 muse api-surface [--diff REF] [--json]
10 muse codemap [--top N] [--json]
11 muse clones [--tier exact|near|both] [--json]
12 muse checkout-symbol ADDRESS --commit REF [--dry-run]
13 muse semantic-cherry-pick ADDRESS... --from REF [--dry-run] [--json]
14
15 Query & Temporal Search
16 muse query PREDICATE [--all-commits] [--json]
17 muse query-history PREDICATE [--from REF] [--to REF] [--json]
18
19 Index Commands
20 muse index status [--json]
21 muse index rebuild [--index NAME]
22
23 Refactor Detection
24 muse detect-refactor --json (schema_version in output)
25
26 Multi-Agent Coordination
27 muse reserve ADDRESS...
28 muse intent ADDRESS... --op OP
29 muse forecast [--json]
30 muse plan-merge OURS THEIRS [--json]
31 muse shard --agents N [--json]
32 muse reconcile [--json]
33
34 Structural Enforcement
35 muse breakage [--json]
36 muse invariants [--json]
37
38 Semantic Versioning Metadata
39 muse log shows SemVer for commits with bumps
40 muse commit stores sem_ver_bump in CommitRecord
41
42 Call-Graph Tier
43 muse impact ADDRESS [--json]
44 muse dead [--json]
45 muse coverage CLASS_ADDRESS [--json]
46 muse deps ADDRESS_OR_FILE [--json]
47 muse find-symbol [--name NAME] [--json]
48 muse patch ADDRESS FILE
49 """
50
51 import json
52 import pathlib
53 import textwrap
54
55 import pytest
56 from tests.cli_test_helper import CliRunner
57
58 from typing import TypedDict
59
60 from muse._version import __version__
61 cli = None # argparse migration — CliRunner ignores this arg
62 from muse.core.refs import get_head_commit_id
63 from muse.core.commits import CommitDict
64 from muse.core.types import Manifest
65 from muse.core.paths import coordination_dir, indices_dir, muse_dir, ref_path, repo_json_path
66
67 type _ImportsMap = dict[str, list[str]]
68 type _ImportsSetMap = dict[str, set[str]]
69 type _KindsMap = dict[str, int]
70
71 runner = CliRunner()
72
73
74 # ---------------------------------------------------------------------------
75 # Shared fixtures
76 # ---------------------------------------------------------------------------
77
78
79 @pytest.fixture
80 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
81 """Initialise a fresh code-domain Muse repo."""
82 monkeypatch.chdir(tmp_path)
83 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
84 result = runner.invoke(cli, ["init", "--domain", "code"])
85 assert result.exit_code == 0, result.output
86 return tmp_path
87
88
89 @pytest.fixture
90 def code_repo(repo: pathlib.Path) -> pathlib.Path:
91 """Repo with two Python commits for analysis commands."""
92 work = repo
93 # Commit 1 — define compute_total and Invoice class.
94 (work / "billing.py").write_text(textwrap.dedent("""\
95 class Invoice:
96 def compute_total(self, items):
97 return sum(items)
98
99 def apply_discount(self, total, pct):
100 return total * (1 - pct)
101
102 def process_order(invoice, items):
103 return invoice.compute_total(items)
104 """))
105 runner.invoke(cli, ["code", "add", "billing.py"])
106 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
107 assert r.exit_code == 0, r.output
108
109 # Commit 2 — rename compute_total, add new function.
110 (work / "billing.py").write_text(textwrap.dedent("""\
111 class Invoice:
112 def compute_invoice_total(self, items):
113 return sum(items)
114
115 def apply_discount(self, total, pct):
116 return total * (1 - pct)
117
118 def generate_pdf(self):
119 return b"pdf"
120
121 def process_order(invoice, items):
122 return invoice.compute_invoice_total(items)
123
124 def send_email(address):
125 pass
126 """))
127 runner.invoke(cli, ["code", "add", "billing.py"])
128 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
129 assert r.exit_code == 0, r.output
130 return repo
131
132
133 # ---------------------------------------------------------------------------
134 # muse lineage
135 # ---------------------------------------------------------------------------
136
137
138 class TestLineage:
139 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
141 assert result.exit_code == 0, result.output
142
143 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
144 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
145 assert result.exit_code == 0, result.output
146 data = json.loads(result.output)
147 assert isinstance(data, dict)
148 assert "events" in data
149
150 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
151 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_func"])
152 # Should not crash — exit 0 or 1, but no unhandled exception.
153 assert result.exit_code in (0, 1)
154
155 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
156 monkeypatch.chdir(tmp_path)
157 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
158 assert result.exit_code != 0
159
160
161 # ---------------------------------------------------------------------------
162 # muse api-surface
163 # ---------------------------------------------------------------------------
164
165
166 class TestApiSurface:
167 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
168 result = runner.invoke(cli, ["code", "api-surface"])
169 assert result.exit_code == 0, result.output
170
171 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
172 result = runner.invoke(cli, ["code", "api-surface", "--json"])
173 assert result.exit_code == 0
174 data = json.loads(result.output)
175 assert isinstance(data, dict)
176
177 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
178 commits = _all_commit_ids(code_repo)
179 if len(commits) >= 2:
180 result = runner.invoke(cli, ["code", "api-surface", "--diff", commits[-2]])
181 assert result.exit_code == 0
182
183 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
184 result = runner.invoke(cli, ["code", "api-surface"])
185 assert result.exit_code in (0, 1)
186
187
188 # ---------------------------------------------------------------------------
189 # muse codemap
190 # ---------------------------------------------------------------------------
191
192
193 class TestCodemap:
194 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
195 result = runner.invoke(cli, ["code", "codemap"])
196 assert result.exit_code == 0, result.output
197
198 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
199 result = runner.invoke(cli, ["code", "codemap", "--top", "3"])
200 assert result.exit_code == 0
201
202 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
203 result = runner.invoke(cli, ["code", "codemap", "--json"])
204 assert result.exit_code == 0
205 data = json.loads(result.output)
206 assert isinstance(data, dict)
207
208
209 # ---------------------------------------------------------------------------
210 # muse clones
211 # ---------------------------------------------------------------------------
212
213
214 class TestClones:
215 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
216 result = runner.invoke(cli, ["code", "clones"])
217 assert result.exit_code == 0, result.output
218
219 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
220 result = runner.invoke(cli, ["code", "clones", "--tier", "exact"])
221 assert result.exit_code == 0
222
223 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
224 result = runner.invoke(cli, ["code", "clones", "--tier", "near"])
225 assert result.exit_code == 0
226
227 def test_clones_json(self, code_repo: pathlib.Path) -> None:
228 result = runner.invoke(cli, ["code", "clones", "--tier", "both", "--json"])
229 assert result.exit_code == 0
230 data = json.loads(result.output)
231 assert isinstance(data, dict)
232
233
234 # ---------------------------------------------------------------------------
235 # muse checkout-symbol
236 # ---------------------------------------------------------------------------
237
238
239 class TestCheckoutSymbol:
240 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
241 commits = _all_commit_ids(code_repo)
242 if len(commits) < 2:
243 pytest.skip("need at least 2 commits")
244 first_commit = commits[-2] # oldest commit (list is newest-first)
245 result = runner.invoke(cli, [
246 "code", "checkout-symbol", "--commit", first_commit, "--dry-run",
247 "billing.py::Invoice.compute_total",
248 ])
249 # May fail if symbol is not present; should not crash unhandled.
250 assert result.exit_code in (0, 1, 2)
251
252 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
253 result = runner.invoke(cli, ["code", "checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
254 assert result.exit_code != 0
255
256
257 # ---------------------------------------------------------------------------
258 # muse semantic-cherry-pick
259 # ---------------------------------------------------------------------------
260
261
262 class TestSemanticCherryPick:
263 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
264 commits = _all_commit_ids(code_repo)
265 if len(commits) < 2:
266 pytest.skip("need at least 2 commits")
267 first_commit = commits[-2]
268 result = runner.invoke(cli, [
269 "code", "semantic-cherry-pick",
270 "--from", first_commit,
271 "--dry-run",
272 "billing.py::Invoice.compute_total",
273 ])
274 assert result.exit_code in (0, 1)
275
276 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
277 result = runner.invoke(cli, ["code", "semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
278 assert result.exit_code != 0
279
280
281 # ---------------------------------------------------------------------------
282 # muse query
283 # ---------------------------------------------------------------------------
284
285
286 class TestQueryV2:
287 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
288 result = runner.invoke(cli, ["code", "query", "kind=function"])
289 assert result.exit_code == 0, result.output
290
291 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
292 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
293 assert result.exit_code == 0
294 data = json.loads(result.output)
295 assert "muse_version" in data
296
297 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
298 result = runner.invoke(cli, ["code", "query", "kind=function", "OR", "kind=method"])
299 assert result.exit_code == 0
300
301 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
302 result = runner.invoke(cli, ["code", "query", "NOT", "kind=import"])
303 assert result.exit_code == 0
304
305 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
306 result = runner.invoke(cli, ["code", "query", "--all-commits", "kind=function"])
307 assert result.exit_code == 0
308
309 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
310 result = runner.invoke(cli, ["code", "query", "name~=total"])
311 assert result.exit_code == 0
312 # Should find compute_invoice_total.
313 assert "total" in result.output.lower()
314
315 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
316 # query with kind=class to match everything of a known type.
317 result = runner.invoke(cli, ["code", "query", "kind=class"])
318 assert result.exit_code == 0
319 assert "Invoice" in result.output
320
321 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
322 result = runner.invoke(cli, ["code", "query", "lineno_gt=1"])
323 assert result.exit_code == 0
324
325 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
326 monkeypatch.chdir(tmp_path)
327 result = runner.invoke(cli, ["code", "query", "kind=function"])
328 assert result.exit_code != 0
329
330 # ── new v2.1 flags ────────────────────────────────────────────────────────
331
332 def test_query_count_only(self, code_repo: pathlib.Path) -> None:
333 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
334 assert result.exit_code == 0, result.output
335 # Output should be a single integer.
336 assert result.output.strip().isdigit()
337
338 def test_query_count_nonzero(self, code_repo: pathlib.Path) -> None:
339 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
340 assert int(result.output.strip()) >= 1
341
342 def test_query_limit_caps_results(self, code_repo: pathlib.Path) -> None:
343 all_r = runner.invoke(cli, ["code", "query", "kind=function"])
344 lim_r = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
345 assert lim_r.exit_code == 0, lim_r.output
346 # Limited output should be shorter than unlimited.
347 assert len(lim_r.output) <= len(all_r.output)
348
349 def test_query_limit_truncation_noted(self, code_repo: pathlib.Path) -> None:
350 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
351 assert "limited to 1" in result.output or "match" in result.output
352
353 def test_query_limit_zero_unlimited(self, code_repo: pathlib.Path) -> None:
354 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "0"])
355 assert result.exit_code == 0, result.output
356
357 def test_query_sort_name(self, code_repo: pathlib.Path) -> None:
358 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "name"])
359 assert result.exit_code == 0, result.output
360
361 def test_query_sort_size(self, code_repo: pathlib.Path) -> None:
362 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "size"])
363 assert result.exit_code == 0, result.output
364 # Size column should appear in output.
365 assert "L" in result.output
366
367 def test_query_sort_kind(self, code_repo: pathlib.Path) -> None:
368 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "kind"])
369 assert result.exit_code == 0, result.output
370
371 def test_query_sort_lineno(self, code_repo: pathlib.Path) -> None:
372 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "lineno"])
373 assert result.exit_code == 0, result.output
374
375 def test_query_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
376 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "zzz"])
377 assert result.exit_code != 0
378
379 def test_query_unique_bodies_exits_zero(self, code_repo: pathlib.Path) -> None:
380 result = runner.invoke(cli, ["code", "query", "kind=function", "--unique-bodies"])
381 assert result.exit_code == 0, result.output
382
383 def test_query_unique_bodies_count_lte_all(self, code_repo: pathlib.Path) -> None:
384 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
385 uniq_r = runner.invoke(cli, ["code", "query", "--count", "--unique-bodies", "kind=function"])
386 assert int(uniq_r.output.strip()) <= int(all_r.output.strip())
387
388 def test_query_size_gt_predicate(self, code_repo: pathlib.Path) -> None:
389 result = runner.invoke(cli, ["code", "query", "kind=function", "size_gt=0"])
390 assert result.exit_code == 0, result.output
391
392 def test_query_size_lt_predicate(self, code_repo: pathlib.Path) -> None:
393 result = runner.invoke(cli, ["code", "query", "kind=function", "size_lt=1000"])
394 assert result.exit_code == 0, result.output
395
396 def test_query_size_gt_excludes_small(self, code_repo: pathlib.Path) -> None:
397 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
398 large_r = runner.invoke(cli, ["code", "query", "--count", "kind=function", "size_gt=100"])
399 # Large-only count should be <= total.
400 assert int(large_r.output.strip()) <= int(all_r.output.strip())
401
402 def test_query_json_includes_size(self, code_repo: pathlib.Path) -> None:
403 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
404 data = json.loads(result.output)
405 for r in data["results"]:
406 assert "size" in r
407
408 def test_query_json_includes_sort_field(self, code_repo: pathlib.Path) -> None:
409 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--sort", "name"])
410 data = json.loads(result.output)
411 assert data["sort"] == "name"
412
413 def test_query_json_includes_unique_bodies(self, code_repo: pathlib.Path) -> None:
414 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--unique-bodies"])
415 data = json.loads(result.output)
416 assert data["unique_bodies"] is True
417
418 def test_query_since_without_all_commits_rejected(self, code_repo: pathlib.Path) -> None:
419 result = runner.invoke(cli, ["code", "query", "kind=function", "--since", "2026-01-01"])
420 assert result.exit_code != 0
421
422 def test_query_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
423 result = runner.invoke(
424 cli,
425 ["code", "query", "kind=function", "--all-commits", "--since", "not-a-date"],
426 )
427 assert result.exit_code != 0
428
429 def test_query_all_commits_since_future_empty(self, code_repo: pathlib.Path) -> None:
430 result = runner.invoke(
431 cli,
432 ["code", "query", "kind=function", "--all-commits", "--since", "2099-01-01"],
433 )
434 assert result.exit_code == 0, result.output
435 # Future date means no commits match.
436 assert "no symbols" in result.output.lower() or result.output.strip() == ""
437
438 def test_query_max_commits_caps_walk(self, code_repo: pathlib.Path) -> None:
439 result = runner.invoke(
440 cli,
441 ["code", "query", "kind=function", "--all-commits", "--max-commits", "1"],
442 )
443 assert result.exit_code == 0, result.output
444
445
446 # ---------------------------------------------------------------------------
447 # muse query-history
448 # ---------------------------------------------------------------------------
449
450
451 class TestQueryHistory:
452 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
453 result = runner.invoke(cli, ["code", "query-history", "kind=function"])
454 assert result.exit_code == 0, result.output
455
456 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
457 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=function"])
458 assert result.exit_code == 0
459 data = json.loads(result.output)
460 assert "muse_version" in data
461 assert "results" in data
462
463 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
464 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
465 assert result.exit_code == 0
466
467 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
468 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
469 assert result.exit_code == 0
470 data = json.loads(result.output)
471 for entry in data.get("results", []):
472 assert "commit_count" in entry
473 assert "change_count" in entry
474
475 # ── new v2 flags ──────────────────────────────────────────────────────────
476
477 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
478 result = runner.invoke(
479 cli, ["code", "query-history", "--changed-only", "kind=function"]
480 )
481 assert result.exit_code == 0, result.output
482
483 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
484 result = runner.invoke(
485 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
486 )
487 assert result.exit_code == 0
488 data = json.loads(result.output)
489 for entry in data["results"]:
490 assert entry["change_count"] > 1
491
492 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
493 result = runner.invoke(
494 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
495 )
496 assert result.exit_code == 0, result.output
497
498 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
499 result = runner.invoke(
500 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
501 )
502 assert result.exit_code == 0, result.output
503
504 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
505 result = runner.invoke(
506 cli, ["code", "query-history", "--sort", "first", "kind=function"]
507 )
508 assert result.exit_code == 0, result.output
509
510 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
511 result = runner.invoke(
512 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
513 )
514 assert result.exit_code != 0
515
516 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
517 result = runner.invoke(
518 cli, ["code", "query-history", "--count", "kind=function"]
519 )
520 assert result.exit_code == 0, result.output
521 assert result.output.strip().isdigit()
522 assert int(result.output.strip()) >= 1
523
524 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
525 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
526 lim_r = runner.invoke(
527 cli, ["code", "query-history", "--limit", "1", "kind=function"]
528 )
529 assert lim_r.exit_code == 0, lim_r.output
530 assert len(lim_r.output) <= len(all_r.output)
531
532 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
533 result = runner.invoke(
534 cli, ["code", "query-history", "--limit", "1", "kind=function"]
535 )
536 assert "1" in result.output
537
538 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
539 result = runner.invoke(
540 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
541 )
542 assert result.exit_code == 0
543 data = json.loads(result.output)
544 for entry in data["results"]:
545 assert entry["change_count"] >= 2
546
547 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
548 result = runner.invoke(
549 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
550 )
551 assert result.exit_code != 0
552
553 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
554 result = runner.invoke(
555 cli, ["code", "query-history", "--introduced-only", "kind=function"]
556 )
557 assert result.exit_code == 0, result.output
558
559 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
560 result = runner.invoke(
561 cli, ["code", "query-history", "--removed-only", "kind=function"]
562 )
563 assert result.exit_code == 0, result.output
564
565 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
566 result = runner.invoke(
567 cli,
568 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
569 )
570 assert result.exit_code == 0
571 data = json.loads(result.output)
572 assert data["mode"] == "introduced-only"
573 assert "symbols_found" in data
574 for entry in data["results"]:
575 assert entry["status"] == "introduced"
576
577 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
578 result = runner.invoke(
579 cli,
580 ["code", "query-history", "--removed-only", "--json", "kind=function"],
581 )
582 assert result.exit_code == 0
583 data = json.loads(result.output)
584 assert data["mode"] == "removed-only"
585 assert "symbols_found" in data
586 for entry in data["results"]:
587 assert entry["status"] == "removed"
588
589 def test_query_history_mode_flags_mutually_exclusive(
590 self, code_repo: pathlib.Path
591 ) -> None:
592 result = runner.invoke(
593 cli,
594 [
595 "code", "query-history",
596 "--changed-only", "--introduced-only",
597 "kind=function",
598 ],
599 )
600 assert result.exit_code != 0
601
602 def test_query_history_json_has_full_commit_ids(
603 self, code_repo: pathlib.Path
604 ) -> None:
605 result = runner.invoke(
606 cli, ["code", "query-history", "--json", "kind=function"]
607 )
608 assert result.exit_code == 0
609 data = json.loads(result.output)
610 for entry in data["results"]:
611 # Full commit IDs should be present (not just 8-char short form).
612 assert len(entry["first_commit_id"]) > 8
613 assert "stable" in entry
614
615 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
616 result = runner.invoke(
617 cli,
618 ["code", "query-history", "--max-commits", "1", "kind=function"],
619 )
620 assert result.exit_code == 0, result.output
621
622 def test_query_history_introduced_count_only(
623 self, code_repo: pathlib.Path
624 ) -> None:
625 result = runner.invoke(
626 cli,
627 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
628 )
629 assert result.exit_code == 0
630 assert result.output.strip().isdigit()
631
632
633 # ---------------------------------------------------------------------------
634 # muse index
635 # ---------------------------------------------------------------------------
636
637
638 class TestIndexCommands:
639 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
640 result = runner.invoke(cli, ["code", "index", "status"])
641 assert result.exit_code == 0, result.output
642
643 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
644 result = runner.invoke(cli, ["code", "index", "status"])
645 # Indexes have not been built yet.
646 assert "absent" in result.output.lower() or result.exit_code == 0
647
648 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
649 result = runner.invoke(cli, ["code", "index", "rebuild"])
650 assert result.exit_code == 0, result.output
651
652 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
653 runner.invoke(cli, ["code", "index", "rebuild"])
654 idx_dir = indices_dir(code_repo)
655 assert idx_dir.exists()
656
657 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
658 runner.invoke(cli, ["code", "index", "rebuild"])
659 result = runner.invoke(cli, ["code", "index", "status"])
660 assert result.exit_code == 0
661 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
662 assert "entries" in result.output.lower() or "✅" in result.output
663
664 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
665 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
666 assert result.exit_code == 0
667
668 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
669 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
670 assert result.exit_code == 0
671
672
673 # ---------------------------------------------------------------------------
674 # muse detect-refactor
675 # ---------------------------------------------------------------------------
676
677
678 class TestHotspots:
679 """Tests for muse code hotspots."""
680
681 # ── basic correctness ────────────────────────────────────────────────────
682
683 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
684 result = runner.invoke(cli, ["code", "hotspots"])
685 assert result.exit_code == 0, result.output
686
687 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
688 """compute_invoice_total was modified across two commits — must appear."""
689 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
690 assert result.exit_code == 0, result.output
691 assert "billing.py" in result.output
692
693 def test_hotspots_excludes_imports_by_default(
694 self, code_repo: pathlib.Path
695 ) -> None:
696 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
697 assert result.exit_code == 0, result.output
698 assert "::import::" not in result.output
699
700 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
701 """--include-imports must surface import pseudo-symbols if any exist."""
702 result = runner.invoke(
703 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
704 )
705 assert result.exit_code == 0, result.output
706 # Just verify it runs cleanly; the repo may or may not have import ops.
707
708 # ── --kind filter (was broken before) ────────────────────────────────────
709
710 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
711 """--kind function must not return class symbols."""
712 result = runner.invoke(
713 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
714 )
715 assert result.exit_code == 0, result.output
716 for line in result.output.splitlines():
717 if "::" in line and "class" in line.lower():
718 # Make sure any class line is not a function kind result
719 # (Addresses that contain the word "class" in their name are OK)
720 pass # Name may contain "class" as substring
721
722 def test_kind_filter_function_returns_functions(
723 self, code_repo: pathlib.Path
724 ) -> None:
725 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
726 result_fn = runner.invoke(
727 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
728 )
729 assert result_fn.exit_code == 0, result_fn.output
730 # filtered result should have <= symbols than unfiltered
731 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
732 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
733 assert len(fn_lines) <= len(all_lines)
734
735 # ── --min filter ──────────────────────────────────────────────────────────
736
737 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
738 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
739 result_min = runner.invoke(
740 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
741 )
742 assert result_min.exit_code == 0, result_min.output
743 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
744 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
745 assert len(min_lines) <= len(all_lines)
746
747 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
748 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
749 assert result.exit_code == 1
750
751 # ── --language filter ─────────────────────────────────────────────────────
752
753 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
754 result = runner.invoke(
755 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
756 )
757 assert result.exit_code == 0, result.output
758 assert "billing.py" in result.output
759
760 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
761 result = runner.invoke(
762 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
763 )
764 assert result.exit_code == 0, result.output
765
766 # ── --top validation ──────────────────────────────────────────────────────
767
768 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
769 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
770 assert result.exit_code == 1
771
772 # ── JSON schema ───────────────────────────────────────────────────────────
773
774 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
775 result = runner.invoke(cli, ["code", "hotspots", "--json"])
776 assert result.exit_code == 0, result.output
777 data = json.loads(result.output)
778 for key in (
779 "from_ref", "to_ref", "commits_analysed", "truncated",
780 "filters", "hotspots",
781 ):
782 assert key in data, f"missing key: {key}"
783 assert isinstance(data["hotspots"], list)
784 assert isinstance(data["truncated"], bool)
785 assert isinstance(data["commits_analysed"], int)
786
787 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
788 result = runner.invoke(
789 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
790 )
791 data = json.loads(result.output)
792 assert data["filters"]["kind"] == "function"
793 assert data["filters"]["min_changes"] == 2
794 assert data["filters"]["include_imports"] is False
795
796 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
797 result = runner.invoke(cli, ["code", "hotspots", "--json"])
798 data = json.loads(result.output)
799 if data["hotspots"]:
800 entry = data["hotspots"][0]
801 assert "address" in entry
802 assert "changes" in entry
803 assert isinstance(entry["changes"], int)
804 assert entry["changes"] >= 1
805
806 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
807 result = runner.invoke(cli, ["code", "hotspots", "--json"])
808 data = json.loads(result.output)
809 addresses = [h["address"] for h in data["hotspots"]]
810 assert not any("::import::" in a for a in addresses)
811
812 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
813 result = runner.invoke(cli, ["code", "hotspots", "--json"])
814 data = json.loads(result.output)
815 counts = [h["changes"] for h in data["hotspots"]]
816 assert counts == sorted(counts, reverse=True)
817
818 # ── --max-commits truncation ──────────────────────────────────────────────
819
820 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
821 result = runner.invoke(
822 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
823 )
824 assert result.exit_code == 0, result.output
825 data = json.loads(result.output)
826 assert data["commits_analysed"] <= 1
827
828 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
829 result = runner.invoke(
830 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
831 )
832 data = json.loads(result.output)
833 assert data["truncated"] is True
834
835
836 class TestDetectRefactorV2:
837 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
838 """JSON output contains all required top-level fields."""
839 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
840 assert result.exit_code == 0, result.output
841 data = json.loads(result.output)
842 for field in ("commits_scanned", "truncated", "total", "events"):
843 assert field in data, f"missing field '{field}'"
844 assert isinstance(data["commits_scanned"], int)
845 assert isinstance(data["truncated"], bool)
846 assert isinstance(data["total"], int)
847 assert isinstance(data["events"], list)
848
849 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
850 """Each JSON event contains the required fields."""
851 # Run over the full history; code_repo has at least one rename event.
852 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
853 assert result.exit_code == 0, result.output
854 data = json.loads(result.output)
855 for ev in data["events"]:
856 for field in ("kind", "address", "detail",
857 "commit_id", "commit_message", "committed_at"):
858 assert field in ev, f"missing event field '{field}'"
859 assert ev["kind"] in ("rename", "move", "signature", "implementation")
860
861 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
862 """A commit that renames a symbol produces a 'rename' event."""
863 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
864 assert result.exit_code == 0, result.output
865 data = json.loads(result.output)
866 kinds = [e["kind"] for e in data["events"]]
867 assert "rename" in kinds, (
868 f"Expected at least one rename event; got: {sorted(set(kinds))}"
869 )
870
871 def test_detect_refactor_classifies_modified_as_implementation(
872 self, code_repo: pathlib.Path
873 ) -> None:
874 """Replace ops with '(modified)' in new_summary are classified as implementation.
875
876 Previously, only '(implementation changed)' triggered implementation
877 classification; '(modified)' was silently dropped.
878 """
879 import datetime
880 root = code_repo
881 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
882 from muse.core.refs import (
883 get_head_commit_id,
884 read_current_branch,
885 )
886 from muse.core.commits import (
887 CommitDict,
888 CommitRecord,
889 write_commit,
890 )
891 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
892 branch = read_current_branch(root)
893 head_id = get_head_commit_id(root, branch)
894
895 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
896 message = "perf: optimise batch"
897 snap_manifest: Manifest = {}
898 snap_id = compute_snapshot_id(snap_manifest)
899 parent_ids = [head_id] if head_id else []
900 commit_id = compute_commit_id(
901 parent_ids=parent_ids,
902 snapshot_id=snap_id,
903 message=message,
904 committed_at_iso=now.isoformat(),
905 author="test",
906 )
907 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
908 commit = CommitRecord(
909 commit_id=commit_id,
910 branch=branch,
911 snapshot_id=snap_id,
912 message=message,
913 committed_at=now,
914 parent_commit_id=head_id,
915 author="test",
916 structured_delta=StructuredDelta(ops=[PatchOp(
917 op="patch",
918 address="billing.py",
919 child_ops=[ReplaceOp(
920 op="replace",
921 address="billing.py::process_batch",
922 new_summary="function process_batch (modified) L10–30",
923 old_summary="function process_batch",
924 )],
925 )]),
926 )
927 write_commit(root, commit)
928 (ref_path(root, branch)).write_text(commit_id)
929
930 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
931 assert result.exit_code == 0, result.output
932 data = json.loads(result.output)
933 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
934 addrs = [e["address"] for e in impl_events]
935 assert "billing.py::process_batch" in addrs, (
936 f"'(modified)' op not classified as implementation; events: {data['events']}"
937 )
938
939 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
940 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
941 import datetime
942 root = code_repo
943 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
944 from muse.core.refs import (
945 get_head_commit_id,
946 read_current_branch,
947 )
948 from muse.core.commits import (
949 CommitDict,
950 CommitRecord,
951 write_commit,
952 )
953 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
954 branch = read_current_branch(root)
955 head_id = get_head_commit_id(root, branch)
956
957 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
958 message = "style: reformat"
959 snap_manifest: Manifest = {}
960 snap_id = compute_snapshot_id(snap_manifest)
961 parent_ids = [head_id] if head_id else []
962 commit_id = compute_commit_id(
963 parent_ids=parent_ids,
964 snapshot_id=snap_id,
965 message=message,
966 committed_at_iso=now.isoformat(),
967 author="test",
968 )
969 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
970 commit = CommitRecord(
971 commit_id=commit_id,
972 branch=branch,
973 snapshot_id=snap_id,
974 message=message,
975 committed_at=now,
976 parent_commit_id=head_id,
977 author="test",
978 structured_delta=StructuredDelta(ops=[PatchOp(
979 op="patch",
980 address="billing.py",
981 child_ops=[ReplaceOp(
982 op="replace",
983 address="billing.py::UniqueReformattedSymbol",
984 new_summary="reformatted — no semantic change",
985 old_summary="",
986 )],
987 )]),
988 )
989 write_commit(root, commit)
990 (ref_path(root, branch)).write_text(commit_id)
991
992 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
993 assert result.exit_code == 0, result.output
994 data = json.loads(result.output)
995 # The reformatted op must not appear as an event.
996 reformatted_events = [
997 e for e in data["events"]
998 if e["address"] == "billing.py::UniqueReformattedSymbol"
999 ]
1000 assert reformatted_events == [], (
1001 f"Reformatted op should be skipped; got: {reformatted_events}"
1002 )
1003
1004 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
1005 """When --max is hit, a truncation warning appears in human output."""
1006 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
1007 assert result.exit_code == 0, result.output
1008 assert "incomplete" in result.output or "limit" in result.output
1009
1010 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
1011 """When --max is hit, truncated=true in JSON."""
1012 result = runner.invoke(
1013 cli, ["code", "detect-refactor", "--max", "1", "--json"]
1014 )
1015 assert result.exit_code == 0, result.output
1016 data = json.loads(result.output)
1017 assert data["truncated"] is True
1018 assert data["commits_scanned"] == 1
1019
1020 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
1021 """--max 0 exits non-zero."""
1022 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
1023 assert result.exit_code != 0
1024
1025 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1026 """``--kind rename`` returns only rename events."""
1027 result = runner.invoke(
1028 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1029 )
1030 assert result.exit_code == 0, result.output
1031 data = json.loads(result.output)
1032 for ev in data["events"]:
1033 assert ev["kind"] == "rename"
1034
1035 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1036 """``--kind`` with an invalid value exits non-zero."""
1037 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1038 assert result.exit_code != 0
1039
1040 def test_detect_refactor_bfs_follows_merge_parent2(
1041 self, code_repo: pathlib.Path
1042 ) -> None:
1043 """BFS walk finds refactoring events on merged feature branches."""
1044 import datetime
1045 root = code_repo
1046 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
1047 from muse.core.refs import (
1048 get_head_commit_id,
1049 read_current_branch,
1050 )
1051 from muse.core.commits import (
1052 CommitRecord,
1053 write_commit,
1054 )
1055 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
1056 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1057 branch = read_current_branch(root)
1058 head_id = get_head_commit_id(root, branch)
1059 assert head_id is not None
1060
1061 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1062 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1063
1064 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1065 feature_id = compute_commit_id(
1066 parent_ids=[head_id],
1067 snapshot_id=feat_snap_id,
1068 message="perf: vectorise",
1069 committed_at_iso=feat_at.isoformat(),
1070 author="test",
1071 )
1072 write_commit(root, CommitRecord(
1073 commit_id=feature_id,
1074 branch="feat/perf",
1075 snapshot_id=feat_snap_id,
1076 message="perf: vectorise",
1077 committed_at=feat_at,
1078 parent_commit_id=head_id,
1079 author="test",
1080 structured_delta=StructuredDelta(ops=[PatchOp(
1081 op="patch",
1082 address="billing.py",
1083 child_ops=[ReplaceOp(
1084 op="replace",
1085 address="billing.py::vectorised_fn",
1086 new_summary="function vectorised_fn (implementation changed) L1–20",
1087 old_summary="function vectorised_fn",
1088 )],
1089 )]),
1090 ))
1091 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1092 merge_id = compute_commit_id(
1093 parent_ids=[head_id, feature_id],
1094 snapshot_id=merge_snap_id,
1095 message="merge feat/perf",
1096 committed_at_iso=merge_at.isoformat(),
1097 author="test",
1098 )
1099 write_commit(root, CommitRecord(
1100 commit_id=merge_id,
1101 branch=branch,
1102 snapshot_id=merge_snap_id,
1103 message="merge feat/perf",
1104 committed_at=merge_at,
1105 parent_commit_id=head_id,
1106 parent2_commit_id=feature_id,
1107 author="test",
1108 ))
1109 (ref_path(root, branch)).write_text(merge_id)
1110
1111 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1112 assert result.exit_code == 0, result.output
1113 data = json.loads(result.output)
1114 addrs = [e["address"] for e in data["events"]]
1115 assert "billing.py::vectorised_fn" in addrs, (
1116 "BFS must find the implementation event on the feature branch"
1117 )
1118
1119
1120 # ---------------------------------------------------------------------------
1121 # muse reserve
1122 # ---------------------------------------------------------------------------
1123
1124
1125 class TestReserve:
1126 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1127 result = runner.invoke(cli, [
1128 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1129 ])
1130 assert result.exit_code == 0, result.output
1131
1132 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1133 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1134 coord_dir = coordination_dir(code_repo) / "reservations"
1135 assert coord_dir.exists()
1136 files = list(coord_dir.glob("*.json"))
1137 assert len(files) >= 1
1138
1139 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1140 result = runner.invoke(cli, [
1141 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1142 ])
1143 assert result.exit_code == 0
1144 data = json.loads(result.output)
1145 assert "reservation_id" in data
1146
1147 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1148 result = runner.invoke(cli, [
1149 "coord", "reserve", "--run-id", "r3",
1150 "billing.py::process_order",
1151 "billing.py::Invoice.apply_discount",
1152 ])
1153 assert result.exit_code == 0
1154
1155 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1156 result = runner.invoke(cli, [
1157 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1158 "billing.py::process_order",
1159 ])
1160 assert result.exit_code == 0
1161
1162 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1163 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1164 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1165 # Should warn but not fail.
1166 assert result.exit_code == 0
1167 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1168
1169
1170 # ---------------------------------------------------------------------------
1171 # muse intent
1172 # ---------------------------------------------------------------------------
1173
1174
1175 class TestIntent:
1176 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1177 result = runner.invoke(cli, [
1178 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1179 "billing.py::process_order",
1180 ])
1181 assert result.exit_code == 0, result.output
1182
1183 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1184 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1185 idir = coordination_dir(code_repo) / "intents"
1186 assert idir.exists()
1187 assert len(list(idir.glob("*.json"))) >= 1
1188
1189 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1190 result = runner.invoke(cli, [
1191 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1192 ])
1193 assert result.exit_code == 0
1194 data = json.loads(result.output)
1195 assert "intent_id" in data or "operation" in data
1196
1197
1198 # ---------------------------------------------------------------------------
1199 # muse forecast
1200 # ---------------------------------------------------------------------------
1201
1202
1203 class TestForecast:
1204 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1205 result = runner.invoke(cli, ["coord", "forecast"])
1206 assert result.exit_code == 0, result.output
1207
1208 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1209 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1210 assert result.exit_code == 0
1211 data = json.loads(result.output)
1212 assert "conflicts" in data
1213
1214 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1215 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1216 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1217 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1218 assert result.exit_code == 0
1219 data = json.loads(result.output)
1220 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1221 assert "address_overlap" in types
1222
1223
1224 # ---------------------------------------------------------------------------
1225 # muse plan-merge
1226 # ---------------------------------------------------------------------------
1227
1228
1229 class TestPlanMerge:
1230 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1231 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1232 assert result.exit_code == 0, result.output
1233
1234 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1235 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1236 assert result.exit_code == 0
1237 data = json.loads(result.output)
1238 assert "conflicts" in data or isinstance(data, dict)
1239
1240 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1241 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1242 assert result.exit_code != 0
1243
1244
1245 # ---------------------------------------------------------------------------
1246 # muse shard
1247 # ---------------------------------------------------------------------------
1248
1249
1250 class TestShard:
1251 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1252 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1253 assert result.exit_code == 0, result.output
1254
1255 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1256 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1257 assert result.exit_code == 0
1258 data = json.loads(result.output)
1259 assert "shards" in data
1260
1261 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1262 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1263 assert result.exit_code == 0
1264
1265 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1266 # N larger than symbol count still works (produces fewer shards).
1267 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1268 assert result.exit_code == 0
1269
1270
1271 # ---------------------------------------------------------------------------
1272 # muse reconcile
1273 # ---------------------------------------------------------------------------
1274
1275
1276 class TestReconcile:
1277 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1278 result = runner.invoke(cli, ["coord", "reconcile"])
1279 assert result.exit_code == 0, result.output
1280
1281 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1282 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1283 assert result.exit_code == 0
1284 data = json.loads(result.output)
1285 assert isinstance(data, dict)
1286
1287
1288 # ---------------------------------------------------------------------------
1289 # muse breakage
1290 # ---------------------------------------------------------------------------
1291
1292
1293 class TestBreakage:
1294 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1295 result = runner.invoke(cli, ["code", "breakage"])
1296 assert result.exit_code == 0, result.output
1297
1298 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1299 result = runner.invoke(cli, ["code", "breakage", "--json"])
1300 assert result.exit_code == 0
1301 data = json.loads(result.output)
1302 # breakage JSON has "issues" list and error count.
1303 assert "issues" in data
1304 assert isinstance(data["issues"], list)
1305
1306 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1307 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1308 assert result.exit_code == 0
1309
1310 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1311 monkeypatch.chdir(tmp_path)
1312 result = runner.invoke(cli, ["code", "breakage"])
1313 assert result.exit_code != 0
1314
1315
1316 # ---------------------------------------------------------------------------
1317 # muse invariants
1318 # ---------------------------------------------------------------------------
1319
1320
1321 class TestInvariants:
1322 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1323 result = runner.invoke(cli, ["code", "invariants"])
1324 toml_path = muse_dir(code_repo) / "invariants.toml"
1325 assert result.exit_code == 0 or toml_path.exists()
1326
1327 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1328 # Create empty invariants.toml
1329 (muse_dir(code_repo) / "invariants.toml").write_text("# No rules\n")
1330 result = runner.invoke(cli, ["code", "invariants", "--json"])
1331 assert result.exit_code == 0
1332 # Output may be JSON or human-readable depending on rules count.
1333 output = result.output.strip()
1334 if output and not output.startswith("#"):
1335 try:
1336 data = json.loads(output)
1337 assert isinstance(data, dict)
1338 except json.JSONDecodeError:
1339 pass # Human-readable output is also acceptable.
1340
1341 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1342 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1343 [[rules]]
1344 type = "no_cycles"
1345 name = "no import cycles"
1346 """))
1347 result = runner.invoke(cli, ["code", "invariants"])
1348 assert result.exit_code == 0
1349
1350 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1351 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1352 [[rules]]
1353 type = "forbidden_dependency"
1354 name = "billing must not import utils"
1355 source_pattern = "billing.py"
1356 forbidden_pattern = "utils.py"
1357 """))
1358 result = runner.invoke(cli, ["code", "invariants"])
1359 assert result.exit_code == 0
1360
1361 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1362 (muse_dir(code_repo) / "invariants.toml").write_text(textwrap.dedent("""\
1363 [[rules]]
1364 type = "required_test"
1365 name = "billing must have tests"
1366 source_pattern = "billing.py"
1367 test_pattern = "test_billing.py"
1368 """))
1369 result = runner.invoke(cli, ["code", "invariants"])
1370 # May pass or fail depending on whether test_billing.py exists; should not crash.
1371 assert result.exit_code in (0, 1)
1372
1373 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1374 (muse_dir(code_repo) / "invariants.toml").write_text("# empty\n")
1375 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1376 assert result.exit_code == 0
1377
1378
1379 # ---------------------------------------------------------------------------
1380 # muse commit — semantic versioning
1381 # ---------------------------------------------------------------------------
1382
1383
1384 class TestSemVerInCommit:
1385 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1386 from muse.core.refs import get_head_commit_id
1387 from muse.core.commits import (
1388 CommitDict,
1389 read_commit,
1390 )
1391 commit_id = get_head_commit_id(code_repo, "main")
1392 assert commit_id is not None
1393 commit = read_commit(code_repo, commit_id)
1394 assert commit is not None
1395 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1396
1397 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1398 from muse.core.refs import get_head_commit_id
1399 from muse.core.commits import (
1400 CommitDict,
1401 read_commit,
1402 )
1403 commit_id = get_head_commit_id(code_repo, "main")
1404 assert commit_id is not None
1405 commit = read_commit(code_repo, commit_id)
1406 assert commit is not None
1407 assert isinstance(commit.breaking_changes, list)
1408
1409 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1410 from muse.core.refs import get_head_commit_id
1411 from muse.core.commits import (
1412 CommitDict,
1413 read_commit,
1414 )
1415 commit_id = get_head_commit_id(code_repo, "main")
1416 assert commit_id is not None
1417 commit = read_commit(code_repo, commit_id)
1418 assert commit is not None
1419 if commit.sem_ver_bump == "major":
1420 result = runner.invoke(cli, ["log"])
1421 assert "MAJOR" in result.output or "major" in result.output.lower()
1422
1423
1424 # ---------------------------------------------------------------------------
1425 # Call-graph tier — muse impact
1426 # ---------------------------------------------------------------------------
1427
1428
1429 class TestImpact:
1430 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1431 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1432 assert result.exit_code == 0, result.output
1433
1434 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1435 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1436 assert result.exit_code == 0
1437 data = json.loads(result.output)
1438 assert isinstance(data, dict)
1439 assert "blast_radius" in data
1440 assert "total" in data
1441 assert "commit_id" in data
1442 assert data["mode"] == "reverse"
1443
1444 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1445 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1446 assert result.exit_code in (0, 1)
1447
1448 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1449 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1450 assert result.exit_code == 0
1451 assert result.output.strip().isdigit()
1452
1453 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1454 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1455 assert result.exit_code == 1
1456
1457 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1458 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1459 assert result.exit_code == 0
1460
1461 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1462 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1463 assert result.exit_code == 0
1464 data = json.loads(result.output)
1465 assert data["mode"] == "forward"
1466 assert "callees" in data
1467 assert "total" in data
1468 assert "commit_id" in data
1469
1470 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1471 result = runner.invoke(cli, [
1472 "code", "impact", "--forward", "--compare", "HEAD",
1473 "--", "billing.py::process_order",
1474 ])
1475 assert result.exit_code == 1
1476
1477 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1478 result = runner.invoke(cli, [
1479 "code", "impact", "--file", "billing.py",
1480 "--", "billing.py::Invoice.compute_invoice_total",
1481 ])
1482 assert result.exit_code == 0
1483
1484 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1485 result = runner.invoke(cli, [
1486 "code", "impact", "--file", "billing.py", "--json",
1487 "--", "billing.py::Invoice.compute_invoice_total",
1488 ])
1489 assert result.exit_code == 0
1490 data = json.loads(result.output)
1491 assert data["file_filter"] == "billing.py"
1492 for depth_addrs in data["blast_radius"].values():
1493 for addr in depth_addrs:
1494 assert addr.startswith("billing.py::")
1495
1496 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1497 result = runner.invoke(cli, [
1498 "code", "impact", "--compare", "HEAD",
1499 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1500 ])
1501 assert result.exit_code == 0
1502 data = json.loads(result.output)
1503 assert "compare_commit_id" in data
1504 assert "added_callers" in data
1505 assert "removed_callers" in data
1506 assert "net_change" in data
1507 assert isinstance(data["added_callers"], list)
1508 assert isinstance(data["removed_callers"], list)
1509
1510 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1511 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1512 assert result.exit_code == 0
1513 assert result.output.strip().isdigit()
1514
1515
1516 # ---------------------------------------------------------------------------
1517 # Call-graph tier — muse dead
1518 # ---------------------------------------------------------------------------
1519
1520
1521 class TestDead:
1522 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1523 result = runner.invoke(cli, ["code", "dead"])
1524 assert result.exit_code == 0, result.output
1525
1526 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1527 result = runner.invoke(cli, ["code", "dead", "--json"])
1528 assert result.exit_code == 0
1529 data = json.loads(result.output)
1530 assert isinstance(data, dict)
1531 assert "results" in data
1532 assert "high_confidence_count" in data
1533 assert "total_files_scanned" in data
1534 assert "duration_ms" in data
1535
1536 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1537 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1538 assert result.exit_code == 0
1539
1540 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1541 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1542 assert result.exit_code == 0
1543
1544 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1545 result = runner.invoke(cli, ["code", "dead", "--count"])
1546 assert result.exit_code == 0
1547 assert result.output.strip().isdigit()
1548
1549 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1550 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1551 assert result.exit_code == 0
1552 data = json.loads(result.output)
1553 assert "compare_commit_id" in data
1554 assert "new_dead" in data
1555 assert "recovered" in data
1556 assert "net_change" in data
1557 assert isinstance(data["new_dead"], list)
1558 assert isinstance(data["recovered"], list)
1559
1560 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1561 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1562 assert result.exit_code == 0
1563
1564 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1565 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1566 assert result.exit_code == 1
1567
1568 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1569 out_file = tmp_path / "allowlist.json"
1570 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1571 assert result.exit_code == 0
1572 if out_file.exists():
1573 data = json.loads(out_file.read_text())
1574 assert isinstance(data, list)
1575 assert all(isinstance(x, str) for x in data)
1576
1577 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1578 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1579 assert result.exit_code == 0
1580 data = json.loads(result.output)
1581 for c in data["results"]:
1582 assert c["confidence"] == "high"
1583
1584 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1585 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1586 assert result.exit_code == 0
1587
1588
1589 # ---------------------------------------------------------------------------
1590 # muse code cat
1591 # ---------------------------------------------------------------------------
1592
1593
1594 class TestCat:
1595 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1596 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1597 assert result.exit_code == 0, result.output
1598 assert "class Invoice" in result.output
1599
1600 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1601 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1602 assert result.exit_code == 0, result.output
1603 assert "def compute_invoice_total" in result.output
1604
1605 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1606 # Invoice is unique — short name should resolve.
1607 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1608 assert result.exit_code == 0
1609
1610 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1611 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1612 assert result.exit_code != 0
1613
1614 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1615 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1616 assert result.exit_code != 0
1617
1618 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1619 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1620 assert result.exit_code != 0
1621
1622 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1623 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1624 assert result.exit_code == 0
1625 # Line numbers prefix lines with digits.
1626 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1627 first_code_line = next((ln for ln in lines if ln.strip()), "")
1628 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1629
1630 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1631 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1632 assert result.exit_code == 0
1633 data = json.loads(result.output)
1634 assert "results" in data
1635 assert "errors" in data
1636 assert "source_ref" in data
1637 assert len(data["results"]) == 1
1638 r = data["results"][0]
1639 assert r["path"] == "billing.py"
1640 assert r["kind"] in ("class", "function", "method")
1641 assert isinstance(r["lineno"], int)
1642 assert isinstance(r["end_lineno"], int)
1643 assert "class Invoice" in r["source"]
1644
1645 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1646 result = runner.invoke(
1647 cli,
1648 [
1649 "code", "cat",
1650 "billing.py::Invoice",
1651 "billing.py::Invoice.compute_invoice_total",
1652 "--json",
1653 ],
1654 )
1655 assert result.exit_code == 0, result.output
1656 data = json.loads(result.output)
1657 assert len(data["results"]) == 2
1658
1659 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1660 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1661 assert result.exit_code == 0
1662 assert "Invoice" in result.output
1663
1664 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1665 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1666 assert result.exit_code == 0
1667
1668 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1669 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1670 assert result.exit_code == 0
1671 data = json.loads(result.output)
1672 assert len(data["results"]) > 0
1673 # Every result has required fields.
1674 for r in data["results"]:
1675 assert "address" in r
1676 assert "lineno" in r
1677 assert "source" in r
1678
1679 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1680 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1681 result_ctx = runner.invoke(
1682 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1683 )
1684 assert result_ctx.exit_code == 0
1685 # With context we get at least as many lines.
1686 plain_lines = result_plain.output.count("\n")
1687 ctx_lines = result_ctx.output.count("\n")
1688 assert ctx_lines >= plain_lines
1689
1690 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1691 # In --json mode a missing symbol goes to the errors field, not a crash.
1692 result = runner.invoke(
1693 cli,
1694 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1695 )
1696 # Output must be valid JSON (no stderr bleed into stdout).
1697 data = json.loads(result.output)
1698 assert len(data["results"]) == 1
1699 assert len(data["errors"]) == 1
1700 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1701
1702 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1703 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1704 assert result.exit_code == 0
1705 assert "working tree" in result.output
1706
1707 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1708 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1709 assert result.exit_code == 0
1710 assert "Invoice" in result.output
1711
1712 def test_cat_wrong_file_fallback_finds_symbol(
1713 self, code_repo: pathlib.Path, tmp_path: pathlib.Path
1714 ) -> None:
1715 """FILE::SYMBOL where SYMBOL lives in a different file — should fall back
1716 to a global snapshot search and cat it from its actual location, exit 0."""
1717 # Add a second file with a unique function the billing module doesn't have.
1718 work = pathlib.Path.cwd()
1719 (work / "utils.py").write_text(
1720 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1721 )
1722 runner.invoke(cli, ["code", "add", "utils.py"])
1723 runner.invoke(cli, ["commit", "-m", "Add utils"])
1724
1725 # Ask for utils.format_currency but specify the wrong file (billing.py).
1726 result = runner.invoke(
1727 cli, ["code", "cat", "billing.py::format_currency"]
1728 )
1729 assert result.exit_code == 0, result.output
1730 assert "format_currency" in result.output
1731
1732 def test_cat_wrong_file_fallback_json(self, code_repo: pathlib.Path) -> None:
1733 """Same fallback in --json mode: result is in results[], not errors[]."""
1734 work = pathlib.Path.cwd()
1735 (work / "utils.py").write_text(
1736 "def format_currency(amount):\n return f'${amount:.2f}'\n"
1737 )
1738 runner.invoke(cli, ["code", "add", "utils.py"])
1739 runner.invoke(cli, ["commit", "-m", "Add utils"])
1740
1741 result = runner.invoke(
1742 cli, ["code", "cat", "billing.py::format_currency", "--json"]
1743 )
1744 assert result.exit_code == 0, result.output
1745 data = json.loads(result.output)
1746 assert len(data["results"]) == 1
1747 assert data["results"][0]["symbol"] == "format_currency"
1748 assert data["results"][0]["path"] == "utils.py"
1749
1750 def test_cat_wrong_file_fallback_ambiguous_exits_nonzero(
1751 self, code_repo: pathlib.Path
1752 ) -> None:
1753 """If the symbol exists in multiple files, fallback reports ambiguity and exits 1."""
1754 work = pathlib.Path.cwd()
1755 (work / "utils.py").write_text("def send_email(to): pass\n")
1756 runner.invoke(cli, ["code", "add", "utils.py"])
1757 runner.invoke(cli, ["commit", "-m", "Duplicate send_email in utils"])
1758
1759 # billing.py already has send_email; utils.py now also has it.
1760 result = runner.invoke(
1761 cli, ["code", "cat", "nope.py::send_email"]
1762 )
1763 assert result.exit_code != 0
1764
1765 def test_cat_truly_missing_symbol_still_errors(self, code_repo: pathlib.Path) -> None:
1766 """A symbol that doesn't exist anywhere in the snapshot still exits 1."""
1767 result = runner.invoke(cli, ["code", "cat", "billing.py::AbsolutelyNowhere"])
1768 assert result.exit_code != 0
1769
1770
1771 # ---------------------------------------------------------------------------
1772 # Call-graph tier — muse coverage
1773 # ---------------------------------------------------------------------------
1774
1775
1776 class TestCoverage:
1777 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1778 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1779 assert result.exit_code == 0, result.output
1780
1781 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1782 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1783 assert result.exit_code == 0
1784 data = json.loads(result.output)
1785 assert isinstance(data, dict)
1786 assert "methods" in data
1787 assert "total_methods" in data
1788 assert "covered" in data
1789 assert "percent" in data
1790 assert "commit_id" in data
1791 assert "filters" in data
1792 for m in data["methods"]:
1793 assert "address" in m
1794 assert "called" in m
1795 assert "callers" in m
1796
1797 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1798 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1799 assert result.exit_code in (0, 1)
1800
1801 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1802 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1803 assert result.exit_code == 0
1804 # Output should be "n/total" format
1805 assert "/" in result.output.strip()
1806
1807 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1808 result = runner.invoke(cli, [
1809 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1810 ])
1811 assert result.exit_code == 0
1812 data = json.loads(result.output)
1813 assert data["filters"]["exclude_dunder"] is True
1814 for m in data["methods"]:
1815 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1816
1817 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1818 result = runner.invoke(cli, [
1819 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1820 ])
1821 assert result.exit_code == 0
1822 data = json.loads(result.output)
1823 assert data["filters"]["exclude_private"] is True
1824
1825 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1826 result = runner.invoke(cli, [
1827 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1828 ])
1829 assert result.exit_code == 0
1830 data = json.loads(result.output)
1831 assert data["filters"]["min_callers"] == 2
1832
1833 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1834 result = runner.invoke(cli, [
1835 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1836 ])
1837 assert result.exit_code == 0
1838 data = json.loads(result.output)
1839 assert data["filters"]["exclude_self"] is True
1840 # All reported callers should be from a different file
1841 for m in data["methods"]:
1842 for caller in m["callers"]:
1843 assert not caller.startswith("billing.py::")
1844
1845 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1846 result = runner.invoke(cli, [
1847 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1848 ])
1849 assert result.exit_code == 0
1850 data = json.loads(result.output)
1851 assert "compare_commit_id" in data
1852 assert "newly_covered" in data
1853 assert "newly_uncovered" in data
1854 assert "percent_change" in data
1855
1856 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1857 result = runner.invoke(cli, [
1858 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1859 ])
1860 assert result.exit_code == 0
1861
1862 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1863 result = runner.invoke(cli, [
1864 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1865 ])
1866 assert result.exit_code == 0
1867
1868
1869 # ---------------------------------------------------------------------------
1870 # Call-graph tier — muse deps
1871 # ---------------------------------------------------------------------------
1872
1873
1874 class TestDeps:
1875 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1876 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1877 assert result.exit_code == 0, result.output
1878
1879 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1880 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1881 assert result.exit_code == 0
1882
1883 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1884 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1885 assert result.exit_code == 0
1886 data = json.loads(result.output)
1887 assert isinstance(data, dict)
1888
1889 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1890 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1891 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1892
1893 # ── new flags ──────────────────────────────────────────────────────────────
1894
1895 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1896 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1897 assert result.exit_code == 0, result.output
1898 assert result.output.strip().isdigit()
1899
1900 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1901 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1902 assert result.exit_code == 0, result.output
1903 assert result.output.strip().isdigit()
1904
1905 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1906 result = runner.invoke(
1907 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1908 )
1909 assert result.exit_code == 0, result.output
1910
1911 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1912 # --depth > 1 in file mode is fine (just filters imports as before).
1913 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1914 assert result.exit_code == 0, result.output
1915
1916 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1917 result = runner.invoke(
1918 cli,
1919 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1920 )
1921 assert result.exit_code != 0
1922
1923 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1924 result = runner.invoke(
1925 cli,
1926 ["code", "deps", "--reverse", "--depth", "2",
1927 "billing.py::Invoice.compute_invoice_total"],
1928 )
1929 assert result.exit_code == 0, result.output
1930
1931 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1932 result = runner.invoke(
1933 cli,
1934 ["code", "deps", "--transitive",
1935 "billing.py::Invoice.compute_invoice_total"],
1936 )
1937 assert result.exit_code == 0, result.output
1938
1939 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1940 result = runner.invoke(
1941 cli,
1942 ["code", "deps", "--transitive", "--count",
1943 "billing.py::Invoice.compute_invoice_total"],
1944 )
1945 assert result.exit_code == 0
1946 assert result.output.strip().isdigit()
1947
1948 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1949 result = runner.invoke(
1950 cli,
1951 ["code", "deps", "--transitive", "--json",
1952 "billing.py::Invoice.compute_invoice_total"],
1953 )
1954 assert result.exit_code == 0
1955 data = json.loads(result.output)
1956 assert "by_depth" in data
1957 assert data["transitive"] is True
1958
1959 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1960 result = runner.invoke(
1961 cli,
1962 ["code", "deps", "--reverse", "--depth", "2", "--json",
1963 "billing.py::Invoice.compute_invoice_total"],
1964 )
1965 assert result.exit_code == 0
1966 data = json.loads(result.output)
1967 assert "by_depth" in data
1968 assert data["depth"] == 2
1969
1970 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1971 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1972 assert result.exit_code != 0
1973
1974 def test_deps_empty_file_rel_in_symbol_rejected(
1975 self, code_repo: pathlib.Path
1976 ) -> None:
1977 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1978 assert result.exit_code != 0
1979
1980 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1981 result = runner.invoke(
1982 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1983 )
1984 assert result.exit_code == 0
1985 data = json.loads(result.output)
1986 assert "imported_by" in data
1987 assert isinstance(data["imported_by"], list)
1988
1989
1990 # ---------------------------------------------------------------------------
1991 # Call-graph tier — muse find-symbol
1992 # ---------------------------------------------------------------------------
1993
1994
1995 class TestFindSymbol:
1996 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1997 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1998 assert result.exit_code == 0, result.output
1999
2000 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
2001 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
2002 assert result.exit_code == 0
2003 data = json.loads(result.output)
2004 assert isinstance(data, dict)
2005 assert "results" in data
2006 assert "query" in data
2007 assert "total" in data
2008
2009 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
2010 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
2011 assert result.exit_code == 0
2012 assert result.output is not None
2013
2014 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
2015 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
2016 assert result.exit_code == 0
2017 assert "no matching" in result.output
2018
2019 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
2020 result = runner.invoke(cli, ["code", "find-symbol"])
2021 assert result.exit_code == 1
2022
2023 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
2024 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2025 assert result.exit_code == 0
2026 assert result.output.strip().isdigit()
2027
2028 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
2029 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
2030 assert result.exit_code == 1
2031
2032 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
2033 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
2034 assert result.exit_code == 1
2035
2036 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
2037 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
2038 assert result.exit_code == 1
2039
2040 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
2041 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
2042 assert result.exit_code == 1
2043
2044 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
2045 result = runner.invoke(cli, [
2046 "code", "find-symbol", "--name", "process_order",
2047 "--since", "2099-01-01",
2048 ])
2049 assert result.exit_code == 0
2050 assert "no matching" in result.output
2051
2052 def test_find_limit(self, code_repo: pathlib.Path) -> None:
2053 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
2054 assert result.exit_code == 0
2055
2056 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
2057 result = runner.invoke(cli, [
2058 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
2059 ])
2060 assert result.exit_code == 0
2061
2062 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
2063 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
2064 assert result.exit_code == 0
2065 data = json.loads(result.output)
2066 for ap in data["results"]:
2067 assert ap["name"].lower().startswith("process")
2068
2069 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
2070 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
2071 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
2072 assert result_all.exit_code == 0
2073 assert result_first.exit_code == 0
2074 count_all = int(result_all.output.strip())
2075 count_first = int(result_first.output.strip())
2076 assert count_first <= count_all
2077
2078 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
2079 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
2080 assert result.exit_code == 0
2081 data = json.loads(result.output)
2082 assert "query" in data
2083 assert "results" in data
2084 assert "total" in data
2085 assert data["total"] == len(data["results"])
2086 if data["results"]:
2087 ap = data["results"][0]
2088 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
2089 assert key in ap
2090
2091
2092 # ---------------------------------------------------------------------------
2093 # Call-graph tier — muse patch
2094 # ---------------------------------------------------------------------------
2095
2096
2097 class TestPatch:
2098 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
2099 new_impl = textwrap.dedent("""\
2100 def send_email(address):
2101 return f"Sending to {address}"
2102 """)
2103 impl_file = code_repo / "send_email_impl.py"
2104 impl_file.write_text(new_impl)
2105 # patch takes ADDRESS SOURCE — put options before address.
2106 result = runner.invoke(cli, [
2107 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
2108 ])
2109 assert result.exit_code in (0, 1, 2)
2110
2111 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
2112 bad_impl = "def broken(\n not valid python at all{"
2113 bad_file = code_repo / "bad.py"
2114 bad_file.write_text(bad_impl)
2115 result = runner.invoke(cli, [
2116 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2117 ])
2118 # Invalid syntax must be rejected or command handles gracefully.
2119 assert result.exit_code in (0, 1, 2)
2120
2121
2122 # ---------------------------------------------------------------------------
2123 # Security — path traversal guards
2124 # ---------------------------------------------------------------------------
2125
2126
2127 class TestPatchPathTraversal:
2128 """patch must reject addresses whose file component escapes the repo root."""
2129
2130 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2131 body = code_repo / "body.py"
2132 body.write_text("def foo(): pass\n")
2133 result = runner.invoke(cli, [
2134 "code", "patch",
2135 "--body", str(body),
2136 "../../etc/passwd::foo",
2137 ])
2138 assert result.exit_code == 1
2139
2140 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2141 body = code_repo / "body.py"
2142 body.write_text("def foo(): pass\n")
2143 result = runner.invoke(cli, [
2144 "code", "patch",
2145 "--body", str(body),
2146 "../../../tmp/malicious::foo",
2147 ])
2148 assert result.exit_code == 1
2149
2150 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2151 """--json flag returns parseable JSON on a dry-run."""
2152 body = code_repo / "body.py"
2153 body.write_text("def send_email(address):\n return address\n")
2154 result = runner.invoke(cli, [
2155 "code", "patch",
2156 "--body", str(body),
2157 "--dry-run",
2158 "--json",
2159 "billing.py::send_email",
2160 ])
2161 # Address may or may not exist; if it exits 0 the output must be JSON.
2162 if result.exit_code == 0:
2163 data = json.loads(result.output)
2164 assert data["address"] == "billing.py::send_email"
2165 assert data["dry_run"] is True
2166
2167
2168 class TestCheckoutSymbolPathTraversal:
2169 """checkout-symbol must reject addresses whose file component escapes root."""
2170
2171 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2172 result = runner.invoke(cli, [
2173 "code", "checkout-symbol",
2174 "--commit", "HEAD",
2175 "../../etc/passwd::foo",
2176 ])
2177 assert result.exit_code == 1
2178
2179 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2180 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2181 result = runner.invoke(cli, [
2182 "code", "checkout-symbol",
2183 "--commit", "HEAD",
2184 "--json",
2185 "billing.py::nonexistent_func_xyz",
2186 ])
2187 # Either exits 1 (symbol not found) — but must not crash.
2188 assert result.exit_code in (0, 1)
2189
2190
2191 class TestSemanticCherryPickPathTraversal:
2192 """semantic-cherry-pick must reject addresses that escape the repo root."""
2193
2194 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2195 result = runner.invoke(cli, [
2196 "code", "semantic-cherry-pick",
2197 "--from", "HEAD",
2198 "../../etc/passwd::foo",
2199 ])
2200 # The traversal-rejected symbol is recorded as not_found but the
2201 # command exits 0 (failed symbols don't abort the batch).
2202 # The key invariant is that no file outside the repo is written.
2203 # We assert exit_code is 0 (graceful) and the output does NOT write.
2204 assert result.exit_code in (0, 1)
2205 # No file was created outside the repo.
2206 assert not pathlib.Path("/etc/passwd_copy").exists()
2207
2208 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2209 result = runner.invoke(cli, [
2210 "code", "semantic-cherry-pick",
2211 "--from", "HEAD",
2212 "--json",
2213 "../../etc/passwd::foo",
2214 ])
2215 assert result.exit_code in (0, 1)
2216 if result.exit_code == 0:
2217 data = json.loads(result.output)
2218 assert data["applied"] == 0
2219 # The traversal-escaped address should be marked as not_found
2220 results = data.get("results", [])
2221 assert any(r["status"] == "not_found" for r in results)
2222
2223
2224 # ---------------------------------------------------------------------------
2225 # muse code blame
2226 # ---------------------------------------------------------------------------
2227
2228
2229 @pytest.fixture
2230 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2231 """Repo with four commits: seed → creation → modification → rename.
2232
2233 A seed commit is required so that the billing.py creation commit has
2234 a parent (and therefore a structured_delta with insert ops).
2235
2236 Timeline (oldest → newest):
2237 commit 0: README.md only (seed — gives billing.py commit a parent)
2238 commit 1: billing.py created — defines compute_total + process_order
2239 commit 2: compute_total implementation modified (same name)
2240 commit 3: compute_total renamed to compute_invoice_total
2241 """
2242 work = repo
2243
2244 # Seed commit so billing.py introduction has a parent and structured_delta.
2245 (work / "README.md").write_text("# Billing module\n")
2246 runner.invoke(cli, ["code", "add", "README.md"])
2247 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2248 assert r.exit_code == 0, r.output
2249
2250 (work / "billing.py").write_text(textwrap.dedent("""\
2251 def compute_total(items):
2252 return sum(items)
2253
2254 def process_order(items):
2255 return compute_total(items)
2256 """))
2257 runner.invoke(cli, ["code", "add", "billing.py"])
2258 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2259 assert r.exit_code == 0, r.output
2260
2261 (work / "billing.py").write_text(textwrap.dedent("""\
2262 def compute_total(items):
2263 # faster implementation
2264 return sum(x for x in items)
2265
2266 def process_order(items):
2267 return compute_total(items)
2268 """))
2269 runner.invoke(cli, ["code", "add", "billing.py"])
2270 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2271 assert r.exit_code == 0, r.output
2272
2273 (work / "billing.py").write_text(textwrap.dedent("""\
2274 def compute_invoice_total(items):
2275 # faster implementation
2276 return sum(x for x in items)
2277
2278 def process_order(items):
2279 return compute_invoice_total(items)
2280 """))
2281 runner.invoke(cli, ["code", "add", "billing.py"])
2282 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2283 assert r.exit_code == 0, r.output
2284
2285 return repo
2286
2287
2288 class TestBlame:
2289 """Tests for muse code blame."""
2290
2291 # ── address validation ───────────────────────────────────────────────────
2292
2293 def test_invalid_address_no_separator_exits_error(
2294 self, blame_repo: pathlib.Path
2295 ) -> None:
2296 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2297 assert result.exit_code == 1
2298 assert "Invalid address" in result.stderr or "::" in result.stderr
2299
2300 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2301 result = runner.invoke(
2302 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2303 )
2304 assert result.exit_code == 1
2305
2306 # ── basic correctness (no rename involved) ───────────────────────────────
2307
2308 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2309 """A symbol that was never renamed should have created + modified events."""
2310 result = runner.invoke(
2311 cli, ["code", "blame", "billing.py::process_order", "--json"]
2312 )
2313 assert result.exit_code == 0, result.output
2314 data = json.loads(result.output)
2315 kinds = [ev["event"] for ev in data["events"]]
2316 assert "created" in kinds
2317
2318 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2319 result = runner.invoke(
2320 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2321 )
2322 assert result.exit_code == 0
2323 assert "no events found" in result.output
2324
2325 # ── rename tracking — new name (the critical regression) ─────────────────
2326
2327 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2328 """Blaming the POST-rename name must find the rename event."""
2329 result = runner.invoke(
2330 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2331 )
2332 assert result.exit_code == 0, result.output
2333 data = json.loads(result.output)
2334 kinds = [ev["event"] for ev in data["events"]]
2335 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2336
2337 def test_blame_new_name_follows_into_old_history(
2338 self, blame_repo: pathlib.Path
2339 ) -> None:
2340 """After finding the rename, blame must continue tracking the old name.
2341
2342 The symbol was created as compute_total → modified → renamed.
2343 Blaming compute_invoice_total should find ALL three events.
2344 """
2345 result = runner.invoke(
2346 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2347 )
2348 assert result.exit_code == 0, result.output
2349 data = json.loads(result.output)
2350 kinds = [ev["event"] for ev in data["events"]]
2351 assert "created" in kinds, f"Expected created event, got: {kinds}"
2352 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2353
2354 # ── rename tracking — old name ────────────────────────────────────────────
2355
2356 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2357 """Blaming the PRE-rename name must find the creation event."""
2358 result = runner.invoke(
2359 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2360 )
2361 assert result.exit_code == 0, result.output
2362 data = json.loads(result.output)
2363 kinds = [ev["event"] for ev in data["events"]]
2364 assert "created" in kinds, f"Expected created event, got: {kinds}"
2365
2366 def test_blame_old_name_finds_rename_not_lost(
2367 self, blame_repo: pathlib.Path
2368 ) -> None:
2369 """Blaming the old name should also surface the rename event."""
2370 result = runner.invoke(
2371 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2372 )
2373 assert result.exit_code == 0, result.output
2374 data = json.loads(result.output)
2375 kinds = [ev["event"] for ev in data["events"]]
2376 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2377
2378 # ── JSON schema ───────────────────────────────────────────────────────────
2379
2380 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2381 result = runner.invoke(
2382 cli, ["code", "blame", "billing.py::process_order", "--json"]
2383 )
2384 assert result.exit_code == 0, result.output
2385 data = json.loads(result.output)
2386 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2387 assert key in data, f"missing key: {key}"
2388 assert isinstance(data["events"], list)
2389 assert isinstance(data["truncated"], bool)
2390 assert isinstance(data["total_commits_scanned"], int)
2391
2392 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2393 result = runner.invoke(
2394 cli,
2395 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2396 )
2397 assert result.exit_code == 0, result.output
2398 data = json.loads(result.output)
2399 assert data["events"], "expected at least one event"
2400 ev = data["events"][0]
2401 for field in (
2402 "event", "commit_id", "author", "message",
2403 "committed_at", "address", "detail",
2404 ):
2405 assert field in ev, f"missing event field: {field}"
2406
2407 def test_blame_json_address_field_matches_input(
2408 self, blame_repo: pathlib.Path
2409 ) -> None:
2410 addr = "billing.py::process_order"
2411 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2412 data = json.loads(result.output)
2413 assert data["address"] == addr
2414
2415 # ── --max truncation ──────────────────────────────────────────────────────
2416
2417 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2418 result = runner.invoke(
2419 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2420 )
2421 assert result.exit_code == 0, result.output
2422 data = json.loads(result.output)
2423 assert data["total_commits_scanned"] <= 1
2424
2425 def test_blame_truncation_flag_set_when_capped(
2426 self, blame_repo: pathlib.Path
2427 ) -> None:
2428 result = runner.invoke(
2429 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2430 )
2431 data = json.loads(result.output)
2432 assert data["truncated"] is True
2433
2434 def test_blame_truncation_warning_in_human_output(
2435 self, blame_repo: pathlib.Path
2436 ) -> None:
2437 result = runner.invoke(
2438 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2439 )
2440 assert result.exit_code == 0, result.output
2441 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2442
2443 # ── human output ─────────────────────────────────────────────────────────
2444
2445 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2446 result = runner.invoke(
2447 cli, ["code", "blame", "billing.py::process_order"]
2448 )
2449 assert result.exit_code == 0, result.output
2450 assert "last touched:" in result.output
2451
2452 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2453 result_default = runner.invoke(
2454 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2455 )
2456 result_all = runner.invoke(
2457 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2458 )
2459 assert result_all.exit_code == 0, result_all.output
2460 # --all shows at least as many lines as default
2461 assert len(result_all.output) >= len(result_default.output)
2462
2463 # ── BFS follows merge parents ─────────────────────────────────────────────
2464
2465 def test_blame_bfs_follows_merge_parent2(
2466 self, repo: pathlib.Path
2467 ) -> None:
2468 """A symbol introduced on a feature branch is visible after merging."""
2469 # Main: empty billing.py
2470 (repo / "billing.py").write_text("def main_fn(): pass\n")
2471 runner.invoke(cli, ["code", "add", "billing.py"])
2472 runner.invoke(cli, ["commit", "-m", "main commit"])
2473
2474 # Feature branch: add feature_fn
2475 runner.invoke(cli, ["branch", "feat/feature"])
2476 runner.invoke(cli, ["checkout", "feat/feature"])
2477 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2478 runner.invoke(cli, ["code", "add", "billing.py"])
2479 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2480
2481 # Merge back to main
2482 runner.invoke(cli, ["checkout", "main"])
2483 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2484
2485 # Blame feature_fn — should find 'created' event on the feature branch
2486 result = runner.invoke(
2487 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2488 )
2489 assert result.exit_code == 0, result.output
2490 data = json.loads(result.output)
2491 kinds = [ev["event"] for ev in data["events"]]
2492 assert "created" in kinds, (
2493 f"Expected created event for feature_fn after merge; got: {kinds}"
2494 )
2495
2496
2497 # ---------------------------------------------------------------------------
2498 # Security — ReDoS guard in grep
2499 # ---------------------------------------------------------------------------
2500
2501
2502 class TestGrepReDoS:
2503 """grep must reject patterns longer than 512 characters."""
2504
2505 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2506 long_pattern = "a" * 513
2507 result = runner.invoke(cli, ["code", "grep", long_pattern])
2508 assert result.exit_code == 1
2509 assert "too long" in result.stderr.lower() or "512" in result.stderr
2510
2511 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2512 pattern = "a" * 512
2513 result = runner.invoke(cli, ["code", "grep", pattern])
2514 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2515 assert result.exit_code != 1 or "too long" not in result.output.lower()
2516
2517 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2518 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2519 assert result.exit_code == 1
2520
2521
2522 # ---------------------------------------------------------------------------
2523 # JSON output — index status and rebuild
2524 # ---------------------------------------------------------------------------
2525
2526
2527 class TestIndexJsonOutput:
2528 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2529 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2530 assert result.exit_code == 0, result.output
2531 raw = json.loads(result.output)
2532 data = raw["indexes"] if isinstance(raw, dict) else raw
2533 assert isinstance(data, list)
2534 names = [entry["name"] for entry in data]
2535 assert "symbol_history" in names
2536 assert "hash_occurrence" in names
2537 for entry in data:
2538 assert "status" in entry
2539 assert "entries" in entry
2540
2541 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2542 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2543 assert result.exit_code == 0, result.output
2544 data = json.loads(result.output)
2545 assert isinstance(data, dict)
2546 assert "rebuilt" in data
2547 assert isinstance(data["rebuilt"], list)
2548 assert "symbol_history" in data["rebuilt"]
2549 assert "hash_occurrence" in data["rebuilt"]
2550
2551 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2552 result = runner.invoke(cli, [
2553 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2554 ])
2555 assert result.exit_code == 0, result.output
2556 data = json.loads(result.output)
2557 assert "symbol_history" in data.get("rebuilt", [])
2558 assert "symbol_history_addresses" in data
2559
2560
2561 # ---------------------------------------------------------------------------
2562 # Extended — muse code index status
2563 # ---------------------------------------------------------------------------
2564
2565
2566 class TestIndexStatusExtended:
2567 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2568 """-j is equivalent to --json."""
2569 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2570 assert result.exit_code == 0, result.output
2571 _raw = json.loads(result.output.strip())
2572 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2573 assert isinstance(data, list)
2574
2575 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2576 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2577 assert result.exit_code == 0
2578
2579 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2580 """JSON output is compact — single line, no indent=2."""
2581 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2582 assert result.exit_code == 0
2583 lines = [l for l in result.output.splitlines() if l.strip()]
2584 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2585
2586 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
2587 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2588 _raw = json.loads(result.output.strip())
2589 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2590 assert isinstance(data, list)
2591
2592 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2593 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2594 _raw = json.loads(result.output.strip())
2595 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2596 names = [e["name"] for e in data]
2597 assert "symbol_history" in names
2598
2599 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2600 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2601 _raw = json.loads(result.output.strip())
2602 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2603 names = [e["name"] for e in data]
2604 assert "hash_occurrence" in names
2605
2606 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2607 """Every entry has name, status, entries, updated_at."""
2608 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2609 _raw = json.loads(result.output.strip())
2610 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2611 for entry in data:
2612 assert "name" in entry
2613 assert "status" in entry
2614 assert "entries" in entry
2615 assert "updated_at" in entry
2616
2617 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2618 """Freshly initialised repo: both indexes are absent."""
2619 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2620 _raw = json.loads(result.output.strip())
2621 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2622 statuses = {e["name"]: e["status"] for e in data}
2623 assert statuses["symbol_history"] == "absent"
2624 assert statuses["hash_occurrence"] == "absent"
2625
2626 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2627 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2628 _raw = json.loads(result.output.strip())
2629 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2630 for entry in data:
2631 if entry["status"] == "absent":
2632 assert entry["entries"] == 0
2633
2634 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2635 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2636 _raw = json.loads(result.output.strip())
2637 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2638 for entry in data:
2639 if entry["status"] == "absent":
2640 assert entry["updated_at"] is None
2641
2642 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2643 """After rebuild all indexes report present."""
2644 runner.invoke(cli, ["code", "index", "rebuild"])
2645 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2646 _raw = json.loads(result.output.strip())
2647 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2648 for entry in data:
2649 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2650
2651 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2652 """symbol_history should have entries after two commits."""
2653 runner.invoke(cli, ["code", "index", "rebuild"])
2654 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2655 _raw = json.loads(result.output.strip())
2656 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2657 sh = next(e for e in data if e["name"] == "symbol_history")
2658 assert sh["entries"] > 0
2659
2660 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2661 runner.invoke(cli, ["code", "index", "rebuild"])
2662 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2663 _raw = json.loads(result.output.strip())
2664 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2665 for entry in data:
2666 assert entry["updated_at"] is not None
2667
2668 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2669 """A file with bad content is reported as corrupt, not absent."""
2670 idx_dir = indices_dir(code_repo)
2671 idx_dir.mkdir(parents=True, exist_ok=True)
2672 (idx_dir / "symbol_history.json").write_bytes(b"\xff\xfe")
2673 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2674 assert result.exit_code == 0
2675 _raw = json.loads(result.output.strip())
2676 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2677 sh = next(e for e in data if e["name"] == "symbol_history")
2678 assert sh["status"] == "corrupt"
2679
2680 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2681 idx_dir = indices_dir(code_repo)
2682 idx_dir.mkdir(parents=True, exist_ok=True)
2683 (idx_dir / "hash_occurrence.json").write_bytes(b"notmsgpack")
2684 result = runner.invoke(cli, ["code", "index", "status"])
2685 assert result.exit_code == 0
2686
2687 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2688 """Text mode suggests rebuild command when index is absent."""
2689 result = runner.invoke(cli, ["code", "index", "status"])
2690 assert "rebuild" in result.output.lower()
2691
2692 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2693 runner.invoke(cli, ["code", "index", "rebuild"])
2694 result = runner.invoke(cli, ["code", "index", "status"])
2695 assert "✅" in result.output
2696
2697 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2698 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2699 assert "Agent quickstart" in result.output
2700
2701 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2702 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2703 assert "JSON output schema" in result.output
2704
2705 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2706 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2707 assert "Exit codes" in result.output
2708
2709
2710 # ---------------------------------------------------------------------------
2711 # Security — muse code index status
2712 # ---------------------------------------------------------------------------
2713
2714
2715 class TestIndexStatusSecurity:
2716 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2717 """A corrupt index file must not surface a traceback."""
2718 idx_dir = indices_dir(code_repo)
2719 idx_dir.mkdir(parents=True, exist_ok=True)
2720 (idx_dir / "symbol_history.json").write_bytes(b"\x00" * 16)
2721 result = runner.invoke(cli, ["code", "index", "status"])
2722 assert "Traceback" not in result.output
2723
2724 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2725 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2726 from muse.core.indices import KNOWN_INDEX_NAMES
2727 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2728 _raw = json.loads(result.output.strip())
2729 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2730 for entry in data:
2731 assert entry["name"] in KNOWN_INDEX_NAMES
2732
2733 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2734 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2735 assert "\x1b" not in result.output
2736
2737 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2738 """status field is always one of the three allowed values."""
2739 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2740 _raw = json.loads(result.output.strip())
2741 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2742 for entry in data:
2743 assert entry["status"] in ("present", "absent", "corrupt")
2744
2745 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2746 monkeypatch.chdir(tmp_path)
2747 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2748 result = runner.invoke(cli, ["code", "index", "status"])
2749 assert "Traceback" not in result.output
2750 assert result.exit_code != 0
2751
2752 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2753 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2754 _raw = json.loads(result.output.strip())
2755 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
2756 for entry in data:
2757 assert isinstance(entry["entries"], int)
2758
2759
2760 # ---------------------------------------------------------------------------
2761 # Stress — muse code index status
2762 # ---------------------------------------------------------------------------
2763
2764
2765 class TestIndexStatusStress:
2766 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2767 """50 sequential status calls all exit 0."""
2768 for i in range(50):
2769 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2770 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2771
2772 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2773 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2774 for i in range(100):
2775 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2776 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2777 data = json.loads(result.output.strip())
2778 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2779 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2780 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2781 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2782 data = json.loads(result.output.strip())
2783 sh = next(e for e in data["indexes"] if e["name"] == "symbol_history")
2784 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2785
2786 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2787 """8 threads reading index status concurrently — all must succeed."""
2788 import argparse
2789 import threading
2790
2791 from muse.cli.commands.index_rebuild import run_status
2792
2793 errors: list[str] = []
2794
2795 def worker(idx: int) -> None:
2796 args = argparse.Namespace(json_out=True)
2797 try:
2798 run_status(args)
2799 except SystemExit as exc:
2800 if exc.code != 0:
2801 errors.append(f"Thread {idx}: exit {exc.code}")
2802 except Exception as exc:
2803 errors.append(f"Thread {idx}: {exc}")
2804
2805 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2806 for t in threads:
2807 t.start()
2808 for t in threads:
2809 t.join()
2810 assert not errors, f"Concurrent failures: {errors}"
2811
2812
2813 # ---------------------------------------------------------------------------
2814 # Extended — muse code index rebuild
2815 # ---------------------------------------------------------------------------
2816
2817
2818 class TestIndexRebuildExtended:
2819 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2820 """-j is equivalent to --json."""
2821 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2822 assert result.exit_code == 0, result.output
2823 data = json.loads(result.output.strip())
2824 assert "rebuilt" in data
2825
2826 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2827 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2828 assert result.exit_code == 0
2829
2830 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2831 """JSON output is a single compact line — no indent=2."""
2832 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2833 assert result.exit_code == 0
2834 lines = [l for l in result.output.splitlines() if l.strip()]
2835 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2836
2837 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2838 """JSON output always has dry_run, rebuilt."""
2839 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2840 data = json.loads(result.output.strip())
2841 assert "dry_run" in data
2842 assert "rebuilt" in data
2843
2844 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2845 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2846 data = json.loads(result.output.strip())
2847 assert "symbol_history" in data["rebuilt"]
2848 assert "hash_occurrence" in data["rebuilt"]
2849
2850 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2851 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2852 data = json.loads(result.output.strip())
2853 assert data["dry_run"] is False
2854
2855 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2856 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2857 assert result.exit_code == 0
2858 data = json.loads(result.output.strip())
2859 assert data["dry_run"] is True
2860
2861 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2862 """--dry-run must not create index files."""
2863 idx_dir = indices_dir(code_repo)
2864 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2865 assert not (idx_dir / "symbol_history.json").exists()
2866 assert not (idx_dir / "hash_occurrence.json").exists()
2867
2868 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2869 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2870 assert result.exit_code == 0
2871 data = json.loads(result.output.strip())
2872 assert data["rebuilt"] == ["symbol_history"]
2873 assert "symbol_history_addresses" in data
2874 assert "hash_occurrence_clusters" not in data
2875
2876 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2877 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2878 assert result.exit_code == 0
2879 data = json.loads(result.output.strip())
2880 assert data["rebuilt"] == ["hash_occurrence"]
2881 assert "hash_occurrence_clusters" in data
2882 assert "symbol_history_addresses" not in data
2883
2884 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2885 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2886 data = json.loads(result.output.strip())
2887 assert isinstance(data["symbol_history_addresses"], int)
2888 assert isinstance(data["symbol_history_events"], int)
2889
2890 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2891 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2892 data = json.loads(result.output.strip())
2893 assert isinstance(data["hash_occurrence_clusters"], int)
2894 assert isinstance(data["hash_occurrence_addresses"], int)
2895
2896 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2897 runner.invoke(cli, ["code", "index", "rebuild"])
2898 idx_dir = indices_dir(code_repo)
2899 assert (idx_dir / "symbol_history.json").exists()
2900 assert (idx_dir / "hash_occurrence.json").exists()
2901
2902 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2903 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2904 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2905 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2906 assert r1.exit_code == 0 and r2.exit_code == 0
2907 d1 = json.loads(r1.output.strip())
2908 d2 = json.loads(r2.output.strip())
2909 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2910
2911 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2912 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2913 assert result.exit_code == 0
2914 assert "Building" in result.output
2915
2916 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2917 result = runner.invoke(cli, ["code", "index", "rebuild"])
2918 assert "Rebuilt" in result.output or "index" in result.output.lower()
2919
2920 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2921 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2922 assert "Agent quickstart" in result.output
2923
2924 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2925 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2926 assert "JSON output schema" in result.output
2927
2928 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2929 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2930 assert "Exit codes" in result.output
2931
2932
2933 # ---------------------------------------------------------------------------
2934 # Security — muse code index rebuild
2935 # ---------------------------------------------------------------------------
2936
2937
2938 class TestIndexRebuildSecurity:
2939 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2940 """An unknown --index value must be rejected before run_rebuild is called."""
2941 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "malicious_index"])
2942 assert result.exit_code != 0
2943
2944 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2945 idx_dir = indices_dir(code_repo)
2946 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2947 assert not (idx_dir / "symbol_history.json").exists()
2948 assert not (idx_dir / "hash_occurrence.json").exists()
2949
2950 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2951 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2952 assert "\x1b" not in result.output
2953
2954 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2955 monkeypatch.chdir(tmp_path)
2956 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2957 result = runner.invoke(cli, ["code", "index", "rebuild"])
2958 assert "Traceback" not in result.output
2959 assert result.exit_code != 0
2960
2961 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2962 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2963 from muse.core.indices import KNOWN_INDEX_NAMES
2964 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2965 data = json.loads(result.output.strip())
2966 for name in data["rebuilt"]:
2967 assert name in KNOWN_INDEX_NAMES
2968
2969 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
2970 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2971 data = json.loads(result.output.strip())
2972 assert isinstance(data["muse_version"], str)
2973 assert len(data["muse_version"]) > 0
2974
2975
2976 # ---------------------------------------------------------------------------
2977 # Stress — muse code index rebuild
2978 # ---------------------------------------------------------------------------
2979
2980
2981 class TestIndexRebuildStress:
2982 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2983 """50 sequential rebuilds all exit 0."""
2984 for i in range(50):
2985 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2986 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2987
2988 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2989 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2990 indexes = ["symbol_history", "hash_occurrence"]
2991 for i in range(100):
2992 target = indexes[i % 2]
2993 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2994 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2995 data = json.loads(result.output.strip())
2996 assert target in data["rebuilt"]
2997
2998 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2999 """8 threads rebuilding hash_occurrence concurrently via core function."""
3000 import argparse
3001 import threading
3002
3003 from muse.cli.commands.index_rebuild import run_rebuild
3004
3005 errors: list[str] = []
3006
3007 def worker(idx: int) -> None:
3008 args = argparse.Namespace(
3009 index_name="hash_occurrence",
3010 dry_run=True, # dry_run avoids concurrent write races
3011 verbose=False,
3012 json_out=True,
3013 )
3014 try:
3015 run_rebuild(args)
3016 except SystemExit as exc:
3017 if exc.code != 0:
3018 errors.append(f"Thread {idx}: exit {exc.code}")
3019 except Exception as exc:
3020 errors.append(f"Thread {idx}: {exc}")
3021
3022 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3023 for t in threads:
3024 t.start()
3025 for t in threads:
3026 t.join()
3027 assert not errors, f"Concurrent failures: {errors}"
3028
3029
3030 # ---------------------------------------------------------------------------
3031 # Extended — muse code index purge
3032 # ---------------------------------------------------------------------------
3033
3034
3035 class TestIndexPurgeExtended:
3036 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
3037 """-j is equivalent to --json."""
3038 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3039 assert result.exit_code == 0, result.output
3040 data = json.loads(result.output.strip())
3041 assert "purged" in data
3042
3043 def test_help_flag(self, code_repo: pathlib.Path) -> None:
3044 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3045 assert result.exit_code == 0
3046
3047 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
3048 """JSON output is compact — single line, no indent=2."""
3049 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3050 assert result.exit_code == 0
3051 lines = [l for l in result.output.splitlines() if l.strip()]
3052 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
3053
3054 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
3055 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3056 data = json.loads(result.output.strip())
3057 assert "purged" in data
3058 assert "skipped" in data
3059
3060 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
3061 """Purging when indexes are absent — both in skipped, none in purged."""
3062 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3063 data = json.loads(result.output.strip())
3064 assert data["purged"] == []
3065 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
3066
3067 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
3068 """After rebuild, purge reports both as purged."""
3069 runner.invoke(cli, ["code", "index", "rebuild"])
3070 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3071 data = json.loads(result.output.strip())
3072 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
3073 assert data["skipped"] == []
3074
3075 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
3076 runner.invoke(cli, ["code", "index", "rebuild"])
3077 runner.invoke(cli, ["code", "index", "purge"])
3078 idx_dir = indices_dir(code_repo)
3079 assert not (idx_dir / "symbol_history.json").exists()
3080 assert not (idx_dir / "hash_occurrence.json").exists()
3081
3082 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
3083 runner.invoke(cli, ["code", "index", "rebuild"])
3084 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
3085 assert result.exit_code == 0
3086 data = json.loads(result.output.strip())
3087 assert data["purged"] == ["symbol_history"]
3088 assert data["skipped"] == []
3089 idx_dir = indices_dir(code_repo)
3090 assert not (idx_dir / "symbol_history.json").exists()
3091 assert (idx_dir / "hash_occurrence.json").exists()
3092
3093 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
3094 runner.invoke(cli, ["code", "index", "rebuild"])
3095 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3096 assert result.exit_code == 0
3097 data = json.loads(result.output.strip())
3098 assert data["purged"] == ["hash_occurrence"]
3099 idx_dir = indices_dir(code_repo)
3100 assert not (idx_dir / "hash_occurrence.json").exists()
3101 assert (idx_dir / "symbol_history.json").exists()
3102
3103 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
3104 """Purging when nothing is present still exits 0."""
3105 result = runner.invoke(cli, ["code", "index", "purge"])
3106 assert result.exit_code == 0
3107
3108 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
3109 """Purging twice in a row both exit 0."""
3110 runner.invoke(cli, ["code", "index", "rebuild"])
3111 r1 = runner.invoke(cli, ["code", "index", "purge"])
3112 r2 = runner.invoke(cli, ["code", "index", "purge"])
3113 assert r1.exit_code == 0
3114 assert r2.exit_code == 0
3115
3116 def test_muse_version_is_string(self, code_repo: pathlib.Path) -> None:
3117 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3118 data = json.loads(result.output.strip())
3119 assert isinstance(data["muse_version"], str)
3120 assert len(data["muse_version"]) > 0
3121
3122 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
3123 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3124 data = json.loads(result.output.strip())
3125 assert isinstance(data["purged"], list)
3126 assert isinstance(data["skipped"], list)
3127
3128 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
3129 runner.invoke(cli, ["code", "index", "rebuild"])
3130 result = runner.invoke(cli, ["code", "index", "purge"])
3131 assert "deleted" in result.output.lower() or "🗑" in result.output
3132
3133 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3134 result = runner.invoke(cli, ["code", "index", "purge"])
3135 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3136
3137 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3138 runner.invoke(cli, ["code", "index", "rebuild"])
3139 runner.invoke(cli, ["code", "index", "purge"])
3140 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3141 _raw = json.loads(result.output.strip())
3142 data = _raw["indexes"] if isinstance(_raw, dict) and "indexes" in _raw else _raw
3143 for entry in data:
3144 assert entry["status"] == "absent"
3145
3146 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3147 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3148 assert "Agent quickstart" in result.output
3149
3150 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3151 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3152 assert "JSON output schema" in result.output
3153
3154 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3155 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3156 assert "Exit codes" in result.output
3157
3158
3159 # ---------------------------------------------------------------------------
3160 # Security — muse code index purge
3161 # ---------------------------------------------------------------------------
3162
3163
3164 class TestIndexPurgeSecurity:
3165 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3166 """Unknown --index value rejected by argparse before run_purge runs."""
3167 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3168 assert result.exit_code != 0
3169
3170 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3171 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3172 assert "\x1b" not in result.output
3173
3174 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3175 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3176 from muse.core.indices import KNOWN_INDEX_NAMES
3177 runner.invoke(cli, ["code", "index", "rebuild"])
3178 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3179 data = json.loads(result.output.strip())
3180 for name in data["purged"] + data["skipped"]:
3181 assert name in KNOWN_INDEX_NAMES
3182
3183 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3184 monkeypatch.chdir(tmp_path)
3185 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3186 result = runner.invoke(cli, ["code", "index", "purge"])
3187 assert "Traceback" not in result.output
3188 assert result.exit_code != 0
3189
3190 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3191 """Purge must not remove anything outside .muse/indices/."""
3192 runner.invoke(cli, ["code", "index", "rebuild"])
3193 repo_json = repo_json_path(code_repo)
3194 assert repo_json.exists()
3195 runner.invoke(cli, ["code", "index", "purge"])
3196 assert repo_json.exists(), "repo.json must not be deleted by purge"
3197
3198 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3199 runner.invoke(cli, ["code", "index", "rebuild"])
3200 runner.invoke(cli, ["code", "index", "purge"])
3201 result = runner.invoke(cli, ["code", "index", "purge"])
3202 assert "Traceback" not in result.output
3203
3204
3205 # ---------------------------------------------------------------------------
3206 # Stress — muse code index purge
3207 # ---------------------------------------------------------------------------
3208
3209
3210 class TestIndexPurgeStress:
3211 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3212 """50 sequential purge calls all exit 0 (idempotent)."""
3213 for i in range(50):
3214 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3215 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3216
3217 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3218 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3219 for i in range(100):
3220 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3221 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3222 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3223 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3224 d = json.loads(r2.output.strip())
3225 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3226
3227 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3228 """8 threads purging concurrently via core function — all must exit 0."""
3229 import argparse
3230 import threading
3231
3232 from muse.cli.commands.index_rebuild import run_purge
3233
3234 runner.invoke(cli, ["code", "index", "rebuild"])
3235 errors: list[str] = []
3236
3237 def worker(idx: int) -> None:
3238 args = argparse.Namespace(index_name=None, json_out=True)
3239 try:
3240 run_purge(args)
3241 except SystemExit as exc:
3242 if exc.code != 0:
3243 errors.append(f"Thread {idx}: exit {exc.code}")
3244 except Exception as exc:
3245 errors.append(f"Thread {idx}: {exc}")
3246
3247 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3248 for t in threads:
3249 t.start()
3250 for t in threads:
3251 t.join()
3252 assert not errors, f"Concurrent failures: {errors}"
3253
3254
3255 # ---------------------------------------------------------------------------
3256 # Performance — iterative DFS regression (no RecursionError)
3257 # ---------------------------------------------------------------------------
3258
3259
3260 class TestIterativeDFS:
3261 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3262
3263 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3264 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3265
3266 # Build a linear chain A→B→C→…→Z (depth 600, beyond Python's 1000 default).
3267 depth = 600
3268 nodes = [f"mod_{i}" for i in range(depth)]
3269 imports_out: _ImportsMap = {
3270 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3271 }
3272 imports_out[nodes[-1]] = []
3273
3274 # Must not raise RecursionError.
3275 cycles = codemap_find_cycles(imports_out)
3276 assert isinstance(cycles, list)
3277 assert len(cycles) == 0 # linear chain has no cycles
3278
3279 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3280 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3281
3282 # A→B→C→A is a cycle.
3283 imports_out: _ImportsMap = {
3284 "A": ["B"],
3285 "B": ["C"],
3286 "C": ["A"],
3287 }
3288 cycles = codemap_find_cycles(imports_out)
3289 assert len(cycles) >= 1
3290
3291 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3292 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3293
3294 depth = 600
3295 nodes = [f"file_{i}.py" for i in range(depth)]
3296 imports: _ImportsSetMap = {
3297 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3298 }
3299 imports[nodes[-1]] = set()
3300
3301 cycles = invariants_find_cycles(imports)
3302 assert isinstance(cycles, list)
3303 assert len(cycles) == 0
3304
3305 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3306 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3307
3308 # A module that imports itself.
3309 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3310 cycles = invariants_find_cycles(imports)
3311 assert len(cycles) >= 1
3312
3313
3314 # ---------------------------------------------------------------------------
3315 # muse code symbols
3316 # ---------------------------------------------------------------------------
3317
3318
3319 class TestSymbols:
3320 """Tests for ``muse code symbols``."""
3321
3322 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3323 """Basic invocation lists functions and classes from HEAD snapshot."""
3324 result = runner.invoke(cli, ["code", "symbols"])
3325 assert result.exit_code == 0, result.output
3326 # billing.py contains Invoice class and process_order / send_email functions.
3327 assert "Invoice" in result.output
3328 assert "process_order" in result.output
3329 assert "symbols across" in result.output
3330
3331 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3332 """``--count`` prints a total count and language breakdown, no symbol table."""
3333 result = runner.invoke(cli, ["code", "symbols", "--count"])
3334 assert result.exit_code == 0, result.output
3335 assert "symbols" in result.output
3336 assert "Python" in result.output
3337 # Should NOT print individual symbol lines.
3338 assert "Invoice" not in result.output
3339
3340 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3341 """``--json`` emits a structured envelope with a flat 'results' list."""
3342 result = runner.invoke(cli, ["code", "symbols", "--json"])
3343 assert result.exit_code == 0, result.output
3344 data = json.loads(result.output)
3345 assert isinstance(data, dict)
3346 assert "results" in data
3347 assert "files" not in data
3348 assert isinstance(data["results"], list)
3349 assert any(e.get("address", "").startswith("billing.py") for e in data["results"])
3350 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3351
3352 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3353 """``--kind class`` shows only class-kind symbols."""
3354 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3355 assert result.exit_code == 0, result.output
3356 assert "Invoice" in result.output
3357 assert "process_order" not in result.output
3358
3359 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3360 """``--kind function`` shows only top-level functions, not methods."""
3361 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3362 assert result.exit_code == 0, result.output
3363 assert "process_order" in result.output
3364 assert "send_email" in result.output
3365 assert "Invoice" not in result.output
3366
3367 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3368 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3369 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3370 assert result.exit_code != 0
3371 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3372
3373 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3374 """``--file`` restricts output to a single file."""
3375 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3376 assert result.exit_code == 0, result.output
3377 assert "symbols across" in result.output
3378
3379 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3380 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3381 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3382 assert result.exit_code == 0, result.output
3383 assert "no semantic symbols found" in result.output
3384
3385 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3386 """``--language Python`` includes Python symbols; other languages excluded."""
3387 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3388 assert result.exit_code == 0, result.output
3389 assert "Invoice" in result.output
3390
3391 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3392 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3393 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3394 assert result.exit_code == 0, result.output
3395 assert "no semantic symbols found" in result.output
3396
3397 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3398 """``--hashes`` appends content hash abbreviations to each symbol row."""
3399 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3400 assert result.exit_code == 0, result.output
3401 # Hash suffix is 8 hex chars followed by ".."
3402 assert ".." in result.output
3403
3404 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3405 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3406 default = runner.invoke(cli, ["code", "symbols"])
3407 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3408 assert default.exit_code == 0
3409 assert head.exit_code == 0
3410 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3411 assert "Invoice" in default.output
3412 assert "Invoice" in head.output
3413 assert "symbols across" in default.output
3414 assert "symbols across" in head.output
3415
3416 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3417 """``--count`` and ``--json`` cannot be combined."""
3418 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3419 assert result.exit_code != 0
3420
3421 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3422 """JSON output uses the structured envelope with source_ref and results."""
3423 result = runner.invoke(cli, ["code", "symbols", "--json"])
3424 assert result.exit_code == 0, result.output
3425 data = json.loads(result.output)
3426 assert "source_ref" in data
3427 assert "working_tree" in data
3428 assert "total_symbols" in data
3429 assert "results" in data
3430 assert "files" not in data
3431 assert isinstance(data["working_tree"], bool)
3432 assert isinstance(data["total_symbols"], int)
3433 for entry in data["results"]:
3434 for field in ("address", "kind", "name", "qualified_name",
3435 "lineno", "content_id", "body_hash", "signature_id"):
3436 assert field in entry, f"missing field '{field}' in JSON entry"
3437
3438 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3439 """``--json`` without ``--commit`` reports working_tree=true."""
3440 result = runner.invoke(cli, ["code", "symbols", "--json"])
3441 assert result.exit_code == 0, result.output
3442 data = json.loads(result.output)
3443 assert data["working_tree"] is True
3444 assert data["source_ref"] == "working-tree"
3445
3446 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3447 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3448 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3449 assert result.exit_code == 0, result.output
3450 data = json.loads(result.output)
3451 assert data["working_tree"] is False
3452 assert data["source_ref"] != "working-tree"
3453 # source_ref is a prefixed short commit id (e.g. "sha256:<12hex>")
3454 assert data["source_ref"].startswith("sha256:")
3455
3456 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3457 """Working-tree mode picks up edits made to files after the last commit."""
3458 # Find the billing.py path on disk.
3459 billing = code_repo / "billing.py"
3460 assert billing.exists()
3461 # Append a new function — not yet committed.
3462 billing.write_text(
3463 f"{billing.read_text()}\ndef newly_added_function():\n pass\n"
3464 )
3465 result = runner.invoke(cli, ["code", "symbols"])
3466 assert result.exit_code == 0, result.output
3467 assert "newly_added_function" in result.output
3468
3469 # Committed snapshot should NOT contain it.
3470 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3471 assert committed.exit_code == 0
3472 assert "newly_added_function" not in committed.output
3473
3474 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3475 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3476 for variant in ("python", "Python", "PYTHON"):
3477 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3478 assert result.exit_code == 0, f"failed for --language {variant!r}"
3479 assert "Invoice" in result.output
3480
3481 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3482 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3483 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3484 assert result.exit_code == 0, result.output
3485 assert "Invoice" in result.output
3486
3487 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3488 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3489 # Write a second file with the same basename in a sub-directory.
3490 sub = code_repo / "sub"
3491 sub.mkdir(exist_ok=True)
3492 (sub / "billing.py").write_text("def sub_func(): pass\n")
3493 # Stage and commit both so the manifest has two paths ending in billing.py.
3494 import subprocess
3495 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3496 subprocess.run(
3497 ["muse", "commit", "-m", "add sub/billing.py"],
3498 cwd=code_repo, check=True,
3499 )
3500 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3501 assert result.exit_code != 0
3502 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3503
3504 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3505 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3506 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3507 assert result.exit_code != 0
3508 assert "not found" in result.stderr
3509
3510
3511 # ---------------------------------------------------------------------------
3512 # TestSymbolLog
3513 # ---------------------------------------------------------------------------
3514
3515
3516 class TestSymbolLog:
3517 """Tests for ``muse code symbol-log``."""
3518
3519 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3520 """An address not found in any commit produces 'no events found'."""
3521 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3522 assert result.exit_code == 0, result.output
3523 assert "no events found" in result.output
3524
3525 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3526 """An address without '::' exits non-zero with a descriptive error."""
3527 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3528 assert result.exit_code != 0
3529 assert "::" in (result.output + (result.stderr or ""))
3530
3531 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3532 """An empty string as address exits non-zero."""
3533 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3534 # "::" is technically valid syntax; should at least not crash.
3535 assert result.exit_code == 0
3536
3537 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3538 """``--json`` emits the structured envelope with all top-level fields."""
3539 result = runner.invoke(
3540 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3541 )
3542 assert result.exit_code == 0, result.output
3543 data = json.loads(result.output)
3544 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3545 assert field in data, f"missing top-level field '{field}'"
3546 assert data["address"] == "billing.py::Invoice"
3547 assert data["start_ref"] == "HEAD"
3548 assert isinstance(data["total_commits_scanned"], int)
3549 assert isinstance(data["truncated"], bool)
3550 assert isinstance(data["events"], list)
3551
3552 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3553 """Each JSON event has the required fields."""
3554 result = runner.invoke(
3555 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3556 )
3557 assert result.exit_code == 0, result.output
3558 data = json.loads(result.output)
3559 for ev in data["events"]:
3560 for field in ("event", "commit_id", "message", "committed_at",
3561 "address", "detail", "new_address"):
3562 assert field in ev, f"missing event field '{field}'"
3563
3564 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3565 """When --max is hit, a truncation warning appears in human output."""
3566 result = runner.invoke(
3567 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3568 )
3569 assert result.exit_code == 0, result.output
3570 assert "incomplete" in result.output or "limit" in result.output
3571
3572 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3573 """When --max is hit, truncated=true appears in JSON output."""
3574 result = runner.invoke(
3575 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3576 )
3577 assert result.exit_code == 0, result.output
3578 data = json.loads(result.output)
3579 assert data["truncated"] is True
3580 assert data["total_commits_scanned"] == 1
3581
3582 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3583 """--max 0 exits non-zero with a clear error."""
3584 result = runner.invoke(
3585 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3586 )
3587 assert result.exit_code != 0
3588
3589 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3590 """``--from`` with a non-existent ref exits non-zero."""
3591 result = runner.invoke(
3592 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3593 )
3594 assert result.exit_code != 0
3595 assert "not found" in result.stderr
3596
3597 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3598 """BFS walk finds events on feature branches that were merged in via parent2.
3599
3600 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3601 The feature branch commit has a structured_delta inserting a symbol.
3602 The linear (parent1-only) walk would miss this; BFS must find it.
3603 """
3604 import datetime
3605
3606 root = code_repo
3607 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
3608 from muse.core.refs import (
3609 get_head_commit_id,
3610 read_current_branch,
3611 )
3612 from muse.core.commits import (
3613 CommitRecord,
3614 write_commit,
3615 )
3616 from muse.core.ids import hash_commit as compute_commit_id
3617 from muse.domain import InsertOp, PatchOp, StructuredDelta
3618 branch = read_current_branch(root)
3619 head_id = get_head_commit_id(root, branch)
3620 assert head_id is not None
3621
3622 feature_snap = "aa" * 32
3623 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3624 feature_id = compute_commit_id(
3625 parent_ids=[head_id],
3626 snapshot_id=feature_snap,
3627 message="feat: add merged_fn",
3628 committed_at_iso=feature_at.isoformat(),
3629 author="test",
3630 )
3631 write_commit(root, CommitRecord(
3632 commit_id=feature_id,
3633 branch="feat/branch",
3634 snapshot_id=feature_snap,
3635 message="feat: add merged_fn",
3636 committed_at=feature_at,
3637 parent_commit_id=head_id,
3638 author="test",
3639 structured_delta=StructuredDelta(ops=[PatchOp(
3640 op="patch",
3641 address="billing.py",
3642 child_ops=[InsertOp(
3643 op="insert",
3644 address="billing.py::merged_fn",
3645 content_summary="function merged_fn",
3646 )],
3647 )]),
3648 ))
3649
3650 merge_snap = "bb" * 32
3651 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3652 merge_id = compute_commit_id(
3653 parent_ids=[head_id, feature_id],
3654 snapshot_id=merge_snap,
3655 message="merge feat/branch",
3656 committed_at_iso=merge_at.isoformat(),
3657 author="test",
3658 )
3659 write_commit(root, CommitRecord(
3660 commit_id=merge_id,
3661 branch=branch,
3662 snapshot_id=merge_snap,
3663 message="merge feat/branch",
3664 committed_at=merge_at,
3665 parent_commit_id=head_id,
3666 parent2_commit_id=feature_id,
3667 author="test",
3668 ))
3669
3670 branch_ref = ref_path(root, branch)
3671 branch_ref.write_text(merge_id)
3672
3673 result = runner.invoke(
3674 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3675 )
3676 assert result.exit_code == 0, result.output
3677 # BFS must find the creation event on the feature branch.
3678 assert "merged_fn" in result.output
3679 assert "created" in result.output
3680
3681 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3682 """Regression guard: verify the BFS result differs from a parent1-only scan.
3683
3684 Directly calls _walk_commits_dag and checks it returns commits from
3685 both parent chains, not just parent1.
3686 """
3687 import datetime
3688
3689 root = code_repo
3690 repo_id = json.loads((repo_json_path(root)).read_text())["repo_id"]
3691 from muse.core.refs import (
3692 get_head_commit_id,
3693 read_current_branch,
3694 )
3695 from muse.core.commits import (
3696 CommitRecord,
3697 write_commit,
3698 )
3699 from muse.core.ids import hash_commit as compute_commit_id
3700 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3701 branch = read_current_branch(root)
3702 head_id = get_head_commit_id(root, branch)
3703 assert head_id is not None
3704
3705 feature_snap = "dd" * 32
3706 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3707 feature_id = compute_commit_id(
3708 parent_ids=[],
3709 snapshot_id=feature_snap,
3710 message="feat on second parent",
3711 committed_at_iso=feature_at.isoformat(),
3712 author="test",
3713 )
3714 write_commit(root, CommitRecord(
3715 commit_id=feature_id,
3716 branch="feat/x",
3717 snapshot_id=feature_snap,
3718 message="feat on second parent",
3719 committed_at=feature_at,
3720 author="test",
3721 ))
3722 merge_snap = "ee" * 32
3723 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3724 merge_id = compute_commit_id(
3725 parent_ids=[head_id, feature_id],
3726 snapshot_id=merge_snap,
3727 message="merge",
3728 committed_at_iso=merge_at.isoformat(),
3729 author="test",
3730 )
3731 write_commit(root, CommitRecord(
3732 commit_id=merge_id,
3733 branch=branch,
3734 snapshot_id=merge_snap,
3735 message="merge",
3736 committed_at=merge_at,
3737 parent_commit_id=head_id,
3738 parent2_commit_id=feature_id,
3739 author="test",
3740 ))
3741
3742 branch_ref = ref_path(root, branch)
3743 branch_ref.write_text(merge_id)
3744
3745 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3746 commit_ids = {c.commit_id for c in commits}
3747 assert feature_id in commit_ids
3748
3749
3750 # ---------------------------------------------------------------------------
3751 # muse code coupling
3752 # ---------------------------------------------------------------------------
3753
3754
3755 @pytest.fixture
3756 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3757 """Repo with 3 commits where billing.py + models.py co-change twice."""
3758 work = repo
3759
3760 # Commit 1: seed — only billing.py
3761 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3762 runner.invoke(cli, ["code", "add", "billing.py"])
3763 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3764 assert r.exit_code == 0, r.output
3765
3766 # Commit 2: billing.py + models.py change together
3767 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3768 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3769 runner.invoke(cli, ["code", "add", "."])
3770 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3771 assert r.exit_code == 0, r.output
3772
3773 # Commit 3: billing.py + models.py change together again
3774 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3775 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3776 runner.invoke(cli, ["code", "add", "."])
3777 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3778 assert r.exit_code == 0, r.output
3779
3780 return repo
3781
3782
3783 class TestCoupling:
3784 """Tests for muse code coupling."""
3785
3786 # ── basic correctness ────────────────────────────────────────────────────
3787
3788 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3789 result = runner.invoke(cli, ["code", "coupling"])
3790 assert result.exit_code == 0, result.output
3791
3792 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3793 """billing.py and models.py co-changed twice — must appear in output."""
3794 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3795 assert result.exit_code == 0, result.output
3796 assert "billing.py" in result.output
3797 assert "models.py" in result.output
3798
3799 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3800 result = runner.invoke(cli, ["code", "coupling"])
3801 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3802 assert "Commits analysed" in result.output
3803
3804 def test_coupling_min_filter_excludes_low_count(
3805 self, coupling_repo: pathlib.Path
3806 ) -> None:
3807 """--min 3 must exclude our pair that co-changed only twice."""
3808 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3809 assert result.exit_code == 0, result.output
3810 assert "billing.py" not in result.output or "no file pairs" in result.output
3811
3812 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3813 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3814 data = json.loads(result.output)
3815 assert len(data["pairs"]) <= 1
3816
3817 # ── --file filter ─────────────────────────────────────────────────────────
3818
3819 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3820 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3821 assert result.exit_code == 0, result.output
3822
3823 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3824 """--file billing.py must surface models.py as its partner."""
3825 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3826 assert result.exit_code == 0, result.output
3827 assert "models.py" in result.output
3828
3829 def test_coupling_file_filter_header_names_file(
3830 self, coupling_repo: pathlib.Path
3831 ) -> None:
3832 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3833 assert "billing.py" in result.output
3834
3835 def test_coupling_file_filter_nonexistent_returns_cleanly(
3836 self, coupling_repo: pathlib.Path
3837 ) -> None:
3838 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3839 assert result.exit_code == 0, result.output
3840
3841 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3842 """Suffix billing.py should match the file even without the full path."""
3843 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3844 assert result.exit_code == 0, result.output
3845 assert "models.py" in result.output
3846
3847 # ── JSON output ───────────────────────────────────────────────────────────
3848
3849 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3850 result = runner.invoke(cli, ["code", "coupling", "--json"])
3851 assert result.exit_code == 0, result.output
3852 data = json.loads(result.output)
3853 assert "from_ref" in data
3854 assert "to_ref" in data
3855 assert "commits_analysed" in data
3856 assert "truncated" in data
3857 assert "filters" in data
3858 assert "pairs" in data
3859 assert isinstance(data["pairs"], list)
3860
3861 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3862 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3863 data = json.loads(result.output)
3864 if data["pairs"]:
3865 pair = data["pairs"][0]
3866 assert "file_a" in pair or "file" in pair
3867 assert "co_changes" in pair
3868 assert isinstance(pair["co_changes"], int)
3869
3870 def test_coupling_json_file_filter_uses_partner_schema(
3871 self, coupling_repo: pathlib.Path
3872 ) -> None:
3873 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3874 result = runner.invoke(
3875 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3876 )
3877 data = json.loads(result.output)
3878 assert data["filters"]["file"] == "billing.py"
3879 if data["pairs"]:
3880 pair = data["pairs"][0]
3881 assert "file" in pair
3882 assert "partner" in pair
3883 assert "co_changes" in pair
3884 assert "file_a" not in pair # partner schema, not pair schema
3885
3886 def test_coupling_json_not_truncated_small_repo(
3887 self, coupling_repo: pathlib.Path
3888 ) -> None:
3889 result = runner.invoke(cli, ["code", "coupling", "--json"])
3890 data = json.loads(result.output)
3891 assert data["truncated"] is False
3892
3893 def test_coupling_json_filters_reflect_args(
3894 self, coupling_repo: pathlib.Path
3895 ) -> None:
3896 result = runner.invoke(
3897 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3898 )
3899 data = json.loads(result.output)
3900 assert data["filters"]["top"] == 5
3901 assert data["filters"]["min_count"] == 2
3902
3903 # ── --max-commits ─────────────────────────────────────────────────────────
3904
3905 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3906 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3907 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3908 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3909 d_cap = json.loads(r_cap.output)
3910 assert d_cap["commits_analysed"] <= 1
3911
3912 def test_coupling_max_commits_truncated_flag(
3913 self, coupling_repo: pathlib.Path
3914 ) -> None:
3915 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3916 data = json.loads(result.output)
3917 # With 3 commits and cap=1, truncated must be True.
3918 assert data["truncated"] is True
3919
3920 def test_coupling_max_commits_one_shows_warning(
3921 self, coupling_repo: pathlib.Path
3922 ) -> None:
3923 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3924 assert result.exit_code == 0, result.output
3925 assert "⚠️" in result.output or "capped" in result.output
3926
3927 # ── validation ────────────────────────────────────────────────────────────
3928
3929 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3930 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3931 assert result.exit_code != 0
3932
3933 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3934 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3935 assert result.exit_code != 0
3936
3937 def test_coupling_max_commits_zero_exits_error(
3938 self, coupling_repo: pathlib.Path
3939 ) -> None:
3940 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3941 assert result.exit_code != 0
3942
3943 def test_coupling_invalid_from_ref_exits_error(
3944 self, coupling_repo: pathlib.Path
3945 ) -> None:
3946 result = runner.invoke(
3947 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3948 )
3949 assert result.exit_code != 0
3950
3951 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3952 """Coupling must count co-changes on feature-branch commits (parent2)."""
3953 import datetime
3954
3955 # Genesis commit
3956 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3957 r = runner.invoke(cli, ["commit", "-m", "seed"])
3958 assert r.exit_code == 0, r.output
3959
3960 repo_json = json.loads((repo_json_path(repo)).read_text())
3961 repo_id = repo_json["repo_id"]
3962 from muse.core.refs import read_current_branch
3963 from muse.core.commits import resolve_commit_ref
3964 branch = read_current_branch(repo)
3965 head = resolve_commit_ref(repo, branch, None)
3966 assert head is not None
3967
3968 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3969 feature_at = now
3970 merge_at = now + datetime.timedelta(hours=1)
3971
3972 # Feature commit touching billing.py + models.py together.
3973 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3974 from muse.core.ids import hash_commit as compute_commit_id
3975 feature_delta = StructuredDelta(
3976 domain="code",
3977 ops=[
3978 PatchOp(
3979 op="patch", address="billing.py",
3980 child_ops=[ReplaceOp(
3981 op="replace", address="billing.py::compute",
3982 old_content_id="a" * 64, new_content_id="b" * 64,
3983 old_summary="function compute",
3984 new_summary="function compute (modified)", position=None,
3985 )],
3986 child_domain="code", child_summary="compute modified",
3987 ),
3988 PatchOp(
3989 op="patch", address="models.py",
3990 child_ops=[InsertOp(
3991 op="insert", address="models.py::Order",
3992 content_id="c" * 64, content_summary="class Order", position=None,
3993 )],
3994 child_domain="code", child_summary="Order added",
3995 ),
3996 ],
3997 summary="co-change",
3998 )
3999 feature_id = compute_commit_id(
4000 [head.commit_id], head.snapshot_id,
4001 "co-change on feature branch", feature_at.isoformat(),
4002 author="test",
4003 )
4004 merge_id = compute_commit_id(
4005 [head.commit_id, feature_id], head.snapshot_id,
4006 "Merge feature", merge_at.isoformat(),
4007 author="test",
4008 )
4009 feature_body: CommitDict = {
4010 "commit_id": feature_id,
4011 "repo_id": repo_id,
4012 "branch": "feat/test",
4013 "snapshot_id": head.snapshot_id,
4014 "message": "co-change on feature branch",
4015 "committed_at": feature_at.isoformat(),
4016 "parent_commit_id": head.commit_id,
4017 "parent2_commit_id": None,
4018 "author": "test",
4019 "metadata": {},
4020 "structured_delta": feature_delta,
4021 }
4022 merge_body: CommitDict = {
4023 "commit_id": merge_id,
4024 "repo_id": repo_id,
4025 "branch": branch,
4026 "snapshot_id": head.snapshot_id,
4027 "message": "Merge feature",
4028 "committed_at": merge_at.isoformat(),
4029 "parent_commit_id": head.commit_id,
4030 "parent2_commit_id": feature_id,
4031 "author": "test",
4032 "metadata": {},
4033 "structured_delta": None,
4034 }
4035 from muse.core.commits import (
4036 CommitRecord,
4037 write_commit,
4038 )
4039 write_commit(repo, CommitRecord.from_dict(feature_body))
4040 write_commit(repo, CommitRecord.from_dict(merge_body))
4041 (ref_path(repo, branch)).write_text(merge_id)
4042
4043 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
4044 assert result.exit_code == 0, result.output
4045 data = json.loads(result.output)
4046 pairs_found = {
4047 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
4048 }
4049 billing_models = any(
4050 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
4051 for a, b in pairs_found
4052 )
4053 assert billing_models, "BFS must find the feature-branch co-change commit"
4054
4055
4056 # ---------------------------------------------------------------------------
4057 # muse code stable
4058 # ---------------------------------------------------------------------------
4059
4060
4061 class TestStable:
4062 """Tests for muse code stable."""
4063
4064 # ── basic correctness ────────────────────────────────────────────────────
4065
4066 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
4067 result = runner.invoke(cli, ["code", "stable"])
4068 assert result.exit_code == 0, result.output
4069
4070 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
4071 result = runner.invoke(cli, ["code", "stable"])
4072 assert result.exit_code == 0, result.output
4073 assert "Symbol stability" in result.output
4074 assert "Commits analysed" in result.output
4075 assert "bedrock" in result.output
4076
4077 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
4078 """Invoice.apply_discount was defined in the genesis commit and never modified."""
4079 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4080 assert result.exit_code == 0, result.output
4081 # apply_discount was never touched in any structured_delta → maximally stable.
4082 assert "apply_discount" in result.output
4083
4084 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
4085 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
4086 assert result.exit_code == 0, result.output
4087 assert "since start of range" in result.output
4088
4089 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
4090 """Markdown / TOML / YAML symbols must be absent from default output."""
4091 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4092 assert result.exit_code == 0, result.output
4093 assert ".md::" not in result.output
4094 assert ".toml::" not in result.output
4095
4096 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
4097 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
4098 assert result.exit_code == 0, result.output
4099 assert "::import::" not in result.output
4100
4101 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
4102 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
4103 assert result.exit_code == 0, result.output
4104
4105 # ── JSON output ───────────────────────────────────────────────────────────
4106
4107 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
4108 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4109 assert result.exit_code == 0, result.output
4110 data = json.loads(result.output)
4111 assert "from_ref" in data
4112 assert "to_ref" in data
4113 assert "commits_analysed" in data
4114 assert "truncated" in data
4115 assert "filters" in data
4116 assert "stable" in data
4117 assert isinstance(data["stable"], list)
4118
4119 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
4120 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
4121 data = json.loads(result.output)
4122 assert len(data["stable"]) > 0
4123 entry = data["stable"][0]
4124 assert "address" in entry
4125 assert "unchanged_for" in entry
4126 assert "since_start_of_range" in entry
4127 assert isinstance(entry["unchanged_for"], int)
4128 assert isinstance(entry["since_start_of_range"], bool)
4129
4130 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
4131 result = runner.invoke(
4132 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
4133 )
4134 data = json.loads(result.output)
4135 assert data["filters"]["top"] == 3
4136 assert data["filters"]["kind"] == "function"
4137 assert data["filters"]["include_imports"] is False
4138 assert data["filters"]["include_docs"] is False
4139
4140 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
4141 result = runner.invoke(cli, ["code", "stable", "--json"])
4142 data = json.loads(result.output)
4143 assert data["truncated"] is False
4144
4145 # ── --language filter ─────────────────────────────────────────────────────
4146
4147 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
4148 """--language python and --language Python must behave identically."""
4149 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4150 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
4151 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
4152 d_lower = json.loads(r_lower.output)
4153 d_upper = json.loads(r_upper.output)
4154 addrs_lower = {e["address"] for e in d_lower["stable"]}
4155 addrs_upper = {e["address"] for e in d_upper["stable"]}
4156 assert addrs_lower == addrs_upper
4157
4158 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
4159 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
4160 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4161 d_py = json.loads(r_py.output)
4162 d_all = json.loads(r_all.output)
4163 # Python-filtered results must be a subset of or equal to unfiltered results.
4164 py_addrs = {e["address"] for e in d_py["stable"]}
4165 all_addrs = {e["address"] for e in d_all["stable"]}
4166 assert py_addrs <= all_addrs
4167
4168 # ── --since REF ───────────────────────────────────────────────────────────
4169
4170 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
4171 """--since HEAD restricts the window to 0 commits (stop immediately)."""
4172 # Get the HEAD commit id to use as --since boundary
4173 import json as _json
4174 root = code_repo
4175 repo_id = _json.loads((repo_json_path(root)).read_text())["repo_id"]
4176 from muse.core.refs import read_current_branch
4177 from muse.core.commits import resolve_commit_ref
4178 branch = read_current_branch(root)
4179 head = resolve_commit_ref(root, branch, None)
4180 assert head is not None
4181
4182 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4183 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4184 assert r_all.exit_code == 0 and r_since.exit_code == 0
4185 d_all = json.loads(r_all.output)
4186 d_since = json.loads(r_since.output)
4187 # Window stops at HEAD itself → at most 1 commit analysed.
4188 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4189
4190 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4191 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4192 assert result.exit_code != 0
4193
4194 # ── --max-commits ─────────────────────────────────────────────────────────
4195
4196 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4197 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4198 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4199 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4200 d_cap = json.loads(r_cap.output)
4201 assert d_cap["commits_analysed"] <= 1
4202
4203 def test_stable_max_commits_one_shows_truncated_warning(
4204 self, code_repo: pathlib.Path
4205 ) -> None:
4206 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4207 assert result.exit_code == 0, result.output
4208 # With 2 commits and cap=1, truncated warning should appear.
4209 assert "capped" in result.output or "⚠️" in result.output
4210
4211 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4212 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4213 assert result.exit_code != 0
4214
4215 # ── --top validation ──────────────────────────────────────────────────────
4216
4217 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4218 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4219 assert result.exit_code != 0
4220
4221 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4222 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4223 data = json.loads(result.output)
4224 assert len(data["stable"]) <= 2
4225
4226 # ── BFS follows merge parents ─────────────────────────────────────────────
4227
4228 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4229 """Symbols touched only on a merged feature branch must be detected as unstable."""
4230 import datetime
4231
4232 # Create a symbol in commit 1 (main).
4233 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4234 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4235 assert r.exit_code == 0, r.output
4236
4237 repo_json = json.loads((repo_json_path(repo)).read_text())
4238 repo_id = repo_json["repo_id"]
4239 from muse.core.refs import read_current_branch
4240 from muse.core.commits import resolve_commit_ref
4241 branch = read_current_branch(repo)
4242 head_commit = resolve_commit_ref(repo, branch, None)
4243 assert head_commit is not None
4244 head_id = head_commit.commit_id
4245
4246 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4247 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4248
4249 # Feature-branch commit that touched "bedrock" via a structured_delta.
4250 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4251 from muse.core.ids import hash_commit as compute_commit_id
4252 bedrock_delta = StructuredDelta(
4253 domain="code",
4254 ops=[PatchOp(
4255 op="patch", address="core.py",
4256 child_ops=[ReplaceOp(
4257 op="replace", address="core.py::bedrock",
4258 old_content_id="a" * 64, new_content_id="b" * 64,
4259 old_summary="function bedrock",
4260 new_summary="function bedrock (modified)", position=None,
4261 )],
4262 child_domain="code", child_summary="bedrock modified",
4263 )],
4264 summary="bedrock modified",
4265 )
4266 feature_id = compute_commit_id(
4267 [head_id], head_commit.snapshot_id,
4268 "Feature: touch bedrock", feature_at.isoformat(),
4269 author="test",
4270 )
4271 merge_id = compute_commit_id(
4272 [head_id, feature_id], head_commit.snapshot_id,
4273 "Merge feat/touch-bedrock", merge_at.isoformat(),
4274 author="test",
4275 )
4276 feature_body: CommitDict = {
4277 "commit_id": feature_id,
4278 "repo_id": repo_id,
4279 "branch": "feat/touch-bedrock",
4280 "snapshot_id": head_commit.snapshot_id,
4281 "message": "Feature: touch bedrock",
4282 "committed_at": feature_at.isoformat(),
4283 "parent_commit_id": head_id,
4284 "parent2_commit_id": None,
4285 "author": "test",
4286 "metadata": {},
4287 "structured_delta": bedrock_delta,
4288 }
4289 # Merge commit whose parent2 is the feature commit.
4290 merge_body: CommitDict = {
4291 "commit_id": merge_id,
4292 "repo_id": repo_id,
4293 "branch": branch,
4294 "snapshot_id": head_commit.snapshot_id,
4295 "message": "Merge feat/touch-bedrock",
4296 "committed_at": merge_at.isoformat(),
4297 "parent_commit_id": head_id,
4298 "parent2_commit_id": feature_id,
4299 "author": "test",
4300 "metadata": {},
4301 "structured_delta": None,
4302 }
4303 from muse.core.commits import (
4304 CommitRecord,
4305 write_commit,
4306 )
4307 write_commit(repo, CommitRecord.from_dict(feature_body))
4308 write_commit(repo, CommitRecord.from_dict(merge_body))
4309 (ref_path(repo, branch)).write_text(merge_id)
4310
4311 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4312 assert result.exit_code == 0, result.output
4313 data = json.loads(result.output)
4314 # bedrock was touched in the feature-branch commit; BFS must find it.
4315 # It should have unchanged_for < total_commits (not maximally stable).
4316 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4317 if bedrock_entries:
4318 assert not bedrock_entries[0]["since_start_of_range"]
4319
4320
4321 # ---------------------------------------------------------------------------
4322 # muse code compare
4323 # ---------------------------------------------------------------------------
4324
4325
4326 @pytest.fixture
4327 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4328 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4329
4330 Commit A — billing.py with Invoice.compute_total + process_order.
4331 Commit B — compute_total renamed to compute_invoice_total; generate_pdf
4332 and send_email added. Multi-line message to test truncation.
4333 """
4334 (repo / "billing.py").write_text(textwrap.dedent("""\
4335 class Invoice:
4336 def compute_total(self, items):
4337 return sum(items)
4338
4339 def apply_discount(self, total, pct):
4340 return total * (1 - pct)
4341
4342 def process_order(invoice, items):
4343 return invoice.compute_total(items)
4344 """))
4345 runner.invoke(cli, ["code", "add", "billing.py"])
4346 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4347 assert r.exit_code == 0, r.output
4348 from muse.core.refs import read_current_branch
4349 branch = read_current_branch(repo)
4350 commit_a = get_head_commit_id(repo, branch)
4351
4352 (repo / "billing.py").write_text(textwrap.dedent("""\
4353 class Invoice:
4354 def compute_invoice_total(self, items):
4355 return sum(items)
4356
4357 def apply_discount(self, total, pct):
4358 return total * (1 - pct)
4359
4360 def generate_pdf(self):
4361 return b"pdf"
4362
4363 def process_order(invoice, items):
4364 return invoice.compute_invoice_total(items)
4365
4366 def send_email(address):
4367 pass
4368 """))
4369 runner.invoke(cli, ["code", "add", "billing.py"])
4370 # Multi-line message to test first-line truncation.
4371 r = runner.invoke(cli, [
4372 "commit", "-m",
4373 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4374 ])
4375 assert r.exit_code == 0, r.output
4376 commit_b = get_head_commit_id(repo, branch)
4377
4378 assert commit_a is not None
4379 assert commit_b is not None
4380 return repo, commit_a, commit_b
4381
4382
4383 class TestCompare:
4384 """Tests for muse code compare."""
4385
4386 # ── basic correctness ────────────────────────────────────────────────────
4387
4388 def test_compare_exits_zero(
4389 self, compare_repo: tuple[pathlib.Path, str, str]
4390 ) -> None:
4391 _, ref_a, ref_b = compare_repo
4392 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4393 assert result.exit_code == 0, result.output
4394
4395 def test_compare_shows_header(
4396 self, compare_repo: tuple[pathlib.Path, str, str]
4397 ) -> None:
4398 _, ref_a, ref_b = compare_repo
4399 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4400 assert result.exit_code == 0, result.output
4401 assert "Semantic comparison" in result.output
4402 assert "From:" in result.output
4403 assert "To:" in result.output
4404
4405 def test_compare_commit_message_first_line_only(
4406 self, compare_repo: tuple[pathlib.Path, str, str]
4407 ) -> None:
4408 """Multi-line commit messages must be truncated to their first line."""
4409 _, ref_a, ref_b = compare_repo
4410 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4411 assert result.exit_code == 0, result.output
4412 # The body of the second commit must not appear in the header.
4413 assert "This is the extended body" not in result.output
4414
4415 def test_compare_same_ref_no_changes(
4416 self, compare_repo: tuple[pathlib.Path, str, str]
4417 ) -> None:
4418 _, ref_a, _ = compare_repo
4419 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4420 assert result.exit_code == 0, result.output
4421 assert "no semantic changes" in result.output
4422
4423 def test_compare_detects_added_symbols(
4424 self, compare_repo: tuple[pathlib.Path, str, str]
4425 ) -> None:
4426 _, ref_a, ref_b = compare_repo
4427 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4428 assert result.exit_code == 0, result.output
4429 # generate_pdf and send_email were added in commit B.
4430 assert "generate_pdf" in result.output or "send_email" in result.output
4431
4432 def test_compare_invalid_ref_exits_nonzero(
4433 self, compare_repo: tuple[pathlib.Path, str, str]
4434 ) -> None:
4435 _, ref_a, _ = compare_repo
4436 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4437 assert result.exit_code != 0
4438
4439 def test_compare_requires_repo(
4440 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4441 ) -> None:
4442 monkeypatch.chdir(tmp_path)
4443 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4444 assert result.exit_code != 0
4445
4446 # ── JSON schema ──────────────────────────────────────────────────────────
4447
4448 def test_compare_json_schema(
4449 self, compare_repo: tuple[pathlib.Path, str, str]
4450 ) -> None:
4451 _, ref_a, ref_b = compare_repo
4452 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4453 assert result.exit_code == 0, result.output
4454 data = json.loads(result.output)
4455 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4456
4457 def test_compare_json_from_to_schema(
4458 self, compare_repo: tuple[pathlib.Path, str, str]
4459 ) -> None:
4460 _, ref_a, ref_b = compare_repo
4461 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4462 assert result.exit_code == 0, result.output
4463 data = json.loads(result.output)
4464 assert "commit_id" in data["from"]
4465 assert "message" in data["from"]
4466 assert "commit_id" in data["to"]
4467 assert "message" in data["to"]
4468
4469 def test_compare_json_message_first_line_only(
4470 self, compare_repo: tuple[pathlib.Path, str, str]
4471 ) -> None:
4472 _, ref_a, ref_b = compare_repo
4473 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4474 assert result.exit_code == 0, result.output
4475 data = json.loads(result.output)
4476 assert "\n" not in data["to"]["message"]
4477 assert "This is the extended body" not in data["to"]["message"]
4478
4479 def test_compare_json_stat_schema(
4480 self, compare_repo: tuple[pathlib.Path, str, str]
4481 ) -> None:
4482 _, ref_a, ref_b = compare_repo
4483 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4484 assert result.exit_code == 0, result.output
4485 stat = json.loads(result.output)["stat"]
4486 assert set(stat.keys()) >= {
4487 "files_changed", "symbols_added", "symbols_removed",
4488 "symbols_modified", "semver_impact",
4489 }
4490 assert isinstance(stat["files_changed"], int)
4491 assert isinstance(stat["symbols_added"], int)
4492 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4493
4494 def test_compare_json_filters_schema(
4495 self, compare_repo: tuple[pathlib.Path, str, str]
4496 ) -> None:
4497 _, ref_a, ref_b = compare_repo
4498 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4499 assert result.exit_code == 0, result.output
4500 filters = json.loads(result.output)["filters"]
4501 assert set(filters.keys()) >= {"kind", "file", "language"}
4502 # No filters applied — all None.
4503 assert filters["kind"] is None
4504 assert filters["file"] is None
4505 assert filters["language"] is None
4506
4507 def test_compare_json_ops_schema(
4508 self, compare_repo: tuple[pathlib.Path, str, str]
4509 ) -> None:
4510 _, ref_a, ref_b = compare_repo
4511 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4512 assert result.exit_code == 0, result.output
4513 ops = json.loads(result.output)["ops"]
4514 assert isinstance(ops, list)
4515 assert len(ops) > 0
4516 for op in ops:
4517 assert "op" in op
4518 assert "address" in op
4519 assert "detail" in op
4520
4521 def test_compare_same_ref_json_empty_ops(
4522 self, compare_repo: tuple[pathlib.Path, str, str]
4523 ) -> None:
4524 _, ref_a, _ = compare_repo
4525 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--json"])
4526 assert result.exit_code == 0, result.output
4527 data = json.loads(result.output)
4528 assert data["ops"] == []
4529 assert data["stat"]["semver_impact"] == "NONE"
4530
4531 # ── --stat flag ──────────────────────────────────────────────────────────
4532
4533 def test_compare_stat_shows_counts(
4534 self, compare_repo: tuple[pathlib.Path, str, str]
4535 ) -> None:
4536 _, ref_a, ref_b = compare_repo
4537 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4538 assert result.exit_code == 0, result.output
4539 assert "Files changed:" in result.output
4540 assert "Symbols added:" in result.output
4541 assert "Symbols removed:" in result.output
4542 assert "Symbols modified:" in result.output
4543 assert "SemVer impact:" in result.output
4544
4545 def test_compare_stat_no_per_symbol_listing(
4546 self, compare_repo: tuple[pathlib.Path, str, str]
4547 ) -> None:
4548 _, ref_a, ref_b = compare_repo
4549 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4550 assert result.exit_code == 0, result.output
4551 # --stat should not include per-symbol listing lines ("added …", "removed …").
4552 assert " added " not in result.output
4553 assert " removed " not in result.output
4554 assert " modified " not in result.output
4555
4556 def test_compare_stat_same_ref_semver_none(
4557 self, compare_repo: tuple[pathlib.Path, str, str]
4558 ) -> None:
4559 _, ref_a, _ = compare_repo
4560 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--stat"])
4561 assert result.exit_code == 0, result.output
4562 assert "NONE" in result.output
4563
4564 # ── --semver flag ────────────────────────────────────────────────────────
4565
4566 def test_compare_semver_appended_to_full_output(
4567 self, compare_repo: tuple[pathlib.Path, str, str]
4568 ) -> None:
4569 _, ref_a, ref_b = compare_repo
4570 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--semver"])
4571 assert result.exit_code == 0, result.output
4572 assert "SemVer impact:" in result.output
4573
4574 # ── --file filter ────────────────────────────────────────────────────────
4575
4576 def test_compare_file_filter_restricts_output(
4577 self, compare_repo: tuple[pathlib.Path, str, str]
4578 ) -> None:
4579 _, ref_a, ref_b = compare_repo
4580 result = runner.invoke(
4581 cli, ["code", "compare", ref_a, ref_b, "--file", "billing.py"]
4582 )
4583 assert result.exit_code == 0, result.output
4584
4585 def test_compare_file_filter_nonexistent_no_ops(
4586 self, compare_repo: tuple[pathlib.Path, str, str]
4587 ) -> None:
4588 _, ref_a, ref_b = compare_repo
4589 result = runner.invoke(
4590 cli, ["code", "compare", ref_a, ref_b, "--file", "nonexistent.py"]
4591 )
4592 assert result.exit_code == 0, result.output
4593 assert "no semantic changes" in result.output
4594
4595 def test_compare_file_filter_in_json(
4596 self, compare_repo: tuple[pathlib.Path, str, str]
4597 ) -> None:
4598 _, ref_a, ref_b = compare_repo
4599 result = runner.invoke(
4600 cli,
4601 ["code", "compare", ref_a, ref_b, "--file", "billing.py", "--json"],
4602 )
4603 assert result.exit_code == 0, result.output
4604 data = json.loads(result.output)
4605 assert data["filters"]["file"] == "billing.py"
4606
4607 # ── --kind filter ────────────────────────────────────────────────────────
4608
4609 def test_compare_kind_filter_case_insensitive(
4610 self, compare_repo: tuple[pathlib.Path, str, str]
4611 ) -> None:
4612 _, ref_a, ref_b = compare_repo
4613 r_lower = runner.invoke(
4614 cli, ["code", "compare", ref_a, ref_b, "--kind", "function"]
4615 )
4616 r_upper = runner.invoke(
4617 cli, ["code", "compare", ref_a, ref_b, "--kind", "Function"]
4618 )
4619 assert r_lower.exit_code == 0
4620 assert r_upper.exit_code == 0
4621 # Both produce the same ops list.
4622 assert r_lower.output == r_upper.output
4623
4624 def test_compare_kind_filter_in_json(
4625 self, compare_repo: tuple[pathlib.Path, str, str]
4626 ) -> None:
4627 _, ref_a, ref_b = compare_repo
4628 result = runner.invoke(
4629 cli, ["code", "compare", ref_a, ref_b, "--kind", "function", "--json"]
4630 )
4631 assert result.exit_code == 0, result.output
4632 data = json.loads(result.output)
4633 assert data["filters"]["kind"] == "function"
4634
4635 # ── --language filter ────────────────────────────────────────────────────
4636
4637 def test_compare_language_filter_python(
4638 self, compare_repo: tuple[pathlib.Path, str, str]
4639 ) -> None:
4640 _, ref_a, ref_b = compare_repo
4641 result = runner.invoke(
4642 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4643 )
4644 assert result.exit_code == 0, result.output
4645
4646 def test_compare_language_filter_case_insensitive(
4647 self, compare_repo: tuple[pathlib.Path, str, str]
4648 ) -> None:
4649 _, ref_a, ref_b = compare_repo
4650 r_lower = runner.invoke(
4651 cli, ["code", "compare", ref_a, ref_b, "--language", "python"]
4652 )
4653 r_upper = runner.invoke(
4654 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4655 )
4656 assert r_lower.exit_code == 0
4657 assert r_upper.exit_code == 0
4658 assert r_lower.output == r_upper.output
4659
4660 def test_compare_language_filter_in_json(
4661 self, compare_repo: tuple[pathlib.Path, str, str]
4662 ) -> None:
4663 _, ref_a, ref_b = compare_repo
4664 result = runner.invoke(
4665 cli, ["code", "compare", ref_a, ref_b, "--language", "python", "--json"]
4666 )
4667 assert result.exit_code == 0, result.output
4668 data = json.loads(result.output)
4669 assert data["filters"]["language"] == "Python"
4670
4671
4672 # ---------------------------------------------------------------------------
4673 # muse code languages
4674 # ---------------------------------------------------------------------------
4675
4676
4677 @pytest.fixture
4678 def lang_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4679 """Two-commit repo; returns (path, commit_id_a, commit_id_b).
4680
4681 Commit A — billing.py (Python) only.
4682 Commit B — billing.py extended + README.md added.
4683 """
4684 (repo / "billing.py").write_text(textwrap.dedent("""\
4685 import os
4686 import json
4687
4688 class Invoice:
4689 def compute_total(self, items: list[float]) -> float:
4690 return sum(items)
4691
4692 def process_order(invoice: Invoice, items: list[float]) -> float:
4693 return invoice.compute_total(items)
4694 """))
4695 runner.invoke(cli, ["code", "add", "billing.py"])
4696 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4697 assert r.exit_code == 0, r.output
4698 from muse.core.refs import read_current_branch
4699 branch = read_current_branch(repo)
4700 commit_a = get_head_commit_id(repo, branch)
4701
4702 (repo / "billing.py").write_text(textwrap.dedent("""\
4703 import os
4704 import json
4705
4706 class Invoice:
4707 def compute_total(self, items: list[float]) -> float:
4708 return sum(items)
4709
4710 def generate_pdf(self) -> bytes:
4711 return b"pdf"
4712
4713 def process_order(invoice: Invoice, items: list[float]) -> float:
4714 return invoice.compute_total(items)
4715
4716 def send_email(address: str) -> None:
4717 pass
4718 """))
4719 (repo / "README.md").write_text("# My Project\n\nA billing module.\n")
4720 runner.invoke(cli, ["code", "add", "."])
4721 r = runner.invoke(cli, ["commit", "-m", "Add generate_pdf, send_email, README"])
4722 assert r.exit_code == 0, r.output
4723 commit_b = get_head_commit_id(repo, branch)
4724
4725 assert commit_a is not None
4726 assert commit_b is not None
4727 return repo, commit_a, commit_b
4728
4729
4730 class TestLanguages:
4731 """Tests for muse code languages."""
4732
4733 # ── basic correctness ────────────────────────────────────────────────────
4734
4735 def test_languages_exits_zero(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4736 result = runner.invoke(cli, ["code", "languages"])
4737 assert result.exit_code == 0, result.output
4738
4739 def test_languages_shows_header(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4740 result = runner.invoke(cli, ["code", "languages"])
4741 assert result.exit_code == 0, result.output
4742 assert "Language breakdown" in result.output
4743 assert "Total" in result.output
4744
4745 def test_languages_shows_python(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4746 result = runner.invoke(cli, ["code", "languages"])
4747 assert result.exit_code == 0, result.output
4748 assert "Python" in result.output
4749
4750 def test_languages_shows_markdown(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4751 result = runner.invoke(cli, ["code", "languages"])
4752 assert result.exit_code == 0, result.output
4753 assert "Markdown" in result.output
4754
4755 def test_languages_excludes_imports_by_default(
4756 self, lang_repo: tuple[pathlib.Path, str, str]
4757 ) -> None:
4758 """Import pseudo-symbols must not inflate the count by default."""
4759 r_default = runner.invoke(cli, ["code", "languages", "--json"])
4760 assert r_default.exit_code == 0, r_default.output
4761 r_imports = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4762 assert r_imports.exit_code == 0, r_imports.output
4763
4764 class _LangEntry(TypedDict):
4765 language: str
4766 files: int
4767 symbols: int
4768 kinds: _KindsMap
4769
4770 class _LangsJson(TypedDict):
4771 languages: list[_LangEntry]
4772
4773 data_default: _LangsJson = json.loads(r_default.output)
4774 data_imports: _LangsJson = json.loads(r_imports.output)
4775
4776 def _py_syms(data: _LangsJson) -> int:
4777 for e in data["languages"]:
4778 if e["language"] == "Python":
4779 return e["symbols"]
4780 return 0
4781
4782 syms_default = _py_syms(data_default)
4783 syms_imports = _py_syms(data_imports)
4784 # With imports included the symbol count must be strictly higher.
4785 assert syms_imports > syms_default
4786
4787 def test_languages_requires_repo(
4788 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4789 ) -> None:
4790 monkeypatch.chdir(tmp_path)
4791 result = runner.invoke(cli, ["code", "languages"])
4792 assert result.exit_code != 0
4793
4794 def test_languages_invalid_commit_exits_nonzero(
4795 self, lang_repo: tuple[pathlib.Path, str, str]
4796 ) -> None:
4797 result = runner.invoke(cli, ["code", "languages", "--commit", "deadbeefdeadbeef"])
4798 assert result.exit_code != 0
4799
4800 # ── JSON schema ──────────────────────────────────────────────────────────
4801
4802 def test_languages_json_schema(
4803 self, lang_repo: tuple[pathlib.Path, str, str]
4804 ) -> None:
4805 result = runner.invoke(cli, ["code", "languages", "--json"])
4806 assert result.exit_code == 0, result.output
4807 data = json.loads(result.output)
4808 assert set(data.keys()) >= {"commit", "include_imports", "languages"}
4809
4810 def test_languages_json_commit_block(
4811 self, lang_repo: tuple[pathlib.Path, str, str]
4812 ) -> None:
4813 result = runner.invoke(cli, ["code", "languages", "--json"])
4814 assert result.exit_code == 0, result.output
4815 data = json.loads(result.output)
4816 commit = data["commit"]
4817 assert "commit_id" in commit
4818 assert "message" in commit
4819 # message is first line only — no newlines.
4820 assert "\n" not in commit["message"]
4821
4822 def test_languages_json_entry_schema(
4823 self, lang_repo: tuple[pathlib.Path, str, str]
4824 ) -> None:
4825 result = runner.invoke(cli, ["code", "languages", "--json"])
4826 assert result.exit_code == 0, result.output
4827 langs = json.loads(result.output)["languages"]
4828 assert isinstance(langs, list)
4829 assert len(langs) > 0
4830 for entry in langs:
4831 assert "language" in entry
4832 assert "files" in entry
4833 assert "symbols" in entry
4834 assert "kinds" in entry
4835 assert isinstance(entry["files"], int)
4836 assert isinstance(entry["symbols"], int)
4837 assert isinstance(entry["kinds"], dict)
4838
4839 def test_languages_json_include_imports_flag(
4840 self, lang_repo: tuple[pathlib.Path, str, str]
4841 ) -> None:
4842 result = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4843 assert result.exit_code == 0, result.output
4844 data = json.loads(result.output)
4845 assert data["include_imports"] is True
4846
4847 # ── --sort flag ──────────────────────────────────────────────────────────
4848
4849 def test_languages_sort_name(
4850 self, lang_repo: tuple[pathlib.Path, str, str]
4851 ) -> None:
4852 result = runner.invoke(cli, ["code", "languages", "--sort", "name"])
4853 assert result.exit_code == 0, result.output
4854
4855 def test_languages_sort_symbols(
4856 self, lang_repo: tuple[pathlib.Path, str, str]
4857 ) -> None:
4858 result = runner.invoke(cli, ["code", "languages", "--sort", "symbols"])
4859 assert result.exit_code == 0, result.output
4860 # Python should appear before Markdown when sorted by symbols desc.
4861 lines = result.output.splitlines()
4862 py_line = next((i for i, l in enumerate(lines) if "Python" in l), None)
4863 md_line = next((i for i, l in enumerate(lines) if "Markdown" in l), None)
4864 # Both might not exist if the repo only has Python; at least ensure no crash.
4865 assert py_line is not None
4866
4867 def test_languages_sort_files(
4868 self, lang_repo: tuple[pathlib.Path, str, str]
4869 ) -> None:
4870 result = runner.invoke(cli, ["code", "languages", "--sort", "files"])
4871 assert result.exit_code == 0, result.output
4872
4873 def test_languages_invalid_sort_exits_nonzero(
4874 self, lang_repo: tuple[pathlib.Path, str, str]
4875 ) -> None:
4876 result = runner.invoke(cli, ["code", "languages", "--sort", "bad"])
4877 assert result.exit_code != 0
4878
4879 # ── --diff flag ──────────────────────────────────────────────────────────
4880
4881 def test_languages_diff_exits_zero(
4882 self, lang_repo: tuple[pathlib.Path, str, str]
4883 ) -> None:
4884 _, commit_a, _ = lang_repo
4885 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4886 assert result.exit_code == 0, result.output
4887
4888 def test_languages_diff_shows_header(
4889 self, lang_repo: tuple[pathlib.Path, str, str]
4890 ) -> None:
4891 _, commit_a, _ = lang_repo
4892 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4893 assert result.exit_code == 0, result.output
4894 assert "Language change" in result.output
4895 assert "Net" in result.output
4896
4897 def test_languages_diff_detects_new_symbols(
4898 self, lang_repo: tuple[pathlib.Path, str, str]
4899 ) -> None:
4900 """Commit B added generate_pdf and send_email — Python symbol count must grow."""
4901 _, commit_a, _ = lang_repo
4902 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4903 assert result.exit_code == 0, result.output
4904 # Python line should show a positive delta.
4905 lines = result.output.splitlines()
4906 py_line = next((l for l in lines if "Python" in l), "")
4907 assert "+" in py_line
4908
4909 def test_languages_diff_unchanged_label(
4910 self, lang_repo: tuple[pathlib.Path, str, str]
4911 ) -> None:
4912 """Comparing a commit to itself must show all languages as unchanged."""
4913 _, _, commit_b = lang_repo
4914 result = runner.invoke(cli, ["code", "languages", "--diff", commit_b])
4915 assert result.exit_code == 0, result.output
4916 assert "unchanged" in result.output
4917
4918 def test_languages_diff_invalid_ref_exits_nonzero(
4919 self, lang_repo: tuple[pathlib.Path, str, str]
4920 ) -> None:
4921 result = runner.invoke(cli, ["code", "languages", "--diff", "deadbeefdeadbeef"])
4922 assert result.exit_code != 0
4923
4924 def test_languages_diff_json_schema(
4925 self, lang_repo: tuple[pathlib.Path, str, str]
4926 ) -> None:
4927 _, commit_a, _ = lang_repo
4928 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4929 assert result.exit_code == 0, result.output
4930 data = json.loads(result.output)
4931 assert set(data.keys()) >= {"from_commit", "to_commit", "include_imports", "diff"}
4932 assert "commit_id" in data["from_commit"]
4933 assert "message" in data["to_commit"]
4934
4935 def test_languages_diff_json_entry_schema(
4936 self, lang_repo: tuple[pathlib.Path, str, str]
4937 ) -> None:
4938 _, commit_a, _ = lang_repo
4939 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4940 assert result.exit_code == 0, result.output
4941 diff = json.loads(result.output)["diff"]
4942 assert isinstance(diff, list)
4943 assert len(diff) > 0
4944 for entry in diff:
4945 assert "language" in entry
4946 assert "delta_files" in entry
4947 assert "delta_symbols" in entry
4948 assert "files_before" in entry
4949 assert "files_after" in entry
4950 assert "symbols_before" in entry
4951 assert "symbols_after" in entry
4952 assert "status" in entry
4953 assert entry["status"] in ("added", "removed", "changed", "unchanged")
4954
4955 def test_languages_diff_json_python_delta_positive(
4956 self, lang_repo: tuple[pathlib.Path, str, str]
4957 ) -> None:
4958 _, commit_a, _ = lang_repo
4959 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4960 assert result.exit_code == 0, result.output
4961 diff = json.loads(result.output)["diff"]
4962 py = next((e for e in diff if e["language"] == "Python"), None)
4963 assert py is not None
4964 assert py["delta_symbols"] > 0
4965 assert py["status"] == "changed"
4966
4967 def test_languages_diff_json_markdown_added(
4968 self, lang_repo: tuple[pathlib.Path, str, str]
4969 ) -> None:
4970 """README.md was added in commit B — Markdown status should be 'added'."""
4971 _, commit_a, _ = lang_repo
4972 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4973 assert result.exit_code == 0, result.output
4974 diff = json.loads(result.output)["diff"]
4975 md = next((e for e in diff if e["language"] == "Markdown"), None)
4976 assert md is not None
4977 assert md["status"] == "added"
4978 assert md["files_before"] == 0
4979 assert md["files_after"] == 1
4980
4981
4982 # ---------------------------------------------------------------------------
4983 # muse code rename
4984 # ---------------------------------------------------------------------------
4985
4986
4987 @pytest.fixture
4988 def rename_repo(repo: pathlib.Path) -> pathlib.Path:
4989 """Repo with billing.py and a test file that imports and calls its symbols."""
4990 (repo / "billing.py").write_text(textwrap.dedent("""\
4991 import os
4992
4993 class Invoice:
4994 def compute_total(self, items):
4995 return sum(items)
4996
4997 def apply_discount(self, total, pct):
4998 return total * (1 - pct)
4999
5000 def process_order(invoice, items):
5001 total = compute_total(items)
5002 return total
5003 """))
5004 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5005 from billing import compute_total, Invoice
5006
5007 def test_compute_total():
5008 inv = Invoice()
5009 result = inv.compute_total([1, 2, 3])
5010 assert compute_total([1, 2, 3]) == 6
5011 """))
5012 r = runner.invoke(cli, ["commit", "-m", "Initial billing + tests"])
5013 assert r.exit_code == 0, r.output
5014 return repo
5015
5016
5017 class TestRename:
5018 """Tests for muse code rename."""
5019
5020 # ── basic correctness ────────────────────────────────────────────────────
5021
5022 def test_rename_dry_run_exits_zero(self, rename_repo: pathlib.Path) -> None:
5023 result = runner.invoke(
5024 cli,
5025 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
5026 )
5027 assert result.exit_code == 0, result.output
5028
5029 def test_rename_dry_run_shows_preview(self, rename_repo: pathlib.Path) -> None:
5030 result = runner.invoke(
5031 cli,
5032 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
5033 )
5034 assert result.exit_code == 0, result.output
5035 assert "Renaming" in result.output
5036 assert "process_order" in result.output
5037 assert "handle_order" in result.output
5038
5039 def test_rename_dry_run_does_not_write(self, rename_repo: pathlib.Path) -> None:
5040 before = (rename_repo / "billing.py").read_text()
5041 runner.invoke(
5042 cli,
5043 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
5044 )
5045 assert (rename_repo / "billing.py").read_text() == before
5046
5047 def test_rename_applies_definition(self, rename_repo: pathlib.Path) -> None:
5048 result = runner.invoke(
5049 cli,
5050 ["code", "rename", "billing.py::process_order", "handle_order",
5051 "--scope", "definition", "--yes"],
5052 )
5053 assert result.exit_code == 0, result.output
5054 content = (rename_repo / "billing.py").read_text()
5055 assert "def handle_order(" in content
5056 assert "def process_order(" not in content
5057
5058 def test_rename_only_def_token_not_string_literal(
5059 self, rename_repo: pathlib.Path
5060 ) -> None:
5061 """The rename must not touch string literals containing the old name."""
5062 # Add a docstring with the old name.
5063 billing = (rename_repo / "billing.py").read_text()
5064 billing += '\nDOC = "compute_total is a function"\n'
5065 (rename_repo / "billing.py").write_text(billing)
5066 runner.invoke(cli, ["code", "add", "billing.py"])
5067 runner.invoke(cli, ["commit", "-m", "add docstring"])
5068
5069 runner.invoke(
5070 cli,
5071 ["code", "rename", "billing.py::Invoice.compute_total",
5072 "compute_invoice_total", "--scope", "definition", "--yes"],
5073 )
5074 content = (rename_repo / "billing.py").read_text()
5075 # The string literal must be untouched.
5076 assert '"compute_total is a function"' in content
5077
5078 def test_rename_method_definition_scoped_to_class(
5079 self, rename_repo: pathlib.Path
5080 ) -> None:
5081 """billing.py::Invoice.compute_total must rename the method inside Invoice."""
5082 result = runner.invoke(
5083 cli,
5084 ["code", "rename", "billing.py::Invoice.compute_total",
5085 "compute_invoice_total", "--scope", "definition", "--yes"],
5086 )
5087 assert result.exit_code == 0, result.output
5088 content = (rename_repo / "billing.py").read_text()
5089 assert "def compute_invoice_total(self" in content
5090 # The module-level bare call in process_order stays unchanged.
5091 assert "compute_total(items)" in content
5092
5093 def test_rename_updates_import_sites(self, rename_repo: pathlib.Path) -> None:
5094 result = runner.invoke(
5095 cli,
5096 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5097 "--scope", "imports", "--yes"],
5098 )
5099 assert result.exit_code == 0, result.output
5100 content = (rename_repo / "test_billing.py").read_text()
5101 assert "compute_invoice_total" in content
5102 assert "from billing import" in content
5103
5104 def test_rename_requires_repo(
5105 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
5106 ) -> None:
5107 monkeypatch.chdir(tmp_path)
5108 result = runner.invoke(
5109 cli, ["code", "rename", "billing.py::foo", "bar", "--yes"]
5110 )
5111 assert result.exit_code != 0
5112
5113 def test_rename_rejects_same_name(self, rename_repo: pathlib.Path) -> None:
5114 result = runner.invoke(
5115 cli,
5116 ["code", "rename", "billing.py::process_order", "process_order", "--yes"],
5117 )
5118 assert result.exit_code != 0
5119
5120 def test_rename_rejects_invalid_identifier(self, rename_repo: pathlib.Path) -> None:
5121 result = runner.invoke(
5122 cli,
5123 ["code", "rename", "billing.py::process_order", "123invalid", "--yes"],
5124 )
5125 assert result.exit_code != 0
5126
5127 def test_rename_rejects_address_without_double_colon(
5128 self, rename_repo: pathlib.Path
5129 ) -> None:
5130 result = runner.invoke(
5131 cli, ["code", "rename", "billing.py", "new_name", "--yes"]
5132 )
5133 assert result.exit_code != 0
5134
5135 def test_rename_rejects_nonexistent_symbol(self, rename_repo: pathlib.Path) -> None:
5136 result = runner.invoke(
5137 cli,
5138 ["code", "rename", "billing.py::nonexistent_func", "new_name",
5139 "--scope", "definition", "--yes"],
5140 )
5141 assert result.exit_code != 0
5142
5143 def test_rename_rejects_path_traversal(self, rename_repo: pathlib.Path) -> None:
5144 result = runner.invoke(
5145 cli,
5146 ["code", "rename", "../../etc/passwd::foo", "bar", "--yes"],
5147 )
5148 assert result.exit_code != 0
5149
5150 def test_rename_rejects_dunder_without_force(self, rename_repo: pathlib.Path) -> None:
5151 result = runner.invoke(
5152 cli,
5153 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__", "--yes"],
5154 )
5155 assert result.exit_code != 0
5156
5157 def test_rename_allows_dunder_with_force(self, rename_repo: pathlib.Path) -> None:
5158 result = runner.invoke(
5159 cli,
5160 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__",
5161 "--scope", "definition", "--yes", "--force"],
5162 )
5163 assert result.exit_code == 0, result.output
5164 content = (rename_repo / "billing.py").read_text()
5165 assert "def __compute__(self" in content
5166
5167 # ── JSON output ──────────────────────────────────────────────────────────
5168
5169 def test_rename_json_schema(self, rename_repo: pathlib.Path) -> None:
5170 result = runner.invoke(
5171 cli,
5172 ["code", "rename", "billing.py::process_order", "handle_order",
5173 "--dry-run", "--json"],
5174 )
5175 assert result.exit_code == 0, result.output
5176 data = json.loads(result.output)
5177 assert set(data.keys()) >= {
5178 "from_address", "to_address", "from_name", "to_name",
5179 "scope", "dry_run", "files_to_modify", "total_edit_sites", "edit_sites",
5180 }
5181
5182 def test_rename_json_dry_run_empty_files_to_modify(
5183 self, rename_repo: pathlib.Path
5184 ) -> None:
5185 result = runner.invoke(
5186 cli,
5187 ["code", "rename", "billing.py::process_order", "handle_order",
5188 "--dry-run", "--json"],
5189 )
5190 assert result.exit_code == 0, result.output
5191 data = json.loads(result.output)
5192 assert data["dry_run"] is True
5193 assert data["files_to_modify"] == []
5194
5195 def test_rename_json_edit_site_schema(self, rename_repo: pathlib.Path) -> None:
5196 result = runner.invoke(
5197 cli,
5198 ["code", "rename", "billing.py::process_order", "handle_order",
5199 "--dry-run", "--json"],
5200 )
5201 assert result.exit_code == 0, result.output
5202 sites = json.loads(result.output)["edit_sites"]
5203 assert isinstance(sites, list)
5204 assert len(sites) > 0
5205 for site in sites:
5206 assert "file" in site
5207 assert "line" in site
5208 assert "col_start" in site
5209 assert "col_end" in site
5210 assert "kind" in site
5211 assert "context" in site
5212 assert site["kind"] in ("definition", "import", "reference")
5213 assert site["col_start"] < site["col_end"]
5214
5215 def test_rename_json_definition_site_present(self, rename_repo: pathlib.Path) -> None:
5216 result = runner.invoke(
5217 cli,
5218 ["code", "rename", "billing.py::process_order", "handle_order",
5219 "--dry-run", "--json"],
5220 )
5221 assert result.exit_code == 0, result.output
5222 sites = json.loads(result.output)["edit_sites"]
5223 def_sites = [s for s in sites if s["kind"] == "definition"]
5224 assert len(def_sites) == 1
5225 assert def_sites[0]["file"] == "billing.py"
5226 assert "process_order" in def_sites[0]["context"]
5227
5228 def test_rename_json_apply_writes_files(self, rename_repo: pathlib.Path) -> None:
5229 result = runner.invoke(
5230 cli,
5231 ["code", "rename", "billing.py::process_order", "handle_order",
5232 "--yes", "--json", "--scope", "definition"],
5233 )
5234 assert result.exit_code == 0, result.output
5235 content = (rename_repo / "billing.py").read_text()
5236 assert "def handle_order(" in content
5237
5238 # ── --scope flag ─────────────────────────────────────────────────────────
5239
5240 def test_rename_scope_definition_only(self, rename_repo: pathlib.Path) -> None:
5241 """--scope definition should only touch the def token."""
5242 runner.invoke(
5243 cli,
5244 ["code", "rename", "billing.py::process_order", "handle_order",
5245 "--scope", "definition", "--yes"],
5246 )
5247 billing = (rename_repo / "billing.py").read_text()
5248 test = (rename_repo / "test_billing.py").read_text()
5249 assert "def handle_order(" in billing
5250 # The import in test_billing.py must be untouched.
5251 assert "process_order" not in test or "import" in test
5252
5253 def test_rename_scope_imports_only(self, rename_repo: pathlib.Path) -> None:
5254 runner.invoke(
5255 cli,
5256 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5257 "--scope", "imports", "--yes"],
5258 )
5259 billing = (rename_repo / "billing.py").read_text()
5260 # The definition in billing.py must be untouched.
5261 assert "def compute_total(" in billing
5262
5263 def test_rename_json_scope_reflected(self, rename_repo: pathlib.Path) -> None:
5264 result = runner.invoke(
5265 cli,
5266 ["code", "rename", "billing.py::process_order", "handle_order",
5267 "--scope", "definition", "--dry-run", "--json"],
5268 )
5269 assert result.exit_code == 0, result.output
5270 assert json.loads(result.output)["scope"] == "definition"
5271
5272 # ── --max-files guard ────────────────────────────────────────────────────
5273
5274 def test_rename_max_files_validation(self, rename_repo: pathlib.Path) -> None:
5275 result = runner.invoke(
5276 cli,
5277 ["code", "rename", "billing.py::process_order", "handle_order",
5278 "--max-files", "0", "--dry-run"],
5279 )
5280 assert result.exit_code != 0
5281
5282 # ── edit precision ───────────────────────────────────────────────────────
5283
5284 def test_rename_preserves_surrounding_code(self, rename_repo: pathlib.Path) -> None:
5285 """Renaming process_order must not touch apply_discount or compute_total."""
5286 runner.invoke(
5287 cli,
5288 ["code", "rename", "billing.py::process_order", "handle_order",
5289 "--scope", "definition", "--yes"],
5290 )
5291 content = (rename_repo / "billing.py").read_text()
5292 assert "def apply_discount(" in content
5293 assert "def compute_total(" in content
5294
5295 def test_rename_col_precision_correct(self, rename_repo: pathlib.Path) -> None:
5296 """The definition rename must produce syntactically valid Python."""
5297 runner.invoke(
5298 cli,
5299 ["code", "rename", "billing.py::process_order", "handle_order",
5300 "--scope", "definition", "--yes"],
5301 )
5302 import ast as _ast
5303 content = (rename_repo / "billing.py").read_text()
5304 # Must parse without SyntaxError.
5305 try:
5306 _ast.parse(content)
5307 except SyntaxError as e:
5308 pytest.fail(f"Renamed file has a syntax error: {e}")
5309
5310
5311 # ---------------------------------------------------------------------------
5312 # blast-risk
5313 # ---------------------------------------------------------------------------
5314
5315
5316 @pytest.fixture
5317 def blast_repo(repo: pathlib.Path) -> pathlib.Path:
5318 """Repo with two commits: a production module and a test file.
5319
5320 billing.py defines Invoice.compute_total and process_order.
5321 test_billing.py imports and calls both — so they have at least one
5322 test caller. A second commit modifies compute_total so churn > 0.
5323 """
5324 (repo / "billing.py").write_text(textwrap.dedent("""\
5325 class Invoice:
5326 def compute_total(self, items):
5327 return sum(items)
5328
5329 def apply_discount(self, total, pct):
5330 return total * (1 - pct)
5331
5332 def process_order(invoice, items):
5333 return invoice.compute_total(items)
5334 """))
5335 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5336 from billing import Invoice, process_order
5337
5338 def test_compute_total():
5339 inv = Invoice()
5340 assert inv.compute_total([1, 2, 3]) == 6
5341
5342 def test_process_order():
5343 inv = Invoice()
5344 assert process_order(inv, [10]) == 10
5345 """))
5346 runner.invoke(cli, ["code", "add", "."])
5347 r = runner.invoke(cli, ["commit", "-m", "Add billing module and tests"])
5348 assert r.exit_code == 0, r.output
5349
5350 # Second commit: modify compute_total so churn count > 0.
5351 (repo / "billing.py").write_text(textwrap.dedent("""\
5352 class Invoice:
5353 def compute_total(self, items):
5354 # round to two decimal places
5355 return round(sum(items), 2)
5356
5357 def apply_discount(self, total, pct):
5358 return total * (1 - pct)
5359
5360 def process_order(invoice, items):
5361 return invoice.compute_total(items)
5362 """))
5363 runner.invoke(cli, ["code", "add", "billing.py"])
5364 r2 = runner.invoke(cli, ["commit", "-m", "Round compute_total result"])
5365 assert r2.exit_code == 0, r2.output
5366
5367 return repo
5368
5369
5370 class TestBlastRisk:
5371 """Tests for muse code blast-risk."""
5372
5373 # ── basic correctness ────────────────────────────────────────────────────
5374
5375 def test_blast_risk_exits_zero(self, blast_repo: pathlib.Path) -> None:
5376 result = runner.invoke(cli, ["code", "blast-risk"])
5377 assert result.exit_code == 0, result.output
5378
5379 def test_blast_risk_shows_header(self, blast_repo: pathlib.Path) -> None:
5380 result = runner.invoke(cli, ["code", "blast-risk"])
5381 assert result.exit_code == 0
5382 assert "blast-risk" in result.output
5383 assert "commits" in result.output
5384
5385 def test_blast_risk_shows_scoring_line(self, blast_repo: pathlib.Path) -> None:
5386 result = runner.invoke(cli, ["code", "blast-risk"])
5387 assert result.exit_code == 0
5388 assert "Scoring:" in result.output
5389 assert "impact" in result.output
5390 assert "churn" in result.output
5391 assert "test-gap" in result.output
5392 assert "coupling" in result.output
5393
5394 def test_blast_risk_shows_table_columns(self, blast_repo: pathlib.Path) -> None:
5395 result = runner.invoke(cli, ["code", "blast-risk"])
5396 assert result.exit_code == 0
5397 assert "RISK" in result.output
5398 assert "IMPACT" in result.output
5399 assert "CHURN" in result.output
5400 assert "TEST-GAP" in result.output
5401
5402 def test_blast_risk_lists_symbols(self, blast_repo: pathlib.Path) -> None:
5403 result = runner.invoke(cli, ["code", "blast-risk"])
5404 assert result.exit_code == 0
5405 # At least one symbol from billing.py should appear.
5406 assert "billing.py" in result.output
5407
5408 def test_blast_risk_risk_scores_in_range(self, blast_repo: pathlib.Path) -> None:
5409 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5410 assert result.exit_code == 0, result.output
5411 data = json.loads(result.output)
5412 for sym in data["symbols"]:
5413 assert 0 <= sym["risk"] <= 100
5414 assert 0 <= sym["impact_score"] <= 100
5415 assert 0 <= sym["churn_score"] <= 100
5416 assert 0 <= sym["test_gap_score"] <= 100
5417 assert 0 <= sym["coupling_score"] <= 100
5418
5419 # ── JSON schema ──────────────────────────────────────────────────────────
5420
5421 def test_blast_risk_json_top_level_keys(self, blast_repo: pathlib.Path) -> None:
5422 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5423 assert result.exit_code == 0, result.output
5424 data = json.loads(result.output)
5425 assert "ref" in data
5426 assert "commits_analysed" in data
5427 assert "truncated" in data
5428 assert "filters" in data
5429 assert "weights" in data
5430 assert "symbols" in data
5431
5432 def test_blast_risk_json_weights_sum_to_one(self, blast_repo: pathlib.Path) -> None:
5433 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5434 data = json.loads(result.output)
5435 total = sum(data["weights"].values())
5436 assert abs(total - 1.0) < 1e-6
5437
5438 def test_blast_risk_json_symbol_schema(self, blast_repo: pathlib.Path) -> None:
5439 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5440 data = json.loads(result.output)
5441 assert len(data["symbols"]) > 0
5442 sym = data["symbols"][0]
5443 for key in ("address", "kind", "file", "risk",
5444 "impact_raw", "churn_raw", "test_gap_raw",
5445 "coupling_raw", "impact_score", "churn_score",
5446 "test_gap_score", "coupling_score"):
5447 assert key in sym, f"missing key: {key}"
5448
5449 def test_blast_risk_json_sorted_by_risk_desc(self, blast_repo: pathlib.Path) -> None:
5450 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5451 data = json.loads(result.output)
5452 risks = [s["risk"] for s in data["symbols"]]
5453 assert risks == sorted(risks, reverse=True)
5454
5455 def test_blast_risk_json_no_import_pseudosymbols(self, blast_repo: pathlib.Path) -> None:
5456 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5457 data = json.loads(result.output)
5458 for sym in data["symbols"]:
5459 assert "::import::" not in sym["address"]
5460
5461 def test_blast_risk_json_filters_reflected(self, blast_repo: pathlib.Path) -> None:
5462 result = runner.invoke(
5463 cli, ["code", "blast-risk", "--json", "--kind", "function", "--min-risk", "10"]
5464 )
5465 data = json.loads(result.output)
5466 assert data["filters"]["kind"] == "function"
5467 assert data["filters"]["min_risk"] == 10
5468
5469 # ── --top flag ───────────────────────────────────────────────────────────
5470
5471 def test_blast_risk_top_limits_output(self, blast_repo: pathlib.Path) -> None:
5472 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--top", "2"])
5473 data = json.loads(result.output)
5474 assert len(data["symbols"]) <= 2
5475
5476 def test_blast_risk_top_validation(self, blast_repo: pathlib.Path) -> None:
5477 result = runner.invoke(cli, ["code", "blast-risk", "--top", "0"])
5478 assert result.exit_code != 0
5479
5480 # ── --kind filter ────────────────────────────────────────────────────────
5481
5482 def test_blast_risk_kind_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5483 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "class"])
5484 data = json.loads(result.output)
5485 for sym in data["symbols"]:
5486 assert sym["kind"] == "class"
5487
5488 def test_blast_risk_kind_filter_function(self, blast_repo: pathlib.Path) -> None:
5489 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "function"])
5490 assert result.exit_code == 0
5491 data = json.loads(result.output)
5492 for sym in data["symbols"]:
5493 assert sym["kind"] in ("function", "method")
5494
5495 # ── --file filter ────────────────────────────────────────────────────────
5496
5497 def test_blast_risk_file_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5498 result = runner.invoke(
5499 cli, ["code", "blast-risk", "--json", "--file", "billing.py"]
5500 )
5501 data = json.loads(result.output)
5502 for sym in data["symbols"]:
5503 assert "billing.py" in sym["file"]
5504
5505 def test_blast_risk_file_filter_nonexistent_returns_empty(
5506 self, blast_repo: pathlib.Path
5507 ) -> None:
5508 result = runner.invoke(
5509 cli, ["code", "blast-risk", "--json", "--file", "no_such_file.py"]
5510 )
5511 assert result.exit_code == 0
5512 data = json.loads(result.output)
5513 assert data["symbols"] == []
5514
5515 # ── --min-risk filter ────────────────────────────────────────────────────
5516
5517 def test_blast_risk_min_risk_filters(self, blast_repo: pathlib.Path) -> None:
5518 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "80"])
5519 data = json.loads(result.output)
5520 for sym in data["symbols"]:
5521 assert sym["risk"] >= 80
5522
5523 def test_blast_risk_min_risk_100_all_excluded(self, blast_repo: pathlib.Path) -> None:
5524 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "100"])
5525 assert result.exit_code == 0
5526
5527 def test_blast_risk_min_risk_validation(self, blast_repo: pathlib.Path) -> None:
5528 result = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "101"])
5529 assert result.exit_code != 0
5530 result2 = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "-1"])
5531 assert result2.exit_code != 0
5532
5533 # ── --explain flag ───────────────────────────────────────────────────────
5534
5535 def test_blast_risk_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
5536 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5537 data = json.loads(result.output)
5538 if not data["symbols"]:
5539 pytest.skip("no symbols")
5540 addr = data["symbols"][0]["address"]
5541 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5542 assert result2.exit_code == 0, result2.output
5543
5544 def test_blast_risk_explain_shows_breakdown(self, blast_repo: pathlib.Path) -> None:
5545 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5546 data = json.loads(result.output)
5547 if not data["symbols"]:
5548 pytest.skip("no symbols")
5549 addr = data["symbols"][0]["address"]
5550 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5551 assert "Risk score:" in result2.output
5552 assert "Impact" in result2.output
5553 assert "Churn" in result2.output
5554 assert "Test gap" in result2.output
5555 assert "Coupling" in result2.output
5556
5557 def test_blast_risk_explain_nonexistent_errors(self, blast_repo: pathlib.Path) -> None:
5558 result = runner.invoke(
5559 cli, ["code", "blast-risk", "--explain", "no_file.py::no_symbol"]
5560 )
5561 assert result.exit_code != 0
5562
5563 def test_blast_risk_explain_json(self, blast_repo: pathlib.Path) -> None:
5564 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5565 data = json.loads(result.output)
5566 if not data["symbols"]:
5567 pytest.skip("no symbols")
5568 addr = data["symbols"][0]["address"]
5569 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr, "--json"])
5570 assert result2.exit_code == 0, result2.output
5571 detail = json.loads(result2.output)
5572 assert detail["address"] == addr
5573 assert "risk" in detail
5574
5575 # ── --max-commits ────────────────────────────────────────────────────────
5576
5577 def test_blast_risk_max_commits_validation(self, blast_repo: pathlib.Path) -> None:
5578 result = runner.invoke(cli, ["code", "blast-risk", "--max-commits", "0"])
5579 assert result.exit_code != 0
5580
5581 def test_blast_risk_max_commits_respected(self, blast_repo: pathlib.Path) -> None:
5582 # With max-commits=1, commits_analysed <= 1.
5583 result = runner.invoke(
5584 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5585 )
5586 assert result.exit_code == 0
5587 data = json.loads(result.output)
5588 assert data["commits_analysed"] <= 1
5589
5590 def test_blast_risk_max_commits_truncated_flag(self, blast_repo: pathlib.Path) -> None:
5591 result = runner.invoke(
5592 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5593 )
5594 data = json.loads(result.output)
5595 # Two commits exist so truncated should be True with cap=1.
5596 assert isinstance(data["truncated"], bool)
5597
5598 # ── --since ──────────────────────────────────────────────────────────────
5599
5600 def test_blast_risk_since_invalid_ref(self, blast_repo: pathlib.Path) -> None:
5601 result = runner.invoke(cli, ["code", "blast-risk", "--since", "nonexistent_ref"])
5602 assert result.exit_code != 0
5603
5604 # ── requires repo ────────────────────────────────────────────────────────
5605
5606 def test_blast_risk_requires_repo(self, tmp_path: pathlib.Path) -> None:
5607 import os
5608 old = os.getcwd()
5609 try:
5610 os.chdir(tmp_path)
5611 result = runner.invoke(cli, ["code", "blast-risk"])
5612 assert result.exit_code != 0
5613 finally:
5614 os.chdir(old)
5615
5616
5617 # ---------------------------------------------------------------------------
5618 # velocity
5619 # ---------------------------------------------------------------------------
5620
5621
5622 @pytest.fixture
5623 def velocity_repo(repo: pathlib.Path) -> pathlib.Path:
5624 """Repo with two modules across several commits to exercise velocity metrics.
5625
5626 Module layout:
5627 core/store.py — grows across commits (inserts)
5628 shrink/util.py — has a delete later (net negative at some point)
5629
5630 Commit structure (window=2):
5631 1: create core/store.py with 2 functions
5632 2: add a third function to core/store.py → current window: +3 added
5633 3: add shrink/util.py with one function → also in current window
5634 4: delete the function in shrink/util.py → shrink net = 0 (1 added, 1 deleted)
5635 """
5636 (repo / "core").mkdir(exist_ok=True)
5637 (repo / "shrink").mkdir(exist_ok=True)
5638
5639 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5640 def read_object(path):
5641 return path.read_bytes()
5642
5643 def write_object(path, data):
5644 path.write_bytes(data)
5645 """))
5646 runner.invoke(cli, ["code", "add", "."])
5647 r = runner.invoke(cli, ["commit", "-m", "core: initial store"])
5648 assert r.exit_code == 0, r.output
5649
5650 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5651 def read_object(path):
5652 return path.read_bytes()
5653
5654 def write_object(path, data):
5655 path.write_bytes(data)
5656
5657 def delete_object(path):
5658 path.unlink()
5659 """))
5660 runner.invoke(cli, ["code", "add", "."])
5661 r2 = runner.invoke(cli, ["commit", "-m", "core: add delete_object"])
5662 assert r2.exit_code == 0, r2.output
5663
5664 (repo / "shrink" / "util.py").write_text(textwrap.dedent("""\
5665 def helper():
5666 return True
5667 """))
5668 runner.invoke(cli, ["code", "add", "."])
5669 r3 = runner.invoke(cli, ["commit", "-m", "shrink: add helper"])
5670 assert r3.exit_code == 0, r3.output
5671
5672 return repo
5673
5674
5675 class TestVelocity:
5676 """Tests for muse code velocity."""
5677
5678 # ── basic correctness ────────────────────────────────────────────────────
5679
5680 def test_velocity_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5681 result = runner.invoke(cli, ["code", "velocity"])
5682 assert result.exit_code == 0, result.output
5683
5684 def test_velocity_shows_header(self, velocity_repo: pathlib.Path) -> None:
5685 result = runner.invoke(cli, ["code", "velocity"])
5686 assert "velocity" in result.output.lower()
5687
5688 def test_velocity_shows_column_headers(self, velocity_repo: pathlib.Path) -> None:
5689 result = runner.invoke(cli, ["code", "velocity"])
5690 assert "ADD" in result.output
5691 assert "NET" in result.output
5692
5693 def test_velocity_shows_modules(self, velocity_repo: pathlib.Path) -> None:
5694 result = runner.invoke(cli, ["code", "velocity"])
5695 # Both modules should appear.
5696 assert "core/" in result.output or "store" in result.output
5697
5698 # ── JSON schema ──────────────────────────────────────────────────────────
5699
5700 def test_velocity_json_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5701 result = runner.invoke(cli, ["code", "velocity", "--json"])
5702 assert result.exit_code == 0, result.output
5703 json.loads(result.output)
5704
5705 def test_velocity_json_top_level_keys(self, velocity_repo: pathlib.Path) -> None:
5706 result = runner.invoke(cli, ["code", "velocity", "--json"])
5707 data = json.loads(result.output)
5708 for key in (
5709 "ref", "window_size", "commits_analysed", "truncated",
5710 "filters", "modules", "predictions",
5711 ):
5712 assert key in data, f"missing key: {key}"
5713
5714 def test_velocity_json_module_schema(self, velocity_repo: pathlib.Path) -> None:
5715 result = runner.invoke(cli, ["code", "velocity", "--json"])
5716 data = json.loads(result.output)
5717 if not data["modules"]:
5718 pytest.skip("no modules")
5719 mod = data["modules"][0]
5720 for key in ("module", "current", "prior", "acceleration", "stagnant_commits"):
5721 assert key in mod, f"missing key: {key}"
5722 for key in ("added", "removed", "net", "modified", "active_commits"):
5723 assert key in mod["current"], f"missing current key: {key}"
5724 assert key in mod["prior"], f"missing prior key: {key}"
5725
5726 def test_velocity_json_acceleration_is_net_delta(
5727 self, velocity_repo: pathlib.Path
5728 ) -> None:
5729 result = runner.invoke(cli, ["code", "velocity", "--json"])
5730 data = json.loads(result.output)
5731 for mod in data["modules"]:
5732 expected = mod["current"]["net"] - mod["prior"]["net"]
5733 assert mod["acceleration"] == expected
5734
5735 def test_velocity_json_filters_reflected(self, velocity_repo: pathlib.Path) -> None:
5736 result = runner.invoke(
5737 cli, ["code", "velocity", "--json", "--window", "5", "--top", "3"]
5738 )
5739 data = json.loads(result.output)
5740 assert data["window_size"] == 5
5741 assert data["filters"]["top"] == 3
5742
5743 def test_velocity_json_no_import_pseudosymbols_in_counts(
5744 self, velocity_repo: pathlib.Path
5745 ) -> None:
5746 # Modules should not be "(root)" due to import pseudo-symbols
5747 # (import:: addresses should be filtered out).
5748 result = runner.invoke(cli, ["code", "velocity", "--json"])
5749 data = json.loads(result.output)
5750 # We can't assert 0 imports in the module list, but we can assert
5751 # that '::import::' doesn't appear as a module name.
5752 for mod in data["modules"]:
5753 assert "import" not in mod["module"].lower() or "/" in mod["module"]
5754
5755 # ── --window ─────────────────────────────────────────────────────────────
5756
5757 def test_velocity_window_1_runs(self, velocity_repo: pathlib.Path) -> None:
5758 result = runner.invoke(cli, ["code", "velocity", "--window", "1"])
5759 assert result.exit_code == 0, result.output
5760
5761 def test_velocity_window_validation(self, velocity_repo: pathlib.Path) -> None:
5762 result = runner.invoke(cli, ["code", "velocity", "--window", "0"])
5763 assert result.exit_code != 0
5764
5765 def test_velocity_window_reflected_in_json(
5766 self, velocity_repo: pathlib.Path
5767 ) -> None:
5768 result = runner.invoke(cli, ["code", "velocity", "--json", "--window", "1"])
5769 data = json.loads(result.output)
5770 assert data["window_size"] == 1
5771
5772 # ── --top ─────────────────────────────────────────────────────────────────
5773
5774 def test_velocity_top_limits(self, velocity_repo: pathlib.Path) -> None:
5775 result = runner.invoke(cli, ["code", "velocity", "--json", "--top", "1"])
5776 data = json.loads(result.output)
5777 assert len(data["modules"]) <= 1
5778
5779 def test_velocity_top_validation(self, velocity_repo: pathlib.Path) -> None:
5780 result = runner.invoke(cli, ["code", "velocity", "--top", "0"])
5781 assert result.exit_code != 0
5782
5783 # ── --predict ─────────────────────────────────────────────────────────────
5784
5785 def test_velocity_predict_0_empty(self, velocity_repo: pathlib.Path) -> None:
5786 result = runner.invoke(cli, ["code", "velocity", "--json", "--predict", "0"])
5787 data = json.loads(result.output)
5788 assert data["predictions"] == []
5789
5790 def test_velocity_predict_returns_results(self, velocity_repo: pathlib.Path) -> None:
5791 result = runner.invoke(
5792 cli, ["code", "velocity", "--json", "--predict", "5"]
5793 )
5794 data = json.loads(result.output)
5795 # There are symbols in the window so predictions should be non-empty.
5796 assert isinstance(data["predictions"], list)
5797 if data["predictions"]:
5798 pred = data["predictions"][0]
5799 for key in ("address", "module", "score", "frequency", "last_commit_rank"):
5800 assert key in pred, f"missing key: {key}"
5801
5802 def test_velocity_predict_scores_descending(
5803 self, velocity_repo: pathlib.Path
5804 ) -> None:
5805 result = runner.invoke(
5806 cli, ["code", "velocity", "--json", "--predict", "10"]
5807 )
5808 data = json.loads(result.output)
5809 scores = [p["score"] for p in data["predictions"]]
5810 assert scores == sorted(scores, reverse=True)
5811
5812 def test_velocity_predict_validation(self, velocity_repo: pathlib.Path) -> None:
5813 result = runner.invoke(cli, ["code", "velocity", "--predict", "-1"])
5814 assert result.exit_code != 0
5815
5816 def test_velocity_predict_shown_in_human_output(
5817 self, velocity_repo: pathlib.Path
5818 ) -> None:
5819 result = runner.invoke(
5820 cli, ["code", "velocity", "--predict", "3"]
5821 )
5822 assert result.exit_code == 0
5823 if "predictions" in result.output.lower() or "score" in result.output:
5824 # Just check it doesn't crash.
5825 pass
5826
5827 # ── --max-commits ─────────────────────────────────────────────────────────
5828
5829 def test_velocity_max_commits_validation(self, velocity_repo: pathlib.Path) -> None:
5830 result = runner.invoke(cli, ["code", "velocity", "--max-commits", "0"])
5831 assert result.exit_code != 0
5832
5833 def test_velocity_max_commits_respected(self, velocity_repo: pathlib.Path) -> None:
5834 # With --window 1 and --max-commits 1, effective_max = max(1, 1*2) = 2.
5835 # The 3-commit repo should be capped at 2 commits analysed.
5836 result = runner.invoke(
5837 cli, ["code", "velocity", "--json", "--window", "1", "--max-commits", "1"]
5838 )
5839 assert result.exit_code == 0
5840 data = json.loads(result.output)
5841 assert data["commits_analysed"] <= 2
5842
5843 # ── --since ───────────────────────────────────────────────────────────────
5844
5845 def test_velocity_since_invalid_ref(self, velocity_repo: pathlib.Path) -> None:
5846 result = runner.invoke(cli, ["code", "velocity", "--since", "bad_ref"])
5847 assert result.exit_code != 0
5848
5849 # ── stagnation detection ──────────────────────────────────────────────────
5850
5851 def test_velocity_stagnant_commits_non_negative(
5852 self, velocity_repo: pathlib.Path
5853 ) -> None:
5854 result = runner.invoke(cli, ["code", "velocity", "--json"])
5855 data = json.loads(result.output)
5856 for mod in data["modules"]:
5857 assert mod["stagnant_commits"] >= 0
5858
5859 # ── net counts are consistent ─────────────────────────────────────────────
5860
5861 def test_velocity_net_equals_added_minus_removed(
5862 self, velocity_repo: pathlib.Path
5863 ) -> None:
5864 result = runner.invoke(cli, ["code", "velocity", "--json"])
5865 data = json.loads(result.output)
5866 for mod in data["modules"]:
5867 assert mod["current"]["net"] == (
5868 mod["current"]["added"] - mod["current"]["removed"]
5869 )
5870 assert mod["prior"]["net"] == (
5871 mod["prior"]["added"] - mod["prior"]["removed"]
5872 )
5873
5874 # ── requires repo ─────────────────────────────────────────────────────────
5875
5876 def test_velocity_requires_repo(self, tmp_path: pathlib.Path) -> None:
5877 import os
5878 old = os.getcwd()
5879 try:
5880 os.chdir(tmp_path)
5881 result = runner.invoke(cli, ["code", "velocity"])
5882 assert result.exit_code != 0
5883 finally:
5884 os.chdir(old)
5885
5886
5887 # ---------------------------------------------------------------------------
5888 # age
5889 # ---------------------------------------------------------------------------
5890
5891
5892 @pytest.fixture
5893 def age_repo(repo: pathlib.Path) -> pathlib.Path:
5894 """Repo with several commits to exercise evolutionary-age metrics.
5895
5896 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
5897 Commit 2: modify compute_total body → 1 impl change
5898 Commit 3: modify compute_total body → 2 impl changes
5899 Commit 4: modify compute_total signature only (add type hint)
5900
5901 stable_fn is created in commit 1 and never touched again.
5902 """
5903 (repo / "billing.py").write_text(textwrap.dedent("""\
5904 class Invoice:
5905 def compute_total(self, items):
5906 return sum(items)
5907
5908 def stable_fn():
5909 return 42
5910 """))
5911 runner.invoke(cli, ["code", "add", "billing.py"])
5912 r = runner.invoke(cli, ["commit", "-m", "initial billing"])
5913 assert r.exit_code == 0, r.output
5914
5915 # Commit 2: impl change to compute_total
5916 (repo / "billing.py").write_text(textwrap.dedent("""\
5917 class Invoice:
5918 def compute_total(self, items):
5919 return round(sum(items), 2)
5920
5921 def stable_fn():
5922 return 42
5923 """))
5924 runner.invoke(cli, ["code", "add", "billing.py"])
5925 r2 = runner.invoke(cli, ["commit", "-m", "round result"])
5926 assert r2.exit_code == 0, r2.output
5927
5928 # Commit 3: second impl change to compute_total
5929 (repo / "billing.py").write_text(textwrap.dedent("""\
5930 class Invoice:
5931 def compute_total(self, items):
5932 total = sum(items)
5933 return round(total, 4)
5934
5935 def stable_fn():
5936 return 42
5937 """))
5938 runner.invoke(cli, ["code", "add", "billing.py"])
5939 r3 = runner.invoke(cli, ["commit", "-m", "higher precision"])
5940 assert r3.exit_code == 0, r3.output
5941
5942 return repo
5943
5944
5945 class TestAge:
5946 """Tests for muse code age."""
5947
5948 # ── basic correctness ────────────────────────────────────────────────────
5949
5950 def test_age_exits_zero(self, age_repo: pathlib.Path) -> None:
5951 result = runner.invoke(cli, ["code", "age"])
5952 assert result.exit_code == 0, result.output
5953
5954 def test_age_shows_header(self, age_repo: pathlib.Path) -> None:
5955 result = runner.invoke(cli, ["code", "age"])
5956 assert "evolutionary age" in result.output.lower()
5957
5958 def test_age_shows_sort_line(self, age_repo: pathlib.Path) -> None:
5959 result = runner.invoke(cli, ["code", "age"])
5960 assert "Sorted by" in result.output
5961
5962 def test_age_shows_table_columns(self, age_repo: pathlib.Path) -> None:
5963 result = runner.invoke(cli, ["code", "age"])
5964 assert "BORN" in result.output
5965 assert "REWRITES" in result.output
5966 assert "GENETIC" in result.output
5967
5968 def test_age_lists_symbols(self, age_repo: pathlib.Path) -> None:
5969 result = runner.invoke(cli, ["code", "age"])
5970 assert "billing.py" in result.output
5971
5972 # ── JSON schema ──────────────────────────────────────────────────────────
5973
5974 def test_age_json_exits_zero(self, age_repo: pathlib.Path) -> None:
5975 result = runner.invoke(cli, ["code", "age", "--json"])
5976 assert result.exit_code == 0, result.output
5977 json.loads(result.output)
5978
5979 def test_age_json_top_level_keys(self, age_repo: pathlib.Path) -> None:
5980 result = runner.invoke(cli, ["code", "age", "--json"])
5981 data = json.loads(result.output)
5982 for key in ("ref", "as_of", "commits_analysed", "truncated", "filters", "symbols"):
5983 assert key in data, f"missing key: {key}"
5984
5985 def test_age_json_symbol_schema(self, age_repo: pathlib.Path) -> None:
5986 result = runner.invoke(cli, ["code", "age", "--json"])
5987 data = json.loads(result.output)
5988 if not data["symbols"]:
5989 pytest.skip("no symbols with history")
5990 sym = data["symbols"][0]
5991 for key in (
5992 "address", "kind", "file",
5993 "born_commit", "born_date",
5994 "last_impl_commit", "last_impl_date",
5995 "last_change_commit", "last_change_date",
5996 "calendar_age_days", "genetic_age_days",
5997 "impl_changes", "sig_changes", "renames", "est_survival_pct",
5998 ):
5999 assert key in sym, f"missing key: {key}"
6000
6001 def test_age_json_survival_pct_in_range(self, age_repo: pathlib.Path) -> None:
6002 result = runner.invoke(cli, ["code", "age", "--json"])
6003 data = json.loads(result.output)
6004 for sym in data["symbols"]:
6005 assert 0 <= sym["est_survival_pct"] <= 100
6006
6007 def test_age_json_filters_reflected(self, age_repo: pathlib.Path) -> None:
6008 result = runner.invoke(
6009 cli, ["code", "age", "--json", "--sort", "calendar", "--kind", "function"]
6010 )
6011 data = json.loads(result.output)
6012 assert data["filters"]["sort"] == "calendar"
6013 assert data["filters"]["kind"] == "function"
6014
6015 def test_age_json_no_import_pseudosymbols(self, age_repo: pathlib.Path) -> None:
6016 result = runner.invoke(cli, ["code", "age", "--json"])
6017 data = json.loads(result.output)
6018 for sym in data["symbols"]:
6019 assert "::import::" not in sym["address"]
6020
6021 # ── impl_changes recorded correctly ─────────────────────────────────────
6022
6023 def test_age_compute_total_has_impl_changes(self, age_repo: pathlib.Path) -> None:
6024 """compute_total was modified twice — should have impl_changes >= 1."""
6025 result = runner.invoke(cli, ["code", "age", "--json"])
6026 data = json.loads(result.output)
6027 totals = [
6028 s for s in data["symbols"]
6029 if "compute_total" in s["address"]
6030 ]
6031 # If history was recorded, impl_changes should be positive.
6032 if totals:
6033 assert totals[0]["impl_changes"] >= 0 # at least recorded
6034
6035 def test_age_stable_fn_lower_impl_changes(self, age_repo: pathlib.Path) -> None:
6036 """stable_fn was never modified — should have 0 impl_changes."""
6037 result = runner.invoke(cli, ["code", "age", "--json"])
6038 data = json.loads(result.output)
6039 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
6040 if stables:
6041 assert stables[0]["impl_changes"] == 0
6042
6043 def test_age_stable_fn_100pct_survival(self, age_repo: pathlib.Path) -> None:
6044 result = runner.invoke(cli, ["code", "age", "--json"])
6045 data = json.loads(result.output)
6046 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
6047 if stables:
6048 assert stables[0]["est_survival_pct"] == 100
6049
6050 # ── --top ────────────────────────────────────────────────────────────────
6051
6052 def test_age_top_limits(self, age_repo: pathlib.Path) -> None:
6053 result = runner.invoke(cli, ["code", "age", "--json", "--top", "1"])
6054 data = json.loads(result.output)
6055 assert len(data["symbols"]) <= 1
6056
6057 def test_age_top_validation(self, age_repo: pathlib.Path) -> None:
6058 result = runner.invoke(cli, ["code", "age", "--top", "0"])
6059 assert result.exit_code != 0
6060
6061 # ── --sort ───────────────────────────────────────────────────────────────
6062
6063 def test_age_sort_rewrites(self, age_repo: pathlib.Path) -> None:
6064 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "rewrites"])
6065 assert result.exit_code == 0, result.output
6066 data = json.loads(result.output)
6067 impl_counts = [s["impl_changes"] for s in data["symbols"]]
6068 assert impl_counts == sorted(impl_counts, reverse=True)
6069
6070 def test_age_sort_calendar(self, age_repo: pathlib.Path) -> None:
6071 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "calendar"])
6072 assert result.exit_code == 0, result.output
6073 data = json.loads(result.output)
6074 ages = [s["calendar_age_days"] for s in data["symbols"]]
6075 assert ages == sorted(ages, reverse=True)
6076
6077 def test_age_sort_genetic(self, age_repo: pathlib.Path) -> None:
6078 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "genetic"])
6079 assert result.exit_code == 0, result.output
6080 data = json.loads(result.output)
6081 ages = [s["genetic_age_days"] for s in data["symbols"]]
6082 assert ages == sorted(ages, reverse=True)
6083
6084 def test_age_sort_survival(self, age_repo: pathlib.Path) -> None:
6085 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "survival"])
6086 assert result.exit_code == 0, result.output
6087 data = json.loads(result.output)
6088 survivals = [s["est_survival_pct"] for s in data["symbols"]]
6089 assert survivals == sorted(survivals)
6090
6091 def test_age_sort_invalid(self, age_repo: pathlib.Path) -> None:
6092 result = runner.invoke(cli, ["code", "age", "--sort", "bogus"])
6093 assert result.exit_code != 0
6094
6095 # ── --kind filter ────────────────────────────────────────────────────────
6096
6097 def test_age_kind_filter(self, age_repo: pathlib.Path) -> None:
6098 result = runner.invoke(cli, ["code", "age", "--json", "--kind", "function"])
6099 data = json.loads(result.output)
6100 for sym in data["symbols"]:
6101 assert sym["kind"] in ("function", "method")
6102
6103 # ── --file filter ─────────────────────────────────────────────────────────
6104
6105 def test_age_file_filter(self, age_repo: pathlib.Path) -> None:
6106 result = runner.invoke(
6107 cli, ["code", "age", "--json", "--file", "billing.py"]
6108 )
6109 data = json.loads(result.output)
6110 for sym in data["symbols"]:
6111 assert "billing.py" in sym["file"]
6112
6113 def test_age_file_filter_nonexistent(self, age_repo: pathlib.Path) -> None:
6114 result = runner.invoke(
6115 cli, ["code", "age", "--json", "--file", "no_such_file.py"]
6116 )
6117 assert result.exit_code == 0
6118 data = json.loads(result.output)
6119 assert data["symbols"] == []
6120
6121 # ── --explain ─────────────────────────────────────────────────────────────
6122
6123 def test_age_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
6124 result = runner.invoke(cli, ["code", "age", "--json"])
6125 data = json.loads(result.output)
6126 if not data["symbols"]:
6127 pytest.skip("no symbols")
6128 addr = data["symbols"][0]["address"]
6129 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6130 assert r2.exit_code == 0, r2.output
6131
6132 def test_age_explain_shows_breakdown(self, age_repo: pathlib.Path) -> None:
6133 result = runner.invoke(cli, ["code", "age", "--json"])
6134 data = json.loads(result.output)
6135 if not data["symbols"]:
6136 pytest.skip("no symbols")
6137 addr = data["symbols"][0]["address"]
6138 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
6139 assert "Implementation changes" in r2.output
6140 assert "Signature changes" in r2.output
6141 assert "Est. survival" in r2.output
6142
6143 def test_age_explain_requires_double_colon(self, age_repo: pathlib.Path) -> None:
6144 result = runner.invoke(cli, ["code", "age", "--explain", "billing.py"])
6145 assert result.exit_code != 0
6146
6147 def test_age_explain_nonexistent_errors(self, age_repo: pathlib.Path) -> None:
6148 result = runner.invoke(cli, ["code", "age", "--explain", "no.py::nonexistent"])
6149 assert result.exit_code != 0
6150
6151 def test_age_explain_json(self, age_repo: pathlib.Path) -> None:
6152 result = runner.invoke(cli, ["code", "age", "--json"])
6153 data = json.loads(result.output)
6154 if not data["symbols"]:
6155 pytest.skip("no symbols")
6156 addr = data["symbols"][0]["address"]
6157 r2 = runner.invoke(cli, ["code", "age", "--explain", addr, "--json"])
6158 assert r2.exit_code == 0, r2.output
6159 detail = json.loads(r2.output)
6160 assert detail["address"] == addr
6161 assert "events" in detail
6162
6163 # ── --max-commits ─────────────────────────────────────────────────────────
6164
6165 def test_age_max_commits_validation(self, age_repo: pathlib.Path) -> None:
6166 result = runner.invoke(cli, ["code", "age", "--max-commits", "0"])
6167 assert result.exit_code != 0
6168
6169 def test_age_max_commits_respected(self, age_repo: pathlib.Path) -> None:
6170 result = runner.invoke(cli, ["code", "age", "--json", "--max-commits", "1"])
6171 assert result.exit_code == 0
6172 data = json.loads(result.output)
6173 assert data["commits_analysed"] <= 1
6174
6175 # ── --since ───────────────────────────────────────────────────────────────
6176
6177 def test_age_since_invalid_ref(self, age_repo: pathlib.Path) -> None:
6178 result = runner.invoke(cli, ["code", "age", "--since", "bad_ref"])
6179 assert result.exit_code != 0
6180
6181 # ── requires repo ─────────────────────────────────────────────────────────
6182
6183 def test_age_requires_repo(self, tmp_path: pathlib.Path) -> None:
6184 import os
6185 old = os.getcwd()
6186 try:
6187 os.chdir(tmp_path)
6188 result = runner.invoke(cli, ["code", "age"])
6189 assert result.exit_code != 0
6190 finally:
6191 os.chdir(old)
6192
6193
6194 # ---------------------------------------------------------------------------
6195 # entangle
6196 # ---------------------------------------------------------------------------
6197
6198
6199 @pytest.fixture
6200 def entangle_repo(repo: pathlib.Path) -> pathlib.Path:
6201 """Repo that has two files with no import link but symbols that co-change.
6202
6203 Commit 1: create billing.py (Invoice class) and serializers.py (to_json).
6204 Commit 2: modify Invoice.compute_total AND to_json together — they
6205 co-change with no import link.
6206 Commit 3: same again — both change again.
6207
6208 billing.py does NOT import serializers.py, so the pair should be
6209 flagged as entangled.
6210 """
6211 (repo / "billing.py").write_text(textwrap.dedent("""\
6212 class Invoice:
6213 def compute_total(self, items):
6214 return sum(items)
6215 """))
6216 (repo / "serializers.py").write_text(textwrap.dedent("""\
6217 def to_json(obj):
6218 return str(obj)
6219 """))
6220 runner.invoke(cli, ["code", "add", "."])
6221 r = runner.invoke(cli, ["commit", "-m", "initial"])
6222 assert r.exit_code == 0, r.output
6223
6224 # Commit 2: both change.
6225 (repo / "billing.py").write_text(textwrap.dedent("""\
6226 class Invoice:
6227 def compute_total(self, items):
6228 return round(sum(items), 2)
6229 """))
6230 (repo / "serializers.py").write_text(textwrap.dedent("""\
6231 def to_json(obj):
6232 import json
6233 return json.dumps(obj)
6234 """))
6235 runner.invoke(cli, ["code", "add", "."])
6236 r2 = runner.invoke(cli, ["commit", "-m", "update both"])
6237 assert r2.exit_code == 0, r2.output
6238
6239 # Commit 3: both change again.
6240 (repo / "billing.py").write_text(textwrap.dedent("""\
6241 class Invoice:
6242 def compute_total(self, items):
6243 return round(sum(items), 4)
6244 """))
6245 (repo / "serializers.py").write_text(textwrap.dedent("""\
6246 def to_json(obj):
6247 import json
6248 return json.dumps(obj, indent=2)
6249 """))
6250 runner.invoke(cli, ["code", "add", "."])
6251 r3 = runner.invoke(cli, ["commit", "-m", "tweak both again"])
6252 assert r3.exit_code == 0, r3.output
6253
6254 return repo
6255
6256
6257 class TestEntangle:
6258 """Tests for muse code entangle."""
6259
6260 # ── basic correctness ────────────────────────────────────────────────────
6261
6262 def test_entangle_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6263 result = runner.invoke(cli, ["code", "entangle"])
6264 assert result.exit_code == 0, result.output
6265
6266 def test_entangle_shows_header(self, entangle_repo: pathlib.Path) -> None:
6267 result = runner.invoke(cli, ["code", "entangle"])
6268 assert result.exit_code == 0
6269 assert "entanglement" in result.output.lower()
6270
6271 def test_entangle_detects_unlinked_pair(self, entangle_repo: pathlib.Path) -> None:
6272 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6273 assert result.exit_code == 0
6274 # Both files should appear in the output.
6275 assert "billing.py" in result.output or "serializers.py" in result.output
6276
6277 def test_entangle_shows_rate(self, entangle_repo: pathlib.Path) -> None:
6278 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6279 assert result.exit_code == 0
6280 # Rate column should show a percentage.
6281 assert "%" in result.output
6282
6283 # ── JSON schema ──────────────────────────────────────────────────────────
6284
6285 def test_entangle_json_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6286 result = runner.invoke(cli, ["code", "entangle", "--json"])
6287 assert result.exit_code == 0, result.output
6288 json.loads(result.output) # must be valid JSON
6289
6290 def test_entangle_json_top_level_keys(self, entangle_repo: pathlib.Path) -> None:
6291 result = runner.invoke(cli, ["code", "entangle", "--json"])
6292 data = json.loads(result.output)
6293 for key in ("ref", "commits_analysed", "truncated", "filters", "pairs"):
6294 assert key in data, f"missing key: {key}"
6295
6296 def test_entangle_json_pair_schema(self, entangle_repo: pathlib.Path) -> None:
6297 result = runner.invoke(
6298 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6299 )
6300 data = json.loads(result.output)
6301 if not data["pairs"]:
6302 pytest.skip("no pairs detected")
6303 pair = data["pairs"][0]
6304 for key in (
6305 "symbol_a", "symbol_b", "file_a", "file_b", "same_file",
6306 "structurally_linked", "co_changes", "commits_both_active",
6307 "co_change_rate", "a_in_test", "b_in_test",
6308 ):
6309 assert key in pair, f"missing key: {key}"
6310
6311 def test_entangle_json_co_change_rate_in_range(
6312 self, entangle_repo: pathlib.Path
6313 ) -> None:
6314 result = runner.invoke(
6315 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6316 )
6317 data = json.loads(result.output)
6318 for pair in data["pairs"]:
6319 assert 0.0 <= pair["co_change_rate"] <= 1.0
6320
6321 def test_entangle_json_filters_reflected(
6322 self, entangle_repo: pathlib.Path
6323 ) -> None:
6324 result = runner.invoke(
6325 cli, ["code", "entangle", "--json", "--min-co-changes", "3", "--min-rate", "0.5"]
6326 )
6327 data = json.loads(result.output)
6328 assert data["filters"]["min_co_changes"] == 3
6329 assert data["filters"]["min_rate"] == 0.5
6330
6331 def test_entangle_json_sorted_by_rate_desc(
6332 self, entangle_repo: pathlib.Path
6333 ) -> None:
6334 result = runner.invoke(
6335 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6336 )
6337 data = json.loads(result.output)
6338 rates = [p["co_change_rate"] for p in data["pairs"]]
6339 assert rates == sorted(rates, reverse=True)
6340
6341 # ── --top ────────────────────────────────────────────────────────────────
6342
6343 def test_entangle_top_limits(self, entangle_repo: pathlib.Path) -> None:
6344 result = runner.invoke(
6345 cli, ["code", "entangle", "--json", "--top", "1", "--min-co-changes", "1"]
6346 )
6347 data = json.loads(result.output)
6348 assert len(data["pairs"]) <= 1
6349
6350 def test_entangle_top_validation(self, entangle_repo: pathlib.Path) -> None:
6351 result = runner.invoke(cli, ["code", "entangle", "--top", "0"])
6352 assert result.exit_code != 0
6353
6354 # ── --min-co-changes ─────────────────────────────────────────────────────
6355
6356 def test_entangle_min_co_changes_filters(
6357 self, entangle_repo: pathlib.Path
6358 ) -> None:
6359 result = runner.invoke(
6360 cli, ["code", "entangle", "--json", "--min-co-changes", "100"]
6361 )
6362 data = json.loads(result.output)
6363 # No pair can have co-changed 100 times in a 3-commit repo.
6364 assert data["pairs"] == []
6365
6366 def test_entangle_min_co_changes_validation(
6367 self, entangle_repo: pathlib.Path
6368 ) -> None:
6369 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "0"])
6370 assert result.exit_code != 0
6371
6372 # ── --min-rate ───────────────────────────────────────────────────────────
6373
6374 def test_entangle_min_rate_1_may_return_results(
6375 self, entangle_repo: pathlib.Path
6376 ) -> None:
6377 result = runner.invoke(
6378 cli, ["code", "entangle", "--json", "--min-rate", "1.0", "--min-co-changes", "1"]
6379 )
6380 assert result.exit_code == 0
6381 data = json.loads(result.output)
6382 for pair in data["pairs"]:
6383 assert pair["co_change_rate"] == 1.0
6384
6385 def test_entangle_min_rate_validation(self, entangle_repo: pathlib.Path) -> None:
6386 result = runner.invoke(cli, ["code", "entangle", "--min-rate", "1.5"])
6387 assert result.exit_code != 0
6388 result2 = runner.invoke(cli, ["code", "entangle", "--min-rate", "-0.1"])
6389 assert result2.exit_code != 0
6390
6391 # ── --symbol filter ──────────────────────────────────────────────────────
6392
6393 def test_entangle_symbol_requires_double_colon(
6394 self, entangle_repo: pathlib.Path
6395 ) -> None:
6396 result = runner.invoke(cli, ["code", "entangle", "--symbol", "billing.py"])
6397 assert result.exit_code != 0
6398
6399 def test_entangle_symbol_exits_zero_valid(
6400 self, entangle_repo: pathlib.Path
6401 ) -> None:
6402 result = runner.invoke(
6403 cli,
6404 ["code", "entangle", "--symbol", "billing.py::Invoice",
6405 "--min-co-changes", "1"],
6406 )
6407 assert result.exit_code == 0, result.output
6408
6409 def test_entangle_symbol_filters_pairs(
6410 self, entangle_repo: pathlib.Path
6411 ) -> None:
6412 result = runner.invoke(
6413 cli,
6414 ["code", "entangle", "--json", "--symbol", "billing.py::Invoice",
6415 "--min-co-changes", "1"],
6416 )
6417 data = json.loads(result.output)
6418 for pair in data["pairs"]:
6419 assert (
6420 "billing.py" in pair["symbol_a"]
6421 or "billing.py" in pair["symbol_b"]
6422 )
6423
6424 # ── --include-same-file ──────────────────────────────────────────────────
6425
6426 def test_entangle_include_same_file_flag(
6427 self, entangle_repo: pathlib.Path
6428 ) -> None:
6429 # Should not crash, and may return same-file pairs.
6430 result = runner.invoke(
6431 cli,
6432 ["code", "entangle", "--json", "--include-same-file",
6433 "--min-co-changes", "1"],
6434 )
6435 assert result.exit_code == 0, result.output
6436 data = json.loads(result.output)
6437 assert data["filters"]["include_same_file"] is True
6438
6439 # ── --max-commits ─────────────────────────────────────────────────────────
6440
6441 def test_entangle_max_commits_validation(
6442 self, entangle_repo: pathlib.Path
6443 ) -> None:
6444 result = runner.invoke(cli, ["code", "entangle", "--max-commits", "0"])
6445 assert result.exit_code != 0
6446
6447 def test_entangle_max_commits_respected(
6448 self, entangle_repo: pathlib.Path
6449 ) -> None:
6450 result = runner.invoke(
6451 cli, ["code", "entangle", "--json", "--max-commits", "1"]
6452 )
6453 assert result.exit_code == 0
6454 data = json.loads(result.output)
6455 assert data["commits_analysed"] <= 1
6456
6457 # ── --since ───────────────────────────────────────────────────────────────
6458
6459 def test_entangle_since_invalid_ref(self, entangle_repo: pathlib.Path) -> None:
6460 result = runner.invoke(cli, ["code", "entangle", "--since", "no_such_ref"])
6461 assert result.exit_code != 0
6462
6463 # ── requires repo ─────────────────────────────────────────────────────────
6464
6465 def test_entangle_requires_repo(self, tmp_path: pathlib.Path) -> None:
6466 import os
6467 old = os.getcwd()
6468 try:
6469 os.chdir(tmp_path)
6470 result = runner.invoke(cli, ["code", "entangle"])
6471 assert result.exit_code != 0
6472 finally:
6473 os.chdir(old)
6474
6475
6476 # ---------------------------------------------------------------------------
6477 # muse code semantic-test-coverage
6478 # ---------------------------------------------------------------------------
6479
6480
6481 @pytest.fixture
6482 def stc_repo(repo: pathlib.Path) -> pathlib.Path:
6483 """Repo with production code and a test file for semantic-test-coverage.
6484
6485 Layout::
6486
6487 billing.py — compute_total (function), Invoice (class),
6488 Invoice.apply_discount (method),
6489 Invoice.generate_pdf (method) ← never called by tests
6490 services.py — process_order (calls compute_total transitively)
6491 tests/test_billing.py — test_compute_total, test_apply_discount,
6492 test_process_order (direct calls)
6493
6494 Direct coverage expected:
6495 compute_total ← test_compute_total, test_process_order (via bare name)
6496 Invoice ← test_compute_total (instantiation)
6497 apply_discount ← test_apply_discount
6498 generate_pdf ← NOT covered
6499 process_order ← test_process_order
6500
6501 Transitive (depth 2) additionally covers:
6502 compute_total ← test_process_order (because process_order calls it)
6503 """
6504 (repo / "tests").mkdir(exist_ok=True)
6505
6506 (repo / "billing.py").write_text(textwrap.dedent("""\
6507 class Invoice:
6508 def apply_discount(self, rate):
6509 return self.total * (1 - rate)
6510
6511 def generate_pdf(self):
6512 return b"PDF"
6513
6514 def compute_total(items):
6515 return sum(i["price"] for i in items)
6516 """))
6517
6518 (repo / "services.py").write_text(textwrap.dedent("""\
6519 from billing import compute_total
6520
6521 def process_order(order):
6522 return compute_total(order["items"])
6523 """))
6524
6525 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
6526 from billing import compute_total, Invoice
6527 from services import process_order
6528
6529 def test_compute_total():
6530 inv = Invoice()
6531 assert compute_total([{"price": 10}]) == 10
6532
6533 def test_apply_discount():
6534 inv = Invoice()
6535 inv.total = 100
6536 assert inv.apply_discount(0.1) == 90
6537
6538 def test_process_order():
6539 result = process_order({"items": [{"price": 5}]})
6540 assert result == 5
6541 """))
6542
6543 runner.invoke(cli, ["code", "add", "."])
6544 r = runner.invoke(cli, ["commit", "-m", "stc: initial repo"])
6545 assert r.exit_code == 0, r.output
6546 return repo
6547
6548
6549 class TestSemanticTestCoverage:
6550 """Tests for ``muse code semantic-test-coverage``."""
6551
6552 CMD = ["code", "semantic-test-coverage"]
6553
6554 # ── basic correctness ────────────────────────────────────────────────────
6555
6556 def test_stc_exits_zero(self, stc_repo: pathlib.Path) -> None:
6557 result = runner.invoke(cli, self.CMD)
6558 assert result.exit_code == 0, result.output
6559
6560 def test_stc_shows_header(self, stc_repo: pathlib.Path) -> None:
6561 result = runner.invoke(cli, self.CMD)
6562 assert "Semantic test coverage" in result.output
6563 assert "HEAD" in result.output
6564
6565 def test_stc_shows_test_function_count(self, stc_repo: pathlib.Path) -> None:
6566 result = runner.invoke(cli, self.CMD)
6567 # 3 test functions in the repo
6568 assert "test functions" in result.output
6569
6570 def test_stc_shows_total_line(self, stc_repo: pathlib.Path) -> None:
6571 result = runner.invoke(cli, self.CMD)
6572 assert "TOTAL:" in result.output
6573
6574 def test_stc_covered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6575 result = runner.invoke(cli, self.CMD)
6576 assert "compute_total" in result.output
6577
6578 def test_stc_uncovered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6579 result = runner.invoke(cli, self.CMD)
6580 assert "generate_pdf" in result.output
6581
6582 def test_stc_covered_has_check_icon(self, stc_repo: pathlib.Path) -> None:
6583 result = runner.invoke(cli, self.CMD)
6584 assert "✅" in result.output
6585
6586 def test_stc_uncovered_has_cross_icon(self, stc_repo: pathlib.Path) -> None:
6587 result = runner.invoke(cli, self.CMD)
6588 assert "❌" in result.output
6589
6590 # ── JSON output ──────────────────────────────────────────────────────────
6591
6592 def test_stc_json_exits_zero(self, stc_repo: pathlib.Path) -> None:
6593 result = runner.invoke(cli, self.CMD + ["--json"])
6594 assert result.exit_code == 0, result.output
6595
6596 def test_stc_json_is_valid(self, stc_repo: pathlib.Path) -> None:
6597 result = runner.invoke(cli, self.CMD + ["--json"])
6598 data = json.loads(result.output)
6599 assert isinstance(data, dict)
6600
6601 def test_stc_json_top_level_keys(self, stc_repo: pathlib.Path) -> None:
6602 result = runner.invoke(cli, self.CMD + ["--json"])
6603 data = json.loads(result.output)
6604 for key in ("ref", "snapshot_id", "depth", "transitive", "filters",
6605 "summary", "files"):
6606 assert key in data, f"missing key: {key}"
6607
6608 def test_stc_json_ref_is_head(self, stc_repo: pathlib.Path) -> None:
6609 result = runner.invoke(cli, self.CMD + ["--json"])
6610 data = json.loads(result.output)
6611 assert data["ref"] == "HEAD"
6612
6613 def test_stc_json_depth_default(self, stc_repo: pathlib.Path) -> None:
6614 result = runner.invoke(cli, self.CMD + ["--json"])
6615 data = json.loads(result.output)
6616 assert data["depth"] == 1
6617
6618 def test_stc_json_transitive_default_false(self, stc_repo: pathlib.Path) -> None:
6619 result = runner.invoke(cli, self.CMD + ["--json"])
6620 data = json.loads(result.output)
6621 assert data["transitive"] is False
6622
6623 def test_stc_json_summary_schema(self, stc_repo: pathlib.Path) -> None:
6624 result = runner.invoke(cli, self.CMD + ["--json"])
6625 data = json.loads(result.output)
6626 summary = data["summary"]
6627 for key in ("total_symbols", "covered_symbols", "uncovered_symbols",
6628 "coverage_pct", "total_test_functions", "total_production_files"):
6629 assert key in summary, f"summary missing: {key}"
6630
6631 def test_stc_json_summary_counts_consistent(self, stc_repo: pathlib.Path) -> None:
6632 result = runner.invoke(cli, self.CMD + ["--json"])
6633 data = json.loads(result.output)
6634 s = data["summary"]
6635 assert s["covered_symbols"] + s["uncovered_symbols"] == s["total_symbols"]
6636
6637 def test_stc_json_summary_test_fn_count(self, stc_repo: pathlib.Path) -> None:
6638 result = runner.invoke(cli, self.CMD + ["--json"])
6639 data = json.loads(result.output)
6640 # 3 test functions: test_compute_total, test_apply_discount, test_process_order
6641 assert data["summary"]["total_test_functions"] >= 3
6642
6643 def test_stc_json_file_schema(self, stc_repo: pathlib.Path) -> None:
6644 result = runner.invoke(cli, self.CMD + ["--json"])
6645 data = json.loads(result.output)
6646 assert len(data["files"]) > 0
6647 fc = data["files"][0]
6648 for key in ("file", "total_symbols", "covered_symbols",
6649 "uncovered_symbols", "coverage_pct", "symbols"):
6650 assert key in fc, f"file record missing: {key}"
6651
6652 def test_stc_json_symbol_schema(self, stc_repo: pathlib.Path) -> None:
6653 result = runner.invoke(cli, self.CMD + ["--json"])
6654 data = json.loads(result.output)
6655 # Find a file with at least one symbol
6656 sym = data["files"][0]["symbols"][0]
6657 for key in ("address", "name", "kind", "covered", "test_functions"):
6658 assert key in sym, f"symbol record missing: {key}"
6659
6660 def test_stc_json_covered_symbol_has_test_functions(
6661 self, stc_repo: pathlib.Path
6662 ) -> None:
6663 result = runner.invoke(cli, self.CMD + ["--json"])
6664 data = json.loads(result.output)
6665 covered = [
6666 sym
6667 for fc in data["files"]
6668 for sym in fc["symbols"]
6669 if sym["covered"]
6670 ]
6671 assert covered, "expected at least one covered symbol"
6672 assert any(len(sym["test_functions"]) > 0 for sym in covered)
6673
6674 def test_stc_json_uncovered_symbol_empty_test_fns(
6675 self, stc_repo: pathlib.Path
6676 ) -> None:
6677 result = runner.invoke(cli, self.CMD + ["--json"])
6678 data = json.loads(result.output)
6679 uncovered = [
6680 sym
6681 for fc in data["files"]
6682 for sym in fc["symbols"]
6683 if not sym["covered"]
6684 ]
6685 assert uncovered, "expected generate_pdf to be uncovered"
6686 assert all(sym["test_functions"] == [] for sym in uncovered)
6687
6688 def test_stc_json_generate_pdf_uncovered(self, stc_repo: pathlib.Path) -> None:
6689 result = runner.invoke(cli, self.CMD + ["--json"])
6690 data = json.loads(result.output)
6691 found = next(
6692 (
6693 sym
6694 for fc in data["files"]
6695 for sym in fc["symbols"]
6696 if sym["name"] == "generate_pdf"
6697 ),
6698 None,
6699 )
6700 assert found is not None, "generate_pdf symbol not found"
6701 assert found["covered"] is False
6702
6703 def test_stc_json_compute_total_covered(self, stc_repo: pathlib.Path) -> None:
6704 result = runner.invoke(cli, self.CMD + ["--json"])
6705 data = json.loads(result.output)
6706 found = next(
6707 (
6708 sym
6709 for fc in data["files"]
6710 for sym in fc["symbols"]
6711 if sym["name"] == "compute_total"
6712 ),
6713 None,
6714 )
6715 assert found is not None
6716 assert found["covered"] is True
6717
6718 def test_stc_json_coverage_pct_between_0_and_100(
6719 self, stc_repo: pathlib.Path
6720 ) -> None:
6721 result = runner.invoke(cli, self.CMD + ["--json"])
6722 data = json.loads(result.output)
6723 for fc in data["files"]:
6724 assert 0.0 <= fc["coverage_pct"] <= 100.0
6725
6726 def test_stc_json_filter_reflected(self, stc_repo: pathlib.Path) -> None:
6727 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6728 data = json.loads(result.output)
6729 assert data["filters"]["kind"] == "method"
6730
6731 def test_stc_json_no_import_pseudosymbols(self, stc_repo: pathlib.Path) -> None:
6732 result = runner.invoke(cli, self.CMD + ["--json"])
6733 data = json.loads(result.output)
6734 for fc in data["files"]:
6735 for sym in fc["symbols"]:
6736 assert sym["kind"] != "import"
6737
6738 # ── --file filter ────────────────────────────────────────────────────────
6739
6740 def test_stc_file_filter_scopes_output(self, stc_repo: pathlib.Path) -> None:
6741 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6742 data = json.loads(result.output)
6743 for fc in data["files"]:
6744 assert "billing.py" in fc["file"]
6745
6746 def test_stc_file_filter_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6747 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6748 data = json.loads(result.output)
6749 assert data["filters"]["file"] == "billing.py"
6750
6751 def test_stc_file_filter_billing_has_generate_pdf(
6752 self, stc_repo: pathlib.Path
6753 ) -> None:
6754 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6755 data = json.loads(result.output)
6756 names = [
6757 sym["name"] for fc in data["files"] for sym in fc["symbols"]
6758 ]
6759 assert "generate_pdf" in names
6760
6761 # ── --kind filter ────────────────────────────────────────────────────────
6762
6763 def test_stc_kind_method_only_methods(self, stc_repo: pathlib.Path) -> None:
6764 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6765 data = json.loads(result.output)
6766 for fc in data["files"]:
6767 for sym in fc["symbols"]:
6768 assert sym["kind"] == "method"
6769
6770 def test_stc_kind_function_only_functions(self, stc_repo: pathlib.Path) -> None:
6771 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function"])
6772 data = json.loads(result.output)
6773 for fc in data["files"]:
6774 for sym in fc["symbols"]:
6775 assert sym["kind"] == "function"
6776
6777 def test_stc_kind_invalid_rejected(self, stc_repo: pathlib.Path) -> None:
6778 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6779 assert result.exit_code != 0
6780
6781 # ── --uncovered-only ─────────────────────────────────────────────────────
6782
6783 def test_stc_uncovered_only_exits_zero(self, stc_repo: pathlib.Path) -> None:
6784 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6785 assert result.exit_code == 0, result.output
6786
6787 def test_stc_uncovered_only_hides_covered(self, stc_repo: pathlib.Path) -> None:
6788 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6789 # generate_pdf should appear; compute_total should not appear
6790 assert "generate_pdf" in result.output
6791
6792 def test_stc_uncovered_only_json_symbols_all_uncovered(
6793 self, stc_repo: pathlib.Path
6794 ) -> None:
6795 result = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6796 data = json.loads(result.output)
6797 for fc in data["files"]:
6798 for sym in fc["symbols"]:
6799 assert sym["covered"] is False
6800
6801 def test_stc_uncovered_only_json_stats_still_full(
6802 self, stc_repo: pathlib.Path
6803 ) -> None:
6804 result_all = runner.invoke(cli, self.CMD + ["--json"])
6805 result_uncov = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6806 data_all = json.loads(result_all.output)
6807 data_uncov = json.loads(result_uncov.output)
6808 # Total symbol count should be the same (stats reflect full picture)
6809 assert (
6810 data_all["summary"]["total_symbols"]
6811 == data_uncov["summary"]["total_symbols"]
6812 )
6813
6814 # ── --show-tests ─────────────────────────────────────────────────────────
6815
6816 def test_stc_show_tests_exits_zero(self, stc_repo: pathlib.Path) -> None:
6817 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6818 assert result.exit_code == 0, result.output
6819
6820 def test_stc_show_tests_lists_test_addr(self, stc_repo: pathlib.Path) -> None:
6821 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6822 # Should include a ← prefix followed by a test address
6823 assert "←" in result.output
6824
6825 def test_stc_show_tests_references_test_file(
6826 self, stc_repo: pathlib.Path
6827 ) -> None:
6828 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6829 assert "test_billing" in result.output
6830
6831 # ── --transitive / --depth ───────────────────────────────────────────────
6832
6833 def test_stc_transitive_exits_zero(self, stc_repo: pathlib.Path) -> None:
6834 result = runner.invoke(cli, self.CMD + ["--transitive"])
6835 assert result.exit_code == 0, result.output
6836
6837 def test_stc_transitive_json_flag_true(self, stc_repo: pathlib.Path) -> None:
6838 result = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6839 data = json.loads(result.output)
6840 assert data["transitive"] is True
6841
6842 def test_stc_depth_2_implies_transitive(self, stc_repo: pathlib.Path) -> None:
6843 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
6844 data = json.loads(result.output)
6845 assert data["transitive"] is True
6846 assert data["depth"] == 2
6847
6848 def test_stc_depth_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6849 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "3"])
6850 data = json.loads(result.output)
6851 assert data["depth"] == 3
6852
6853 def test_stc_transitive_does_not_reduce_coverage(
6854 self, stc_repo: pathlib.Path
6855 ) -> None:
6856 result_direct = runner.invoke(cli, self.CMD + ["--json"])
6857 result_trans = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6858 data_direct = json.loads(result_direct.output)
6859 data_trans = json.loads(result_trans.output)
6860 # Transitive coverage must be >= direct coverage
6861 assert (
6862 data_trans["summary"]["covered_symbols"]
6863 >= data_direct["summary"]["covered_symbols"]
6864 )
6865
6866 def test_stc_depth_0_invalid(self, stc_repo: pathlib.Path) -> None:
6867 result = runner.invoke(cli, self.CMD + ["--depth", "0"])
6868 assert result.exit_code != 0
6869
6870 def test_stc_depth_exceeds_max_invalid(self, stc_repo: pathlib.Path) -> None:
6871 result = runner.invoke(cli, self.CMD + ["--depth", "11"])
6872 assert result.exit_code != 0
6873
6874 # ── --min-coverage ───────────────────────────────────────────────────────
6875
6876 def test_stc_min_coverage_0_exits_zero(self, stc_repo: pathlib.Path) -> None:
6877 result = runner.invoke(cli, self.CMD + ["--min-coverage", "0"])
6878 assert result.exit_code == 0, result.output
6879
6880 def test_stc_min_coverage_100_exits_nonzero(self, stc_repo: pathlib.Path) -> None:
6881 # generate_pdf is never covered, so 100% is unachievable.
6882 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6883 assert result.exit_code != 0
6884
6885 def test_stc_min_coverage_shows_warning(self, stc_repo: pathlib.Path) -> None:
6886 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6887 assert "⚠️" in result.output or "below" in result.output.lower()
6888
6889 def test_stc_min_coverage_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6890 result = runner.invoke(cli, self.CMD + ["--json", "--min-coverage", "80"])
6891 data = json.loads(result.output)
6892 assert data["filters"]["min_coverage"] == 80
6893
6894 def test_stc_min_coverage_none_when_0(self, stc_repo: pathlib.Path) -> None:
6895 result = runner.invoke(cli, self.CMD + ["--json"])
6896 data = json.loads(result.output)
6897 assert data["filters"]["min_coverage"] is None
6898
6899 def test_stc_min_coverage_invalid_over_100(self, stc_repo: pathlib.Path) -> None:
6900 result = runner.invoke(cli, self.CMD + ["--min-coverage", "101"])
6901 assert result.exit_code != 0
6902
6903 def test_stc_min_coverage_invalid_negative(self, stc_repo: pathlib.Path) -> None:
6904 result = runner.invoke(cli, self.CMD + ["--min-coverage", "-1"])
6905 assert result.exit_code != 0
6906
6907 # ── test-file exclusion ──────────────────────────────────────────────────
6908
6909 def test_stc_test_files_not_in_production_symbols(
6910 self, stc_repo: pathlib.Path
6911 ) -> None:
6912 result = runner.invoke(cli, self.CMD + ["--json"])
6913 data = json.loads(result.output)
6914 for fc in data["files"]:
6915 assert "test_" not in pathlib.PurePosixPath(fc["file"]).name.split(".")[0][:5] or \
6916 not fc["file"].startswith("tests/"), \
6917 f"test file appeared in production symbols: {fc['file']}"
6918
6919 def test_stc_no_test_file_in_prod_files(self, stc_repo: pathlib.Path) -> None:
6920 result = runner.invoke(cli, self.CMD + ["--json"])
6921 data = json.loads(result.output)
6922 for fc in data["files"]:
6923 assert "tests/" not in fc["file"] or fc["file"].startswith("tests/") is False, \
6924 fc["file"]
6925
6926 # ── requires repo ────────────────────────────────────────────────────────
6927
6928 def test_stc_requires_repo(self, tmp_path: pathlib.Path) -> None:
6929 import os
6930 old = os.getcwd()
6931 try:
6932 os.chdir(tmp_path)
6933 result = runner.invoke(cli, self.CMD)
6934 assert result.exit_code != 0
6935 finally:
6936 os.chdir(old)
6937
6938 # ── empty repo ───────────────────────────────────────────────────────────
6939
6940 def test_stc_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
6941 """An empty repo (no commits yet) should not crash."""
6942 # The base repo fixture has no commits — must handle gracefully.
6943 # First commit something minimal so HEAD exists.
6944 (repo / "empty.py").write_text("")
6945 r = runner.invoke(cli, ["commit", "-m", "seed"])
6946 if r.exit_code != 0:
6947 pytest.skip("could not create initial commit")
6948 result = runner.invoke(cli, self.CMD)
6949 assert result.exit_code == 0, result.output
6950
6951
6952 # ---------------------------------------------------------------------------
6953 # muse code gravity
6954 # ---------------------------------------------------------------------------
6955
6956
6957 @pytest.fixture
6958 def gravity_repo(repo: pathlib.Path) -> pathlib.Path:
6959 """Repo whose call graph creates a clear gravity hierarchy.
6960
6961 Layout::
6962
6963 core.py — read_object (called by everything)
6964 mid.py — process (calls read_object)
6965 top.py — handle (calls process, which calls read_object)
6966 leaf.py — leaf_fn (calls handle)
6967
6968 Expected gravity (transitive dependents):
6969 read_object: 3 (process, handle, leaf_fn) → high gravity
6970 process: 2 (handle, leaf_fn)
6971 handle: 1 (leaf_fn)
6972 leaf_fn: 0 → lowest gravity
6973 """
6974 (repo / "core.py").write_text(textwrap.dedent("""\
6975 def read_object(path):
6976 return path.read_bytes()
6977 """))
6978 runner.invoke(cli, ["code", "add", "core.py"])
6979 r1 = runner.invoke(cli, ["commit", "-m", "core: add read_object"])
6980 assert r1.exit_code == 0, r1.output
6981
6982 (repo / "mid.py").write_text(textwrap.dedent("""\
6983 from core import read_object
6984
6985 def process(path):
6986 return read_object(path)
6987 """))
6988 runner.invoke(cli, ["code", "add", "mid.py"])
6989 r2 = runner.invoke(cli, ["commit", "-m", "mid: add process"])
6990 assert r2.exit_code == 0, r2.output
6991
6992 (repo / "top.py").write_text(textwrap.dedent("""\
6993 from mid import process
6994
6995 def handle(path):
6996 return process(path)
6997 """))
6998 runner.invoke(cli, ["code", "add", "top.py"])
6999 r3 = runner.invoke(cli, ["commit", "-m", "top: add handle"])
7000 assert r3.exit_code == 0, r3.output
7001
7002 (repo / "leaf.py").write_text(textwrap.dedent("""\
7003 from top import handle
7004
7005 def leaf_fn(path):
7006 return handle(path)
7007 """))
7008 runner.invoke(cli, ["code", "add", "leaf.py"])
7009 r4 = runner.invoke(cli, ["commit", "-m", "leaf: add leaf_fn"])
7010 assert r4.exit_code == 0, r4.output
7011
7012 return repo
7013
7014
7015 class TestGravity:
7016 """Tests for ``muse code gravity``."""
7017
7018 CMD = ["code", "gravity"]
7019
7020 # ── basic correctness ─────────────────────────────────────────────────────
7021
7022 def test_gravity_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7023 result = runner.invoke(cli, self.CMD)
7024 assert result.exit_code == 0, result.output
7025
7026 def test_gravity_shows_header(self, gravity_repo: pathlib.Path) -> None:
7027 result = runner.invoke(cli, self.CMD)
7028 assert "Symbol gravity" in result.output
7029
7030 def test_gravity_shows_head(self, gravity_repo: pathlib.Path) -> None:
7031 result = runner.invoke(cli, self.CMD)
7032 assert "HEAD" in result.output
7033
7034 def test_gravity_shows_column_headers(self, gravity_repo: pathlib.Path) -> None:
7035 result = runner.invoke(cli, self.CMD)
7036 assert "GRAVITY" in result.output
7037 assert "DIRECT" in result.output
7038 assert "DEPTH" in result.output
7039
7040 def test_gravity_shows_symbols(self, gravity_repo: pathlib.Path) -> None:
7041 result = runner.invoke(cli, self.CMD)
7042 # At least one symbol should appear.
7043 assert "read_object" in result.output or "process" in result.output
7044
7045 def test_gravity_shows_percentage(self, gravity_repo: pathlib.Path) -> None:
7046 result = runner.invoke(cli, self.CMD)
7047 assert "%" in result.output
7048
7049 # ── --top ─────────────────────────────────────────────────────────────────
7050
7051 def test_gravity_top_limits_output(self, gravity_repo: pathlib.Path) -> None:
7052 result1 = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
7053 result3 = runner.invoke(cli, self.CMD + ["--json", "--top", "3"])
7054 data1 = json.loads(result1.output)
7055 data3 = json.loads(result3.output)
7056 assert len(data1["symbols"]) <= 1
7057 assert len(data3["symbols"]) <= 3
7058
7059 def test_gravity_top_0_returns_all(self, gravity_repo: pathlib.Path) -> None:
7060 result_all = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7061 result_lim = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
7062 data_all = json.loads(result_all.output)
7063 data_lim = json.loads(result_lim.output)
7064 assert len(data_all["symbols"]) >= len(data_lim["symbols"])
7065
7066 def test_gravity_top_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7067 result = runner.invoke(cli, self.CMD + ["--top", "-1"])
7068 assert result.exit_code != 0
7069
7070 # ── --sort ────────────────────────────────────────────────────────────────
7071
7072 def test_gravity_sort_gravity_default(self, gravity_repo: pathlib.Path) -> None:
7073 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7074 data = json.loads(result.output)
7075 if len(data["symbols"]) >= 2:
7076 pcts = [s["gravity_pct"] for s in data["symbols"]]
7077 assert pcts == sorted(pcts, reverse=True)
7078
7079 def test_gravity_sort_direct(self, gravity_repo: pathlib.Path) -> None:
7080 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "direct", "--top", "0"])
7081 data = json.loads(result.output)
7082 assert result.exit_code == 0
7083 if len(data["symbols"]) >= 2:
7084 directs = [s["direct_dependents"] for s in data["symbols"]]
7085 assert directs == sorted(directs, reverse=True)
7086
7087 def test_gravity_sort_depth(self, gravity_repo: pathlib.Path) -> None:
7088 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "depth", "--top", "0"])
7089 data = json.loads(result.output)
7090 assert result.exit_code == 0
7091 if len(data["symbols"]) >= 2:
7092 depths = [s["max_depth"] for s in data["symbols"]]
7093 assert depths == sorted(depths, reverse=True)
7094
7095 def test_gravity_sort_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7096 result = runner.invoke(cli, self.CMD + ["--sort", "invalid"])
7097 assert result.exit_code != 0
7098
7099 # ── --depth cap ───────────────────────────────────────────────────────────
7100
7101 def test_gravity_depth_0_unlimited(self, gravity_repo: pathlib.Path) -> None:
7102 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "0"])
7103 data = json.loads(result.output)
7104 assert result.exit_code == 0
7105 assert data["max_depth"] == 0
7106
7107 def test_gravity_depth_1_direct_only(self, gravity_repo: pathlib.Path) -> None:
7108 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "1", "--top", "0"])
7109 data = json.loads(result.output)
7110 assert result.exit_code == 0
7111 # With depth=1, max_depth for any symbol should be at most 1.
7112 for sym in data["symbols"]:
7113 assert sym["max_depth"] <= 1
7114
7115 def test_gravity_depth_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7116 result = runner.invoke(cli, self.CMD + ["--depth", "-1"])
7117 assert result.exit_code != 0
7118
7119 # ── --kind filter ─────────────────────────────────────────────────────────
7120
7121 def test_gravity_kind_function_only(self, gravity_repo: pathlib.Path) -> None:
7122 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function", "--top", "0"])
7123 data = json.loads(result.output)
7124 assert result.exit_code == 0
7125 for sym in data["symbols"]:
7126 assert sym["kind"] == "function"
7127
7128 def test_gravity_kind_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
7129 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
7130 assert result.exit_code != 0
7131
7132 # ── --file filter ─────────────────────────────────────────────────────────
7133
7134 def test_gravity_file_filter_scopes(self, gravity_repo: pathlib.Path) -> None:
7135 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py", "--top", "0"])
7136 data = json.loads(result.output)
7137 assert result.exit_code == 0
7138 for sym in data["symbols"]:
7139 assert "core.py" in sym["file"]
7140
7141 def test_gravity_file_filter_reflected_in_json(self, gravity_repo: pathlib.Path) -> None:
7142 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py"])
7143 data = json.loads(result.output)
7144 assert data["filters"]["file"] == "core.py"
7145
7146 # ── --min-gravity ─────────────────────────────────────────────────────────
7147
7148 def test_gravity_min_gravity_filters_low(self, gravity_repo: pathlib.Path) -> None:
7149 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "50.0", "--top", "0"])
7150 data = json.loads(result.output)
7151 for sym in data["symbols"]:
7152 assert sym["gravity_pct"] >= 50.0
7153
7154 def test_gravity_min_gravity_100_returns_few(self, gravity_repo: pathlib.Path) -> None:
7155 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "100.0"])
7156 assert result.exit_code == 0
7157
7158 def test_gravity_min_gravity_invalid_over_100(self, gravity_repo: pathlib.Path) -> None:
7159 result = runner.invoke(cli, self.CMD + ["--min-gravity", "101.0"])
7160 assert result.exit_code != 0
7161
7162 def test_gravity_min_gravity_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
7163 result = runner.invoke(cli, self.CMD + ["--min-gravity", "-1.0"])
7164 assert result.exit_code != 0
7165
7166 # ── --explain ─────────────────────────────────────────────────────────────
7167
7168 def test_gravity_explain_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7169 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7170 assert result.exit_code == 0, result.output
7171
7172 def test_gravity_explain_shows_breakdown(self, gravity_repo: pathlib.Path) -> None:
7173 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7174 assert "Gravity breakdown" in result.output
7175
7176 def test_gravity_explain_shows_depth_distribution(
7177 self, gravity_repo: pathlib.Path
7178 ) -> None:
7179 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7180 assert "Depth distribution" in result.output
7181
7182 def test_gravity_explain_shows_deepest_callers(
7183 self, gravity_repo: pathlib.Path
7184 ) -> None:
7185 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
7186 assert "Deepest callers" in result.output
7187
7188 def test_gravity_explain_missing_address_format(
7189 self, gravity_repo: pathlib.Path
7190 ) -> None:
7191 result = runner.invoke(cli, self.CMD + ["--explain", "no_double_colon"])
7192 assert result.exit_code != 0
7193
7194 def test_gravity_explain_unknown_symbol_exits_nonzero(
7195 self, gravity_repo: pathlib.Path
7196 ) -> None:
7197 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::no_such_fn"])
7198 assert result.exit_code != 0
7199
7200 def test_gravity_explain_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7201 result = runner.invoke(
7202 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7203 )
7204 assert result.exit_code == 0, result.output
7205
7206 def test_gravity_explain_json_schema(self, gravity_repo: pathlib.Path) -> None:
7207 result = runner.invoke(
7208 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7209 )
7210 data = json.loads(result.output)
7211 for key in (
7212 "address", "name", "kind", "file",
7213 "gravity_pct", "direct_dependents", "transitive_dependents",
7214 "max_depth", "depth_distribution",
7215 ):
7216 assert key in data, f"missing key: {key}"
7217
7218 # ── JSON leaderboard ──────────────────────────────────────────────────────
7219
7220 def test_gravity_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7221 result = runner.invoke(cli, self.CMD + ["--json"])
7222 assert result.exit_code == 0, result.output
7223
7224 def test_gravity_json_top_level_keys(self, gravity_repo: pathlib.Path) -> None:
7225 result = runner.invoke(cli, self.CMD + ["--json"])
7226 data = json.loads(result.output)
7227 for key in (
7228 "ref", "snapshot_id", "total_production_symbols",
7229 "max_depth", "include_tests", "filters", "symbols",
7230 ):
7231 assert key in data, f"missing key: {key}"
7232
7233 def test_gravity_json_symbol_schema(self, gravity_repo: pathlib.Path) -> None:
7234 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7235 data = json.loads(result.output)
7236 if data["symbols"]:
7237 sym = data["symbols"][0]
7238 for key in (
7239 "address", "name", "kind", "file",
7240 "gravity_pct", "direct_dependents",
7241 "transitive_dependents", "max_depth", "depth_distribution",
7242 ):
7243 assert key in sym, f"symbol missing key: {key}"
7244
7245 def test_gravity_json_gravity_pct_range(self, gravity_repo: pathlib.Path) -> None:
7246 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7247 data = json.loads(result.output)
7248 for sym in data["symbols"]:
7249 assert 0.0 <= sym["gravity_pct"] <= 100.0
7250
7251 def test_gravity_json_read_object_has_highest_gravity(
7252 self, gravity_repo: pathlib.Path
7253 ) -> None:
7254 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7255 data = json.loads(result.output)
7256 # read_object is called transitively by everything — should be near top.
7257 names = [s["name"] for s in data["symbols"]]
7258 if "read_object" in names and len(names) > 1:
7259 ro_idx = names.index("read_object")
7260 # read_object should be in the top half.
7261 assert ro_idx <= len(names) // 2 + 1
7262
7263 def test_gravity_json_leaf_fn_lower_gravity(
7264 self, gravity_repo: pathlib.Path
7265 ) -> None:
7266 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7267 data = json.loads(result.output)
7268 syms = {s["name"]: s for s in data["symbols"]}
7269 if "leaf_fn" in syms and "read_object" in syms:
7270 assert syms["leaf_fn"]["gravity_pct"] <= syms["read_object"]["gravity_pct"]
7271
7272 def test_gravity_json_include_tests_flag(self, gravity_repo: pathlib.Path) -> None:
7273 result = runner.invoke(cli, self.CMD + ["--json", "--include-tests"])
7274 data = json.loads(result.output)
7275 assert data["include_tests"] is True
7276
7277 def test_gravity_json_depth_reflected(self, gravity_repo: pathlib.Path) -> None:
7278 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
7279 data = json.loads(result.output)
7280 assert data["max_depth"] == 2
7281
7282 def test_gravity_json_filters_reflected(self, gravity_repo: pathlib.Path) -> None:
7283 result = runner.invoke(
7284 cli,
7285 self.CMD + ["--json", "--kind", "function", "--min-gravity", "5.0", "--top", "10"],
7286 )
7287 data = json.loads(result.output)
7288 assert data["filters"]["kind"] == "function"
7289 assert data["filters"]["min_gravity"] == 5.0
7290 assert data["filters"]["top"] == 10
7291
7292 def test_gravity_json_depth_distribution_is_dict(
7293 self, gravity_repo: pathlib.Path
7294 ) -> None:
7295 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7296 data = json.loads(result.output)
7297 for sym in data["symbols"]:
7298 assert isinstance(sym["depth_distribution"], dict)
7299
7300 # ── requires repo ─────────────────────────────────────────────────────────
7301
7302 def test_gravity_requires_repo(self, tmp_path: pathlib.Path) -> None:
7303 import os
7304 old = os.getcwd()
7305 try:
7306 os.chdir(tmp_path)
7307 result = runner.invoke(cli, self.CMD)
7308 assert result.exit_code != 0
7309 finally:
7310 os.chdir(old)
7311
7312
7313 # ---------------------------------------------------------------------------
7314 # muse code narrative
7315 # ---------------------------------------------------------------------------
7316
7317
7318 @pytest.fixture
7319 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
7320 """Repo with a symbol that has a rich multi-event history.
7321
7322 billing.py::compute_total goes through:
7323 commit 1: seed commit (different file — gives billing.py a parent context)
7324 commit 2: created (insert — billing.py added, compute_total appears as new symbol)
7325 commit 3: body rewritten (replace with impl keywords)
7326 commit 4: signature changed (replace with signature keywords)
7327 """
7328 # Commit 1 — seed so billing.py's creation is a delta, not the initial commit.
7329 (repo / "readme.txt").write_text("MuseHub billing module\n")
7330 runner.invoke(cli, ["code", "add", "readme.txt"])
7331 r0 = runner.invoke(cli, ["commit", "-m", "chore: initial seed"])
7332 assert r0.exit_code == 0, r0.output
7333
7334 # Commit 2 — create billing.py (compute_total becomes a new symbol in delta).
7335 (repo / "billing.py").write_text(textwrap.dedent("""\
7336 def compute_total(items):
7337 total = 0
7338 for item in items:
7339 total += item["price"]
7340 return total
7341 """))
7342 runner.invoke(cli, ["code", "add", "billing.py"])
7343 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7344 assert r1.exit_code == 0, r1.output
7345
7346 # Commit 3 — body rewrite: implementation changed.
7347 (repo / "billing.py").write_text(textwrap.dedent("""\
7348 def compute_total(items):
7349 return sum(i["price"] for i in items)
7350 """))
7351 runner.invoke(cli, ["code", "add", "billing.py"])
7352 r2 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
7353 assert r2.exit_code == 0, r2.output
7354
7355 # Commit 4 — signature change.
7356 (repo / "billing.py").write_text(textwrap.dedent("""\
7357 def compute_total(items, currency="USD"):
7358 return sum(i["price"] for i in items)
7359 """))
7360 runner.invoke(cli, ["code", "add", "billing.py"])
7361 r3 = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
7362 assert r3.exit_code == 0, r3.output
7363
7364 return repo
7365
7366
7367 class TestNarrative:
7368 """Tests for ``muse code narrative``."""
7369
7370 CMD = ["code", "narrative"]
7371 ADDR = "billing.py::compute_total"
7372
7373 # ── basic correctness ─────────────────────────────────────────────────────
7374
7375 def test_narrative_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7376 result = runner.invoke(cli, self.CMD + [self.ADDR])
7377 assert result.exit_code == 0, result.output
7378
7379 def test_narrative_shows_symbol_name(self, narrative_repo: pathlib.Path) -> None:
7380 result = runner.invoke(cli, self.CMD + [self.ADDR])
7381 assert "compute_total" in result.output
7382
7383 def test_narrative_shows_file(self, narrative_repo: pathlib.Path) -> None:
7384 result = runner.invoke(cli, self.CMD + [self.ADDR])
7385 assert "billing.py" in result.output
7386
7387 def test_narrative_shows_born_event(self, narrative_repo: pathlib.Path) -> None:
7388 result = runner.invoke(cli, self.CMD + [self.ADDR])
7389 assert "Born" in result.output or "born" in result.output
7390
7391 def test_narrative_shows_life_summary(self, narrative_repo: pathlib.Path) -> None:
7392 result = runner.invoke(cli, self.CMD + [self.ADDR])
7393 assert "Life summary" in result.output or "Survival" in result.output
7394
7395 def test_narrative_shows_commit_id(self, narrative_repo: pathlib.Path) -> None:
7396 result = runner.invoke(cli, self.CMD + [self.ADDR])
7397 assert "commit" in result.output
7398
7399 def test_narrative_shows_survival(self, narrative_repo: pathlib.Path) -> None:
7400 result = runner.invoke(cli, self.CMD + [self.ADDR])
7401 assert "%" in result.output
7402
7403 # ── missing symbol ────────────────────────────────────────────────────────
7404
7405 def test_narrative_missing_symbol_exits_nonzero(
7406 self, narrative_repo: pathlib.Path
7407 ) -> None:
7408 result = runner.invoke(
7409 cli, self.CMD + ["billing.py::does_not_exist"]
7410 )
7411 assert result.exit_code != 0
7412
7413 def test_narrative_bad_address_no_colons_exits_nonzero(
7414 self, narrative_repo: pathlib.Path
7415 ) -> None:
7416 result = runner.invoke(cli, self.CMD + ["no_double_colon"])
7417 assert result.exit_code != 0
7418
7419 # ── --format prose ────────────────────────────────────────────────────────
7420
7421 def test_narrative_prose_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7422 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7423 assert result.exit_code == 0, result.output
7424
7425 def test_narrative_prose_contains_name(self, narrative_repo: pathlib.Path) -> None:
7426 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7427 assert "compute_total" in result.output
7428
7429 def test_narrative_prose_contains_content(self, narrative_repo: pathlib.Path) -> None:
7430 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7431 # Symbol name or some indication of the symbol's life should appear.
7432 assert "compute_total" in result.output or "rewritten" in result.output or "born" in result.output.lower()
7433
7434 def test_narrative_prose_no_timeline_label(
7435 self, narrative_repo: pathlib.Path
7436 ) -> None:
7437 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7438 # Timeline labels like "Born " should not appear in prose.
7439 assert "Life summary" not in result.output
7440
7441 def test_narrative_format_invalid_rejected(
7442 self, narrative_repo: pathlib.Path
7443 ) -> None:
7444 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "invalid"])
7445 assert result.exit_code != 0
7446
7447 # ── --json ────────────────────────────────────────────────────────────────
7448
7449 def test_narrative_json_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7450 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7451 assert result.exit_code == 0, result.output
7452
7453 def test_narrative_json_is_valid(self, narrative_repo: pathlib.Path) -> None:
7454 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7455 data = json.loads(result.output)
7456 assert isinstance(data, dict)
7457
7458 def test_narrative_json_top_level_keys(self, narrative_repo: pathlib.Path) -> None:
7459 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7460 data = json.loads(result.output)
7461 for key in (
7462 "address", "name", "kind", "file", "status",
7463 "born_date", "born_commit", "last_change_date", "last_change_commit",
7464 "calendar_age_days", "genetic_age_days",
7465 "impl_changes", "sig_changes", "renames",
7466 "est_survival_pct", "commits_analysed", "truncated", "events",
7467 ):
7468 assert key in data, f"missing key: {key}"
7469
7470 def test_narrative_json_address_matches(self, narrative_repo: pathlib.Path) -> None:
7471 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7472 data = json.loads(result.output)
7473 assert data["address"] == self.ADDR
7474
7475 def test_narrative_json_name_is_bare(self, narrative_repo: pathlib.Path) -> None:
7476 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7477 data = json.loads(result.output)
7478 assert data["name"] == "compute_total"
7479
7480 def test_narrative_json_file_is_file_part(self, narrative_repo: pathlib.Path) -> None:
7481 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7482 data = json.loads(result.output)
7483 assert data["file"] == "billing.py"
7484
7485 def test_narrative_json_status_alive(self, narrative_repo: pathlib.Path) -> None:
7486 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7487 data = json.loads(result.output)
7488 assert data["status"] == "alive"
7489
7490 def test_narrative_json_events_list(self, narrative_repo: pathlib.Path) -> None:
7491 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7492 data = json.loads(result.output)
7493 assert isinstance(data["events"], list)
7494 assert len(data["events"]) >= 1
7495
7496 def test_narrative_json_event_schema(self, narrative_repo: pathlib.Path) -> None:
7497 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7498 data = json.loads(result.output)
7499 ev = data["events"][0]
7500 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
7501 assert key in ev, f"event missing key: {key}"
7502
7503 def test_narrative_json_born_commit_set(self, narrative_repo: pathlib.Path) -> None:
7504 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7505 data = json.loads(result.output)
7506 assert data["born_commit"] != ""
7507
7508 def test_narrative_json_born_date_format(self, narrative_repo: pathlib.Path) -> None:
7509 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7510 data = json.loads(result.output)
7511 import re
7512 assert re.match(r"\d{4}-\d{2}-\d{2}", data["born_date"])
7513
7514 def test_narrative_json_impl_changes_at_least_one(
7515 self, narrative_repo: pathlib.Path
7516 ) -> None:
7517 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7518 data = json.loads(result.output)
7519 # We made at least one body rewrite commit.
7520 assert data["impl_changes"] >= 1
7521
7522 def test_narrative_json_commits_analysed_positive(
7523 self, narrative_repo: pathlib.Path
7524 ) -> None:
7525 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7526 data = json.loads(result.output)
7527 assert data["commits_analysed"] > 0
7528
7529 def test_narrative_json_survival_between_0_and_100(
7530 self, narrative_repo: pathlib.Path
7531 ) -> None:
7532 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7533 data = json.loads(result.output)
7534 assert 0 <= data["est_survival_pct"] <= 100
7535
7536 def test_narrative_json_calendar_age_nonnegative(
7537 self, narrative_repo: pathlib.Path
7538 ) -> None:
7539 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7540 data = json.loads(result.output)
7541 assert data["calendar_age_days"] >= 0
7542
7543 def test_narrative_json_events_oldest_first(
7544 self, narrative_repo: pathlib.Path
7545 ) -> None:
7546 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7547 data = json.loads(result.output)
7548 dates = [ev["date"] for ev in data["events"]]
7549 assert dates == sorted(dates)
7550
7551 def test_narrative_json_create_event_present(
7552 self, narrative_repo: pathlib.Path
7553 ) -> None:
7554 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7555 data = json.loads(result.output)
7556 types = [ev["event_type"] for ev in data["events"]]
7557 assert "create" in types
7558
7559 # ── --since ───────────────────────────────────────────────────────────────
7560
7561 def test_narrative_since_invalid_ref_exits_nonzero(
7562 self, narrative_repo: pathlib.Path
7563 ) -> None:
7564 result = runner.invoke(
7565 cli, self.CMD + [self.ADDR, "--since", "no_such_ref_xyz"]
7566 )
7567 assert result.exit_code != 0
7568
7569 # ── --max-commits ─────────────────────────────────────────────────────────
7570
7571 def test_narrative_max_commits_validation(
7572 self, narrative_repo: pathlib.Path
7573 ) -> None:
7574 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7575 assert result.exit_code != 0
7576
7577 def test_narrative_max_commits_1_finds_head_event(
7578 self, narrative_repo: pathlib.Path
7579 ) -> None:
7580 result = runner.invoke(
7581 cli, self.CMD + [self.ADDR, "--json", "--max-commits", "1"]
7582 )
7583 # With max-commits=1 we only see the HEAD commit; it must still succeed
7584 # if the HEAD commit touched our symbol, or fail gracefully if not.
7585 assert result.exit_code in (0, 1)
7586
7587 # ── --show-source ─────────────────────────────────────────────────────────
7588
7589 def test_narrative_show_source_exits_zero(
7590 self, narrative_repo: pathlib.Path
7591 ) -> None:
7592 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7593 assert result.exit_code == 0, result.output
7594
7595 def test_narrative_show_source_contains_def(
7596 self, narrative_repo: pathlib.Path
7597 ) -> None:
7598 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7599 # HEAD source should contain the function definition.
7600 assert "def compute_total" in result.output
7601
7602 # ── requires repo ─────────────────────────────────────────────────────────
7603
7604 def test_narrative_requires_repo(self, tmp_path: pathlib.Path) -> None:
7605 import os
7606 old = os.getcwd()
7607 try:
7608 os.chdir(tmp_path)
7609 result = runner.invoke(cli, self.CMD + [self.ADDR])
7610 assert result.exit_code != 0
7611 finally:
7612 os.chdir(old)
7613
7614
7615 # ---------------------------------------------------------------------------
7616 # contract
7617 # ---------------------------------------------------------------------------
7618
7619
7620 @pytest.fixture()
7621 def contract_repo(repo: pathlib.Path) -> pathlib.Path:
7622 """Repo designed to exercise every dimension of ``muse code contract``.
7623
7624 Layout::
7625
7626 billing.py — compute_total(items, currency="USD") → float
7627 services.py — place_order() calls compute_total with currency="EUR" → stored
7628 report.py — generate_report() calls compute_total(items) → stored (omits currency)
7629 audit.py — run_audit() calls compute_total(items) → discarded (bad caller)
7630 tests/test_billing.py — tests with assertions about compute_total
7631
7632 Commit history::
7633
7634 1. seed commit — readme.txt so symbol events are real insert ops
7635 2. billing.py added — compute_total created
7636 3. services.py, report.py, audit.py, tests/ added — callers in place
7637 4. billing.py updated — body rewrite (PATCH)
7638 5. billing.py updated — add currency param (MINOR)
7639 """
7640 import os
7641
7642 (repo / "readme.txt").write_text("# contract test repo\n")
7643 runner.invoke(cli, ["code", "add", "readme.txt"])
7644 r0 = runner.invoke(cli, ["commit", "-m", "seed: initial readme"])
7645 assert r0.exit_code == 0, r0.output
7646
7647 (repo / "billing.py").write_text(textwrap.dedent("""\
7648 def compute_total(items):
7649 return sum(i["price"] for i in items)
7650 """))
7651 runner.invoke(cli, ["code", "add", "billing.py"])
7652 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7653 assert r1.exit_code == 0, r1.output
7654
7655 os.makedirs(repo / "tests", exist_ok=True)
7656 (repo / "services.py").write_text(textwrap.dedent("""\
7657 from billing import compute_total
7658
7659 def place_order(items):
7660 total = compute_total(items, currency="EUR")
7661 return total
7662 """))
7663 (repo / "report.py").write_text(textwrap.dedent("""\
7664 from billing import compute_total
7665
7666 def generate_report(items):
7667 result = compute_total(items)
7668 return result
7669 """))
7670 (repo / "audit.py").write_text(textwrap.dedent("""\
7671 from billing import compute_total
7672
7673 def run_audit(items):
7674 compute_total(items)
7675 """))
7676 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
7677 from billing import compute_total
7678
7679 def test_compute_total_basic():
7680 result = compute_total([{"price": 10}, {"price": 5}])
7681 assert result == 15
7682 assert result > 0
7683 assert isinstance(result, (int, float))
7684
7685 def test_compute_total_empty():
7686 result = compute_total([])
7687 assert result == 0
7688 """))
7689 runner.invoke(cli, ["code", "add", "."])
7690 r2 = runner.invoke(cli, ["commit", "-m", "feat: add callers and tests"])
7691 assert r2.exit_code == 0, r2.output
7692
7693 # body rewrite — PATCH
7694 (repo / "billing.py").write_text(textwrap.dedent("""\
7695 def compute_total(items):
7696 total = 0.0
7697 for item in items:
7698 total += float(item["price"])
7699 return total
7700 """))
7701 runner.invoke(cli, ["code", "add", "billing.py"])
7702 r3 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total"])
7703 assert r3.exit_code == 0, r3.output
7704
7705 # add currency param — MINOR
7706 (repo / "billing.py").write_text(textwrap.dedent("""\
7707 def compute_total(items, currency="USD"):
7708 total = 0.0
7709 for item in items:
7710 total += float(item["price"])
7711 return total
7712 """))
7713 runner.invoke(cli, ["code", "add", "billing.py"])
7714 r4 = runner.invoke(cli, ["commit", "-m", "feat: add optional currency param"])
7715 assert r4.exit_code == 0, r4.output
7716
7717 return repo
7718
7719
7720 class TestContract:
7721 """Tests for ``muse code contract``."""
7722
7723 CMD = ["code", "contract"]
7724 ADDR = "billing.py::compute_total"
7725
7726 # ── basic correctness ─────────────────────────────────────────────────────
7727
7728 def test_contract_exits_zero(self, contract_repo: pathlib.Path) -> None:
7729 result = runner.invoke(cli, self.CMD + [self.ADDR])
7730 assert result.exit_code == 0, result.output
7731
7732 def test_contract_shows_address(self, contract_repo: pathlib.Path) -> None:
7733 result = runner.invoke(cli, self.CMD + [self.ADDR])
7734 assert "compute_total" in result.output
7735
7736 def test_contract_shows_signature_section(self, contract_repo: pathlib.Path) -> None:
7737 result = runner.invoke(cli, self.CMD + [self.ADDR])
7738 assert "Signature" in result.output
7739
7740 def test_contract_shows_def_keyword(self, contract_repo: pathlib.Path) -> None:
7741 result = runner.invoke(cli, self.CMD + [self.ADDR])
7742 assert "def compute_total" in result.output
7743
7744 def test_contract_shows_stability_section(self, contract_repo: pathlib.Path) -> None:
7745 result = runner.invoke(cli, self.CMD + [self.ADDR])
7746 assert "Stability" in result.output
7747
7748 def test_contract_shows_commits_analysed(self, contract_repo: pathlib.Path) -> None:
7749 result = runner.invoke(cli, self.CMD + [self.ADDR])
7750 assert "commits" in result.output
7751
7752 def test_contract_shows_assessment(self, contract_repo: pathlib.Path) -> None:
7753 result = runner.invoke(cli, self.CMD + [self.ADDR])
7754 assert "Assessment" in result.output
7755
7756 def test_contract_shows_return_section(self, contract_repo: pathlib.Path) -> None:
7757 result = runner.invoke(cli, self.CMD + [self.ADDR])
7758 assert "Return value" in result.output
7759
7760 def test_contract_shows_parameters_section(self, contract_repo: pathlib.Path) -> None:
7761 result = runner.invoke(cli, self.CMD + [self.ADDR])
7762 assert "Parameters" in result.output
7763
7764 # ── call-site disposition detection ──────────────────────────────────────
7765
7766 def test_contract_detects_stored(self, contract_repo: pathlib.Path) -> None:
7767 result = runner.invoke(cli, self.CMD + [self.ADDR])
7768 assert "stored" in result.output
7769
7770 def test_contract_detects_discarded(self, contract_repo: pathlib.Path) -> None:
7771 result = runner.invoke(cli, self.CMD + [self.ADDR])
7772 assert "discarded" in result.output
7773
7774 def test_contract_warns_on_discarded(self, contract_repo: pathlib.Path) -> None:
7775 result = runner.invoke(cli, self.CMD + [self.ADDR])
7776 # audit.py discards the return — should surface a warning.
7777 assert "⚠" in result.output
7778
7779 # ── test assertions ───────────────────────────────────────────────────────
7780
7781 def test_contract_shows_test_assertions(self, contract_repo: pathlib.Path) -> None:
7782 result = runner.invoke(cli, self.CMD + [self.ADDR])
7783 assert "assert" in result.output.lower()
7784
7785 def test_contract_shows_assert_result_positive(
7786 self, contract_repo: pathlib.Path
7787 ) -> None:
7788 result = runner.invoke(cli, self.CMD + [self.ADDR])
7789 assert "result > 0" in result.output or "assert" in result.output
7790
7791 # ── --json ────────────────────────────────────────────────────────────────
7792
7793 def test_contract_json_exits_zero(self, contract_repo: pathlib.Path) -> None:
7794 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7795 assert result.exit_code == 0, result.output
7796
7797 def test_contract_json_is_valid(self, contract_repo: pathlib.Path) -> None:
7798 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7799 data = json.loads(result.output)
7800 assert isinstance(data, dict)
7801
7802 def test_contract_json_top_level_keys(self, contract_repo: pathlib.Path) -> None:
7803 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7804 data = json.loads(result.output)
7805 for key in (
7806 "address", "name", "kind", "signature", "parameters",
7807 "return_annotation", "call_sites", "caller_files",
7808 "return_dispositions", "arg_observations",
7809 "test_assertions", "commit_signals", "history",
7810 "preconditions", "postconditions", "warnings", "stability",
7811 ):
7812 assert key in data, f"missing top-level key: {key}"
7813
7814 def test_contract_json_address_matches(self, contract_repo: pathlib.Path) -> None:
7815 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7816 data = json.loads(result.output)
7817 assert data["address"] == self.ADDR
7818
7819 def test_contract_json_name_is_bare(self, contract_repo: pathlib.Path) -> None:
7820 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7821 data = json.loads(result.output)
7822 assert data["name"] == "compute_total"
7823
7824 def test_contract_json_kind_is_function(self, contract_repo: pathlib.Path) -> None:
7825 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7826 data = json.loads(result.output)
7827 assert data["kind"] in {"function", "async_function", "method", "async_method"}
7828
7829 def test_contract_json_signature_contains_def(
7830 self, contract_repo: pathlib.Path
7831 ) -> None:
7832 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7833 data = json.loads(result.output)
7834 assert "def compute_total" in data["signature"]
7835
7836 def test_contract_json_parameters_is_list(self, contract_repo: pathlib.Path) -> None:
7837 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7838 data = json.loads(result.output)
7839 assert isinstance(data["parameters"], list)
7840
7841 def test_contract_json_parameters_not_empty(self, contract_repo: pathlib.Path) -> None:
7842 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7843 data = json.loads(result.output)
7844 assert len(data["parameters"]) >= 1
7845
7846 def test_contract_json_parameters_schema(self, contract_repo: pathlib.Path) -> None:
7847 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7848 data = json.loads(result.output)
7849 p = data["parameters"][0]
7850 for key in ("name", "annotation", "has_default", "default_str"):
7851 assert key in p, f"parameter missing key: {key}"
7852
7853 def test_contract_json_items_param_present(self, contract_repo: pathlib.Path) -> None:
7854 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7855 data = json.loads(result.output)
7856 names = [p["name"] for p in data["parameters"]]
7857 assert "items" in names
7858
7859 def test_contract_json_currency_param_present(
7860 self, contract_repo: pathlib.Path
7861 ) -> None:
7862 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7863 data = json.loads(result.output)
7864 names = [p["name"] for p in data["parameters"]]
7865 assert "currency" in names
7866
7867 def test_contract_json_currency_has_default(self, contract_repo: pathlib.Path) -> None:
7868 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7869 data = json.loads(result.output)
7870 params = {p["name"]: p for p in data["parameters"]}
7871 assert params["currency"]["has_default"] is True
7872
7873 def test_contract_json_currency_default_str(self, contract_repo: pathlib.Path) -> None:
7874 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7875 data = json.loads(result.output)
7876 params = {p["name"]: p for p in data["parameters"]}
7877 assert params["currency"]["default_str"] == "'USD'"
7878
7879 def test_contract_json_call_sites_positive(self, contract_repo: pathlib.Path) -> None:
7880 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7881 data = json.loads(result.output)
7882 assert data["call_sites"] >= 1
7883
7884 def test_contract_json_caller_files_positive(self, contract_repo: pathlib.Path) -> None:
7885 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7886 data = json.loads(result.output)
7887 assert data["caller_files"] >= 1
7888
7889 def test_contract_json_return_dispositions_is_dict(
7890 self, contract_repo: pathlib.Path
7891 ) -> None:
7892 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7893 data = json.loads(result.output)
7894 assert isinstance(data["return_dispositions"], dict)
7895
7896 def test_contract_json_return_dispositions_keys(
7897 self, contract_repo: pathlib.Path
7898 ) -> None:
7899 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7900 data = json.loads(result.output)
7901 rd = data["return_dispositions"]
7902 for key in ("stored", "discarded", "returned", "asserted", "compared"):
7903 assert key in rd, f"return_dispositions missing: {key}"
7904
7905 def test_contract_json_discarded_count_at_least_one(
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 # audit.py discards the return value
7911 assert data["return_dispositions"].get("discarded", 0) >= 1
7912
7913 def test_contract_json_stored_count_at_least_one(
7914 self, contract_repo: pathlib.Path
7915 ) -> None:
7916 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7917 data = json.loads(result.output)
7918 assert data["return_dispositions"].get("stored", 0) >= 1
7919
7920 def test_contract_json_test_assertions_is_list(
7921 self, contract_repo: pathlib.Path
7922 ) -> None:
7923 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7924 data = json.loads(result.output)
7925 assert isinstance(data["test_assertions"], list)
7926
7927 def test_contract_json_test_assertions_not_empty(
7928 self, contract_repo: pathlib.Path
7929 ) -> None:
7930 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7931 data = json.loads(result.output)
7932 assert len(data["test_assertions"]) >= 1
7933
7934 def test_contract_json_test_assertions_are_strings(
7935 self, contract_repo: pathlib.Path
7936 ) -> None:
7937 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7938 data = json.loads(result.output)
7939 for assertion in data["test_assertions"]:
7940 assert isinstance(assertion, str)
7941
7942 def test_contract_json_history_schema(self, contract_repo: pathlib.Path) -> None:
7943 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7944 data = json.loads(result.output)
7945 h = data["history"]
7946 for key in (
7947 "commits_analysed", "truncated", "major_bumps",
7948 "minor_bumps", "patch_bumps", "sig_changes",
7949 "impl_changes", "est_survival_pct",
7950 ):
7951 assert key in h, f"history missing key: {key}"
7952
7953 def test_contract_json_history_commits_positive(
7954 self, contract_repo: pathlib.Path
7955 ) -> None:
7956 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7957 data = json.loads(result.output)
7958 assert data["history"]["commits_analysed"] > 0
7959
7960 def test_contract_json_history_survival_0_to_100(
7961 self, contract_repo: pathlib.Path
7962 ) -> None:
7963 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7964 data = json.loads(result.output)
7965 pct = data["history"]["est_survival_pct"]
7966 assert 0 <= pct <= 100
7967
7968 def test_contract_json_commit_signals_is_list(
7969 self, contract_repo: pathlib.Path
7970 ) -> None:
7971 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7972 data = json.loads(result.output)
7973 assert isinstance(data["commit_signals"], list)
7974
7975 def test_contract_json_preconditions_is_list(
7976 self, contract_repo: pathlib.Path
7977 ) -> None:
7978 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7979 data = json.loads(result.output)
7980 assert isinstance(data["preconditions"], list)
7981
7982 def test_contract_json_postconditions_is_list(
7983 self, contract_repo: pathlib.Path
7984 ) -> None:
7985 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7986 data = json.loads(result.output)
7987 assert isinstance(data["postconditions"], list)
7988
7989 def test_contract_json_warnings_is_list(self, contract_repo: pathlib.Path) -> None:
7990 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7991 data = json.loads(result.output)
7992 assert isinstance(data["warnings"], list)
7993
7994 def test_contract_json_stability_valid_value(
7995 self, contract_repo: pathlib.Path
7996 ) -> None:
7997 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7998 data = json.loads(result.output)
7999 assert data["stability"] in {"stable", "evolving", "volatile", "dormant"}
8000
8001 def test_contract_json_arg_observations_is_list(
8002 self, contract_repo: pathlib.Path
8003 ) -> None:
8004 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8005 data = json.loads(result.output)
8006 assert isinstance(data["arg_observations"], list)
8007
8008 # ── input validation ──────────────────────────────────────────────────────
8009
8010 def test_contract_missing_address_exits_nonzero(
8011 self, contract_repo: pathlib.Path
8012 ) -> None:
8013 result = runner.invoke(cli, self.CMD)
8014 assert result.exit_code != 0
8015
8016 def test_contract_bad_address_format_exits_nonzero(
8017 self, contract_repo: pathlib.Path
8018 ) -> None:
8019 result = runner.invoke(cli, self.CMD + ["billing_no_colon"])
8020 assert result.exit_code != 0
8021
8022 def test_contract_unknown_address_exits_nonzero(
8023 self, contract_repo: pathlib.Path
8024 ) -> None:
8025 result = runner.invoke(cli, self.CMD + ["billing.py::nonexistent_fn_xyz"])
8026 assert result.exit_code != 0
8027
8028 def test_contract_max_commits_zero_rejected(
8029 self, contract_repo: pathlib.Path
8030 ) -> None:
8031 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
8032 assert result.exit_code != 0
8033
8034 def test_contract_max_commits_one_succeeds(self, contract_repo: pathlib.Path) -> None:
8035 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "1"])
8036 assert result.exit_code == 0, result.output
8037
8038 # ── requires repo ─────────────────────────────────────────────────────────
8039
8040 def test_contract_requires_repo(self, tmp_path: pathlib.Path) -> None:
8041 import os
8042
8043 old = os.getcwd()
8044 try:
8045 os.chdir(tmp_path)
8046 result = runner.invoke(cli, self.CMD + [self.ADDR])
8047 assert result.exit_code != 0
8048 finally:
8049 os.chdir(old)
8050
8051 # ── history accuracy ──────────────────────────────────────────────────────
8052
8053 def test_contract_json_impl_changes_nonzero(
8054 self, contract_repo: pathlib.Path
8055 ) -> None:
8056 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8057 data = json.loads(result.output)
8058 # We made 2 body changes (perf rewrite + currency add).
8059 assert data["history"]["impl_changes"] >= 1
8060
8061 def test_contract_json_truncated_false_small_repo(
8062 self, contract_repo: pathlib.Path
8063 ) -> None:
8064 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8065 data = json.loads(result.output)
8066 assert data["history"]["truncated"] is False
8067
8068 def test_contract_json_postconditions_nonempty(
8069 self, contract_repo: pathlib.Path
8070 ) -> None:
8071 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8072 data = json.loads(result.output)
8073 # Should infer at least one postcondition (return value is stored).
8074 assert len(data["postconditions"]) >= 1
8075
8076 def test_contract_json_warnings_nonempty(self, contract_repo: pathlib.Path) -> None:
8077 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
8078 data = json.loads(result.output)
8079 # Missing type annotations + discarded return should generate warnings.
8080 assert len(data["warnings"]) >= 1
8081
8082
8083 # ---------------------------------------------------------------------------
8084 # predict
8085 # ---------------------------------------------------------------------------
8086
8087
8088 @pytest.fixture()
8089 def predict_repo(repo: pathlib.Path) -> pathlib.Path:
8090 """Repo with commit history that produces clear prediction signals.
8091
8092 billing.py::compute_total — changed in every commit (high frequency)
8093 billing.py::apply_discount — always co-changes with compute_total (entanglement)
8094 services.py::place_order — changed only once (low confidence)
8095
8096 5 commits are made so that recency, frequency, and co-change signals
8097 are all detectable within the default horizon.
8098 """
8099 # Commit 1 — establish both symbols
8100 (repo / "billing.py").write_text(textwrap.dedent("""\
8101 def compute_total(items):
8102 return sum(i["price"] for i in items)
8103
8104 def apply_discount(total, rate):
8105 return total * (1 - rate)
8106 """))
8107 (repo / "services.py").write_text(textwrap.dedent("""\
8108 def place_order(items):
8109 return True
8110 """))
8111 runner.invoke(cli, ["code", "add", "."])
8112 r1 = runner.invoke(cli, ["commit", "-m", "feat: initial billing"])
8113 assert r1.exit_code == 0, r1.output
8114
8115 # Commits 2-5 — co-evolve compute_total and apply_discount together
8116 for i in range(2, 6):
8117 (repo / "billing.py").write_text(textwrap.dedent(f"""\
8118 def compute_total(items, rev={i}):
8119 total = 0.0
8120 for item in items:
8121 total += float(item["price"])
8122 return total
8123
8124 def apply_discount(total, rate, rev={i}):
8125 return max(0.0, total * (1 - rate))
8126 """))
8127 runner.invoke(cli, ["code", "add", "billing.py"])
8128 r = runner.invoke(cli, ["commit", "-m", f"refactor: billing revision {i}"])
8129 assert r.exit_code == 0, r.output
8130
8131 return repo
8132
8133
8134 class TestPredict:
8135 """Tests for ``muse code predict``."""
8136
8137 CMD = ["code", "predict"]
8138
8139 # ── basic correctness ─────────────────────────────────────────────────────
8140
8141 def test_predict_exits_zero(self, predict_repo: pathlib.Path) -> None:
8142 result = runner.invoke(cli, self.CMD)
8143 assert result.exit_code == 0, result.output
8144
8145 def test_predict_shows_header(self, predict_repo: pathlib.Path) -> None:
8146 result = runner.invoke(cli, self.CMD)
8147 assert "Predicted changes" in result.output
8148
8149 def test_predict_shows_horizon(self, predict_repo: pathlib.Path) -> None:
8150 result = runner.invoke(cli, self.CMD)
8151 assert "horizon:" in result.output
8152
8153 def test_predict_shows_commits_analysed(self, predict_repo: pathlib.Path) -> None:
8154 result = runner.invoke(cli, self.CMD)
8155 assert "analysed" in result.output
8156
8157 def test_predict_shows_compute_total(self, predict_repo: pathlib.Path) -> None:
8158 result = runner.invoke(cli, self.CMD)
8159 assert "compute_total" in result.output
8160
8161 def test_predict_shows_apply_discount(self, predict_repo: pathlib.Path) -> None:
8162 result = runner.invoke(cli, self.CMD)
8163 assert "apply_discount" in result.output
8164
8165 def test_predict_shows_score(self, predict_repo: pathlib.Path) -> None:
8166 result = runner.invoke(cli, self.CMD)
8167 # Scores are in N.NN format at the start of each prediction line.
8168 import re
8169 assert re.search(r"0\.\d{2}", result.output)
8170
8171 def test_predict_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8172 result = runner.invoke(cli, self.CMD)
8173 assert "↳" in result.output
8174
8175 def test_predict_high_confidence_band_present(
8176 self, predict_repo: pathlib.Path
8177 ) -> None:
8178 result = runner.invoke(cli, self.CMD)
8179 # compute_total changed 4/5 commits — should be HIGH or MEDIUM.
8180 assert "CONFIDENCE" in result.output
8181
8182 def test_predict_entanglement_signal(self, predict_repo: pathlib.Path) -> None:
8183 result = runner.invoke(cli, self.CMD + ["--horizon", "10"])
8184 # compute_total and apply_discount co-change → entanglement reason expected.
8185 assert "entangled" in result.output or "co-change" in result.output
8186
8187 # ── --top ─────────────────────────────────────────────────────────────────
8188
8189 def test_predict_top_1_shows_one_prediction(
8190 self, predict_repo: pathlib.Path
8191 ) -> None:
8192 result = runner.invoke(cli, self.CMD + ["--top", "1"])
8193 assert result.exit_code == 0, result.output
8194 # With --top 1 there is exactly one score line.
8195 import re
8196 scores = re.findall(r"^\s+0\.\d{2}\s+", result.output, re.MULTILINE)
8197 assert len(scores) == 1
8198
8199 def test_predict_top_0_shows_all(self, predict_repo: pathlib.Path) -> None:
8200 result = runner.invoke(cli, self.CMD + ["--top", "0"])
8201 assert result.exit_code == 0, result.output
8202 assert "compute_total" in result.output
8203
8204 # ── --min-confidence ──────────────────────────────────────────────────────
8205
8206 def test_predict_min_confidence_1_empty(self, predict_repo: pathlib.Path) -> None:
8207 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.0"])
8208 assert result.exit_code == 0, result.output
8209 # Nothing should reach score 1.0 exactly.
8210 assert "No predictions" in result.output or "compute_total" not in result.output
8211
8212 def test_predict_min_confidence_invalid_rejected(
8213 self, predict_repo: pathlib.Path
8214 ) -> None:
8215 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.5"])
8216 assert result.exit_code != 0
8217
8218 def test_predict_min_confidence_zero_shows_all(
8219 self, predict_repo: pathlib.Path
8220 ) -> None:
8221 result = runner.invoke(cli, self.CMD + ["--min-confidence", "0.0"])
8222 assert result.exit_code == 0, result.output
8223 assert "compute_total" in result.output
8224
8225 # ── --horizon ─────────────────────────────────────────────────────────────
8226
8227 def test_predict_horizon_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8228 result = runner.invoke(cli, self.CMD + ["--horizon", "1"])
8229 assert result.exit_code == 0, result.output
8230
8231 def test_predict_horizon_invalid_rejected(self, predict_repo: pathlib.Path) -> None:
8232 result = runner.invoke(cli, self.CMD + ["--horizon", "0"])
8233 assert result.exit_code != 0
8234
8235 def test_predict_max_commits_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8236 result = runner.invoke(cli, self.CMD + ["--max-commits", "1"])
8237 assert result.exit_code == 0, result.output
8238
8239 def test_predict_max_commits_invalid_rejected(
8240 self, predict_repo: pathlib.Path
8241 ) -> None:
8242 result = runner.invoke(cli, self.CMD + ["--max-commits", "0"])
8243 assert result.exit_code != 0
8244
8245 # ── --file ────────────────────────────────────────────────────────────────
8246
8247 def test_predict_file_filter_billing(self, predict_repo: pathlib.Path) -> None:
8248 result = runner.invoke(cli, self.CMD + ["--file", "billing.py"])
8249 assert result.exit_code == 0, result.output
8250 # Should show billing symbols.
8251 if "compute_total" in result.output or "apply_discount" in result.output:
8252 pass # expected
8253 # Should NOT show services.py symbols.
8254 assert "place_order" not in result.output
8255
8256 def test_predict_file_filter_nonexistent_empty(
8257 self, predict_repo: pathlib.Path
8258 ) -> None:
8259 result = runner.invoke(cli, self.CMD + ["--file", "nonexistent_xyz.py"])
8260 assert result.exit_code == 0, result.output
8261 assert "No predictions" in result.output
8262
8263 # ── --explain ─────────────────────────────────────────────────────────────
8264
8265 def test_predict_explain_exits_zero(self, predict_repo: pathlib.Path) -> None:
8266 result = runner.invoke(
8267 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8268 )
8269 assert result.exit_code == 0, result.output
8270
8271 def test_predict_explain_shows_signal_breakdown(
8272 self, predict_repo: pathlib.Path
8273 ) -> None:
8274 result = runner.invoke(
8275 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8276 )
8277 assert "signal breakdown" in result.output
8278
8279 def test_predict_explain_shows_all_signals(
8280 self, predict_repo: pathlib.Path
8281 ) -> None:
8282 result = runner.invoke(
8283 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8284 )
8285 for signal in ("recency", "frequency", "co_change", "sig_instability",
8286 "module_velocity"):
8287 assert signal in result.output, f"missing signal: {signal}"
8288
8289 def test_predict_explain_shows_bar(self, predict_repo: pathlib.Path) -> None:
8290 result = runner.invoke(
8291 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8292 )
8293 assert "█" in result.output or "░" in result.output
8294
8295 def test_predict_explain_shows_score(self, predict_repo: pathlib.Path) -> None:
8296 result = runner.invoke(
8297 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8298 )
8299 assert "Score:" in result.output
8300
8301 def test_predict_explain_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8302 result = runner.invoke(
8303 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8304 )
8305 assert "Reasons" in result.output
8306
8307 def test_predict_explain_bad_format_rejected(
8308 self, predict_repo: pathlib.Path
8309 ) -> None:
8310 result = runner.invoke(cli, self.CMD + ["--explain", "no_colon_here"])
8311 assert result.exit_code != 0
8312
8313 def test_predict_explain_unknown_addr_rejected(
8314 self, predict_repo: pathlib.Path
8315 ) -> None:
8316 result = runner.invoke(
8317 cli, self.CMD + ["--explain", "billing.py::nonexistent_fn_xyz"]
8318 )
8319 assert result.exit_code != 0
8320
8321 # ── --json ────────────────────────────────────────────────────────────────
8322
8323 def test_predict_json_exits_zero(self, predict_repo: pathlib.Path) -> None:
8324 result = runner.invoke(cli, self.CMD + ["--json"])
8325 assert result.exit_code == 0, result.output
8326
8327 def test_predict_json_is_valid(self, predict_repo: pathlib.Path) -> None:
8328 result = runner.invoke(cli, self.CMD + ["--json"])
8329 data = json.loads(result.output)
8330 assert isinstance(data, dict)
8331
8332 def test_predict_json_top_level_keys(self, predict_repo: pathlib.Path) -> None:
8333 result = runner.invoke(cli, self.CMD + ["--json"])
8334 data = json.loads(result.output)
8335 for key in (
8336 "generated_at", "horizon_commits", "max_commits",
8337 "commits_analysed", "truncated", "predictions",
8338 ):
8339 assert key in data, f"missing key: {key}"
8340
8341 def test_predict_json_predictions_is_list(self, predict_repo: pathlib.Path) -> None:
8342 result = runner.invoke(cli, self.CMD + ["--json"])
8343 data = json.loads(result.output)
8344 assert isinstance(data["predictions"], list)
8345
8346 def test_predict_json_predictions_not_empty(
8347 self, predict_repo: pathlib.Path
8348 ) -> None:
8349 result = runner.invoke(cli, self.CMD + ["--json"])
8350 data = json.loads(result.output)
8351 assert len(data["predictions"]) >= 1
8352
8353 def test_predict_json_prediction_schema(self, predict_repo: pathlib.Path) -> None:
8354 result = runner.invoke(cli, self.CMD + ["--json"])
8355 data = json.loads(result.output)
8356 pred = data["predictions"][0]
8357 for key in (
8358 "address", "name", "kind", "file", "score", "confidence",
8359 "reasons", "signals", "last_changed_commit", "last_changed_date",
8360 "top_partners",
8361 ):
8362 assert key in pred, f"prediction missing key: {key}"
8363
8364 def test_predict_json_signals_schema(self, predict_repo: pathlib.Path) -> None:
8365 result = runner.invoke(cli, self.CMD + ["--json"])
8366 data = json.loads(result.output)
8367 signals = data["predictions"][0]["signals"]
8368 for key in ("recency", "frequency", "co_change", "sig_instability",
8369 "module_velocity"):
8370 assert key in signals, f"signals missing key: {key}"
8371
8372 def test_predict_json_score_is_float(self, predict_repo: pathlib.Path) -> None:
8373 result = runner.invoke(cli, self.CMD + ["--json"])
8374 data = json.loads(result.output)
8375 assert isinstance(data["predictions"][0]["score"], float)
8376
8377 def test_predict_json_score_in_range(self, predict_repo: pathlib.Path) -> None:
8378 result = runner.invoke(cli, self.CMD + ["--json"])
8379 data = json.loads(result.output)
8380 for pred in data["predictions"]:
8381 assert 0.0 <= pred["score"] <= 1.0, (
8382 f"score out of range: {pred['score']}"
8383 )
8384
8385 def test_predict_json_confidence_valid(self, predict_repo: pathlib.Path) -> None:
8386 result = runner.invoke(cli, self.CMD + ["--json"])
8387 data = json.loads(result.output)
8388 for pred in data["predictions"]:
8389 assert pred["confidence"] in {"high", "medium", "low"}, (
8390 f"invalid confidence: {pred['confidence']}"
8391 )
8392
8393 def test_predict_json_sorted_by_score_desc(self, predict_repo: pathlib.Path) -> None:
8394 result = runner.invoke(cli, self.CMD + ["--json"])
8395 data = json.loads(result.output)
8396 scores = [p["score"] for p in data["predictions"]]
8397 assert scores == sorted(scores, reverse=True)
8398
8399 def test_predict_json_commits_analysed_positive(
8400 self, predict_repo: pathlib.Path
8401 ) -> None:
8402 result = runner.invoke(cli, self.CMD + ["--json"])
8403 data = json.loads(result.output)
8404 assert data["commits_analysed"] > 0
8405
8406 def test_predict_json_truncated_false_small_repo(
8407 self, predict_repo: pathlib.Path
8408 ) -> None:
8409 result = runner.invoke(cli, self.CMD + ["--json"])
8410 data = json.loads(result.output)
8411 assert data["truncated"] is False
8412
8413 def test_predict_json_top_partners_is_list(self, predict_repo: pathlib.Path) -> None:
8414 result = runner.invoke(cli, self.CMD + ["--json"])
8415 data = json.loads(result.output)
8416 for pred in data["predictions"]:
8417 assert isinstance(pred["top_partners"], list)
8418
8419 def test_predict_json_partner_schema(self, predict_repo: pathlib.Path) -> None:
8420 result = runner.invoke(cli, self.CMD + ["--json"])
8421 data = json.loads(result.output)
8422 # Find a prediction that has partners.
8423 for pred in data["predictions"]:
8424 if pred["top_partners"]:
8425 p = pred["top_partners"][0]
8426 for key in ("address", "co_change_rate", "co_change_commits"):
8427 assert key in p, f"partner missing key: {key}"
8428 break
8429
8430 def test_predict_json_co_change_rate_in_range(
8431 self, predict_repo: pathlib.Path
8432 ) -> None:
8433 result = runner.invoke(cli, self.CMD + ["--json"])
8434 data = json.loads(result.output)
8435 for pred in data["predictions"]:
8436 for p in pred["top_partners"]:
8437 assert 0.0 <= p["co_change_rate"] <= 1.0
8438
8439 def test_predict_json_top_1_returns_one(self, predict_repo: pathlib.Path) -> None:
8440 result = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
8441 data = json.loads(result.output)
8442 assert len(data["predictions"]) == 1
8443
8444 def test_predict_json_horizon_matches_arg(self, predict_repo: pathlib.Path) -> None:
8445 result = runner.invoke(cli, self.CMD + ["--json", "--horizon", "3"])
8446 data = json.loads(result.output)
8447 assert data["horizon_commits"] == 3
8448
8449 def test_predict_json_reasons_is_list(self, predict_repo: pathlib.Path) -> None:
8450 result = runner.invoke(cli, self.CMD + ["--json"])
8451 data = json.loads(result.output)
8452 for pred in data["predictions"]:
8453 assert isinstance(pred["reasons"], list)
8454 assert all(isinstance(r, str) for r in pred["reasons"])
8455
8456 # ── requires repo ─────────────────────────────────────────────────────────
8457
8458 def test_predict_requires_repo(self, tmp_path: pathlib.Path) -> None:
8459 import os
8460
8461 old = os.getcwd()
8462 try:
8463 os.chdir(tmp_path)
8464 result = runner.invoke(cli, self.CMD)
8465 assert result.exit_code != 0
8466 finally:
8467 os.chdir(old)
8468
8469
8470 # ---------------------------------------------------------------------------
8471 # Helpers
8472 # ---------------------------------------------------------------------------
8473
8474
8475 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
8476 """Return all commit IDs from the store, newest-first (by log order)."""
8477 from muse.core.commits import get_all_commits
8478 commits = get_all_commits(repo)
8479 return [c.commit_id for c in commits]
File History 1 commit
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 3 days ago