gabriel / muse public
test_code_commands.py python
8,264 lines 364.7 KB
Raw
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 14 days ago
1 """Integration tests for code-domain CLI commands.
2
3 Uses a real Muse repository initialised in tmp_path.
4
5 Coverage
6 --------
7 Provenance & Topology
8 muse lineage ADDRESS [--json]
9 muse api-surface [--diff REF] [--json]
10 muse codemap [--top N] [--json]
11 muse clones [--tier exact|near|both] [--json]
12 muse checkout-symbol ADDRESS --commit REF [--dry-run]
13 muse semantic-cherry-pick ADDRESS... --from REF [--dry-run] [--json]
14
15 Query & Temporal Search
16 muse query PREDICATE [--all-commits] [--json]
17 muse query-history PREDICATE [--from REF] [--to REF] [--json]
18
19 Index Commands
20 muse index status [--json]
21 muse index rebuild [--index NAME]
22
23 Refactor Detection
24 muse detect-refactor --json (schema_version in output)
25
26 Multi-Agent Coordination
27 muse reserve ADDRESS...
28 muse intent ADDRESS... --op OP
29 muse forecast [--json]
30 muse plan-merge OURS THEIRS [--json]
31 muse shard --agents N [--json]
32 muse reconcile [--json]
33
34 Structural Enforcement
35 muse breakage [--json]
36 muse invariants [--json]
37
38 Semantic Versioning Metadata
39 muse log shows SemVer for commits with bumps
40 muse commit stores sem_ver_bump in CommitRecord
41
42 Call-Graph Tier
43 muse impact ADDRESS [--json]
44 muse dead [--json]
45 muse coverage CLASS_ADDRESS [--json]
46 muse deps ADDRESS_OR_FILE [--json]
47 muse find-symbol [--name NAME] [--json]
48 muse patch ADDRESS FILE
49 """
50
51 import json
52 import pathlib
53 import textwrap
54
55 import pytest
56 from tests.cli_test_helper import CliRunner
57
58 from typing import TypedDict
59
60 from muse._version import __version__
61 cli = None # argparse migration — CliRunner ignores this arg
62 from muse.core.store import CommitDict, get_head_commit_id
63 from muse.core._types import Manifest
64
65 type _ImportsMap = dict[str, list[str]]
66 type _ImportsSetMap = dict[str, set[str]]
67 type _KindsMap = dict[str, int]
68
69 runner = CliRunner()
70
71
72 # ---------------------------------------------------------------------------
73 # Shared fixtures
74 # ---------------------------------------------------------------------------
75
76
77 @pytest.fixture
78 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
79 """Initialise a fresh code-domain Muse repo."""
80 monkeypatch.chdir(tmp_path)
81 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
82 result = runner.invoke(cli, ["init", "--domain", "code"])
83 assert result.exit_code == 0, result.output
84 return tmp_path
85
86
87 @pytest.fixture
88 def code_repo(repo: pathlib.Path) -> pathlib.Path:
89 """Repo with two Python commits for analysis commands."""
90 work = repo
91 # Commit 1 — define compute_total and Invoice class.
92 (work / "billing.py").write_text(textwrap.dedent("""\
93 class Invoice:
94 def compute_total(self, items):
95 return sum(items)
96
97 def apply_discount(self, total, pct):
98 return total * (1 - pct)
99
100 def process_order(invoice, items):
101 return invoice.compute_total(items)
102 """))
103 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
104 assert r.exit_code == 0, r.output
105
106 # Commit 2 — rename compute_total, add new function.
107 (work / "billing.py").write_text(textwrap.dedent("""\
108 class Invoice:
109 def compute_invoice_total(self, items):
110 return sum(items)
111
112 def apply_discount(self, total, pct):
113 return total * (1 - pct)
114
115 def generate_pdf(self):
116 return b"pdf"
117
118 def process_order(invoice, items):
119 return invoice.compute_invoice_total(items)
120
121 def send_email(address):
122 pass
123 """))
124 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total, add generate_pdf + send_email"])
125 assert r.exit_code == 0, r.output
126 return repo
127
128
129 # ---------------------------------------------------------------------------
130 # muse lineage
131 # ---------------------------------------------------------------------------
132
133
134 class TestLineage:
135 def test_lineage_exits_zero_on_existing_symbol(self, code_repo: pathlib.Path) -> None:
136 result = runner.invoke(cli, ["code", "lineage", "billing.py::process_order"])
137 assert result.exit_code == 0, result.output
138
139 def test_lineage_json_output(self, code_repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["code", "lineage", "--json", "billing.py::process_order"])
141 assert result.exit_code == 0, result.output
142 data = json.loads(result.output)
143 assert isinstance(data, dict)
144 assert "events" in data
145
146 def test_lineage_missing_address_shows_message(self, code_repo: pathlib.Path) -> None:
147 result = runner.invoke(cli, ["code", "lineage", "billing.py::nonexistent_func"])
148 # Should not crash — exit 0 or 1, but no unhandled exception.
149 assert result.exit_code in (0, 1)
150
151 def test_lineage_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
152 monkeypatch.chdir(tmp_path)
153 result = runner.invoke(cli, ["code", "lineage", "src/a.py::f"])
154 assert result.exit_code != 0
155
156
157 # ---------------------------------------------------------------------------
158 # muse api-surface
159 # ---------------------------------------------------------------------------
160
161
162 class TestApiSurface:
163 def test_api_surface_exits_zero(self, code_repo: pathlib.Path) -> None:
164 result = runner.invoke(cli, ["code", "api-surface"])
165 assert result.exit_code == 0, result.output
166
167 def test_api_surface_json(self, code_repo: pathlib.Path) -> None:
168 result = runner.invoke(cli, ["code", "api-surface", "--json"])
169 assert result.exit_code == 0
170 data = json.loads(result.output)
171 assert isinstance(data, dict)
172
173 def test_api_surface_diff(self, code_repo: pathlib.Path) -> None:
174 commits = _all_commit_ids(code_repo)
175 if len(commits) >= 2:
176 result = runner.invoke(cli, ["code", "api-surface", "--diff", commits[-2]])
177 assert result.exit_code == 0
178
179 def test_api_surface_no_commits_handled(self, repo: pathlib.Path) -> None:
180 result = runner.invoke(cli, ["code", "api-surface"])
181 assert result.exit_code in (0, 1)
182
183
184 # ---------------------------------------------------------------------------
185 # muse codemap
186 # ---------------------------------------------------------------------------
187
188
189 class TestCodemap:
190 def test_codemap_exits_zero(self, code_repo: pathlib.Path) -> None:
191 result = runner.invoke(cli, ["code", "codemap"])
192 assert result.exit_code == 0, result.output
193
194 def test_codemap_top_flag(self, code_repo: pathlib.Path) -> None:
195 result = runner.invoke(cli, ["code", "codemap", "--top", "3"])
196 assert result.exit_code == 0
197
198 def test_codemap_json(self, code_repo: pathlib.Path) -> None:
199 result = runner.invoke(cli, ["code", "codemap", "--json"])
200 assert result.exit_code == 0
201 data = json.loads(result.output)
202 assert isinstance(data, dict)
203
204
205 # ---------------------------------------------------------------------------
206 # muse clones
207 # ---------------------------------------------------------------------------
208
209
210 class TestClones:
211 def test_clones_exits_zero(self, code_repo: pathlib.Path) -> None:
212 result = runner.invoke(cli, ["code", "clones"])
213 assert result.exit_code == 0, result.output
214
215 def test_clones_tier_exact(self, code_repo: pathlib.Path) -> None:
216 result = runner.invoke(cli, ["code", "clones", "--tier", "exact"])
217 assert result.exit_code == 0
218
219 def test_clones_tier_near(self, code_repo: pathlib.Path) -> None:
220 result = runner.invoke(cli, ["code", "clones", "--tier", "near"])
221 assert result.exit_code == 0
222
223 def test_clones_json(self, code_repo: pathlib.Path) -> None:
224 result = runner.invoke(cli, ["code", "clones", "--tier", "both", "--json"])
225 assert result.exit_code == 0
226 data = json.loads(result.output)
227 assert isinstance(data, dict)
228
229
230 # ---------------------------------------------------------------------------
231 # muse checkout-symbol
232 # ---------------------------------------------------------------------------
233
234
235 class TestCheckoutSymbol:
236 def test_checkout_symbol_dry_run(self, code_repo: pathlib.Path) -> None:
237 commits = _all_commit_ids(code_repo)
238 if len(commits) < 2:
239 pytest.skip("need at least 2 commits")
240 first_commit = commits[-2] # oldest commit (list is newest-first)
241 result = runner.invoke(cli, [
242 "code", "checkout-symbol", "--commit", first_commit, "--dry-run",
243 "billing.py::Invoice.compute_total",
244 ])
245 # May fail if symbol is not present; should not crash unhandled.
246 assert result.exit_code in (0, 1, 2)
247
248 def test_checkout_symbol_missing_commit_flag_errors(self, code_repo: pathlib.Path) -> None:
249 result = runner.invoke(cli, ["code", "checkout-symbol", "--dry-run", "billing.py::Invoice.compute_total"])
250 assert result.exit_code != 0
251
252
253 # ---------------------------------------------------------------------------
254 # muse semantic-cherry-pick
255 # ---------------------------------------------------------------------------
256
257
258 class TestSemanticCherryPick:
259 def test_dry_run_exits_zero(self, code_repo: pathlib.Path) -> None:
260 commits = _all_commit_ids(code_repo)
261 if len(commits) < 2:
262 pytest.skip("need at least 2 commits")
263 first_commit = commits[-2]
264 result = runner.invoke(cli, [
265 "code", "semantic-cherry-pick",
266 "--from", first_commit,
267 "--dry-run",
268 "billing.py::Invoice.compute_total",
269 ])
270 assert result.exit_code in (0, 1)
271
272 def test_missing_from_flag_errors(self, code_repo: pathlib.Path) -> None:
273 result = runner.invoke(cli, ["code", "semantic-cherry-pick", "--dry-run", "billing.py::Invoice.compute_total"])
274 assert result.exit_code != 0
275
276
277 # ---------------------------------------------------------------------------
278 # muse query
279 # ---------------------------------------------------------------------------
280
281
282 class TestQueryV2:
283 def test_query_kind_function(self, code_repo: pathlib.Path) -> None:
284 result = runner.invoke(cli, ["code", "query", "kind=function"])
285 assert result.exit_code == 0, result.output
286
287 def test_query_json_output(self, code_repo: pathlib.Path) -> None:
288 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
289 assert result.exit_code == 0
290 data = json.loads(result.output)
291 assert "schema_version" in data
292 assert data["schema_version"] == __version__
293
294 def test_query_or_predicate(self, code_repo: pathlib.Path) -> None:
295 result = runner.invoke(cli, ["code", "query", "kind=function", "OR", "kind=method"])
296 assert result.exit_code == 0
297
298 def test_query_not_predicate(self, code_repo: pathlib.Path) -> None:
299 result = runner.invoke(cli, ["code", "query", "NOT", "kind=import"])
300 assert result.exit_code == 0
301
302 def test_query_all_commits(self, code_repo: pathlib.Path) -> None:
303 result = runner.invoke(cli, ["code", "query", "--all-commits", "kind=function"])
304 assert result.exit_code == 0
305
306 def test_query_name_contains(self, code_repo: pathlib.Path) -> None:
307 result = runner.invoke(cli, ["code", "query", "name~=total"])
308 assert result.exit_code == 0
309 # Should find compute_invoice_total.
310 assert "total" in result.output.lower()
311
312 def test_query_no_predicate_matches_all(self, code_repo: pathlib.Path) -> None:
313 # query with kind=class to match everything of a known type.
314 result = runner.invoke(cli, ["code", "query", "kind=class"])
315 assert result.exit_code == 0
316 assert "Invoice" in result.output
317
318 def test_query_lineno_gt(self, code_repo: pathlib.Path) -> None:
319 result = runner.invoke(cli, ["code", "query", "lineno_gt=1"])
320 assert result.exit_code == 0
321
322 def test_query_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
323 monkeypatch.chdir(tmp_path)
324 result = runner.invoke(cli, ["code", "query", "kind=function"])
325 assert result.exit_code != 0
326
327 # ── new v2.1 flags ────────────────────────────────────────────────────────
328
329 def test_query_count_only(self, code_repo: pathlib.Path) -> None:
330 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
331 assert result.exit_code == 0, result.output
332 # Output should be a single integer.
333 assert result.output.strip().isdigit()
334
335 def test_query_count_nonzero(self, code_repo: pathlib.Path) -> None:
336 result = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
337 assert int(result.output.strip()) >= 1
338
339 def test_query_limit_caps_results(self, code_repo: pathlib.Path) -> None:
340 all_r = runner.invoke(cli, ["code", "query", "kind=function"])
341 lim_r = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
342 assert lim_r.exit_code == 0, lim_r.output
343 # Limited output should be shorter than unlimited.
344 assert len(lim_r.output) <= len(all_r.output)
345
346 def test_query_limit_truncation_noted(self, code_repo: pathlib.Path) -> None:
347 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "1"])
348 assert "limited to 1" in result.output or "match" in result.output
349
350 def test_query_limit_zero_unlimited(self, code_repo: pathlib.Path) -> None:
351 result = runner.invoke(cli, ["code", "query", "kind=function", "--limit", "0"])
352 assert result.exit_code == 0, result.output
353
354 def test_query_sort_name(self, code_repo: pathlib.Path) -> None:
355 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "name"])
356 assert result.exit_code == 0, result.output
357
358 def test_query_sort_size(self, code_repo: pathlib.Path) -> None:
359 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "size"])
360 assert result.exit_code == 0, result.output
361 # Size column should appear in output.
362 assert "L" in result.output
363
364 def test_query_sort_kind(self, code_repo: pathlib.Path) -> None:
365 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "kind"])
366 assert result.exit_code == 0, result.output
367
368 def test_query_sort_lineno(self, code_repo: pathlib.Path) -> None:
369 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "lineno"])
370 assert result.exit_code == 0, result.output
371
372 def test_query_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
373 result = runner.invoke(cli, ["code", "query", "kind=function", "--sort", "zzz"])
374 assert result.exit_code != 0
375
376 def test_query_unique_bodies_exits_zero(self, code_repo: pathlib.Path) -> None:
377 result = runner.invoke(cli, ["code", "query", "kind=function", "--unique-bodies"])
378 assert result.exit_code == 0, result.output
379
380 def test_query_unique_bodies_count_lte_all(self, code_repo: pathlib.Path) -> None:
381 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
382 uniq_r = runner.invoke(cli, ["code", "query", "--count", "--unique-bodies", "kind=function"])
383 assert int(uniq_r.output.strip()) <= int(all_r.output.strip())
384
385 def test_query_size_gt_predicate(self, code_repo: pathlib.Path) -> None:
386 result = runner.invoke(cli, ["code", "query", "kind=function", "size_gt=0"])
387 assert result.exit_code == 0, result.output
388
389 def test_query_size_lt_predicate(self, code_repo: pathlib.Path) -> None:
390 result = runner.invoke(cli, ["code", "query", "kind=function", "size_lt=1000"])
391 assert result.exit_code == 0, result.output
392
393 def test_query_size_gt_excludes_small(self, code_repo: pathlib.Path) -> None:
394 all_r = runner.invoke(cli, ["code", "query", "--count", "kind=function"])
395 large_r = runner.invoke(cli, ["code", "query", "--count", "kind=function", "size_gt=100"])
396 # Large-only count should be <= total.
397 assert int(large_r.output.strip()) <= int(all_r.output.strip())
398
399 def test_query_json_includes_size(self, code_repo: pathlib.Path) -> None:
400 result = runner.invoke(cli, ["code", "query", "--json", "kind=function"])
401 data = json.loads(result.output)
402 for r in data["results"]:
403 assert "size" in r
404
405 def test_query_json_includes_sort_field(self, code_repo: pathlib.Path) -> None:
406 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--sort", "name"])
407 data = json.loads(result.output)
408 assert data["sort"] == "name"
409
410 def test_query_json_includes_unique_bodies(self, code_repo: pathlib.Path) -> None:
411 result = runner.invoke(cli, ["code", "query", "--json", "kind=function", "--unique-bodies"])
412 data = json.loads(result.output)
413 assert data["unique_bodies"] is True
414
415 def test_query_since_without_all_commits_rejected(self, code_repo: pathlib.Path) -> None:
416 result = runner.invoke(cli, ["code", "query", "kind=function", "--since", "2026-01-01"])
417 assert result.exit_code != 0
418
419 def test_query_since_invalid_date_rejected(self, code_repo: pathlib.Path) -> None:
420 result = runner.invoke(
421 cli,
422 ["code", "query", "kind=function", "--all-commits", "--since", "not-a-date"],
423 )
424 assert result.exit_code != 0
425
426 def test_query_all_commits_since_future_empty(self, code_repo: pathlib.Path) -> None:
427 result = runner.invoke(
428 cli,
429 ["code", "query", "kind=function", "--all-commits", "--since", "2099-01-01"],
430 )
431 assert result.exit_code == 0, result.output
432 # Future date means no commits match.
433 assert "no symbols" in result.output.lower() or result.output.strip() == ""
434
435 def test_query_max_commits_caps_walk(self, code_repo: pathlib.Path) -> None:
436 result = runner.invoke(
437 cli,
438 ["code", "query", "kind=function", "--all-commits", "--max-commits", "1"],
439 )
440 assert result.exit_code == 0, result.output
441
442
443 # ---------------------------------------------------------------------------
444 # muse query-history
445 # ---------------------------------------------------------------------------
446
447
448 class TestQueryHistory:
449 def test_query_history_exits_zero(self, code_repo: pathlib.Path) -> None:
450 result = runner.invoke(cli, ["code", "query-history", "kind=function"])
451 assert result.exit_code == 0, result.output
452
453 def test_query_history_json(self, code_repo: pathlib.Path) -> None:
454 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=function"])
455 assert result.exit_code == 0
456 data = json.loads(result.output)
457 assert "schema_version" in data
458 assert data["schema_version"] == __version__
459 assert "results" in data
460
461 def test_query_history_with_from_to(self, code_repo: pathlib.Path) -> None:
462 result = runner.invoke(cli, ["code", "query-history", "--from", "HEAD", "kind=function"])
463 assert result.exit_code == 0
464
465 def test_query_history_tracks_change_count(self, code_repo: pathlib.Path) -> None:
466 result = runner.invoke(cli, ["code", "query-history", "--json", "kind=method"])
467 assert result.exit_code == 0
468 data = json.loads(result.output)
469 for entry in data.get("results", []):
470 assert "commit_count" in entry
471 assert "change_count" in entry
472
473 # ── new v2 flags ──────────────────────────────────────────────────────────
474
475 def test_query_history_changed_only(self, code_repo: pathlib.Path) -> None:
476 result = runner.invoke(
477 cli, ["code", "query-history", "--changed-only", "kind=function"]
478 )
479 assert result.exit_code == 0, result.output
480
481 def test_query_history_changed_only_all_gt_one(self, code_repo: pathlib.Path) -> None:
482 result = runner.invoke(
483 cli, ["code", "query-history", "--changed-only", "--json", "kind=function"]
484 )
485 assert result.exit_code == 0
486 data = json.loads(result.output)
487 for entry in data["results"]:
488 assert entry["change_count"] > 1
489
490 def test_query_history_sort_commits(self, code_repo: pathlib.Path) -> None:
491 result = runner.invoke(
492 cli, ["code", "query-history", "--sort", "commits", "kind=function"]
493 )
494 assert result.exit_code == 0, result.output
495
496 def test_query_history_sort_changes(self, code_repo: pathlib.Path) -> None:
497 result = runner.invoke(
498 cli, ["code", "query-history", "--sort", "changes", "kind=function"]
499 )
500 assert result.exit_code == 0, result.output
501
502 def test_query_history_sort_first(self, code_repo: pathlib.Path) -> None:
503 result = runner.invoke(
504 cli, ["code", "query-history", "--sort", "first", "kind=function"]
505 )
506 assert result.exit_code == 0, result.output
507
508 def test_query_history_sort_invalid_rejected(self, code_repo: pathlib.Path) -> None:
509 result = runner.invoke(
510 cli, ["code", "query-history", "--sort", "zzz", "kind=function"]
511 )
512 assert result.exit_code != 0
513
514 def test_query_history_count(self, code_repo: pathlib.Path) -> None:
515 result = runner.invoke(
516 cli, ["code", "query-history", "--count", "kind=function"]
517 )
518 assert result.exit_code == 0, result.output
519 assert result.output.strip().isdigit()
520 assert int(result.output.strip()) >= 1
521
522 def test_query_history_limit(self, code_repo: pathlib.Path) -> None:
523 all_r = runner.invoke(cli, ["code", "query-history", "kind=function"])
524 lim_r = runner.invoke(
525 cli, ["code", "query-history", "--limit", "1", "kind=function"]
526 )
527 assert lim_r.exit_code == 0, lim_r.output
528 assert len(lim_r.output) <= len(all_r.output)
529
530 def test_query_history_limit_note_in_output(self, code_repo: pathlib.Path) -> None:
531 result = runner.invoke(
532 cli, ["code", "query-history", "--limit", "1", "kind=function"]
533 )
534 assert "1" in result.output
535
536 def test_query_history_min_changes(self, code_repo: pathlib.Path) -> None:
537 result = runner.invoke(
538 cli, ["code", "query-history", "--min-changes", "2", "--json", "kind=function"]
539 )
540 assert result.exit_code == 0
541 data = json.loads(result.output)
542 for entry in data["results"]:
543 assert entry["change_count"] >= 2
544
545 def test_query_history_min_changes_zero_rejected(self, code_repo: pathlib.Path) -> None:
546 result = runner.invoke(
547 cli, ["code", "query-history", "--min-changes", "0", "kind=function"]
548 )
549 assert result.exit_code != 0
550
551 def test_query_history_introduced_only(self, code_repo: pathlib.Path) -> None:
552 result = runner.invoke(
553 cli, ["code", "query-history", "--introduced-only", "kind=function"]
554 )
555 assert result.exit_code == 0, result.output
556
557 def test_query_history_removed_only(self, code_repo: pathlib.Path) -> None:
558 result = runner.invoke(
559 cli, ["code", "query-history", "--removed-only", "kind=function"]
560 )
561 assert result.exit_code == 0, result.output
562
563 def test_query_history_introduced_json_schema(self, code_repo: pathlib.Path) -> None:
564 result = runner.invoke(
565 cli,
566 ["code", "query-history", "--introduced-only", "--json", "kind=function"],
567 )
568 assert result.exit_code == 0
569 data = json.loads(result.output)
570 assert data["mode"] == "introduced-only"
571 assert "symbols_found" in data
572 for entry in data["results"]:
573 assert entry["status"] == "introduced"
574
575 def test_query_history_removed_json_schema(self, code_repo: pathlib.Path) -> None:
576 result = runner.invoke(
577 cli,
578 ["code", "query-history", "--removed-only", "--json", "kind=function"],
579 )
580 assert result.exit_code == 0
581 data = json.loads(result.output)
582 assert data["mode"] == "removed-only"
583 assert "symbols_found" in data
584 for entry in data["results"]:
585 assert entry["status"] == "removed"
586
587 def test_query_history_mode_flags_mutually_exclusive(
588 self, code_repo: pathlib.Path
589 ) -> None:
590 result = runner.invoke(
591 cli,
592 [
593 "code", "query-history",
594 "--changed-only", "--introduced-only",
595 "kind=function",
596 ],
597 )
598 assert result.exit_code != 0
599
600 def test_query_history_json_has_full_commit_ids(
601 self, code_repo: pathlib.Path
602 ) -> None:
603 result = runner.invoke(
604 cli, ["code", "query-history", "--json", "kind=function"]
605 )
606 assert result.exit_code == 0
607 data = json.loads(result.output)
608 for entry in data["results"]:
609 # Full commit IDs should be present (not just 8-char short form).
610 assert len(entry["first_commit_id"]) > 8
611 assert "stable" in entry
612
613 def test_query_history_max_commits_cap(self, code_repo: pathlib.Path) -> None:
614 result = runner.invoke(
615 cli,
616 ["code", "query-history", "--max-commits", "1", "kind=function"],
617 )
618 assert result.exit_code == 0, result.output
619
620 def test_query_history_introduced_count_only(
621 self, code_repo: pathlib.Path
622 ) -> None:
623 result = runner.invoke(
624 cli,
625 ["code", "query-history", "--introduced-only", "--count", "kind=function"],
626 )
627 assert result.exit_code == 0
628 assert result.output.strip().isdigit()
629
630
631 # ---------------------------------------------------------------------------
632 # muse index
633 # ---------------------------------------------------------------------------
634
635
636 class TestIndexCommands:
637 def test_index_status_exits_zero(self, code_repo: pathlib.Path) -> None:
638 result = runner.invoke(cli, ["code", "index", "status"])
639 assert result.exit_code == 0, result.output
640
641 def test_index_status_reports_absent(self, code_repo: pathlib.Path) -> None:
642 result = runner.invoke(cli, ["code", "index", "status"])
643 # Indexes have not been built yet.
644 assert "absent" in result.output.lower() or result.exit_code == 0
645
646 def test_index_rebuild_all(self, code_repo: pathlib.Path) -> None:
647 result = runner.invoke(cli, ["code", "index", "rebuild"])
648 assert result.exit_code == 0, result.output
649
650 def test_index_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
651 runner.invoke(cli, ["code", "index", "rebuild"])
652 idx_dir = code_repo / ".muse" / "indices"
653 assert idx_dir.exists()
654
655 def test_index_status_after_rebuild_shows_entries(self, code_repo: pathlib.Path) -> None:
656 runner.invoke(cli, ["code", "index", "rebuild"])
657 result = runner.invoke(cli, ["code", "index", "status"])
658 assert result.exit_code == 0
659 # Output shows ✅ checkmarks and entry counts for rebuilt indexes.
660 assert "entries" in result.output.lower() or "✅" in result.output
661
662 def test_index_rebuild_symbol_history_only(self, code_repo: pathlib.Path) -> None:
663 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
664 assert result.exit_code == 0
665
666 def test_index_rebuild_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
667 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence"])
668 assert result.exit_code == 0
669
670
671 # ---------------------------------------------------------------------------
672 # muse detect-refactor
673 # ---------------------------------------------------------------------------
674
675
676 class TestHotspots:
677 """Tests for muse code hotspots."""
678
679 # ── basic correctness ────────────────────────────────────────────────────
680
681 def test_hotspots_exits_zero(self, code_repo: pathlib.Path) -> None:
682 result = runner.invoke(cli, ["code", "hotspots"])
683 assert result.exit_code == 0, result.output
684
685 def test_hotspots_finds_changed_symbol(self, code_repo: pathlib.Path) -> None:
686 """compute_invoice_total was modified across two commits — must appear."""
687 result = runner.invoke(cli, ["code", "hotspots", "--top", "20"])
688 assert result.exit_code == 0, result.output
689 assert "billing.py" in result.output
690
691 def test_hotspots_excludes_imports_by_default(
692 self, code_repo: pathlib.Path
693 ) -> None:
694 result = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
695 assert result.exit_code == 0, result.output
696 assert "::import::" not in result.output
697
698 def test_hotspots_include_imports_flag(self, code_repo: pathlib.Path) -> None:
699 """--include-imports must surface import pseudo-symbols if any exist."""
700 result = runner.invoke(
701 cli, ["code", "hotspots", "--top", "50", "--include-imports"]
702 )
703 assert result.exit_code == 0, result.output
704 # Just verify it runs cleanly; the repo may or may not have import ops.
705
706 # ── --kind filter (was broken before) ────────────────────────────────────
707
708 def test_kind_filter_excludes_classes(self, code_repo: pathlib.Path) -> None:
709 """--kind function must not return class symbols."""
710 result = runner.invoke(
711 cli, ["code", "hotspots", "--kind", "function", "--top", "20"]
712 )
713 assert result.exit_code == 0, result.output
714 for line in result.output.splitlines():
715 if "::" in line and "class" in line.lower():
716 # Make sure any class line is not a function kind result
717 # (Addresses that contain the word "class" in their name are OK)
718 pass # Name may contain "class" as substring
719
720 def test_kind_filter_function_returns_functions(
721 self, code_repo: pathlib.Path
722 ) -> None:
723 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
724 result_fn = runner.invoke(
725 cli, ["code", "hotspots", "--kind", "function", "--top", "50"]
726 )
727 assert result_fn.exit_code == 0, result_fn.output
728 # filtered result should have <= symbols than unfiltered
729 fn_lines = [l for l in result_fn.output.splitlines() if "::" in l]
730 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
731 assert len(fn_lines) <= len(all_lines)
732
733 # ── --min filter ──────────────────────────────────────────────────────────
734
735 def test_min_filter_raises_threshold(self, code_repo: pathlib.Path) -> None:
736 result_all = runner.invoke(cli, ["code", "hotspots", "--top", "50"])
737 result_min = runner.invoke(
738 cli, ["code", "hotspots", "--min", "2", "--top", "50"]
739 )
740 assert result_min.exit_code == 0, result_min.output
741 min_lines = [l for l in result_min.output.splitlines() if "::" in l]
742 all_lines = [l for l in result_all.output.splitlines() if "::" in l]
743 assert len(min_lines) <= len(all_lines)
744
745 def test_min_zero_exits_error(self, code_repo: pathlib.Path) -> None:
746 result = runner.invoke(cli, ["code", "hotspots", "--min", "0"])
747 assert result.exit_code == 1
748
749 # ── --language filter ─────────────────────────────────────────────────────
750
751 def test_language_filter_lowercase(self, code_repo: pathlib.Path) -> None:
752 result = runner.invoke(
753 cli, ["code", "hotspots", "--language", "python", "--top", "10"]
754 )
755 assert result.exit_code == 0, result.output
756 assert "billing.py" in result.output
757
758 def test_language_filter_uppercase(self, code_repo: pathlib.Path) -> None:
759 result = runner.invoke(
760 cli, ["code", "hotspots", "--language", "PYTHON", "--top", "10"]
761 )
762 assert result.exit_code == 0, result.output
763
764 # ── --top validation ──────────────────────────────────────────────────────
765
766 def test_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
767 result = runner.invoke(cli, ["code", "hotspots", "--top", "0"])
768 assert result.exit_code == 1
769
770 # ── JSON schema ───────────────────────────────────────────────────────────
771
772 def test_json_top_level_schema(self, code_repo: pathlib.Path) -> None:
773 result = runner.invoke(cli, ["code", "hotspots", "--json"])
774 assert result.exit_code == 0, result.output
775 data = json.loads(result.output)
776 for key in (
777 "from_ref", "to_ref", "commits_analysed", "truncated",
778 "filters", "hotspots",
779 ):
780 assert key in data, f"missing key: {key}"
781 assert isinstance(data["hotspots"], list)
782 assert isinstance(data["truncated"], bool)
783 assert isinstance(data["commits_analysed"], int)
784
785 def test_json_filters_field(self, code_repo: pathlib.Path) -> None:
786 result = runner.invoke(
787 cli, ["code", "hotspots", "--kind", "function", "--min", "2", "--json"]
788 )
789 data = json.loads(result.output)
790 assert data["filters"]["kind"] == "function"
791 assert data["filters"]["min_changes"] == 2
792 assert data["filters"]["include_imports"] is False
793
794 def test_json_hotspot_entry_schema(self, code_repo: pathlib.Path) -> None:
795 result = runner.invoke(cli, ["code", "hotspots", "--json"])
796 data = json.loads(result.output)
797 if data["hotspots"]:
798 entry = data["hotspots"][0]
799 assert "address" in entry
800 assert "changes" in entry
801 assert isinstance(entry["changes"], int)
802 assert entry["changes"] >= 1
803
804 def test_json_no_imports_by_default(self, code_repo: pathlib.Path) -> None:
805 result = runner.invoke(cli, ["code", "hotspots", "--json"])
806 data = json.loads(result.output)
807 addresses = [h["address"] for h in data["hotspots"]]
808 assert not any("::import::" in a for a in addresses)
809
810 def test_json_ranked_descending(self, code_repo: pathlib.Path) -> None:
811 result = runner.invoke(cli, ["code", "hotspots", "--json"])
812 data = json.loads(result.output)
813 counts = [h["changes"] for h in data["hotspots"]]
814 assert counts == sorted(counts, reverse=True)
815
816 # ── --max-commits truncation ──────────────────────────────────────────────
817
818 def test_max_commits_flag(self, code_repo: pathlib.Path) -> None:
819 result = runner.invoke(
820 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
821 )
822 assert result.exit_code == 0, result.output
823 data = json.loads(result.output)
824 assert data["commits_analysed"] <= 1
825
826 def test_max_commits_truncation_flag(self, code_repo: pathlib.Path) -> None:
827 result = runner.invoke(
828 cli, ["code", "hotspots", "--max-commits", "1", "--json"]
829 )
830 data = json.loads(result.output)
831 assert data["truncated"] is True
832
833
834 class TestDetectRefactorV2:
835 def test_detect_refactor_json_schema(self, code_repo: pathlib.Path) -> None:
836 """JSON output contains all required top-level fields."""
837 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
838 assert result.exit_code == 0, result.output
839 data = json.loads(result.output)
840 for field in ("schema_version", "from", "to", "commits_scanned",
841 "truncated", "total", "events"):
842 assert field in data, f"missing field '{field}'"
843 assert data["schema_version"] == __version__
844 assert isinstance(data["commits_scanned"], int)
845 assert isinstance(data["truncated"], bool)
846 assert isinstance(data["total"], int)
847 assert isinstance(data["events"], list)
848
849 def test_detect_refactor_json_event_schema(self, code_repo: pathlib.Path) -> None:
850 """Each JSON event contains the required fields."""
851 # Run over the full history; code_repo has at least one rename event.
852 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
853 assert result.exit_code == 0, result.output
854 data = json.loads(result.output)
855 for ev in data["events"]:
856 for field in ("kind", "address", "detail",
857 "commit_id", "commit_message", "committed_at"):
858 assert field in ev, f"missing event field '{field}'"
859 assert ev["kind"] in ("rename", "move", "signature", "implementation")
860
861 def test_detect_refactor_finds_rename(self, code_repo: pathlib.Path) -> None:
862 """A commit that renames a symbol produces a 'rename' event."""
863 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
864 assert result.exit_code == 0, result.output
865 data = json.loads(result.output)
866 kinds = [e["kind"] for e in data["events"]]
867 assert "rename" in kinds, (
868 f"Expected at least one rename event; got: {sorted(set(kinds))}"
869 )
870
871 def test_detect_refactor_classifies_modified_as_implementation(
872 self, code_repo: pathlib.Path
873 ) -> None:
874 """Replace ops with '(modified)' in new_summary are classified as implementation.
875
876 Previously, only '(implementation changed)' triggered implementation
877 classification; '(modified)' was silently dropped.
878 """
879 import datetime
880 root = code_repo
881 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
882 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
883 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
884 branch = read_current_branch(root)
885 head_id = get_head_commit_id(root, branch)
886
887 now = datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
888 message = "perf: optimise batch"
889 snap_manifest: Manifest = {}
890 snap_id = compute_snapshot_id(snap_manifest)
891 parent_ids = [head_id] if head_id else []
892 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
893 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
894 commit = CommitRecord(
895 commit_id=commit_id,
896 repo_id=repo_id,
897 branch=branch,
898 snapshot_id=snap_id,
899 message=message,
900 committed_at=now,
901 parent_commit_id=head_id,
902 author="test",
903 structured_delta=StructuredDelta(ops=[PatchOp(
904 op="patch",
905 address="billing.py",
906 child_ops=[ReplaceOp(
907 op="replace",
908 address="billing.py::process_batch",
909 new_summary="function process_batch (modified) L10–30",
910 old_summary="function process_batch",
911 )],
912 )]),
913 )
914 write_commit(root, commit)
915 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
916
917 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
918 assert result.exit_code == 0, result.output
919 data = json.loads(result.output)
920 impl_events = [e for e in data["events"] if e["kind"] == "implementation"]
921 addrs = [e["address"] for e in impl_events]
922 assert "billing.py::process_batch" in addrs, (
923 f"'(modified)' op not classified as implementation; events: {data['events']}"
924 )
925
926 def test_detect_refactor_skips_reformatted(self, code_repo: pathlib.Path) -> None:
927 """Replace ops with 'reformatted' in new_summary are not emitted as events."""
928 import datetime
929 root = code_repo
930 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
931 from muse.core.store import CommitDict, get_head_commit_id, read_current_branch, write_commit, CommitRecord
932 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
933 branch = read_current_branch(root)
934 head_id = get_head_commit_id(root, branch)
935
936 now = datetime.datetime(2026, 6, 1, 13, 0, 0, tzinfo=datetime.timezone.utc)
937 message = "style: reformat"
938 snap_manifest: Manifest = {}
939 snap_id = compute_snapshot_id(snap_manifest)
940 parent_ids = [head_id] if head_id else []
941 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
942 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
943 commit = CommitRecord(
944 commit_id=commit_id,
945 repo_id=repo_id,
946 branch=branch,
947 snapshot_id=snap_id,
948 message=message,
949 committed_at=now,
950 parent_commit_id=head_id,
951 author="test",
952 structured_delta=StructuredDelta(ops=[PatchOp(
953 op="patch",
954 address="billing.py",
955 child_ops=[ReplaceOp(
956 op="replace",
957 address="billing.py::UniqueReformattedSymbol",
958 new_summary="reformatted — no semantic change",
959 old_summary="",
960 )],
961 )]),
962 )
963 write_commit(root, commit)
964 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
965
966 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
967 assert result.exit_code == 0, result.output
968 data = json.loads(result.output)
969 # The reformatted op must not appear as an event.
970 reformatted_events = [
971 e for e in data["events"]
972 if e["address"] == "billing.py::UniqueReformattedSymbol"
973 ]
974 assert reformatted_events == [], (
975 f"Reformatted op should be skipped; got: {reformatted_events}"
976 )
977
978 def test_detect_refactor_truncation_warning(self, code_repo: pathlib.Path) -> None:
979 """When --max is hit, a truncation warning appears in human output."""
980 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "1"])
981 assert result.exit_code == 0, result.output
982 assert "incomplete" in result.output or "limit" in result.output
983
984 def test_detect_refactor_truncation_in_json(self, code_repo: pathlib.Path) -> None:
985 """When --max is hit, truncated=true in JSON."""
986 result = runner.invoke(
987 cli, ["code", "detect-refactor", "--max", "1", "--json"]
988 )
989 assert result.exit_code == 0, result.output
990 data = json.loads(result.output)
991 assert data["truncated"] is True
992 assert data["commits_scanned"] == 1
993
994 def test_detect_refactor_max_zero_errors(self, code_repo: pathlib.Path) -> None:
995 """--max 0 exits non-zero."""
996 result = runner.invoke(cli, ["code", "detect-refactor", "--max", "0"])
997 assert result.exit_code != 0
998
999 def test_detect_refactor_kind_filter(self, code_repo: pathlib.Path) -> None:
1000 """``--kind rename`` returns only rename events."""
1001 result = runner.invoke(
1002 cli, ["code", "detect-refactor", "--kind", "rename", "--json"]
1003 )
1004 assert result.exit_code == 0, result.output
1005 data = json.loads(result.output)
1006 for ev in data["events"]:
1007 assert ev["kind"] == "rename"
1008
1009 def test_detect_refactor_invalid_kind(self, code_repo: pathlib.Path) -> None:
1010 """``--kind`` with an invalid value exits non-zero."""
1011 result = runner.invoke(cli, ["code", "detect-refactor", "--kind", "potato"])
1012 assert result.exit_code != 0
1013
1014 def test_detect_refactor_bfs_follows_merge_parent2(
1015 self, code_repo: pathlib.Path
1016 ) -> None:
1017 """BFS walk finds refactoring events on merged feature branches."""
1018 import datetime
1019 root = code_repo
1020 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
1021 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
1022 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
1023 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
1024 branch = read_current_branch(root)
1025 head_id = get_head_commit_id(root, branch)
1026 assert head_id is not None
1027
1028 feat_at = datetime.datetime(2026, 7, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
1029 merge_at = datetime.datetime(2026, 7, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
1030
1031 feat_snap_id = compute_snapshot_id({"feat.py": "a" * 64})
1032 feature_id = compute_commit_id([head_id], feat_snap_id, "perf: vectorise", feat_at.isoformat())
1033 write_commit(root, CommitRecord(
1034 commit_id=feature_id,
1035 repo_id=repo_id,
1036 branch="feat/perf",
1037 snapshot_id=feat_snap_id,
1038 message="perf: vectorise",
1039 committed_at=feat_at,
1040 parent_commit_id=head_id,
1041 author="test",
1042 structured_delta=StructuredDelta(ops=[PatchOp(
1043 op="patch",
1044 address="billing.py",
1045 child_ops=[ReplaceOp(
1046 op="replace",
1047 address="billing.py::vectorised_fn",
1048 new_summary="function vectorised_fn (implementation changed) L1–20",
1049 old_summary="function vectorised_fn",
1050 )],
1051 )]),
1052 ))
1053 merge_snap_id = compute_snapshot_id({"merge.py": "b" * 64})
1054 merge_id = compute_commit_id([head_id, feature_id], merge_snap_id, "merge feat/perf", merge_at.isoformat())
1055 write_commit(root, CommitRecord(
1056 commit_id=merge_id,
1057 repo_id=repo_id,
1058 branch=branch,
1059 snapshot_id=merge_snap_id,
1060 message="merge feat/perf",
1061 committed_at=merge_at,
1062 parent_commit_id=head_id,
1063 parent2_commit_id=feature_id,
1064 author="test",
1065 ))
1066 (root / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
1067
1068 result = runner.invoke(cli, ["code", "detect-refactor", "--json"])
1069 assert result.exit_code == 0, result.output
1070 data = json.loads(result.output)
1071 addrs = [e["address"] for e in data["events"]]
1072 assert "billing.py::vectorised_fn" in addrs, (
1073 "BFS must find the implementation event on the feature branch"
1074 )
1075
1076
1077 # ---------------------------------------------------------------------------
1078 # muse reserve
1079 # ---------------------------------------------------------------------------
1080
1081
1082 class TestReserve:
1083 def test_reserve_exits_zero(self, code_repo: pathlib.Path) -> None:
1084 result = runner.invoke(cli, [
1085 "coord", "reserve", "billing.py::process_order", "--run-id", "agent-test"
1086 ])
1087 assert result.exit_code == 0, result.output
1088
1089 def test_reserve_creates_coordination_file(self, code_repo: pathlib.Path) -> None:
1090 runner.invoke(cli, ["coord", "reserve", "billing.py::process_order", "--run-id", "r1"])
1091 coord_dir = code_repo / ".muse" / "coordination" / "reservations"
1092 assert coord_dir.exists()
1093 files = list(coord_dir.glob("*.json"))
1094 assert len(files) >= 1
1095
1096 def test_reserve_json_output(self, code_repo: pathlib.Path) -> None:
1097 result = runner.invoke(cli, [
1098 "coord", "reserve", "--run-id", "r2", "--json", "billing.py::process_order",
1099 ])
1100 assert result.exit_code == 0
1101 data = json.loads(result.output)
1102 assert "reservation_id" in data
1103
1104 def test_reserve_multiple_addresses(self, code_repo: pathlib.Path) -> None:
1105 result = runner.invoke(cli, [
1106 "coord", "reserve", "--run-id", "r3",
1107 "billing.py::process_order",
1108 "billing.py::Invoice.apply_discount",
1109 ])
1110 assert result.exit_code == 0
1111
1112 def test_reserve_with_operation(self, code_repo: pathlib.Path) -> None:
1113 result = runner.invoke(cli, [
1114 "coord", "reserve", "--run-id", "r4", "--op", "rename",
1115 "billing.py::process_order",
1116 ])
1117 assert result.exit_code == 0
1118
1119 def test_reserve_conflict_warning(self, code_repo: pathlib.Path) -> None:
1120 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::process_order"])
1121 result = runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::process_order"])
1122 # Should warn but not fail.
1123 assert result.exit_code == 0
1124 assert "conflict" in result.output.lower() or "already" in result.output.lower() or "reserved" in result.output.lower()
1125
1126
1127 # ---------------------------------------------------------------------------
1128 # muse intent
1129 # ---------------------------------------------------------------------------
1130
1131
1132 class TestIntent:
1133 def test_intent_exits_zero(self, code_repo: pathlib.Path) -> None:
1134 result = runner.invoke(cli, [
1135 "coord", "intent", "--op", "rename", "--detail", "rename to process_invoice",
1136 "billing.py::process_order",
1137 ])
1138 assert result.exit_code == 0, result.output
1139
1140 def test_intent_creates_file(self, code_repo: pathlib.Path) -> None:
1141 runner.invoke(cli, ["coord", "intent", "--op", "modify", "billing.py::Invoice"])
1142 idir = code_repo / ".muse" / "coordination" / "intents"
1143 assert idir.exists()
1144 assert len(list(idir.glob("*.json"))) >= 1
1145
1146 def test_intent_json_output(self, code_repo: pathlib.Path) -> None:
1147 result = runner.invoke(cli, [
1148 "coord", "intent", "--op", "modify", "--json", "billing.py::Invoice",
1149 ])
1150 assert result.exit_code == 0
1151 data = json.loads(result.output)
1152 assert "intent_id" in data or "operation" in data
1153
1154
1155 # ---------------------------------------------------------------------------
1156 # muse forecast
1157 # ---------------------------------------------------------------------------
1158
1159
1160 class TestForecast:
1161 def test_forecast_exits_zero_no_reservations(self, code_repo: pathlib.Path) -> None:
1162 result = runner.invoke(cli, ["coord", "forecast"])
1163 assert result.exit_code == 0, result.output
1164
1165 def test_forecast_json_no_reservations(self, code_repo: pathlib.Path) -> None:
1166 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1167 assert result.exit_code == 0
1168 data = json.loads(result.output)
1169 assert "conflicts" in data
1170
1171 def test_forecast_detects_address_overlap(self, code_repo: pathlib.Path) -> None:
1172 runner.invoke(cli, ["coord", "reserve", "--run-id", "a1", "billing.py::Invoice.apply_discount"])
1173 runner.invoke(cli, ["coord", "reserve", "--run-id", "a2", "billing.py::Invoice.apply_discount"])
1174 result = runner.invoke(cli, ["coord", "forecast", "--json"])
1175 assert result.exit_code == 0
1176 data = json.loads(result.output)
1177 types = [c.get("conflict_type") for c in data.get("conflicts", [])]
1178 assert "address_overlap" in types
1179
1180
1181 # ---------------------------------------------------------------------------
1182 # muse plan-merge
1183 # ---------------------------------------------------------------------------
1184
1185
1186 class TestPlanMerge:
1187 def test_plan_merge_same_commit_no_conflicts(self, code_repo: pathlib.Path) -> None:
1188 result = runner.invoke(cli, ["coord", "plan-merge", "HEAD", "HEAD"])
1189 assert result.exit_code == 0, result.output
1190
1191 def test_plan_merge_json(self, code_repo: pathlib.Path) -> None:
1192 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD", "HEAD"])
1193 assert result.exit_code == 0
1194 data = json.loads(result.output)
1195 assert "conflicts" in data or isinstance(data, dict)
1196
1197 def test_plan_merge_requires_two_args(self, code_repo: pathlib.Path) -> None:
1198 result = runner.invoke(cli, ["coord", "plan-merge", "--json", "HEAD"])
1199 assert result.exit_code != 0
1200
1201
1202 # ---------------------------------------------------------------------------
1203 # muse shard
1204 # ---------------------------------------------------------------------------
1205
1206
1207 class TestShard:
1208 def test_shard_exits_zero(self, code_repo: pathlib.Path) -> None:
1209 result = runner.invoke(cli, ["coord", "shard", "--agents", "2"])
1210 assert result.exit_code == 0, result.output
1211
1212 def test_shard_json(self, code_repo: pathlib.Path) -> None:
1213 result = runner.invoke(cli, ["coord", "shard", "--agents", "2", "--json"])
1214 assert result.exit_code == 0
1215 data = json.loads(result.output)
1216 assert "shards" in data
1217
1218 def test_shard_n_equals_1(self, code_repo: pathlib.Path) -> None:
1219 result = runner.invoke(cli, ["coord", "shard", "--agents", "1"])
1220 assert result.exit_code == 0
1221
1222 def test_shard_large_n(self, code_repo: pathlib.Path) -> None:
1223 # N larger than symbol count still works (produces fewer shards).
1224 result = runner.invoke(cli, ["coord", "shard", "--agents", "100"])
1225 assert result.exit_code == 0
1226
1227
1228 # ---------------------------------------------------------------------------
1229 # muse reconcile
1230 # ---------------------------------------------------------------------------
1231
1232
1233 class TestReconcile:
1234 def test_reconcile_exits_zero(self, code_repo: pathlib.Path) -> None:
1235 result = runner.invoke(cli, ["coord", "reconcile"])
1236 assert result.exit_code == 0, result.output
1237
1238 def test_reconcile_json(self, code_repo: pathlib.Path) -> None:
1239 result = runner.invoke(cli, ["coord", "reconcile", "--json"])
1240 assert result.exit_code == 0
1241 data = json.loads(result.output)
1242 assert isinstance(data, dict)
1243
1244
1245 # ---------------------------------------------------------------------------
1246 # muse breakage
1247 # ---------------------------------------------------------------------------
1248
1249
1250 class TestBreakage:
1251 def test_breakage_exits_zero_clean_tree(self, code_repo: pathlib.Path) -> None:
1252 result = runner.invoke(cli, ["code", "breakage"])
1253 assert result.exit_code == 0, result.output
1254
1255 def test_breakage_json(self, code_repo: pathlib.Path) -> None:
1256 result = runner.invoke(cli, ["code", "breakage", "--json"])
1257 assert result.exit_code == 0
1258 data = json.loads(result.output)
1259 # breakage JSON has "issues" list and error count.
1260 assert "issues" in data
1261 assert isinstance(data["issues"], list)
1262
1263 def test_breakage_language_filter(self, code_repo: pathlib.Path) -> None:
1264 result = runner.invoke(cli, ["code", "breakage", "--language", "Python"])
1265 assert result.exit_code == 0
1266
1267 def test_breakage_no_repo_errors(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
1268 monkeypatch.chdir(tmp_path)
1269 result = runner.invoke(cli, ["code", "breakage"])
1270 assert result.exit_code != 0
1271
1272
1273 # ---------------------------------------------------------------------------
1274 # muse invariants
1275 # ---------------------------------------------------------------------------
1276
1277
1278 class TestInvariants:
1279 def test_invariants_creates_toml_if_absent(self, code_repo: pathlib.Path) -> None:
1280 result = runner.invoke(cli, ["code", "invariants"])
1281 toml_path = code_repo / ".muse" / "invariants.toml"
1282 assert result.exit_code == 0 or toml_path.exists()
1283
1284 def test_invariants_json_with_empty_rules(self, code_repo: pathlib.Path) -> None:
1285 # Create empty invariants.toml
1286 (code_repo / ".muse" / "invariants.toml").write_text("# No rules\n")
1287 result = runner.invoke(cli, ["code", "invariants", "--json"])
1288 assert result.exit_code == 0
1289 # Output may be JSON or human-readable depending on rules count.
1290 output = result.output.strip()
1291 if output and not output.startswith("#"):
1292 try:
1293 data = json.loads(output)
1294 assert isinstance(data, dict)
1295 except json.JSONDecodeError:
1296 pass # Human-readable output is also acceptable.
1297
1298 def test_invariants_no_cycles_rule(self, code_repo: pathlib.Path) -> None:
1299 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1300 [[rules]]
1301 type = "no_cycles"
1302 name = "no import cycles"
1303 """))
1304 result = runner.invoke(cli, ["code", "invariants"])
1305 assert result.exit_code == 0
1306
1307 def test_invariants_forbidden_dependency_rule(self, code_repo: pathlib.Path) -> None:
1308 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1309 [[rules]]
1310 type = "forbidden_dependency"
1311 name = "billing must not import utils"
1312 source_pattern = "billing.py"
1313 forbidden_pattern = "utils.py"
1314 """))
1315 result = runner.invoke(cli, ["code", "invariants"])
1316 assert result.exit_code == 0
1317
1318 def test_invariants_required_test_rule(self, code_repo: pathlib.Path) -> None:
1319 (code_repo / ".muse" / "invariants.toml").write_text(textwrap.dedent("""\
1320 [[rules]]
1321 type = "required_test"
1322 name = "billing must have tests"
1323 source_pattern = "billing.py"
1324 test_pattern = "test_billing.py"
1325 """))
1326 result = runner.invoke(cli, ["code", "invariants"])
1327 # May pass or fail depending on whether test_billing.py exists; should not crash.
1328 assert result.exit_code in (0, 1)
1329
1330 def test_invariants_commit_flag(self, code_repo: pathlib.Path) -> None:
1331 (code_repo / ".muse" / "invariants.toml").write_text("# empty\n")
1332 result = runner.invoke(cli, ["code", "invariants", "--commit", "HEAD"])
1333 assert result.exit_code == 0
1334
1335
1336 # ---------------------------------------------------------------------------
1337 # muse commit — semantic versioning
1338 # ---------------------------------------------------------------------------
1339
1340
1341 class TestSemVerInCommit:
1342 def test_commit_record_has_sem_ver_bump(self, code_repo: pathlib.Path) -> None:
1343 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1344 commit_id = get_head_commit_id(code_repo, "main")
1345 assert commit_id is not None
1346 commit = read_commit(code_repo, commit_id)
1347 assert commit is not None
1348 assert commit.sem_ver_bump in ("major", "minor", "patch", "none")
1349
1350 def test_commit_record_has_breaking_changes(self, code_repo: pathlib.Path) -> None:
1351 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1352 commit_id = get_head_commit_id(code_repo, "main")
1353 assert commit_id is not None
1354 commit = read_commit(code_repo, commit_id)
1355 assert commit is not None
1356 assert isinstance(commit.breaking_changes, list)
1357
1358 def test_log_shows_semver_for_major_bump(self, code_repo: pathlib.Path) -> None:
1359 from muse.core.store import CommitDict, get_head_commit_id, read_commit
1360 commit_id = get_head_commit_id(code_repo, "main")
1361 assert commit_id is not None
1362 commit = read_commit(code_repo, commit_id)
1363 assert commit is not None
1364 if commit.sem_ver_bump == "major":
1365 result = runner.invoke(cli, ["log"])
1366 assert "MAJOR" in result.output or "major" in result.output.lower()
1367
1368
1369 # ---------------------------------------------------------------------------
1370 # Call-graph tier — muse impact
1371 # ---------------------------------------------------------------------------
1372
1373
1374 class TestImpact:
1375 def test_impact_exits_zero(self, code_repo: pathlib.Path) -> None:
1376 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::Invoice.compute_invoice_total"])
1377 assert result.exit_code == 0, result.output
1378
1379 def test_impact_json(self, code_repo: pathlib.Path) -> None:
1380 result = runner.invoke(cli, ["code", "impact", "--json", "billing.py::Invoice.apply_discount"])
1381 assert result.exit_code == 0
1382 data = json.loads(result.output)
1383 assert isinstance(data, dict)
1384 assert "blast_radius" in data
1385 assert "total" in data
1386 assert "commit_id" in data
1387 assert data["mode"] == "reverse"
1388
1389 def test_impact_nonexistent_symbol_handled(self, code_repo: pathlib.Path) -> None:
1390 result = runner.invoke(cli, ["code", "impact", "--", "billing.py::nonexistent"])
1391 assert result.exit_code in (0, 1)
1392
1393 def test_impact_count_only(self, code_repo: pathlib.Path) -> None:
1394 result = runner.invoke(cli, ["code", "impact", "--count", "--", "billing.py::Invoice.compute_invoice_total"])
1395 assert result.exit_code == 0
1396 assert result.output.strip().isdigit()
1397
1398 def test_impact_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1399 result = runner.invoke(cli, ["code", "impact", "--depth", "-1", "--", "billing.py::Invoice.compute_invoice_total"])
1400 assert result.exit_code == 1
1401
1402 def test_impact_forward_exits_zero(self, code_repo: pathlib.Path) -> None:
1403 result = runner.invoke(cli, ["code", "impact", "--forward", "--", "billing.py::Invoice.compute_invoice_total"])
1404 assert result.exit_code == 0
1405
1406 def test_impact_forward_json(self, code_repo: pathlib.Path) -> None:
1407 result = runner.invoke(cli, ["code", "impact", "--forward", "--json", "--", "billing.py::process_order"])
1408 assert result.exit_code == 0
1409 data = json.loads(result.output)
1410 assert data["mode"] == "forward"
1411 assert "callees" in data
1412 assert "total" in data
1413 assert "commit_id" in data
1414
1415 def test_impact_forward_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1416 result = runner.invoke(cli, [
1417 "code", "impact", "--forward", "--compare", "HEAD",
1418 "--", "billing.py::process_order",
1419 ])
1420 assert result.exit_code == 1
1421
1422 def test_impact_file_filter(self, code_repo: pathlib.Path) -> None:
1423 result = runner.invoke(cli, [
1424 "code", "impact", "--file", "billing.py",
1425 "--", "billing.py::Invoice.compute_invoice_total",
1426 ])
1427 assert result.exit_code == 0
1428
1429 def test_impact_file_filter_json(self, code_repo: pathlib.Path) -> None:
1430 result = runner.invoke(cli, [
1431 "code", "impact", "--file", "billing.py", "--json",
1432 "--", "billing.py::Invoice.compute_invoice_total",
1433 ])
1434 assert result.exit_code == 0
1435 data = json.loads(result.output)
1436 assert data["file_filter"] == "billing.py"
1437 for depth_addrs in data["blast_radius"].values():
1438 for addr in depth_addrs:
1439 assert addr.startswith("billing.py::")
1440
1441 def test_impact_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1442 result = runner.invoke(cli, [
1443 "code", "impact", "--compare", "HEAD",
1444 "--json", "--", "billing.py::Invoice.compute_invoice_total",
1445 ])
1446 assert result.exit_code == 0
1447 data = json.loads(result.output)
1448 assert "compare_commit_id" in data
1449 assert "added_callers" in data
1450 assert "removed_callers" in data
1451 assert "net_change" in data
1452 assert isinstance(data["added_callers"], list)
1453 assert isinstance(data["removed_callers"], list)
1454
1455 def test_impact_forward_count(self, code_repo: pathlib.Path) -> None:
1456 result = runner.invoke(cli, ["code", "impact", "--forward", "--count", "--", "billing.py::process_order"])
1457 assert result.exit_code == 0
1458 assert result.output.strip().isdigit()
1459
1460
1461 # ---------------------------------------------------------------------------
1462 # Call-graph tier — muse dead
1463 # ---------------------------------------------------------------------------
1464
1465
1466 class TestDead:
1467 def test_dead_exits_zero(self, code_repo: pathlib.Path) -> None:
1468 result = runner.invoke(cli, ["code", "dead"])
1469 assert result.exit_code == 0, result.output
1470
1471 def test_dead_json(self, code_repo: pathlib.Path) -> None:
1472 result = runner.invoke(cli, ["code", "dead", "--json"])
1473 assert result.exit_code == 0
1474 data = json.loads(result.output)
1475 assert isinstance(data, dict)
1476 assert "results" in data
1477 assert "high_confidence_count" in data
1478 assert "total_files_scanned" in data
1479 assert "elapsed_seconds" in data
1480
1481 def test_dead_kind_filter(self, code_repo: pathlib.Path) -> None:
1482 result = runner.invoke(cli, ["code", "dead", "--kind", "function"])
1483 assert result.exit_code == 0
1484
1485 def test_dead_include_tests(self, code_repo: pathlib.Path) -> None:
1486 result = runner.invoke(cli, ["code", "dead", "--include-tests"])
1487 assert result.exit_code == 0
1488
1489 def test_dead_count_only(self, code_repo: pathlib.Path) -> None:
1490 result = runner.invoke(cli, ["code", "dead", "--count"])
1491 assert result.exit_code == 0
1492 assert result.output.strip().isdigit()
1493
1494 def test_dead_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1495 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
1496 assert result.exit_code == 0
1497 data = json.loads(result.output)
1498 assert "compare_commit_id" in data
1499 assert "new_dead" in data
1500 assert "recovered" in data
1501 assert "net_change" in data
1502 assert isinstance(data["new_dead"], list)
1503 assert isinstance(data["recovered"], list)
1504
1505 def test_dead_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1506 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD"])
1507 assert result.exit_code == 0
1508
1509 def test_dead_delete_and_compare_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1510 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
1511 assert result.exit_code == 1
1512
1513 def test_dead_save_allowlist(self, code_repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1514 out_file = tmp_path / "allowlist.json"
1515 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(out_file)])
1516 assert result.exit_code == 0
1517 if out_file.exists():
1518 data = json.loads(out_file.read_text())
1519 assert isinstance(data, list)
1520 assert all(isinstance(x, str) for x in data)
1521
1522 def test_dead_high_confidence_only_json(self, code_repo: pathlib.Path) -> None:
1523 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
1524 assert result.exit_code == 0
1525 data = json.loads(result.output)
1526 for c in data["results"]:
1527 assert c["confidence"] == "high"
1528
1529 def test_dead_workers_cap_enforced(self, code_repo: pathlib.Path) -> None:
1530 result = runner.invoke(cli, ["code", "dead", "--workers", "999", "--count"])
1531 assert result.exit_code == 0
1532
1533
1534 # ---------------------------------------------------------------------------
1535 # muse code cat
1536 # ---------------------------------------------------------------------------
1537
1538
1539 class TestCat:
1540 def test_cat_basic(self, code_repo: pathlib.Path) -> None:
1541 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1542 assert result.exit_code == 0, result.output
1543 assert "class Invoice" in result.output
1544
1545 def test_cat_method(self, code_repo: pathlib.Path) -> None:
1546 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1547 assert result.exit_code == 0, result.output
1548 assert "def compute_invoice_total" in result.output
1549
1550 def test_cat_bare_name_unambiguous(self, code_repo: pathlib.Path) -> None:
1551 # Invoice is unique — short name should resolve.
1552 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1553 assert result.exit_code == 0
1554
1555 def test_cat_missing_separator_error(self, code_repo: pathlib.Path) -> None:
1556 result = runner.invoke(cli, ["code", "cat", "billing.py"])
1557 assert result.exit_code != 0
1558
1559 def test_cat_unknown_symbol_error(self, code_repo: pathlib.Path) -> None:
1560 result = runner.invoke(cli, ["code", "cat", "billing.py::NoSuchThing"])
1561 assert result.exit_code != 0
1562
1563 def test_cat_unknown_file_error(self, code_repo: pathlib.Path) -> None:
1564 result = runner.invoke(cli, ["code", "cat", "nope.py::Foo"])
1565 assert result.exit_code != 0
1566
1567 def test_cat_line_numbers(self, code_repo: pathlib.Path) -> None:
1568 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--line-numbers"])
1569 assert result.exit_code == 0
1570 # Line numbers prefix lines with digits.
1571 lines = [ln for ln in result.output.splitlines() if not ln.startswith("#")]
1572 first_code_line = next((ln for ln in lines if ln.strip()), "")
1573 assert first_code_line[:1].isdigit(), f"Expected digit prefix, got: {first_code_line!r}"
1574
1575 def test_cat_json_output(self, code_repo: pathlib.Path) -> None:
1576 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--json"])
1577 assert result.exit_code == 0
1578 data = json.loads(result.output)
1579 assert "results" in data
1580 assert "errors" in data
1581 assert "source_ref" in data
1582 assert len(data["results"]) == 1
1583 r = data["results"][0]
1584 assert r["file_path"] == "billing.py"
1585 assert r["kind"] in ("class", "function", "method")
1586 assert isinstance(r["lineno"], int)
1587 assert isinstance(r["end_lineno"], int)
1588 assert "class Invoice" in r["source"]
1589
1590 def test_cat_multi_address(self, code_repo: pathlib.Path) -> None:
1591 result = runner.invoke(
1592 cli,
1593 [
1594 "code", "cat",
1595 "billing.py::Invoice",
1596 "billing.py::Invoice.compute_invoice_total",
1597 "--json",
1598 ],
1599 )
1600 assert result.exit_code == 0, result.output
1601 data = json.loads(result.output)
1602 assert len(data["results"]) == 2
1603
1604 def test_cat_all_mode(self, code_repo: pathlib.Path) -> None:
1605 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all"])
1606 assert result.exit_code == 0
1607 assert "Invoice" in result.output
1608
1609 def test_cat_all_kind_filter(self, code_repo: pathlib.Path) -> None:
1610 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--kind", "function"])
1611 assert result.exit_code == 0
1612
1613 def test_cat_all_json(self, code_repo: pathlib.Path) -> None:
1614 result = runner.invoke(cli, ["code", "cat", "billing.py", "--all", "--json"])
1615 assert result.exit_code == 0
1616 data = json.loads(result.output)
1617 assert len(data["results"]) > 0
1618 # Every result has required fields.
1619 for r in data["results"]:
1620 assert "address" in r
1621 assert "lineno" in r
1622 assert "source" in r
1623
1624 def test_cat_context_lines(self, code_repo: pathlib.Path) -> None:
1625 result_plain = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total"])
1626 result_ctx = runner.invoke(
1627 cli, ["code", "cat", "billing.py::Invoice.compute_invoice_total", "--context", "2"]
1628 )
1629 assert result_ctx.exit_code == 0
1630 # With context we get at least as many lines.
1631 plain_lines = result_plain.output.count("\n")
1632 ctx_lines = result_ctx.output.count("\n")
1633 assert ctx_lines >= plain_lines
1634
1635 def test_cat_json_errors_field_on_bad_address(self, code_repo: pathlib.Path) -> None:
1636 # In --json mode a missing symbol goes to the errors field, not a crash.
1637 result = runner.invoke(
1638 cli,
1639 ["code", "cat", "billing.py::Invoice", "billing.py::NoSuchThing", "--json"],
1640 )
1641 # Output must be valid JSON (no stderr bleed into stdout).
1642 data = json.loads(result.output)
1643 assert len(data["results"]) == 1
1644 assert len(data["errors"]) == 1
1645 assert data["errors"][0]["address"] == "billing.py::NoSuchThing"
1646
1647 def test_cat_header_shows_working_tree(self, code_repo: pathlib.Path) -> None:
1648 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice"])
1649 assert result.exit_code == 0
1650 assert "working tree" in result.output
1651
1652 def test_cat_at_head(self, code_repo: pathlib.Path) -> None:
1653 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice", "--at", "HEAD"])
1654 assert result.exit_code == 0
1655 assert "Invoice" in result.output
1656
1657
1658 # ---------------------------------------------------------------------------
1659 # Call-graph tier — muse coverage
1660 # ---------------------------------------------------------------------------
1661
1662
1663 class TestCoverage:
1664 def test_coverage_exits_zero(self, code_repo: pathlib.Path) -> None:
1665 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::Invoice"])
1666 assert result.exit_code == 0, result.output
1667
1668 def test_coverage_json(self, code_repo: pathlib.Path) -> None:
1669 result = runner.invoke(cli, ["code", "coverage", "--json", "billing.py::Invoice"])
1670 assert result.exit_code == 0
1671 data = json.loads(result.output)
1672 assert isinstance(data, dict)
1673 assert "methods" in data
1674 assert "total_methods" in data
1675 assert "covered" in data
1676 assert "percent" in data
1677 assert "commit_id" in data
1678 assert "filters" in data
1679 for m in data["methods"]:
1680 assert "address" in m
1681 assert "called" in m
1682 assert "callers" in m
1683
1684 def test_coverage_nonexistent_class_handled(self, code_repo: pathlib.Path) -> None:
1685 result = runner.invoke(cli, ["code", "coverage", "--", "billing.py::NonExistent"])
1686 assert result.exit_code in (0, 1)
1687
1688 def test_coverage_count_only(self, code_repo: pathlib.Path) -> None:
1689 result = runner.invoke(cli, ["code", "coverage", "--count", "billing.py::Invoice"])
1690 assert result.exit_code == 0
1691 # Output should be "n/total" format
1692 assert "/" in result.output.strip()
1693
1694 def test_coverage_exclude_dunder(self, code_repo: pathlib.Path) -> None:
1695 result = runner.invoke(cli, [
1696 "code", "coverage", "--exclude-dunder", "--json", "billing.py::Invoice",
1697 ])
1698 assert result.exit_code == 0
1699 data = json.loads(result.output)
1700 assert data["filters"]["exclude_dunder"] is True
1701 for m in data["methods"]:
1702 assert not (m["name"].startswith("__") and m["name"].endswith("__"))
1703
1704 def test_coverage_exclude_private(self, code_repo: pathlib.Path) -> None:
1705 result = runner.invoke(cli, [
1706 "code", "coverage", "--exclude-private", "--json", "billing.py::Invoice",
1707 ])
1708 assert result.exit_code == 0
1709 data = json.loads(result.output)
1710 assert data["filters"]["exclude_private"] is True
1711
1712 def test_coverage_min_callers(self, code_repo: pathlib.Path) -> None:
1713 result = runner.invoke(cli, [
1714 "code", "coverage", "--min-callers", "2", "--json", "billing.py::Invoice",
1715 ])
1716 assert result.exit_code == 0
1717 data = json.loads(result.output)
1718 assert data["filters"]["min_callers"] == 2
1719
1720 def test_coverage_exclude_self(self, code_repo: pathlib.Path) -> None:
1721 result = runner.invoke(cli, [
1722 "code", "coverage", "--exclude-self", "--json", "billing.py::Invoice",
1723 ])
1724 assert result.exit_code == 0
1725 data = json.loads(result.output)
1726 assert data["filters"]["exclude_self"] is True
1727 # All reported callers should be from a different file
1728 for m in data["methods"]:
1729 for caller in m["callers"]:
1730 assert not caller.startswith("billing.py::")
1731
1732 def test_coverage_compare_json_schema(self, code_repo: pathlib.Path) -> None:
1733 result = runner.invoke(cli, [
1734 "code", "coverage", "--compare", "HEAD", "--json", "billing.py::Invoice",
1735 ])
1736 assert result.exit_code == 0
1737 data = json.loads(result.output)
1738 assert "compare_commit_id" in data
1739 assert "newly_covered" in data
1740 assert "newly_uncovered" in data
1741 assert "percent_change" in data
1742
1743 def test_coverage_compare_exits_zero(self, code_repo: pathlib.Path) -> None:
1744 result = runner.invoke(cli, [
1745 "code", "coverage", "--compare", "HEAD", "billing.py::Invoice",
1746 ])
1747 assert result.exit_code == 0
1748
1749 def test_coverage_no_show_callers(self, code_repo: pathlib.Path) -> None:
1750 result = runner.invoke(cli, [
1751 "code", "coverage", "--no-show-callers", "billing.py::Invoice",
1752 ])
1753 assert result.exit_code == 0
1754
1755
1756 # ---------------------------------------------------------------------------
1757 # Call-graph tier — muse deps
1758 # ---------------------------------------------------------------------------
1759
1760
1761 class TestDeps:
1762 def test_deps_file_mode(self, code_repo: pathlib.Path) -> None:
1763 result = runner.invoke(cli, ["code", "deps", "--", "billing.py"])
1764 assert result.exit_code == 0, result.output
1765
1766 def test_deps_reverse(self, code_repo: pathlib.Path) -> None:
1767 result = runner.invoke(cli, ["code", "deps", "--reverse", "billing.py"])
1768 assert result.exit_code == 0
1769
1770 def test_deps_json(self, code_repo: pathlib.Path) -> None:
1771 result = runner.invoke(cli, ["code", "deps", "--json", "billing.py"])
1772 assert result.exit_code == 0
1773 data = json.loads(result.output)
1774 assert isinstance(data, dict)
1775
1776 def test_deps_symbol_mode(self, code_repo: pathlib.Path) -> None:
1777 result = runner.invoke(cli, ["code", "deps", "--", "billing.py::Invoice.compute_invoice_total"])
1778 assert result.exit_code in (0, 1) # May be empty but shouldn't crash.
1779
1780 # ── new flags ──────────────────────────────────────────────────────────────
1781
1782 def test_deps_count_file_mode(self, code_repo: pathlib.Path) -> None:
1783 result = runner.invoke(cli, ["code", "deps", "--count", "billing.py"])
1784 assert result.exit_code == 0, result.output
1785 assert result.output.strip().isdigit()
1786
1787 def test_deps_count_reverse(self, code_repo: pathlib.Path) -> None:
1788 result = runner.invoke(cli, ["code", "deps", "--count", "--reverse", "billing.py"])
1789 assert result.exit_code == 0, result.output
1790 assert result.output.strip().isdigit()
1791
1792 def test_deps_filter_file_mode(self, code_repo: pathlib.Path) -> None:
1793 result = runner.invoke(
1794 cli, ["code", "deps", "--reverse", "--filter", "billing", "billing.py"]
1795 )
1796 assert result.exit_code == 0, result.output
1797
1798 def test_deps_depth_requires_symbol_mode(self, code_repo: pathlib.Path) -> None:
1799 # --depth > 1 in file mode is fine (just filters imports as before).
1800 result = runner.invoke(cli, ["code", "deps", "--depth", "2", "billing.py"])
1801 assert result.exit_code == 0, result.output
1802
1803 def test_deps_depth_negative_rejected(self, code_repo: pathlib.Path) -> None:
1804 result = runner.invoke(
1805 cli,
1806 ["code", "deps", "--depth", "-1", "billing.py::Invoice.compute_invoice_total"],
1807 )
1808 assert result.exit_code != 0
1809
1810 def test_deps_depth_symbol_reverse(self, code_repo: pathlib.Path) -> None:
1811 result = runner.invoke(
1812 cli,
1813 ["code", "deps", "--reverse", "--depth", "2",
1814 "billing.py::Invoice.compute_invoice_total"],
1815 )
1816 assert result.exit_code == 0, result.output
1817
1818 def test_deps_transitive_symbol(self, code_repo: pathlib.Path) -> None:
1819 result = runner.invoke(
1820 cli,
1821 ["code", "deps", "--transitive",
1822 "billing.py::Invoice.compute_invoice_total"],
1823 )
1824 assert result.exit_code == 0, result.output
1825
1826 def test_deps_transitive_count(self, code_repo: pathlib.Path) -> None:
1827 result = runner.invoke(
1828 cli,
1829 ["code", "deps", "--transitive", "--count",
1830 "billing.py::Invoice.compute_invoice_total"],
1831 )
1832 assert result.exit_code == 0
1833 assert result.output.strip().isdigit()
1834
1835 def test_deps_transitive_json_schema(self, code_repo: pathlib.Path) -> None:
1836 result = runner.invoke(
1837 cli,
1838 ["code", "deps", "--transitive", "--json",
1839 "billing.py::Invoice.compute_invoice_total"],
1840 )
1841 assert result.exit_code == 0
1842 data = json.loads(result.output)
1843 assert "by_depth" in data
1844 assert data["transitive"] is True
1845
1846 def test_deps_depth_json_schema(self, code_repo: pathlib.Path) -> None:
1847 result = runner.invoke(
1848 cli,
1849 ["code", "deps", "--reverse", "--depth", "2", "--json",
1850 "billing.py::Invoice.compute_invoice_total"],
1851 )
1852 assert result.exit_code == 0
1853 data = json.loads(result.output)
1854 assert "by_depth" in data
1855 assert data["depth"] == 2
1856
1857 def test_deps_path_traversal_rejected(self, code_repo: pathlib.Path) -> None:
1858 result = runner.invoke(cli, ["code", "deps", "../../../etc/passwd"])
1859 assert result.exit_code != 0
1860
1861 def test_deps_empty_file_rel_in_symbol_rejected(
1862 self, code_repo: pathlib.Path
1863 ) -> None:
1864 result = runner.invoke(cli, ["code", "deps", "--", "::some_func"])
1865 assert result.exit_code != 0
1866
1867 def test_deps_reverse_json_schema(self, code_repo: pathlib.Path) -> None:
1868 result = runner.invoke(
1869 cli, ["code", "deps", "--reverse", "--json", "billing.py"]
1870 )
1871 assert result.exit_code == 0
1872 data = json.loads(result.output)
1873 assert "imported_by" in data
1874 assert isinstance(data["imported_by"], list)
1875
1876
1877 # ---------------------------------------------------------------------------
1878 # Call-graph tier — muse find-symbol
1879 # ---------------------------------------------------------------------------
1880
1881
1882 class TestFindSymbol:
1883 def test_find_by_name(self, code_repo: pathlib.Path) -> None:
1884 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order"])
1885 assert result.exit_code == 0, result.output
1886
1887 def test_find_by_name_json(self, code_repo: pathlib.Path) -> None:
1888 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
1889 assert result.exit_code == 0
1890 data = json.loads(result.output)
1891 assert isinstance(data, dict)
1892 assert "results" in data
1893 assert "query" in data
1894 assert "total" in data
1895
1896 def test_find_by_kind(self, code_repo: pathlib.Path) -> None:
1897 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class"])
1898 assert result.exit_code == 0
1899 assert result.output is not None
1900
1901 def test_find_nonexistent_name_empty(self, code_repo: pathlib.Path) -> None:
1902 result = runner.invoke(cli, ["code", "find-symbol", "--name", "totally_nonexistent_xyzzy"])
1903 assert result.exit_code == 0
1904 assert "no matching" in result.output
1905
1906 def test_find_requires_at_least_one_flag(self, code_repo: pathlib.Path) -> None:
1907 result = runner.invoke(cli, ["code", "find-symbol"])
1908 assert result.exit_code == 1
1909
1910 def test_find_count_only(self, code_repo: pathlib.Path) -> None:
1911 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
1912 assert result.exit_code == 0
1913 assert result.output.strip().isdigit()
1914
1915 def test_find_first_and_last_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
1916 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--first", "--last"])
1917 assert result.exit_code == 1
1918
1919 def test_find_hash_too_short_rejected(self, code_repo: pathlib.Path) -> None:
1920 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
1921 assert result.exit_code == 1
1922
1923 def test_find_since_invalid_date(self, code_repo: pathlib.Path) -> None:
1924 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--since", "not-a-date"])
1925 assert result.exit_code == 1
1926
1927 def test_find_until_invalid_date(self, code_repo: pathlib.Path) -> None:
1928 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--until", "99/99/99"])
1929 assert result.exit_code == 1
1930
1931 def test_find_since_future_returns_empty(self, code_repo: pathlib.Path) -> None:
1932 result = runner.invoke(cli, [
1933 "code", "find-symbol", "--name", "process_order",
1934 "--since", "2099-01-01",
1935 ])
1936 assert result.exit_code == 0
1937 assert "no matching" in result.output
1938
1939 def test_find_limit(self, code_repo: pathlib.Path) -> None:
1940 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
1941 assert result.exit_code == 0
1942
1943 def test_find_file_filter(self, code_repo: pathlib.Path) -> None:
1944 result = runner.invoke(cli, [
1945 "code", "find-symbol", "--kind", "function", "--file", "billing.py",
1946 ])
1947 assert result.exit_code == 0
1948
1949 def test_find_prefix_name(self, code_repo: pathlib.Path) -> None:
1950 result = runner.invoke(cli, ["code", "find-symbol", "--name", "process*", "--json"])
1951 assert result.exit_code == 0
1952 data = json.loads(result.output)
1953 for ap in data["results"]:
1954 assert ap["name"].lower().startswith("process")
1955
1956 def test_find_first_deduplicates(self, code_repo: pathlib.Path) -> None:
1957 result_all = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--count"])
1958 result_first = runner.invoke(cli, ["code", "find-symbol", "--name", "process_order", "--first", "--count"])
1959 assert result_all.exit_code == 0
1960 assert result_first.exit_code == 0
1961 count_all = int(result_all.output.strip())
1962 count_first = int(result_first.output.strip())
1963 assert count_first <= count_all
1964
1965 def test_find_json_schema(self, code_repo: pathlib.Path) -> None:
1966 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
1967 assert result.exit_code == 0
1968 data = json.loads(result.output)
1969 assert "query" in data
1970 assert "results" in data
1971 assert "total" in data
1972 assert data["total"] == len(data["results"])
1973 if data["results"]:
1974 ap = data["results"][0]
1975 for key in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
1976 assert key in ap
1977
1978
1979 # ---------------------------------------------------------------------------
1980 # Call-graph tier — muse patch
1981 # ---------------------------------------------------------------------------
1982
1983
1984 class TestPatch:
1985 def test_patch_dry_run(self, code_repo: pathlib.Path) -> None:
1986 new_impl = textwrap.dedent("""\
1987 def send_email(address):
1988 return f"Sending to {address}"
1989 """)
1990 impl_file = code_repo / "send_email_impl.py"
1991 impl_file.write_text(new_impl)
1992 # patch takes ADDRESS SOURCE — put options before address.
1993 result = runner.invoke(cli, [
1994 "code", "patch", "--dry-run", "--", "billing.py::send_email", str(impl_file),
1995 ])
1996 assert result.exit_code in (0, 1, 2)
1997
1998 def test_patch_syntax_error_rejected(self, code_repo: pathlib.Path) -> None:
1999 bad_impl = "def broken(\n not valid python at all{"
2000 bad_file = code_repo / "bad.py"
2001 bad_file.write_text(bad_impl)
2002 result = runner.invoke(cli, [
2003 "code", "patch", "--", "billing.py::send_email", str(bad_file),
2004 ])
2005 # Invalid syntax must be rejected or command handles gracefully.
2006 assert result.exit_code in (0, 1, 2)
2007
2008
2009 # ---------------------------------------------------------------------------
2010 # Security — path traversal guards
2011 # ---------------------------------------------------------------------------
2012
2013
2014 class TestPatchPathTraversal:
2015 """patch must reject addresses whose file component escapes the repo root."""
2016
2017 def test_patch_traversal_address_rejected(self, code_repo: pathlib.Path) -> None:
2018 body = code_repo / "body.py"
2019 body.write_text("def foo(): pass\n")
2020 result = runner.invoke(cli, [
2021 "code", "patch",
2022 "--body", str(body),
2023 "../../etc/passwd::foo",
2024 ])
2025 assert result.exit_code == 1
2026
2027 def test_patch_traversal_nested_address_rejected(self, code_repo: pathlib.Path) -> None:
2028 body = code_repo / "body.py"
2029 body.write_text("def foo(): pass\n")
2030 result = runner.invoke(cli, [
2031 "code", "patch",
2032 "--body", str(body),
2033 "../../../tmp/evil::foo",
2034 ])
2035 assert result.exit_code == 1
2036
2037 def test_patch_json_valid_address(self, code_repo: pathlib.Path) -> None:
2038 """--json flag returns parseable JSON on a dry-run."""
2039 body = code_repo / "body.py"
2040 body.write_text("def send_email(address):\n return address\n")
2041 result = runner.invoke(cli, [
2042 "code", "patch",
2043 "--body", str(body),
2044 "--dry-run",
2045 "--json",
2046 "billing.py::send_email",
2047 ])
2048 # Address may or may not exist; if it exits 0 the output must be JSON.
2049 if result.exit_code == 0:
2050 data = json.loads(result.output)
2051 assert data["address"] == "billing.py::send_email"
2052 assert data["dry_run"] is True
2053
2054
2055 class TestCheckoutSymbolPathTraversal:
2056 """checkout-symbol must reject addresses whose file component escapes root."""
2057
2058 def test_checkout_symbol_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2059 result = runner.invoke(cli, [
2060 "code", "checkout-symbol",
2061 "--commit", "HEAD",
2062 "../../etc/passwd::foo",
2063 ])
2064 assert result.exit_code == 1
2065
2066 def test_checkout_symbol_json_flag_valid_address(self, code_repo: pathlib.Path) -> None:
2067 """--json with a missing symbol exits non-zero gracefully (no crash)."""
2068 result = runner.invoke(cli, [
2069 "code", "checkout-symbol",
2070 "--commit", "HEAD",
2071 "--json",
2072 "billing.py::nonexistent_func_xyz",
2073 ])
2074 # Either exits 1 (symbol not found) — but must not crash.
2075 assert result.exit_code in (0, 1)
2076
2077
2078 class TestSemanticCherryPickPathTraversal:
2079 """semantic-cherry-pick must reject addresses that escape the repo root."""
2080
2081 def test_scp_traversal_rejected(self, code_repo: pathlib.Path) -> None:
2082 result = runner.invoke(cli, [
2083 "code", "semantic-cherry-pick",
2084 "--from", "HEAD",
2085 "../../etc/passwd::foo",
2086 ])
2087 # The traversal-rejected symbol is recorded as not_found but the
2088 # command exits 0 (failed symbols don't abort the batch).
2089 # The key invariant is that no file outside the repo is written.
2090 # We assert exit_code is 0 (graceful) and the output does NOT write.
2091 assert result.exit_code in (0, 1)
2092 # No file was created outside the repo.
2093 assert not pathlib.Path("/etc/passwd_copy").exists()
2094
2095 def test_scp_traversal_shows_error_in_json(self, code_repo: pathlib.Path) -> None:
2096 result = runner.invoke(cli, [
2097 "code", "semantic-cherry-pick",
2098 "--from", "HEAD",
2099 "--json",
2100 "../../etc/passwd::foo",
2101 ])
2102 assert result.exit_code in (0, 1)
2103 if result.exit_code == 0:
2104 data = json.loads(result.output)
2105 assert data["applied"] == 0
2106 # The traversal-escaped address should be marked as not_found
2107 results = data.get("results", [])
2108 assert any(r["status"] == "not_found" for r in results)
2109
2110
2111 # ---------------------------------------------------------------------------
2112 # muse code blame
2113 # ---------------------------------------------------------------------------
2114
2115
2116 @pytest.fixture
2117 def blame_repo(repo: pathlib.Path) -> pathlib.Path:
2118 """Repo with four commits: seed → creation → modification → rename.
2119
2120 A seed commit is required so that the billing.py creation commit has
2121 a parent (and therefore a structured_delta with insert ops).
2122
2123 Timeline (oldest → newest):
2124 commit 0: README.md only (seed — gives billing.py commit a parent)
2125 commit 1: billing.py created — defines compute_total + process_order
2126 commit 2: compute_total implementation modified (same name)
2127 commit 3: compute_total renamed to compute_invoice_total
2128 """
2129 work = repo
2130
2131 # Seed commit so billing.py introduction has a parent and structured_delta.
2132 (work / "README.md").write_text("# Billing module\n")
2133 r = runner.invoke(cli, ["commit", "-m", "Seed commit"])
2134 assert r.exit_code == 0, r.output
2135
2136 (work / "billing.py").write_text(textwrap.dedent("""\
2137 def compute_total(items):
2138 return sum(items)
2139
2140 def process_order(items):
2141 return compute_total(items)
2142 """))
2143 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
2144 assert r.exit_code == 0, r.output
2145
2146 (work / "billing.py").write_text(textwrap.dedent("""\
2147 def compute_total(items):
2148 # faster implementation
2149 return sum(x for x in items)
2150
2151 def process_order(items):
2152 return compute_total(items)
2153 """))
2154 r = runner.invoke(cli, ["commit", "-m", "Optimise compute_total"])
2155 assert r.exit_code == 0, r.output
2156
2157 (work / "billing.py").write_text(textwrap.dedent("""\
2158 def compute_invoice_total(items):
2159 # faster implementation
2160 return sum(x for x in items)
2161
2162 def process_order(items):
2163 return compute_invoice_total(items)
2164 """))
2165 r = runner.invoke(cli, ["commit", "-m", "Rename compute_total -> compute_invoice_total"])
2166 assert r.exit_code == 0, r.output
2167
2168 return repo
2169
2170
2171 class TestBlame:
2172 """Tests for muse code blame."""
2173
2174 # ── address validation ───────────────────────────────────────────────────
2175
2176 def test_invalid_address_no_separator_exits_error(
2177 self, blame_repo: pathlib.Path
2178 ) -> None:
2179 result = runner.invoke(cli, ["code", "blame", "billing.py"])
2180 assert result.exit_code == 1
2181 assert "Invalid address" in result.output or "::" in result.output
2182
2183 def test_max_zero_exits_error(self, blame_repo: pathlib.Path) -> None:
2184 result = runner.invoke(
2185 cli, ["code", "blame", "billing.py::compute_invoice_total", "--max", "0"]
2186 )
2187 assert result.exit_code == 1
2188
2189 # ── basic correctness (no rename involved) ───────────────────────────────
2190
2191 def test_blame_existing_stable_symbol(self, blame_repo: pathlib.Path) -> None:
2192 """A symbol that was never renamed should have created + modified events."""
2193 result = runner.invoke(
2194 cli, ["code", "blame", "billing.py::process_order", "--json"]
2195 )
2196 assert result.exit_code == 0, result.output
2197 data = json.loads(result.output)
2198 kinds = [ev["event"] for ev in data["events"]]
2199 assert "created" in kinds
2200
2201 def test_blame_no_match_exits_zero(self, blame_repo: pathlib.Path) -> None:
2202 result = runner.invoke(
2203 cli, ["code", "blame", "billing.py::nonexistent_fn"]
2204 )
2205 assert result.exit_code == 0
2206 assert "no events found" in result.output
2207
2208 # ── rename tracking — new name (the critical regression) ─────────────────
2209
2210 def test_blame_new_name_finds_rename_event(self, blame_repo: pathlib.Path) -> None:
2211 """Blaming the POST-rename name must find the rename event."""
2212 result = runner.invoke(
2213 cli, ["code", "blame", "billing.py::compute_invoice_total", "--json"]
2214 )
2215 assert result.exit_code == 0, result.output
2216 data = json.loads(result.output)
2217 kinds = [ev["event"] for ev in data["events"]]
2218 assert "renamed" in kinds, f"Expected rename event, got: {kinds}"
2219
2220 def test_blame_new_name_follows_into_old_history(
2221 self, blame_repo: pathlib.Path
2222 ) -> None:
2223 """After finding the rename, blame must continue tracking the old name.
2224
2225 The symbol was created as compute_total → modified → renamed.
2226 Blaming compute_invoice_total should find ALL three events.
2227 """
2228 result = runner.invoke(
2229 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"]
2230 )
2231 assert result.exit_code == 0, result.output
2232 data = json.loads(result.output)
2233 kinds = [ev["event"] for ev in data["events"]]
2234 assert "created" in kinds, f"Expected created event, got: {kinds}"
2235 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2236
2237 # ── rename tracking — old name ────────────────────────────────────────────
2238
2239 def test_blame_old_name_finds_creation(self, blame_repo: pathlib.Path) -> None:
2240 """Blaming the PRE-rename name must find the creation event."""
2241 result = runner.invoke(
2242 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2243 )
2244 assert result.exit_code == 0, result.output
2245 data = json.loads(result.output)
2246 kinds = [ev["event"] for ev in data["events"]]
2247 assert "created" in kinds, f"Expected created event, got: {kinds}"
2248
2249 def test_blame_old_name_finds_rename_not_lost(
2250 self, blame_repo: pathlib.Path
2251 ) -> None:
2252 """Blaming the old name should also surface the rename event."""
2253 result = runner.invoke(
2254 cli, ["code", "blame", "billing.py::compute_total", "--all", "--json"]
2255 )
2256 assert result.exit_code == 0, result.output
2257 data = json.loads(result.output)
2258 kinds = [ev["event"] for ev in data["events"]]
2259 assert "renamed" in kinds, f"Expected renamed event, got: {kinds}"
2260
2261 # ── JSON schema ───────────────────────────────────────────────────────────
2262
2263 def test_blame_json_top_level_schema(self, blame_repo: pathlib.Path) -> None:
2264 result = runner.invoke(
2265 cli, ["code", "blame", "billing.py::process_order", "--json"]
2266 )
2267 assert result.exit_code == 0, result.output
2268 data = json.loads(result.output)
2269 for key in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
2270 assert key in data, f"missing key: {key}"
2271 assert isinstance(data["events"], list)
2272 assert isinstance(data["truncated"], bool)
2273 assert isinstance(data["total_commits_scanned"], int)
2274
2275 def test_blame_json_event_schema(self, blame_repo: pathlib.Path) -> None:
2276 result = runner.invoke(
2277 cli,
2278 ["code", "blame", "billing.py::compute_invoice_total", "--all", "--json"],
2279 )
2280 assert result.exit_code == 0, result.output
2281 data = json.loads(result.output)
2282 assert data["events"], "expected at least one event"
2283 ev = data["events"][0]
2284 for field in (
2285 "event", "commit_id", "author", "message",
2286 "committed_at", "address", "detail",
2287 ):
2288 assert field in ev, f"missing event field: {field}"
2289
2290 def test_blame_json_address_field_matches_input(
2291 self, blame_repo: pathlib.Path
2292 ) -> None:
2293 addr = "billing.py::process_order"
2294 result = runner.invoke(cli, ["code", "blame", addr, "--json"])
2295 data = json.loads(result.output)
2296 assert data["address"] == addr
2297
2298 # ── --max truncation ──────────────────────────────────────────────────────
2299
2300 def test_blame_max_limits_scan(self, blame_repo: pathlib.Path) -> None:
2301 result = runner.invoke(
2302 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2303 )
2304 assert result.exit_code == 0, result.output
2305 data = json.loads(result.output)
2306 assert data["total_commits_scanned"] <= 1
2307
2308 def test_blame_truncation_flag_set_when_capped(
2309 self, blame_repo: pathlib.Path
2310 ) -> None:
2311 result = runner.invoke(
2312 cli, ["code", "blame", "billing.py::process_order", "--max", "1", "--json"]
2313 )
2314 data = json.loads(result.output)
2315 assert data["truncated"] is True
2316
2317 def test_blame_truncation_warning_in_human_output(
2318 self, blame_repo: pathlib.Path
2319 ) -> None:
2320 result = runner.invoke(
2321 cli, ["code", "blame", "billing.py::process_order", "--max", "1"]
2322 )
2323 assert result.exit_code == 0, result.output
2324 assert "incomplete" in result.output.lower() or "max" in result.output.lower()
2325
2326 # ── human output ─────────────────────────────────────────────────────────
2327
2328 def test_blame_human_shows_last_touched(self, blame_repo: pathlib.Path) -> None:
2329 result = runner.invoke(
2330 cli, ["code", "blame", "billing.py::process_order"]
2331 )
2332 assert result.exit_code == 0, result.output
2333 assert "last touched:" in result.output
2334
2335 def test_blame_show_all_flag(self, blame_repo: pathlib.Path) -> None:
2336 result_default = runner.invoke(
2337 cli, ["code", "blame", "billing.py::compute_invoice_total"]
2338 )
2339 result_all = runner.invoke(
2340 cli, ["code", "blame", "billing.py::compute_invoice_total", "--all"]
2341 )
2342 assert result_all.exit_code == 0, result_all.output
2343 # --all shows at least as many lines as default
2344 assert len(result_all.output) >= len(result_default.output)
2345
2346 # ── BFS follows merge parents ─────────────────────────────────────────────
2347
2348 def test_blame_bfs_follows_merge_parent2(
2349 self, repo: pathlib.Path
2350 ) -> None:
2351 """A symbol introduced on a feature branch is visible after merging."""
2352 # Main: empty billing.py
2353 (repo / "billing.py").write_text("def main_fn(): pass\n")
2354 runner.invoke(cli, ["commit", "-m", "main commit"])
2355
2356 # Feature branch: add feature_fn
2357 runner.invoke(cli, ["branch", "feat/feature"])
2358 runner.invoke(cli, ["checkout", "feat/feature"])
2359 (repo / "billing.py").write_text("def main_fn(): pass\ndef feature_fn(): pass\n")
2360 runner.invoke(cli, ["commit", "-m", "add feature_fn"])
2361
2362 # Merge back to main
2363 runner.invoke(cli, ["checkout", "main"])
2364 runner.invoke(cli, ["merge", "feat/feature", "--force"])
2365
2366 # Blame feature_fn — should find 'created' event on the feature branch
2367 result = runner.invoke(
2368 cli, ["code", "blame", "billing.py::feature_fn", "--json"]
2369 )
2370 assert result.exit_code == 0, result.output
2371 data = json.loads(result.output)
2372 kinds = [ev["event"] for ev in data["events"]]
2373 assert "created" in kinds, (
2374 f"Expected created event for feature_fn after merge; got: {kinds}"
2375 )
2376
2377
2378 # ---------------------------------------------------------------------------
2379 # Security — ReDoS guard in grep
2380 # ---------------------------------------------------------------------------
2381
2382
2383 class TestGrepReDoS:
2384 """grep must reject patterns longer than 512 characters."""
2385
2386 def test_long_pattern_rejected(self, code_repo: pathlib.Path) -> None:
2387 long_pattern = "a" * 513
2388 result = runner.invoke(cli, ["code", "grep", long_pattern])
2389 assert result.exit_code == 1
2390 assert "too long" in result.output.lower() or "512" in result.output
2391
2392 def test_exactly_512_chars_accepted(self, code_repo: pathlib.Path) -> None:
2393 pattern = "a" * 512
2394 result = runner.invoke(cli, ["code", "grep", pattern])
2395 # Should not exit with ReDoS-rejection code (may be 0 or 1 for no matches).
2396 assert result.exit_code != 1 or "too long" not in result.output.lower()
2397
2398 def test_invalid_regex_rejected(self, code_repo: pathlib.Path) -> None:
2399 result = runner.invoke(cli, ["code", "grep", "--regex", "[unclosed"])
2400 assert result.exit_code == 1
2401
2402
2403 # ---------------------------------------------------------------------------
2404 # JSON output — index status and rebuild
2405 # ---------------------------------------------------------------------------
2406
2407
2408 class TestIndexJsonOutput:
2409 def test_index_status_json(self, code_repo: pathlib.Path) -> None:
2410 result = runner.invoke(cli, ["code", "index", "status", "--json"])
2411 assert result.exit_code == 0, result.output
2412 data = json.loads(result.output)
2413 assert isinstance(data, list)
2414 names = [entry["name"] for entry in data]
2415 assert "symbol_history" in names
2416 assert "hash_occurrence" in names
2417 for entry in data:
2418 assert "status" in entry
2419 assert "entries" in entry
2420
2421 def test_index_rebuild_json(self, code_repo: pathlib.Path) -> None:
2422 result = runner.invoke(cli, ["code", "index", "rebuild", "--json"])
2423 assert result.exit_code == 0, result.output
2424 data = json.loads(result.output)
2425 assert isinstance(data, dict)
2426 assert "rebuilt" in data
2427 assert isinstance(data["rebuilt"], list)
2428 assert "symbol_history" in data["rebuilt"]
2429 assert "hash_occurrence" in data["rebuilt"]
2430
2431 def test_index_rebuild_single_json(self, code_repo: pathlib.Path) -> None:
2432 result = runner.invoke(cli, [
2433 "code", "index", "rebuild", "--index", "symbol_history", "--json"
2434 ])
2435 assert result.exit_code == 0, result.output
2436 data = json.loads(result.output)
2437 assert "symbol_history" in data.get("rebuilt", [])
2438 assert "symbol_history_addresses" in data
2439
2440
2441 # ---------------------------------------------------------------------------
2442 # Extended — muse code index status
2443 # ---------------------------------------------------------------------------
2444
2445
2446 class TestIndexStatusExtended:
2447 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2448 """-j is equivalent to --json."""
2449 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2450 assert result.exit_code == 0, result.output
2451 data = json.loads(result.output.strip())
2452 assert isinstance(data, list)
2453
2454 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2455 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2456 assert result.exit_code == 0
2457
2458 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2459 """JSON output is compact — single line, no indent=2."""
2460 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2461 assert result.exit_code == 0
2462 lines = [l for l in result.output.splitlines() if l.strip()]
2463 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2464
2465 def test_json_is_list(self, code_repo: pathlib.Path) -> None:
2466 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2467 data = json.loads(result.output.strip())
2468 assert isinstance(data, list)
2469
2470 def test_json_contains_symbol_history(self, code_repo: pathlib.Path) -> None:
2471 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2472 data = json.loads(result.output.strip())
2473 names = [e["name"] for e in data]
2474 assert "symbol_history" in names
2475
2476 def test_json_contains_hash_occurrence(self, code_repo: pathlib.Path) -> None:
2477 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2478 data = json.loads(result.output.strip())
2479 names = [e["name"] for e in data]
2480 assert "hash_occurrence" in names
2481
2482 def test_json_fields_all_present(self, code_repo: pathlib.Path) -> None:
2483 """Every entry has name, status, entries, updated_at."""
2484 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2485 data = json.loads(result.output.strip())
2486 for entry in data:
2487 assert "name" in entry
2488 assert "status" in entry
2489 assert "entries" in entry
2490 assert "updated_at" in entry
2491
2492 def test_absent_status_before_rebuild(self, code_repo: pathlib.Path) -> None:
2493 """Freshly initialised repo: both indexes are absent."""
2494 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2495 data = json.loads(result.output.strip())
2496 statuses = {e["name"]: e["status"] for e in data}
2497 assert statuses["symbol_history"] == "absent"
2498 assert statuses["hash_occurrence"] == "absent"
2499
2500 def test_absent_entries_is_zero(self, code_repo: pathlib.Path) -> None:
2501 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2502 data = json.loads(result.output.strip())
2503 for entry in data:
2504 if entry["status"] == "absent":
2505 assert entry["entries"] == 0
2506
2507 def test_absent_updated_at_is_null(self, code_repo: pathlib.Path) -> None:
2508 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2509 data = json.loads(result.output.strip())
2510 for entry in data:
2511 if entry["status"] == "absent":
2512 assert entry["updated_at"] is None
2513
2514 def test_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2515 """After rebuild all indexes report present."""
2516 runner.invoke(cli, ["code", "index", "rebuild"])
2517 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2518 data = json.loads(result.output.strip())
2519 for entry in data:
2520 assert entry["status"] == "present", f"{entry['name']} not present after rebuild"
2521
2522 def test_entries_nonzero_after_rebuild(self, code_repo: pathlib.Path) -> None:
2523 """symbol_history should have entries after two commits."""
2524 runner.invoke(cli, ["code", "index", "rebuild"])
2525 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2526 data = json.loads(result.output.strip())
2527 sh = next(e for e in data if e["name"] == "symbol_history")
2528 assert sh["entries"] > 0
2529
2530 def test_updated_at_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2531 runner.invoke(cli, ["code", "index", "rebuild"])
2532 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2533 data = json.loads(result.output.strip())
2534 for entry in data:
2535 assert entry["updated_at"] is not None
2536
2537 def test_corrupt_status_reported(self, code_repo: pathlib.Path) -> None:
2538 """A file with bad content is reported as corrupt, not absent."""
2539 idx_dir = code_repo / ".muse" / "indices"
2540 idx_dir.mkdir(parents=True, exist_ok=True)
2541 (idx_dir / "symbol_history.msgpack").write_bytes(b"\xff\xfe")
2542 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2543 assert result.exit_code == 0
2544 data = json.loads(result.output.strip())
2545 sh = next(e for e in data if e["name"] == "symbol_history")
2546 assert sh["status"] == "corrupt"
2547
2548 def test_corrupt_does_not_crash(self, code_repo: pathlib.Path) -> None:
2549 idx_dir = code_repo / ".muse" / "indices"
2550 idx_dir.mkdir(parents=True, exist_ok=True)
2551 (idx_dir / "hash_occurrence.msgpack").write_bytes(b"notmsgpack")
2552 result = runner.invoke(cli, ["code", "index", "status"])
2553 assert result.exit_code == 0
2554
2555 def test_text_mode_shows_absent_hint(self, code_repo: pathlib.Path) -> None:
2556 """Text mode suggests rebuild command when index is absent."""
2557 result = runner.invoke(cli, ["code", "index", "status"])
2558 assert "rebuild" in result.output.lower()
2559
2560 def test_text_mode_shows_present_after_rebuild(self, code_repo: pathlib.Path) -> None:
2561 runner.invoke(cli, ["code", "index", "rebuild"])
2562 result = runner.invoke(cli, ["code", "index", "status"])
2563 assert "✅" in result.output
2564
2565 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2566 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2567 assert "Agent quickstart" in result.output
2568
2569 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2570 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2571 assert "JSON output schema" in result.output
2572
2573 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2574 result = runner.invoke(cli, ["code", "index", "status", "--help"])
2575 assert "Exit codes" in result.output
2576
2577
2578 # ---------------------------------------------------------------------------
2579 # Security — muse code index status
2580 # ---------------------------------------------------------------------------
2581
2582
2583 class TestIndexStatusSecurity:
2584 def test_corrupt_index_no_traceback(self, code_repo: pathlib.Path) -> None:
2585 """A corrupt index file must not surface a traceback."""
2586 idx_dir = code_repo / ".muse" / "indices"
2587 idx_dir.mkdir(parents=True, exist_ok=True)
2588 (idx_dir / "symbol_history.msgpack").write_bytes(b"\x00" * 16)
2589 result = runner.invoke(cli, ["code", "index", "status"])
2590 assert "Traceback" not in result.output
2591
2592 def test_json_names_come_from_known_list(self, code_repo: pathlib.Path) -> None:
2593 """JSON output names are only from KNOWN_INDEX_NAMES, never user input."""
2594 from muse.core.indices import KNOWN_INDEX_NAMES
2595 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2596 data = json.loads(result.output.strip())
2597 for entry in data:
2598 assert entry["name"] in KNOWN_INDEX_NAMES
2599
2600 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2601 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2602 assert "\x1b" not in result.output
2603
2604 def test_status_valid_values_only(self, code_repo: pathlib.Path) -> None:
2605 """status field is always one of the three allowed values."""
2606 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2607 data = json.loads(result.output.strip())
2608 for entry in data:
2609 assert entry["status"] in ("present", "absent", "corrupt")
2610
2611 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2612 monkeypatch.chdir(tmp_path)
2613 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2614 result = runner.invoke(cli, ["code", "index", "status"])
2615 assert "Traceback" not in result.output
2616 assert result.exit_code != 0
2617
2618 def test_entries_is_always_int(self, code_repo: pathlib.Path) -> None:
2619 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2620 data = json.loads(result.output.strip())
2621 for entry in data:
2622 assert isinstance(entry["entries"], int)
2623
2624
2625 # ---------------------------------------------------------------------------
2626 # Stress — muse code index status
2627 # ---------------------------------------------------------------------------
2628
2629
2630 class TestIndexStatusStress:
2631 def test_50_sequential_status_calls(self, code_repo: pathlib.Path) -> None:
2632 """50 sequential status calls all exit 0."""
2633 for i in range(50):
2634 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2635 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2636
2637 def test_status_stable_after_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
2638 """Status correctly reflects present/absent through 100 rebuild-purge cycles."""
2639 for i in range(100):
2640 runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history"])
2641 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2642 data = json.loads(result.output.strip())
2643 sh = next(e for e in data if e["name"] == "symbol_history")
2644 assert sh["status"] == "present", f"Cycle {i}: expected present, got {sh['status']}"
2645 runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history"])
2646 result = runner.invoke(cli, ["code", "index", "status", "-j"])
2647 data = json.loads(result.output.strip())
2648 sh = next(e for e in data if e["name"] == "symbol_history")
2649 assert sh["status"] == "absent", f"Cycle {i}: expected absent after purge, got {sh['status']}"
2650
2651 def test_concurrent_status_8_threads(self, code_repo: pathlib.Path) -> None:
2652 """8 threads reading index status concurrently — all must succeed."""
2653 import argparse
2654 import threading
2655
2656 from muse.cli.commands.index_rebuild import run_status
2657
2658 errors: list[str] = []
2659
2660 def worker(idx: int) -> None:
2661 args = argparse.Namespace(as_json=True)
2662 try:
2663 run_status(args)
2664 except SystemExit as exc:
2665 if exc.code != 0:
2666 errors.append(f"Thread {idx}: exit {exc.code}")
2667 except Exception as exc:
2668 errors.append(f"Thread {idx}: {exc}")
2669
2670 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2671 for t in threads:
2672 t.start()
2673 for t in threads:
2674 t.join()
2675 assert not errors, f"Concurrent failures: {errors}"
2676
2677
2678 # ---------------------------------------------------------------------------
2679 # Extended — muse code index rebuild
2680 # ---------------------------------------------------------------------------
2681
2682
2683 class TestIndexRebuildExtended:
2684 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2685 """-j is equivalent to --json."""
2686 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2687 assert result.exit_code == 0, result.output
2688 data = json.loads(result.output.strip())
2689 assert "rebuilt" in data
2690
2691 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2692 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2693 assert result.exit_code == 0
2694
2695 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2696 """JSON output is a single compact line — no indent=2."""
2697 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2698 assert result.exit_code == 0
2699 lines = [l for l in result.output.splitlines() if l.strip()]
2700 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2701
2702 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2703 """JSON output always has schema_version, dry_run, rebuilt."""
2704 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2705 data = json.loads(result.output.strip())
2706 assert "schema_version" in data
2707 assert "dry_run" in data
2708 assert "rebuilt" in data
2709
2710 def test_json_rebuilt_contains_both_by_default(self, code_repo: pathlib.Path) -> None:
2711 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2712 data = json.loads(result.output.strip())
2713 assert "symbol_history" in data["rebuilt"]
2714 assert "hash_occurrence" in data["rebuilt"]
2715
2716 def test_json_dry_run_false_by_default(self, code_repo: pathlib.Path) -> None:
2717 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2718 data = json.loads(result.output.strip())
2719 assert data["dry_run"] is False
2720
2721 def test_dry_run_flag_sets_dry_run_true(self, code_repo: pathlib.Path) -> None:
2722 result = runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2723 assert result.exit_code == 0
2724 data = json.loads(result.output.strip())
2725 assert data["dry_run"] is True
2726
2727 def test_dry_run_writes_no_files(self, code_repo: pathlib.Path) -> None:
2728 """--dry-run must not create index files."""
2729 idx_dir = code_repo / ".muse" / "indices"
2730 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run"])
2731 assert not (idx_dir / "symbol_history.msgpack").exists()
2732 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2733
2734 def test_symbol_history_only_flag(self, code_repo: pathlib.Path) -> None:
2735 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2736 assert result.exit_code == 0
2737 data = json.loads(result.output.strip())
2738 assert data["rebuilt"] == ["symbol_history"]
2739 assert "symbol_history_addresses" in data
2740 assert "hash_occurrence_clusters" not in data
2741
2742 def test_hash_occurrence_only_flag(self, code_repo: pathlib.Path) -> None:
2743 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2744 assert result.exit_code == 0
2745 data = json.loads(result.output.strip())
2746 assert data["rebuilt"] == ["hash_occurrence"]
2747 assert "hash_occurrence_clusters" in data
2748 assert "symbol_history_addresses" not in data
2749
2750 def test_symbol_history_addresses_is_int(self, code_repo: pathlib.Path) -> None:
2751 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "symbol_history", "-j"])
2752 data = json.loads(result.output.strip())
2753 assert isinstance(data["symbol_history_addresses"], int)
2754 assert isinstance(data["symbol_history_events"], int)
2755
2756 def test_hash_occurrence_fields_are_int(self, code_repo: pathlib.Path) -> None:
2757 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
2758 data = json.loads(result.output.strip())
2759 assert isinstance(data["hash_occurrence_clusters"], int)
2760 assert isinstance(data["hash_occurrence_addresses"], int)
2761
2762 def test_rebuild_creates_index_files(self, code_repo: pathlib.Path) -> None:
2763 runner.invoke(cli, ["code", "index", "rebuild"])
2764 idx_dir = code_repo / ".muse" / "indices"
2765 assert (idx_dir / "symbol_history.msgpack").exists()
2766 assert (idx_dir / "hash_occurrence.msgpack").exists()
2767
2768 def test_rebuild_is_idempotent(self, code_repo: pathlib.Path) -> None:
2769 """Two sequential rebuilds both exit 0 and produce consistent counts."""
2770 r1 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2771 r2 = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2772 assert r1.exit_code == 0 and r2.exit_code == 0
2773 d1 = json.loads(r1.output.strip())
2774 d2 = json.loads(r2.output.strip())
2775 assert d1["symbol_history_addresses"] == d2["symbol_history_addresses"]
2776
2777 def test_verbose_flag_shows_progress(self, code_repo: pathlib.Path) -> None:
2778 result = runner.invoke(cli, ["code", "index", "rebuild", "--verbose"])
2779 assert result.exit_code == 0
2780 assert "Building" in result.output
2781
2782 def test_text_mode_shows_rebuilt_count(self, code_repo: pathlib.Path) -> None:
2783 result = runner.invoke(cli, ["code", "index", "rebuild"])
2784 assert "Rebuilt" in result.output or "index" in result.output.lower()
2785
2786 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
2787 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2788 assert "Agent quickstart" in result.output
2789
2790 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
2791 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2792 assert "JSON output schema" in result.output
2793
2794 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
2795 result = runner.invoke(cli, ["code", "index", "rebuild", "--help"])
2796 assert "Exit codes" in result.output
2797
2798
2799 # ---------------------------------------------------------------------------
2800 # Security — muse code index rebuild
2801 # ---------------------------------------------------------------------------
2802
2803
2804 class TestIndexRebuildSecurity:
2805 def test_invalid_index_name_rejected_by_argparse(self, code_repo: pathlib.Path) -> None:
2806 """An unknown --index value must be rejected before run_rebuild is called."""
2807 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", "evil_index"])
2808 assert result.exit_code != 0
2809
2810 def test_dry_run_never_writes_files(self, code_repo: pathlib.Path) -> None:
2811 idx_dir = code_repo / ".muse" / "indices"
2812 runner.invoke(cli, ["code", "index", "rebuild", "--dry-run", "-j"])
2813 assert not (idx_dir / "symbol_history.msgpack").exists()
2814 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2815
2816 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
2817 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2818 assert "\x1b" not in result.output
2819
2820 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
2821 monkeypatch.chdir(tmp_path)
2822 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
2823 result = runner.invoke(cli, ["code", "index", "rebuild"])
2824 assert "Traceback" not in result.output
2825 assert result.exit_code != 0
2826
2827 def test_rebuilt_list_only_known_names(self, code_repo: pathlib.Path) -> None:
2828 """rebuilt list must only contain names from KNOWN_INDEX_NAMES."""
2829 from muse.core.indices import KNOWN_INDEX_NAMES
2830 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2831 data = json.loads(result.output.strip())
2832 for name in data["rebuilt"]:
2833 assert name in KNOWN_INDEX_NAMES
2834
2835 def test_schema_version_is_string(self, code_repo: pathlib.Path) -> None:
2836 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2837 data = json.loads(result.output.strip())
2838 assert isinstance(data["schema_version"], str)
2839 assert len(data["schema_version"]) > 0
2840
2841
2842 # ---------------------------------------------------------------------------
2843 # Stress — muse code index rebuild
2844 # ---------------------------------------------------------------------------
2845
2846
2847 class TestIndexRebuildStress:
2848 def test_50_sequential_rebuild_calls(self, code_repo: pathlib.Path) -> None:
2849 """50 sequential rebuilds all exit 0."""
2850 for i in range(50):
2851 result = runner.invoke(cli, ["code", "index", "rebuild", "-j"])
2852 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
2853
2854 def test_100_alternate_single_index_rebuilds(self, code_repo: pathlib.Path) -> None:
2855 """Alternate rebuilding symbol_history and hash_occurrence 100 times."""
2856 indexes = ["symbol_history", "hash_occurrence"]
2857 for i in range(100):
2858 target = indexes[i % 2]
2859 result = runner.invoke(cli, ["code", "index", "rebuild", "--index", target, "-j"])
2860 assert result.exit_code == 0, f"Step {i} ({target}): {result.output}"
2861 data = json.loads(result.output.strip())
2862 assert target in data["rebuilt"]
2863
2864 def test_concurrent_rebuild_8_threads(self, code_repo: pathlib.Path) -> None:
2865 """8 threads rebuilding hash_occurrence concurrently via core function."""
2866 import argparse
2867 import threading
2868
2869 from muse.cli.commands.index_rebuild import run_rebuild
2870
2871 errors: list[str] = []
2872
2873 def worker(idx: int) -> None:
2874 args = argparse.Namespace(
2875 index_name="hash_occurrence",
2876 dry_run=True, # dry_run avoids concurrent write races
2877 verbose=False,
2878 as_json=True,
2879 )
2880 try:
2881 run_rebuild(args)
2882 except SystemExit as exc:
2883 if exc.code != 0:
2884 errors.append(f"Thread {idx}: exit {exc.code}")
2885 except Exception as exc:
2886 errors.append(f"Thread {idx}: {exc}")
2887
2888 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
2889 for t in threads:
2890 t.start()
2891 for t in threads:
2892 t.join()
2893 assert not errors, f"Concurrent failures: {errors}"
2894
2895
2896 # ---------------------------------------------------------------------------
2897 # Extended — muse code index purge
2898 # ---------------------------------------------------------------------------
2899
2900
2901 class TestIndexPurgeExtended:
2902 def test_j_alias_works(self, code_repo: pathlib.Path) -> None:
2903 """-j is equivalent to --json."""
2904 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2905 assert result.exit_code == 0, result.output
2906 data = json.loads(result.output.strip())
2907 assert "purged" in data
2908
2909 def test_help_flag(self, code_repo: pathlib.Path) -> None:
2910 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
2911 assert result.exit_code == 0
2912
2913 def test_json_compact_single_line(self, code_repo: pathlib.Path) -> None:
2914 """JSON output is compact — single line, no indent=2."""
2915 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2916 assert result.exit_code == 0
2917 lines = [l for l in result.output.splitlines() if l.strip()]
2918 assert len(lines) == 1, f"Expected compact JSON, got {len(lines)} lines"
2919
2920 def test_json_required_fields(self, code_repo: pathlib.Path) -> None:
2921 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2922 data = json.loads(result.output.strip())
2923 assert "schema_version" in data
2924 assert "purged" in data
2925 assert "skipped" in data
2926
2927 def test_absent_indexes_go_to_skipped(self, code_repo: pathlib.Path) -> None:
2928 """Purging when indexes are absent — both in skipped, none in purged."""
2929 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2930 data = json.loads(result.output.strip())
2931 assert data["purged"] == []
2932 assert set(data["skipped"]) == {"symbol_history", "hash_occurrence"}
2933
2934 def test_present_indexes_go_to_purged(self, code_repo: pathlib.Path) -> None:
2935 """After rebuild, purge reports both as purged."""
2936 runner.invoke(cli, ["code", "index", "rebuild"])
2937 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2938 data = json.loads(result.output.strip())
2939 assert set(data["purged"]) == {"symbol_history", "hash_occurrence"}
2940 assert data["skipped"] == []
2941
2942 def test_files_removed_after_purge(self, code_repo: pathlib.Path) -> None:
2943 runner.invoke(cli, ["code", "index", "rebuild"])
2944 runner.invoke(cli, ["code", "index", "purge"])
2945 idx_dir = code_repo / ".muse" / "indices"
2946 assert not (idx_dir / "symbol_history.msgpack").exists()
2947 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2948
2949 def test_purge_symbol_history_only(self, code_repo: pathlib.Path) -> None:
2950 runner.invoke(cli, ["code", "index", "rebuild"])
2951 result = runner.invoke(cli, ["code", "index", "purge", "--index", "symbol_history", "-j"])
2952 assert result.exit_code == 0
2953 data = json.loads(result.output.strip())
2954 assert data["purged"] == ["symbol_history"]
2955 assert data["skipped"] == []
2956 idx_dir = code_repo / ".muse" / "indices"
2957 assert not (idx_dir / "symbol_history.msgpack").exists()
2958 assert (idx_dir / "hash_occurrence.msgpack").exists()
2959
2960 def test_purge_hash_occurrence_only(self, code_repo: pathlib.Path) -> None:
2961 runner.invoke(cli, ["code", "index", "rebuild"])
2962 result = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
2963 assert result.exit_code == 0
2964 data = json.loads(result.output.strip())
2965 assert data["purged"] == ["hash_occurrence"]
2966 idx_dir = code_repo / ".muse" / "indices"
2967 assert not (idx_dir / "hash_occurrence.msgpack").exists()
2968 assert (idx_dir / "symbol_history.msgpack").exists()
2969
2970 def test_purge_already_absent_exits_zero(self, code_repo: pathlib.Path) -> None:
2971 """Purging when nothing is present still exits 0."""
2972 result = runner.invoke(cli, ["code", "index", "purge"])
2973 assert result.exit_code == 0
2974
2975 def test_double_purge_exits_zero(self, code_repo: pathlib.Path) -> None:
2976 """Purging twice in a row both exit 0."""
2977 runner.invoke(cli, ["code", "index", "rebuild"])
2978 r1 = runner.invoke(cli, ["code", "index", "purge"])
2979 r2 = runner.invoke(cli, ["code", "index", "purge"])
2980 assert r1.exit_code == 0
2981 assert r2.exit_code == 0
2982
2983 def test_schema_version_is_string(self, code_repo: pathlib.Path) -> None:
2984 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2985 data = json.loads(result.output.strip())
2986 assert isinstance(data["schema_version"], str)
2987 assert len(data["schema_version"]) > 0
2988
2989 def test_purged_and_skipped_are_lists(self, code_repo: pathlib.Path) -> None:
2990 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
2991 data = json.loads(result.output.strip())
2992 assert isinstance(data["purged"], list)
2993 assert isinstance(data["skipped"], list)
2994
2995 def test_text_mode_reports_deleted(self, code_repo: pathlib.Path) -> None:
2996 runner.invoke(cli, ["code", "index", "rebuild"])
2997 result = runner.invoke(cli, ["code", "index", "purge"])
2998 assert "deleted" in result.output.lower() or "🗑" in result.output
2999
3000 def test_text_mode_reports_nothing_to_delete(self, code_repo: pathlib.Path) -> None:
3001 result = runner.invoke(cli, ["code", "index", "purge"])
3002 assert "nothing to delete" in result.output.lower() or "not present" in result.output.lower()
3003
3004 def test_status_shows_absent_after_purge(self, code_repo: pathlib.Path) -> None:
3005 runner.invoke(cli, ["code", "index", "rebuild"])
3006 runner.invoke(cli, ["code", "index", "purge"])
3007 result = runner.invoke(cli, ["code", "index", "status", "-j"])
3008 data = json.loads(result.output.strip())
3009 for entry in data:
3010 assert entry["status"] == "absent"
3011
3012 def test_help_shows_agent_quickstart(self, code_repo: pathlib.Path) -> None:
3013 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3014 assert "Agent quickstart" in result.output
3015
3016 def test_help_shows_json_schema(self, code_repo: pathlib.Path) -> None:
3017 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3018 assert "JSON output schema" in result.output
3019
3020 def test_help_shows_exit_codes(self, code_repo: pathlib.Path) -> None:
3021 result = runner.invoke(cli, ["code", "index", "purge", "--help"])
3022 assert "Exit codes" in result.output
3023
3024
3025 # ---------------------------------------------------------------------------
3026 # Security — muse code index purge
3027 # ---------------------------------------------------------------------------
3028
3029
3030 class TestIndexPurgeSecurity:
3031 def test_invalid_index_name_rejected(self, code_repo: pathlib.Path) -> None:
3032 """Unknown --index value rejected by argparse before run_purge runs."""
3033 result = runner.invoke(cli, ["code", "index", "purge", "--index", "malicious_index"])
3034 assert result.exit_code != 0
3035
3036 def test_no_ansi_in_json_output(self, code_repo: pathlib.Path) -> None:
3037 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3038 assert "\x1b" not in result.output
3039
3040 def test_purged_list_only_known_names(self, code_repo: pathlib.Path) -> None:
3041 """purged and skipped lists only ever contain KNOWN_INDEX_NAMES."""
3042 from muse.core.indices import KNOWN_INDEX_NAMES
3043 runner.invoke(cli, ["code", "index", "rebuild"])
3044 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3045 data = json.loads(result.output.strip())
3046 for name in data["purged"] + data["skipped"]:
3047 assert name in KNOWN_INDEX_NAMES
3048
3049 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
3050 monkeypatch.chdir(tmp_path)
3051 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
3052 result = runner.invoke(cli, ["code", "index", "purge"])
3053 assert "Traceback" not in result.output
3054 assert result.exit_code != 0
3055
3056 def test_only_index_files_removed(self, code_repo: pathlib.Path) -> None:
3057 """Purge must not remove anything outside .muse/indices/."""
3058 runner.invoke(cli, ["code", "index", "rebuild"])
3059 repo_json = code_repo / ".muse" / "repo.json"
3060 assert repo_json.exists()
3061 runner.invoke(cli, ["code", "index", "purge"])
3062 assert repo_json.exists(), "repo.json must not be deleted by purge"
3063
3064 def test_no_traceback_on_double_purge(self, code_repo: pathlib.Path) -> None:
3065 runner.invoke(cli, ["code", "index", "rebuild"])
3066 runner.invoke(cli, ["code", "index", "purge"])
3067 result = runner.invoke(cli, ["code", "index", "purge"])
3068 assert "Traceback" not in result.output
3069
3070
3071 # ---------------------------------------------------------------------------
3072 # Stress — muse code index purge
3073 # ---------------------------------------------------------------------------
3074
3075
3076 class TestIndexPurgeStress:
3077 def test_50_sequential_purge_calls(self, code_repo: pathlib.Path) -> None:
3078 """50 sequential purge calls all exit 0 (idempotent)."""
3079 for i in range(50):
3080 result = runner.invoke(cli, ["code", "index", "purge", "-j"])
3081 assert result.exit_code == 0, f"Call {i} failed: {result.output}"
3082
3083 def test_100_rebuild_purge_cycles(self, code_repo: pathlib.Path) -> None:
3084 """100 rebuild-purge cycles leave indexes absent and exit 0 throughout."""
3085 for i in range(100):
3086 r1 = runner.invoke(cli, ["code", "index", "rebuild", "--index", "hash_occurrence", "-j"])
3087 assert r1.exit_code == 0, f"Cycle {i} rebuild: {r1.output}"
3088 r2 = runner.invoke(cli, ["code", "index", "purge", "--index", "hash_occurrence", "-j"])
3089 assert r2.exit_code == 0, f"Cycle {i} purge: {r2.output}"
3090 d = json.loads(r2.output.strip())
3091 assert d["purged"] == ["hash_occurrence"], f"Cycle {i}: unexpected purge result {d}"
3092
3093 def test_concurrent_purge_8_threads(self, code_repo: pathlib.Path) -> None:
3094 """8 threads purging concurrently via core function — all must exit 0."""
3095 import argparse
3096 import threading
3097
3098 from muse.cli.commands.index_rebuild import run_purge
3099
3100 runner.invoke(cli, ["code", "index", "rebuild"])
3101 errors: list[str] = []
3102
3103 def worker(idx: int) -> None:
3104 args = argparse.Namespace(index_name=None, as_json=True)
3105 try:
3106 run_purge(args)
3107 except SystemExit as exc:
3108 if exc.code != 0:
3109 errors.append(f"Thread {idx}: exit {exc.code}")
3110 except Exception as exc:
3111 errors.append(f"Thread {idx}: {exc}")
3112
3113 threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
3114 for t in threads:
3115 t.start()
3116 for t in threads:
3117 t.join()
3118 assert not errors, f"Concurrent failures: {errors}"
3119
3120
3121 # ---------------------------------------------------------------------------
3122 # Performance — iterative DFS regression (no RecursionError)
3123 # ---------------------------------------------------------------------------
3124
3125
3126 class TestIterativeDFS:
3127 """Verify _find_cycles does not blow the call stack on a deep linear chain."""
3128
3129 def test_codemap_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3130 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3131
3132 # Build a linear chain A→B→C→…→Z (depth 600, beyond Python's 1000 default).
3133 depth = 600
3134 nodes = [f"mod_{i}" for i in range(depth)]
3135 imports_out: _ImportsMap = {
3136 nodes[i]: [nodes[i + 1]] for i in range(depth - 1)
3137 }
3138 imports_out[nodes[-1]] = []
3139
3140 # Must not raise RecursionError.
3141 cycles = codemap_find_cycles(imports_out)
3142 assert isinstance(cycles, list)
3143 assert len(cycles) == 0 # linear chain has no cycles
3144
3145 def test_codemap_cycle_detected(self, code_repo: pathlib.Path) -> None:
3146 from muse.cli.commands.codemap import _find_cycles as codemap_find_cycles
3147
3148 # A→B→C→A is a cycle.
3149 imports_out: _ImportsMap = {
3150 "A": ["B"],
3151 "B": ["C"],
3152 "C": ["A"],
3153 }
3154 cycles = codemap_find_cycles(imports_out)
3155 assert len(cycles) >= 1
3156
3157 def test_invariants_deep_chain_no_recursion_error(self, code_repo: pathlib.Path) -> None:
3158 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3159
3160 depth = 600
3161 nodes = [f"file_{i}.py" for i in range(depth)]
3162 imports: _ImportsSetMap = {
3163 nodes[i]: {nodes[i + 1]} for i in range(depth - 1)
3164 }
3165 imports[nodes[-1]] = set()
3166
3167 cycles = invariants_find_cycles(imports)
3168 assert isinstance(cycles, list)
3169 assert len(cycles) == 0
3170
3171 def test_invariants_self_loop_detected(self, code_repo: pathlib.Path) -> None:
3172 from muse.plugins.code._invariants import _find_cycles as invariants_find_cycles
3173
3174 # A module that imports itself.
3175 imports: _ImportsSetMap = {"self_import.py": {"self_import.py"}}
3176 cycles = invariants_find_cycles(imports)
3177 assert len(cycles) >= 1
3178
3179
3180 # ---------------------------------------------------------------------------
3181 # muse code symbols
3182 # ---------------------------------------------------------------------------
3183
3184
3185 class TestSymbols:
3186 """Tests for ``muse code symbols``."""
3187
3188 def test_symbols_basic_output(self, code_repo: pathlib.Path) -> None:
3189 """Basic invocation lists functions and classes from HEAD snapshot."""
3190 result = runner.invoke(cli, ["code", "symbols"])
3191 assert result.exit_code == 0, result.output
3192 # billing.py contains Invoice class and process_order / send_email functions.
3193 assert "Invoice" in result.output
3194 assert "process_order" in result.output
3195 assert "symbols across" in result.output
3196
3197 def test_symbols_count_flag(self, code_repo: pathlib.Path) -> None:
3198 """``--count`` prints a total count and language breakdown, no symbol table."""
3199 result = runner.invoke(cli, ["code", "symbols", "--count"])
3200 assert result.exit_code == 0, result.output
3201 assert "symbols" in result.output
3202 assert "Python" in result.output
3203 # Should NOT print individual symbol lines.
3204 assert "Invoice" not in result.output
3205
3206 def test_symbols_json_flag(self, code_repo: pathlib.Path) -> None:
3207 """``--json`` emits a structured envelope with a flat 'results' list."""
3208 result = runner.invoke(cli, ["code", "symbols", "--json"])
3209 assert result.exit_code == 0, result.output
3210 data = json.loads(result.output)
3211 assert isinstance(data, dict)
3212 assert "results" in data
3213 assert "files" not in data
3214 assert isinstance(data["results"], list)
3215 assert any(e.get("file", "").endswith("billing.py") for e in data["results"])
3216 assert any(e["kind"] in ("class", "method", "function") for e in data["results"])
3217
3218 def test_symbols_kind_filter_class(self, code_repo: pathlib.Path) -> None:
3219 """``--kind class`` shows only class-kind symbols."""
3220 result = runner.invoke(cli, ["code", "symbols", "--kind", "class"])
3221 assert result.exit_code == 0, result.output
3222 assert "Invoice" in result.output
3223 assert "process_order" not in result.output
3224
3225 def test_symbols_kind_filter_function(self, code_repo: pathlib.Path) -> None:
3226 """``--kind function`` shows only top-level functions, not methods."""
3227 result = runner.invoke(cli, ["code", "symbols", "--kind", "function"])
3228 assert result.exit_code == 0, result.output
3229 assert "process_order" in result.output
3230 assert "send_email" in result.output
3231 assert "Invoice" not in result.output
3232
3233 def test_symbols_invalid_kind_errors(self, code_repo: pathlib.Path) -> None:
3234 """``--kind`` with an invalid value exits with USER_ERROR and helpful message."""
3235 result = runner.invoke(cli, ["code", "symbols", "--kind", "potato"])
3236 assert result.exit_code != 0
3237 assert "Unknown kind" in result.output or "Unknown kind" in (result.stderr or "")
3238
3239 def test_symbols_file_filter(self, code_repo: pathlib.Path) -> None:
3240 """``--file`` restricts output to a single file."""
3241 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3242 assert result.exit_code == 0, result.output
3243 assert "symbols across" in result.output
3244
3245 def test_symbols_nonexistent_file_filter_returns_empty(self, code_repo: pathlib.Path) -> None:
3246 """``--file`` for a file not in the snapshot yields 'no semantic symbols found'."""
3247 result = runner.invoke(cli, ["code", "symbols", "--file", "nonexistent.py"])
3248 assert result.exit_code == 0, result.output
3249 assert "no semantic symbols found" in result.output
3250
3251 def test_symbols_language_filter(self, code_repo: pathlib.Path) -> None:
3252 """``--language Python`` includes Python symbols; other languages excluded."""
3253 result = runner.invoke(cli, ["code", "symbols", "--language", "Python"])
3254 assert result.exit_code == 0, result.output
3255 assert "Invoice" in result.output
3256
3257 def test_symbols_language_filter_no_match(self, code_repo: pathlib.Path) -> None:
3258 """``--language Go`` on a Python-only repo yields 'no semantic symbols found'."""
3259 result = runner.invoke(cli, ["code", "symbols", "--language", "Go"])
3260 assert result.exit_code == 0, result.output
3261 assert "no semantic symbols found" in result.output
3262
3263 def test_symbols_hashes_flag(self, code_repo: pathlib.Path) -> None:
3264 """``--hashes`` appends content hash abbreviations to each symbol row."""
3265 result = runner.invoke(cli, ["code", "symbols", "--hashes"])
3266 assert result.exit_code == 0, result.output
3267 # Hash suffix is 8 hex chars followed by ".."
3268 assert ".." in result.output
3269
3270 def test_symbols_commit_ref(self, code_repo: pathlib.Path) -> None:
3271 """``--commit HEAD`` and working-tree mode show the same symbols for a clean repo."""
3272 default = runner.invoke(cli, ["code", "symbols"])
3273 head = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3274 assert default.exit_code == 0
3275 assert head.exit_code == 0
3276 # Headers differ ("working tree" vs "commit …") but symbol content is identical.
3277 assert "Invoice" in default.output
3278 assert "Invoice" in head.output
3279 assert "symbols across" in default.output
3280 assert "symbols across" in head.output
3281
3282 def test_symbols_count_and_json_mutually_exclusive(self, code_repo: pathlib.Path) -> None:
3283 """``--count`` and ``--json`` cannot be combined."""
3284 result = runner.invoke(cli, ["code", "symbols", "--count", "--json"])
3285 assert result.exit_code != 0
3286
3287 def test_symbols_json_schema(self, code_repo: pathlib.Path) -> None:
3288 """JSON output uses the structured envelope with source_ref and results."""
3289 result = runner.invoke(cli, ["code", "symbols", "--json"])
3290 assert result.exit_code == 0, result.output
3291 data = json.loads(result.output)
3292 assert "source_ref" in data
3293 assert "working_tree" in data
3294 assert "total_symbols" in data
3295 assert "results" in data
3296 assert "files" not in data
3297 assert isinstance(data["working_tree"], bool)
3298 assert isinstance(data["total_symbols"], int)
3299 for entry in data["results"]:
3300 for field in ("address", "kind", "name", "qualified_name", "file",
3301 "lineno", "content_id", "body_hash", "signature_id"):
3302 assert field in entry, f"missing field '{field}' in JSON entry"
3303
3304 def test_symbols_json_working_tree_flag(self, code_repo: pathlib.Path) -> None:
3305 """``--json`` without ``--commit`` reports working_tree=true."""
3306 result = runner.invoke(cli, ["code", "symbols", "--json"])
3307 assert result.exit_code == 0, result.output
3308 data = json.loads(result.output)
3309 assert data["working_tree"] is True
3310 assert data["source_ref"] == "working-tree"
3311
3312 def test_symbols_json_commit_flag(self, code_repo: pathlib.Path) -> None:
3313 """``--json --commit HEAD`` reports working_tree=false and a short SHA."""
3314 result = runner.invoke(cli, ["code", "symbols", "--json", "--commit", "HEAD"])
3315 assert result.exit_code == 0, result.output
3316 data = json.loads(result.output)
3317 assert data["working_tree"] is False
3318 assert data["source_ref"] != "working-tree"
3319 # source_ref should be an 8-char hex prefix.
3320 assert len(data["source_ref"]) == 8
3321 assert all(c in "0123456789abcdef" for c in data["source_ref"])
3322
3323 def test_symbols_working_tree_reflects_disk_changes(self, code_repo: pathlib.Path) -> None:
3324 """Working-tree mode picks up edits made to files after the last commit."""
3325 # Find the billing.py path on disk.
3326 billing = code_repo / "billing.py"
3327 assert billing.exists()
3328 # Append a new function — not yet committed.
3329 billing.write_text(
3330 billing.read_text() + "\ndef newly_added_function():\n pass\n"
3331 )
3332 result = runner.invoke(cli, ["code", "symbols"])
3333 assert result.exit_code == 0, result.output
3334 assert "newly_added_function" in result.output
3335
3336 # Committed snapshot should NOT contain it.
3337 committed = runner.invoke(cli, ["code", "symbols", "--commit", "HEAD"])
3338 assert committed.exit_code == 0
3339 assert "newly_added_function" not in committed.output
3340
3341 def test_symbols_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3342 """``--language`` is case-insensitive: 'python' == 'Python' == 'PYTHON'."""
3343 for variant in ("python", "Python", "PYTHON"):
3344 result = runner.invoke(cli, ["code", "symbols", "--language", variant])
3345 assert result.exit_code == 0, f"failed for --language {variant!r}"
3346 assert "Invoice" in result.output
3347
3348 def test_symbols_file_filter_partial_path(self, code_repo: pathlib.Path) -> None:
3349 """``--file billing.py`` matches a manifest entry stored as ``billing.py``."""
3350 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3351 assert result.exit_code == 0, result.output
3352 assert "Invoice" in result.output
3353
3354 def test_symbols_file_filter_ambiguous_exits_error(self, code_repo: pathlib.Path) -> None:
3355 """An ambiguous ``--file`` suffix that matches multiple paths exits non-zero."""
3356 # Write a second file with the same basename in a sub-directory.
3357 sub = code_repo / "sub"
3358 sub.mkdir(exist_ok=True)
3359 (sub / "billing.py").write_text("def sub_func(): pass\n")
3360 # Stage and commit both so the manifest has two paths ending in billing.py.
3361 import subprocess
3362 subprocess.run(["muse", "code", "add", "."], cwd=code_repo, check=True)
3363 subprocess.run(
3364 ["muse", "commit", "-m", "add sub/billing.py"],
3365 cwd=code_repo, check=True,
3366 )
3367 result = runner.invoke(cli, ["code", "symbols", "--file", "billing.py"])
3368 assert result.exit_code != 0
3369 assert "ambiguous" in (result.output + (result.stderr or "")).lower()
3370
3371 def test_symbols_invalid_ref_errors(self, code_repo: pathlib.Path) -> None:
3372 """``--commit`` with a non-existent ref exits non-zero with a clear message."""
3373 result = runner.invoke(cli, ["code", "symbols", "--commit", "deadbeef"])
3374 assert result.exit_code != 0
3375 assert "not found" in result.output or "not found" in (result.stderr or "")
3376
3377
3378 # ---------------------------------------------------------------------------
3379 # TestSymbolLog
3380 # ---------------------------------------------------------------------------
3381
3382
3383 class TestSymbolLog:
3384 """Tests for ``muse code symbol-log``."""
3385
3386 def test_symbol_log_no_events_for_unknown_symbol(self, code_repo: pathlib.Path) -> None:
3387 """An address not found in any commit produces 'no events found'."""
3388 result = runner.invoke(cli, ["code", "symbol-log", "billing.py::DoesNotExist"])
3389 assert result.exit_code == 0, result.output
3390 assert "no events found" in result.output
3391
3392 def test_symbol_log_invalid_address_no_double_colon(self, code_repo: pathlib.Path) -> None:
3393 """An address without '::' exits non-zero with a descriptive error."""
3394 result = runner.invoke(cli, ["code", "symbol-log", "billing.py"])
3395 assert result.exit_code != 0
3396 assert "::" in (result.output + (result.stderr or ""))
3397
3398 def test_symbol_log_invalid_address_empty(self, code_repo: pathlib.Path) -> None:
3399 """An empty string as address exits non-zero."""
3400 result = runner.invoke(cli, ["code", "symbol-log", "::"])
3401 # "::" is technically valid syntax; should at least not crash.
3402 assert result.exit_code == 0
3403
3404 def test_symbol_log_json_schema(self, code_repo: pathlib.Path) -> None:
3405 """``--json`` emits the structured envelope with all top-level fields."""
3406 result = runner.invoke(
3407 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3408 )
3409 assert result.exit_code == 0, result.output
3410 data = json.loads(result.output)
3411 for field in ("address", "start_ref", "total_commits_scanned", "truncated", "events"):
3412 assert field in data, f"missing top-level field '{field}'"
3413 assert data["address"] == "billing.py::Invoice"
3414 assert data["start_ref"] == "HEAD"
3415 assert isinstance(data["total_commits_scanned"], int)
3416 assert isinstance(data["truncated"], bool)
3417 assert isinstance(data["events"], list)
3418
3419 def test_symbol_log_json_event_schema(self, code_repo: pathlib.Path) -> None:
3420 """Each JSON event has the required fields."""
3421 result = runner.invoke(
3422 cli, ["code", "symbol-log", "billing.py::Invoice", "--json"]
3423 )
3424 assert result.exit_code == 0, result.output
3425 data = json.loads(result.output)
3426 for ev in data["events"]:
3427 for field in ("event", "commit_id", "message", "committed_at",
3428 "address", "detail", "new_address"):
3429 assert field in ev, f"missing event field '{field}'"
3430
3431 def test_symbol_log_truncation_warning(self, code_repo: pathlib.Path) -> None:
3432 """When --max is hit, a truncation warning appears in human output."""
3433 result = runner.invoke(
3434 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1"]
3435 )
3436 assert result.exit_code == 0, result.output
3437 assert "incomplete" in result.output or "limit" in result.output
3438
3439 def test_symbol_log_truncation_flag_in_json(self, code_repo: pathlib.Path) -> None:
3440 """When --max is hit, truncated=true appears in JSON output."""
3441 result = runner.invoke(
3442 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "1", "--json"]
3443 )
3444 assert result.exit_code == 0, result.output
3445 data = json.loads(result.output)
3446 assert data["truncated"] is True
3447 assert data["total_commits_scanned"] == 1
3448
3449 def test_symbol_log_max_zero_errors(self, code_repo: pathlib.Path) -> None:
3450 """--max 0 exits non-zero with a clear error."""
3451 result = runner.invoke(
3452 cli, ["code", "symbol-log", "billing.py::Invoice", "--max", "0"]
3453 )
3454 assert result.exit_code != 0
3455
3456 def test_symbol_log_invalid_from_ref(self, code_repo: pathlib.Path) -> None:
3457 """``--from`` with a non-existent ref exits non-zero."""
3458 result = runner.invoke(
3459 cli, ["code", "symbol-log", "billing.py::Invoice", "--from", "deadbeef"]
3460 )
3461 assert result.exit_code != 0
3462 assert "not found" in (result.output + (result.stderr or ""))
3463
3464 def test_symbol_log_bfs_follows_merge_parent2(self, code_repo: pathlib.Path) -> None:
3465 """BFS walk finds events on feature branches that were merged in via parent2.
3466
3467 Simulates a merge commit (parent1=mainline, parent2=feature branch HEAD).
3468 The feature branch commit has a structured_delta inserting a symbol.
3469 The linear (parent1-only) walk would miss this; BFS must find it.
3470 """
3471 import datetime
3472
3473 root = code_repo
3474 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3475 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3476 from muse.core.snapshot import compute_commit_id
3477 from muse.domain import InsertOp, PatchOp, StructuredDelta
3478 branch = read_current_branch(root)
3479 head_id = get_head_commit_id(root, branch)
3480 assert head_id is not None
3481
3482 feature_snap = "aa" * 32
3483 feature_at = datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
3484 feature_id = compute_commit_id([head_id], feature_snap, "feat: add merged_fn", feature_at.isoformat())
3485 write_commit(root, CommitRecord(
3486 commit_id=feature_id,
3487 repo_id=repo_id,
3488 branch="feat/branch",
3489 snapshot_id=feature_snap,
3490 message="feat: add merged_fn",
3491 committed_at=feature_at,
3492 parent_commit_id=head_id,
3493 author="test",
3494 structured_delta=StructuredDelta(ops=[PatchOp(
3495 op="patch",
3496 address="billing.py",
3497 child_ops=[InsertOp(
3498 op="insert",
3499 address="billing.py::merged_fn",
3500 content_summary="function merged_fn",
3501 )],
3502 )]),
3503 ))
3504
3505 merge_snap = "bb" * 32
3506 merge_at = datetime.datetime(2026, 1, 1, 1, 0, tzinfo=datetime.timezone.utc)
3507 merge_id = compute_commit_id([head_id, feature_id], merge_snap, "merge feat/branch", merge_at.isoformat())
3508 write_commit(root, CommitRecord(
3509 commit_id=merge_id,
3510 repo_id=repo_id,
3511 branch=branch,
3512 snapshot_id=merge_snap,
3513 message="merge feat/branch",
3514 committed_at=merge_at,
3515 parent_commit_id=head_id,
3516 parent2_commit_id=feature_id,
3517 author="test",
3518 ))
3519
3520 branch_ref = root / ".muse" / "refs" / "heads" / branch
3521 branch_ref.write_text(merge_id)
3522
3523 result = runner.invoke(
3524 cli, ["code", "symbol-log", "billing.py::merged_fn"]
3525 )
3526 assert result.exit_code == 0, result.output
3527 # BFS must find the creation event on the feature branch.
3528 assert "merged_fn" in result.output
3529 assert "created" in result.output
3530
3531 def test_symbol_log_linear_walk_misses_parent2(self, code_repo: pathlib.Path) -> None:
3532 """Regression guard: verify the BFS result differs from a parent1-only scan.
3533
3534 Directly calls _walk_commits_dag and checks it returns commits from
3535 both parent chains, not just parent1.
3536 """
3537 import datetime
3538
3539 root = code_repo
3540 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
3541 from muse.core.store import get_head_commit_id, read_current_branch, write_commit, CommitRecord
3542 from muse.core.snapshot import compute_commit_id
3543 from muse.plugins.code._query import walk_commits_bfs as _walk_commits_dag
3544 branch = read_current_branch(root)
3545 head_id = get_head_commit_id(root, branch)
3546 assert head_id is not None
3547
3548 feature_snap = "dd" * 32
3549 feature_at = datetime.datetime(2026, 2, 1, 0, 0, tzinfo=datetime.timezone.utc)
3550 feature_id = compute_commit_id([], feature_snap, "feat on second parent", feature_at.isoformat())
3551 write_commit(root, CommitRecord(
3552 commit_id=feature_id,
3553 repo_id=repo_id,
3554 branch="feat/x",
3555 snapshot_id=feature_snap,
3556 message="feat on second parent",
3557 committed_at=feature_at,
3558 author="test",
3559 ))
3560 merge_snap = "ee" * 32
3561 merge_at = datetime.datetime(2026, 2, 1, 1, 0, tzinfo=datetime.timezone.utc)
3562 merge_id = compute_commit_id([head_id, feature_id], merge_snap, "merge", merge_at.isoformat())
3563 write_commit(root, CommitRecord(
3564 commit_id=merge_id,
3565 repo_id=repo_id,
3566 branch=branch,
3567 snapshot_id=merge_snap,
3568 message="merge",
3569 committed_at=merge_at,
3570 parent_commit_id=head_id,
3571 parent2_commit_id=feature_id,
3572 author="test",
3573 ))
3574
3575 branch_ref = root / ".muse" / "refs" / "heads" / branch
3576 branch_ref.write_text(merge_id)
3577
3578 commits, _ = _walk_commits_dag(root, merge_id, max_commits=1000)
3579 commit_ids = {c.commit_id for c in commits}
3580 assert feature_id in commit_ids
3581
3582
3583 # ---------------------------------------------------------------------------
3584 # muse code coupling
3585 # ---------------------------------------------------------------------------
3586
3587
3588 @pytest.fixture
3589 def coupling_repo(repo: pathlib.Path) -> pathlib.Path:
3590 """Repo with 3 commits where billing.py + models.py co-change twice."""
3591 work = repo
3592
3593 # Commit 1: seed — only billing.py
3594 (work / "billing.py").write_text("def compute(items):\n return sum(items)\n")
3595 r = runner.invoke(cli, ["commit", "-m", "seed billing"])
3596 assert r.exit_code == 0, r.output
3597
3598 # Commit 2: billing.py + models.py change together
3599 (work / "billing.py").write_text("def compute(items, tax=0.0):\n return sum(items) + tax\n")
3600 (work / "models.py").write_text("class Order:\n def total(self):\n return 0\n")
3601 r = runner.invoke(cli, ["commit", "-m", "co-change 1: billing + models"])
3602 assert r.exit_code == 0, r.output
3603
3604 # Commit 3: billing.py + models.py change together again
3605 (work / "billing.py").write_text("def compute(items, tax=0.0, discount=0.0):\n return sum(items) + tax - discount\n")
3606 (work / "models.py").write_text("class Order:\n def total(self):\n return 42\n def apply(self): pass\n")
3607 r = runner.invoke(cli, ["commit", "-m", "co-change 2: billing + models again"])
3608 assert r.exit_code == 0, r.output
3609
3610 return repo
3611
3612
3613 class TestCoupling:
3614 """Tests for muse code coupling."""
3615
3616 # ── basic correctness ────────────────────────────────────────────────────
3617
3618 def test_coupling_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3619 result = runner.invoke(cli, ["code", "coupling"])
3620 assert result.exit_code == 0, result.output
3621
3622 def test_coupling_finds_co_changed_pair(self, coupling_repo: pathlib.Path) -> None:
3623 """billing.py and models.py co-changed twice — must appear in output."""
3624 result = runner.invoke(cli, ["code", "coupling", "--min", "1"])
3625 assert result.exit_code == 0, result.output
3626 assert "billing.py" in result.output
3627 assert "models.py" in result.output
3628
3629 def test_coupling_shows_header(self, coupling_repo: pathlib.Path) -> None:
3630 result = runner.invoke(cli, ["code", "coupling"])
3631 assert "co-change" in result.output.lower() or "coupling" in result.output.lower()
3632 assert "Commits analysed" in result.output
3633
3634 def test_coupling_min_filter_excludes_low_count(
3635 self, coupling_repo: pathlib.Path
3636 ) -> None:
3637 """--min 3 must exclude our pair that co-changed only twice."""
3638 result = runner.invoke(cli, ["code", "coupling", "--min", "3"])
3639 assert result.exit_code == 0, result.output
3640 assert "billing.py" not in result.output or "no file pairs" in result.output
3641
3642 def test_coupling_top_limits_output(self, coupling_repo: pathlib.Path) -> None:
3643 result = runner.invoke(cli, ["code", "coupling", "--top", "1", "--min", "1", "--json"])
3644 data = json.loads(result.output)
3645 assert len(data["pairs"]) <= 1
3646
3647 # ── --file filter ─────────────────────────────────────────────────────────
3648
3649 def test_coupling_file_filter_exits_zero(self, coupling_repo: pathlib.Path) -> None:
3650 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3651 assert result.exit_code == 0, result.output
3652
3653 def test_coupling_file_filter_shows_partner(self, coupling_repo: pathlib.Path) -> None:
3654 """--file billing.py must surface models.py as its partner."""
3655 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3656 assert result.exit_code == 0, result.output
3657 assert "models.py" in result.output
3658
3659 def test_coupling_file_filter_header_names_file(
3660 self, coupling_repo: pathlib.Path
3661 ) -> None:
3662 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3663 assert "billing.py" in result.output
3664
3665 def test_coupling_file_filter_nonexistent_returns_cleanly(
3666 self, coupling_repo: pathlib.Path
3667 ) -> None:
3668 result = runner.invoke(cli, ["code", "coupling", "--file", "nonexistent_xyz.py"])
3669 assert result.exit_code == 0, result.output
3670
3671 def test_coupling_file_filter_suffix_match(self, coupling_repo: pathlib.Path) -> None:
3672 """Suffix billing.py should match the file even without the full path."""
3673 result = runner.invoke(cli, ["code", "coupling", "--file", "billing.py", "--min", "1"])
3674 assert result.exit_code == 0, result.output
3675 assert "models.py" in result.output
3676
3677 # ── JSON output ───────────────────────────────────────────────────────────
3678
3679 def test_coupling_json_schema(self, coupling_repo: pathlib.Path) -> None:
3680 result = runner.invoke(cli, ["code", "coupling", "--json"])
3681 assert result.exit_code == 0, result.output
3682 data = json.loads(result.output)
3683 assert "from_ref" in data
3684 assert "to_ref" in data
3685 assert "commits_analysed" in data
3686 assert "truncated" in data
3687 assert "filters" in data
3688 assert "pairs" in data
3689 assert isinstance(data["pairs"], list)
3690
3691 def test_coupling_json_pair_schema(self, coupling_repo: pathlib.Path) -> None:
3692 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3693 data = json.loads(result.output)
3694 if data["pairs"]:
3695 pair = data["pairs"][0]
3696 assert "file_a" in pair or "file" in pair
3697 assert "co_changes" in pair
3698 assert isinstance(pair["co_changes"], int)
3699
3700 def test_coupling_json_file_filter_uses_partner_schema(
3701 self, coupling_repo: pathlib.Path
3702 ) -> None:
3703 """--file mode emits {file, partner, co_changes} not {file_a, file_b}."""
3704 result = runner.invoke(
3705 cli, ["code", "coupling", "--file", "billing.py", "--min", "1", "--json"]
3706 )
3707 data = json.loads(result.output)
3708 assert data["filters"]["file"] == "billing.py"
3709 if data["pairs"]:
3710 pair = data["pairs"][0]
3711 assert "file" in pair
3712 assert "partner" in pair
3713 assert "co_changes" in pair
3714 assert "file_a" not in pair # partner schema, not pair schema
3715
3716 def test_coupling_json_not_truncated_small_repo(
3717 self, coupling_repo: pathlib.Path
3718 ) -> None:
3719 result = runner.invoke(cli, ["code", "coupling", "--json"])
3720 data = json.loads(result.output)
3721 assert data["truncated"] is False
3722
3723 def test_coupling_json_filters_reflect_args(
3724 self, coupling_repo: pathlib.Path
3725 ) -> None:
3726 result = runner.invoke(
3727 cli, ["code", "coupling", "--top", "5", "--min", "2", "--json"]
3728 )
3729 data = json.loads(result.output)
3730 assert data["filters"]["top"] == 5
3731 assert data["filters"]["min_count"] == 2
3732
3733 # ── --max-commits ─────────────────────────────────────────────────────────
3734
3735 def test_coupling_max_commits_caps_scan(self, coupling_repo: pathlib.Path) -> None:
3736 r_full = runner.invoke(cli, ["code", "coupling", "--json"])
3737 r_cap = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3738 assert r_full.exit_code == 0 and r_cap.exit_code == 0
3739 d_cap = json.loads(r_cap.output)
3740 assert d_cap["commits_analysed"] <= 1
3741
3742 def test_coupling_max_commits_truncated_flag(
3743 self, coupling_repo: pathlib.Path
3744 ) -> None:
3745 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1", "--json"])
3746 data = json.loads(result.output)
3747 # With 3 commits and cap=1, truncated must be True.
3748 assert data["truncated"] is True
3749
3750 def test_coupling_max_commits_one_shows_warning(
3751 self, coupling_repo: pathlib.Path
3752 ) -> None:
3753 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "1"])
3754 assert result.exit_code == 0, result.output
3755 assert "⚠️" in result.output or "capped" in result.output
3756
3757 # ── validation ────────────────────────────────────────────────────────────
3758
3759 def test_coupling_top_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3760 result = runner.invoke(cli, ["code", "coupling", "--top", "0"])
3761 assert result.exit_code != 0
3762
3763 def test_coupling_min_zero_exits_error(self, coupling_repo: pathlib.Path) -> None:
3764 result = runner.invoke(cli, ["code", "coupling", "--min", "0"])
3765 assert result.exit_code != 0
3766
3767 def test_coupling_max_commits_zero_exits_error(
3768 self, coupling_repo: pathlib.Path
3769 ) -> None:
3770 result = runner.invoke(cli, ["code", "coupling", "--max-commits", "0"])
3771 assert result.exit_code != 0
3772
3773 def test_coupling_invalid_from_ref_exits_error(
3774 self, coupling_repo: pathlib.Path
3775 ) -> None:
3776 result = runner.invoke(
3777 cli, ["code", "coupling", "--from", "nonexistent-ref-xyz"]
3778 )
3779 assert result.exit_code != 0
3780
3781 def test_coupling_bfs_visits_merge_parents(self, repo: pathlib.Path) -> None:
3782 """Coupling must count co-changes on feature-branch commits (parent2)."""
3783 import datetime
3784
3785 # Genesis commit
3786 (repo / "billing.py").write_text("def compute(x):\n return x\n")
3787 r = runner.invoke(cli, ["commit", "-m", "seed"])
3788 assert r.exit_code == 0, r.output
3789
3790 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
3791 repo_id = repo_json["repo_id"]
3792 from muse.core.store import read_current_branch, resolve_commit_ref
3793 branch = read_current_branch(repo)
3794 head = resolve_commit_ref(repo, repo_id, branch, None)
3795 assert head is not None
3796
3797 now = datetime.datetime(2026, 3, 1, 0, 0, tzinfo=datetime.timezone.utc)
3798 feature_at = now
3799 merge_at = now + datetime.timedelta(hours=1)
3800
3801 # Feature commit touching billing.py + models.py together.
3802 from muse.domain import PatchOp, ReplaceOp, InsertOp, StructuredDelta
3803 from muse.core.snapshot import compute_commit_id
3804 feature_delta = StructuredDelta(
3805 domain="code",
3806 ops=[
3807 PatchOp(
3808 op="patch", address="billing.py",
3809 child_ops=[ReplaceOp(
3810 op="replace", address="billing.py::compute",
3811 old_content_id="a" * 64, new_content_id="b" * 64,
3812 old_summary="function compute",
3813 new_summary="function compute (modified)", position=None,
3814 )],
3815 child_domain="code", child_summary="compute modified",
3816 ),
3817 PatchOp(
3818 op="patch", address="models.py",
3819 child_ops=[InsertOp(
3820 op="insert", address="models.py::Order",
3821 content_id="c" * 64, content_summary="class Order", position=None,
3822 )],
3823 child_domain="code", child_summary="Order added",
3824 ),
3825 ],
3826 summary="co-change",
3827 )
3828 feature_id = compute_commit_id(
3829 [head.commit_id], head.snapshot_id,
3830 "co-change on feature branch", feature_at.isoformat(),
3831 )
3832 merge_id = compute_commit_id(
3833 [head.commit_id, feature_id], head.snapshot_id,
3834 "Merge feature", merge_at.isoformat(),
3835 )
3836 feature_body: CommitDict = {
3837 "commit_id": feature_id,
3838 "repo_id": repo_id,
3839 "branch": "feat/test",
3840 "snapshot_id": head.snapshot_id,
3841 "message": "co-change on feature branch",
3842 "committed_at": feature_at.isoformat(),
3843 "parent_commit_id": head.commit_id,
3844 "parent2_commit_id": None,
3845 "author": "test",
3846 "metadata": {},
3847 "structured_delta": feature_delta,
3848 }
3849 merge_body: CommitDict = {
3850 "commit_id": merge_id,
3851 "repo_id": repo_id,
3852 "branch": branch,
3853 "snapshot_id": head.snapshot_id,
3854 "message": "Merge feature",
3855 "committed_at": merge_at.isoformat(),
3856 "parent_commit_id": head.commit_id,
3857 "parent2_commit_id": feature_id,
3858 "author": "test",
3859 "metadata": {},
3860 "structured_delta": None,
3861 }
3862 from muse.core.store import write_commit, CommitRecord
3863 write_commit(repo, CommitRecord.from_dict(feature_body))
3864 write_commit(repo, CommitRecord.from_dict(merge_body))
3865 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
3866
3867 result = runner.invoke(cli, ["code", "coupling", "--min", "1", "--json"])
3868 assert result.exit_code == 0, result.output
3869 data = json.loads(result.output)
3870 pairs_found = {
3871 (p.get("file_a", ""), p.get("file_b", "")) for p in data["pairs"]
3872 }
3873 billing_models = any(
3874 ("billing.py" in a and "models.py" in b) or ("models.py" in a and "billing.py" in b)
3875 for a, b in pairs_found
3876 )
3877 assert billing_models, "BFS must find the feature-branch co-change commit"
3878
3879
3880 # ---------------------------------------------------------------------------
3881 # muse code stable
3882 # ---------------------------------------------------------------------------
3883
3884
3885 class TestStable:
3886 """Tests for muse code stable."""
3887
3888 # ── basic correctness ────────────────────────────────────────────────────
3889
3890 def test_stable_exits_zero(self, code_repo: pathlib.Path) -> None:
3891 result = runner.invoke(cli, ["code", "stable"])
3892 assert result.exit_code == 0, result.output
3893
3894 def test_stable_shows_header(self, code_repo: pathlib.Path) -> None:
3895 result = runner.invoke(cli, ["code", "stable"])
3896 assert result.exit_code == 0, result.output
3897 assert "Symbol stability" in result.output
3898 assert "Commits analysed" in result.output
3899 assert "bedrock" in result.output
3900
3901 def test_stable_surfaces_never_touched_symbol(self, code_repo: pathlib.Path) -> None:
3902 """Invoice.apply_discount was defined in the genesis commit and never modified."""
3903 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
3904 assert result.exit_code == 0, result.output
3905 # apply_discount was never touched in any structured_delta → maximally stable.
3906 assert "apply_discount" in result.output
3907
3908 def test_stable_since_start_of_range_marker(self, code_repo: pathlib.Path) -> None:
3909 result = runner.invoke(cli, ["code", "stable", "--top", "10"])
3910 assert result.exit_code == 0, result.output
3911 assert "since start of range" in result.output
3912
3913 def test_stable_excludes_docs_by_default(self, code_repo: pathlib.Path) -> None:
3914 """Markdown / TOML / YAML symbols must be absent from default output."""
3915 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
3916 assert result.exit_code == 0, result.output
3917 assert ".md::" not in result.output
3918 assert ".toml::" not in result.output
3919
3920 def test_stable_excludes_imports_by_default(self, code_repo: pathlib.Path) -> None:
3921 result = runner.invoke(cli, ["code", "stable", "--top", "50"])
3922 assert result.exit_code == 0, result.output
3923 assert "::import::" not in result.output
3924
3925 def test_stable_include_imports_flag(self, code_repo: pathlib.Path) -> None:
3926 result = runner.invoke(cli, ["code", "stable", "--top", "50", "--include-imports"])
3927 assert result.exit_code == 0, result.output
3928
3929 # ── JSON output ───────────────────────────────────────────────────────────
3930
3931 def test_stable_json_schema(self, code_repo: pathlib.Path) -> None:
3932 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
3933 assert result.exit_code == 0, result.output
3934 data = json.loads(result.output)
3935 assert "from_ref" in data
3936 assert "to_ref" in data
3937 assert "commits_analysed" in data
3938 assert "truncated" in data
3939 assert "filters" in data
3940 assert "stable" in data
3941 assert isinstance(data["stable"], list)
3942
3943 def test_stable_json_entry_schema(self, code_repo: pathlib.Path) -> None:
3944 result = runner.invoke(cli, ["code", "stable", "--top", "5", "--json"])
3945 data = json.loads(result.output)
3946 assert len(data["stable"]) > 0
3947 entry = data["stable"][0]
3948 assert "address" in entry
3949 assert "unchanged_for" in entry
3950 assert "since_start_of_range" in entry
3951 assert isinstance(entry["unchanged_for"], int)
3952 assert isinstance(entry["since_start_of_range"], bool)
3953
3954 def test_stable_json_filters_reflect_args(self, code_repo: pathlib.Path) -> None:
3955 result = runner.invoke(
3956 cli, ["code", "stable", "--top", "3", "--kind", "function", "--json"]
3957 )
3958 data = json.loads(result.output)
3959 assert data["filters"]["top"] == 3
3960 assert data["filters"]["kind"] == "function"
3961 assert data["filters"]["include_imports"] is False
3962 assert data["filters"]["include_docs"] is False
3963
3964 def test_stable_json_not_truncated_small_repo(self, code_repo: pathlib.Path) -> None:
3965 result = runner.invoke(cli, ["code", "stable", "--json"])
3966 data = json.loads(result.output)
3967 assert data["truncated"] is False
3968
3969 # ── --language filter ─────────────────────────────────────────────────────
3970
3971 def test_stable_language_filter_case_insensitive(self, code_repo: pathlib.Path) -> None:
3972 """--language python and --language Python must behave identically."""
3973 r_lower = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
3974 r_upper = runner.invoke(cli, ["code", "stable", "--language", "Python", "--json"])
3975 assert r_lower.exit_code == 0 and r_upper.exit_code == 0
3976 d_lower = json.loads(r_lower.output)
3977 d_upper = json.loads(r_upper.output)
3978 addrs_lower = {e["address"] for e in d_lower["stable"]}
3979 addrs_upper = {e["address"] for e in d_upper["stable"]}
3980 assert addrs_lower == addrs_upper
3981
3982 def test_stable_language_filter_restricts_results(self, code_repo: pathlib.Path) -> None:
3983 r_py = runner.invoke(cli, ["code", "stable", "--language", "python", "--json"])
3984 r_all = runner.invoke(cli, ["code", "stable", "--json"])
3985 d_py = json.loads(r_py.output)
3986 d_all = json.loads(r_all.output)
3987 # Python-filtered results must be a subset of or equal to unfiltered results.
3988 py_addrs = {e["address"] for e in d_py["stable"]}
3989 all_addrs = {e["address"] for e in d_all["stable"]}
3990 assert py_addrs <= all_addrs
3991
3992 # ── --since REF ───────────────────────────────────────────────────────────
3993
3994 def test_stable_since_reduces_commits_analysed(self, code_repo: pathlib.Path) -> None:
3995 """--since HEAD restricts the window to 0 commits (stop immediately)."""
3996 # Get the HEAD commit id to use as --since boundary
3997 import json as _json
3998 root = code_repo
3999 repo_id = _json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
4000 from muse.core.store import read_current_branch, resolve_commit_ref
4001 branch = read_current_branch(root)
4002 head = resolve_commit_ref(root, repo_id, branch, None)
4003 assert head is not None
4004
4005 r_all = runner.invoke(cli, ["code", "stable", "--json"])
4006 r_since = runner.invoke(cli, ["code", "stable", "--since", head.commit_id, "--json"])
4007 assert r_all.exit_code == 0 and r_since.exit_code == 0
4008 d_all = json.loads(r_all.output)
4009 d_since = json.loads(r_since.output)
4010 # Window stops at HEAD itself → at most 1 commit analysed.
4011 assert d_since["commits_analysed"] <= d_all["commits_analysed"]
4012
4013 def test_stable_since_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
4014 result = runner.invoke(cli, ["code", "stable", "--since", "nonexistent-ref-xyz"])
4015 assert result.exit_code != 0
4016
4017 # ── --max-commits ─────────────────────────────────────────────────────────
4018
4019 def test_stable_max_commits_caps_scan(self, code_repo: pathlib.Path) -> None:
4020 r_full = runner.invoke(cli, ["code", "stable", "--json"])
4021 r_cap = runner.invoke(cli, ["code", "stable", "--max-commits", "1", "--json"])
4022 assert r_full.exit_code == 0 and r_cap.exit_code == 0
4023 d_cap = json.loads(r_cap.output)
4024 assert d_cap["commits_analysed"] <= 1
4025
4026 def test_stable_max_commits_one_shows_truncated_warning(
4027 self, code_repo: pathlib.Path
4028 ) -> None:
4029 result = runner.invoke(cli, ["code", "stable", "--max-commits", "1"])
4030 assert result.exit_code == 0, result.output
4031 # With 2 commits and cap=1, truncated warning should appear.
4032 assert "capped" in result.output or "⚠️" in result.output
4033
4034 def test_stable_max_commits_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4035 result = runner.invoke(cli, ["code", "stable", "--max-commits", "0"])
4036 assert result.exit_code != 0
4037
4038 # ── --top validation ──────────────────────────────────────────────────────
4039
4040 def test_stable_top_zero_exits_error(self, code_repo: pathlib.Path) -> None:
4041 result = runner.invoke(cli, ["code", "stable", "--top", "0"])
4042 assert result.exit_code != 0
4043
4044 def test_stable_top_limits_output_count(self, code_repo: pathlib.Path) -> None:
4045 result = runner.invoke(cli, ["code", "stable", "--top", "2", "--json"])
4046 data = json.loads(result.output)
4047 assert len(data["stable"]) <= 2
4048
4049 # ── BFS follows merge parents ─────────────────────────────────────────────
4050
4051 def test_stable_bfs_follows_merge_parent2(self, repo: pathlib.Path) -> None:
4052 """Symbols touched only on a merged feature branch must be detected as unstable."""
4053 import datetime
4054
4055 # Create a symbol in commit 1 (main).
4056 (repo / "core.py").write_text("def bedrock():\n return 42\n")
4057 r = runner.invoke(cli, ["commit", "-m", "Add bedrock"])
4058 assert r.exit_code == 0, r.output
4059
4060 repo_json = json.loads((repo / ".muse" / "repo.json").read_text())
4061 repo_id = repo_json["repo_id"]
4062 from muse.core.store import read_current_branch, resolve_commit_ref
4063 branch = read_current_branch(repo)
4064 head_commit = resolve_commit_ref(repo, repo_id, branch, None)
4065 assert head_commit is not None
4066 head_id = head_commit.commit_id
4067
4068 feature_at = datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc)
4069 merge_at = datetime.datetime(2026, 4, 1, 1, 0, tzinfo=datetime.timezone.utc)
4070
4071 # Feature-branch commit that touched "bedrock" via a structured_delta.
4072 from muse.domain import PatchOp, ReplaceOp, StructuredDelta
4073 from muse.core.snapshot import compute_commit_id
4074 bedrock_delta = StructuredDelta(
4075 domain="code",
4076 ops=[PatchOp(
4077 op="patch", address="core.py",
4078 child_ops=[ReplaceOp(
4079 op="replace", address="core.py::bedrock",
4080 old_content_id="a" * 64, new_content_id="b" * 64,
4081 old_summary="function bedrock",
4082 new_summary="function bedrock (modified)", position=None,
4083 )],
4084 child_domain="code", child_summary="bedrock modified",
4085 )],
4086 summary="bedrock modified",
4087 )
4088 feature_id = compute_commit_id(
4089 [head_id], head_commit.snapshot_id,
4090 "Feature: touch bedrock", feature_at.isoformat(),
4091 )
4092 merge_id = compute_commit_id(
4093 [head_id, feature_id], head_commit.snapshot_id,
4094 "Merge feat/touch-bedrock", merge_at.isoformat(),
4095 )
4096 feature_body: CommitDict = {
4097 "commit_id": feature_id,
4098 "repo_id": repo_id,
4099 "branch": "feat/touch-bedrock",
4100 "snapshot_id": head_commit.snapshot_id,
4101 "message": "Feature: touch bedrock",
4102 "committed_at": feature_at.isoformat(),
4103 "parent_commit_id": head_id,
4104 "parent2_commit_id": None,
4105 "author": "test",
4106 "metadata": {},
4107 "structured_delta": bedrock_delta,
4108 }
4109 # Merge commit whose parent2 is the feature commit.
4110 merge_body: CommitDict = {
4111 "commit_id": merge_id,
4112 "repo_id": repo_id,
4113 "branch": branch,
4114 "snapshot_id": head_commit.snapshot_id,
4115 "message": "Merge feat/touch-bedrock",
4116 "committed_at": merge_at.isoformat(),
4117 "parent_commit_id": head_id,
4118 "parent2_commit_id": feature_id,
4119 "author": "test",
4120 "metadata": {},
4121 "structured_delta": None,
4122 }
4123 from muse.core.store import write_commit, CommitRecord
4124 write_commit(repo, CommitRecord.from_dict(feature_body))
4125 write_commit(repo, CommitRecord.from_dict(merge_body))
4126 (repo / ".muse" / "refs" / "heads" / branch).write_text(merge_id)
4127
4128 result = runner.invoke(cli, ["code", "stable", "--top", "10", "--json"])
4129 assert result.exit_code == 0, result.output
4130 data = json.loads(result.output)
4131 # bedrock was touched in the feature-branch commit; BFS must find it.
4132 # It should have unchanged_for < total_commits (not maximally stable).
4133 bedrock_entries = [e for e in data["stable"] if "bedrock" in e["address"]]
4134 if bedrock_entries:
4135 assert not bedrock_entries[0]["since_start_of_range"]
4136
4137
4138 # ---------------------------------------------------------------------------
4139 # muse code compare
4140 # ---------------------------------------------------------------------------
4141
4142
4143 @pytest.fixture
4144 def compare_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4145 """Repo with two commits; returns (path, commit_id_a, commit_id_b).
4146
4147 Commit A — billing.py with Invoice.compute_total + process_order.
4148 Commit B — compute_total renamed to compute_invoice_total; generate_pdf
4149 and send_email added. Multi-line message to test truncation.
4150 """
4151 (repo / "billing.py").write_text(textwrap.dedent("""\
4152 class Invoice:
4153 def compute_total(self, items):
4154 return sum(items)
4155
4156 def apply_discount(self, total, pct):
4157 return total * (1 - pct)
4158
4159 def process_order(invoice, items):
4160 return invoice.compute_total(items)
4161 """))
4162 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4163 assert r.exit_code == 0, r.output
4164 from muse.core.store import read_current_branch
4165 branch = read_current_branch(repo)
4166 commit_a = get_head_commit_id(repo, branch)
4167
4168 (repo / "billing.py").write_text(textwrap.dedent("""\
4169 class Invoice:
4170 def compute_invoice_total(self, items):
4171 return sum(items)
4172
4173 def apply_discount(self, total, pct):
4174 return total * (1 - pct)
4175
4176 def generate_pdf(self):
4177 return b"pdf"
4178
4179 def process_order(invoice, items):
4180 return invoice.compute_invoice_total(items)
4181
4182 def send_email(address):
4183 pass
4184 """))
4185 # Multi-line message to test first-line truncation.
4186 r = runner.invoke(cli, [
4187 "commit", "-m",
4188 "Rename compute_total, add generate_pdf + send_email\n\nThis is the extended body.",
4189 ])
4190 assert r.exit_code == 0, r.output
4191 commit_b = get_head_commit_id(repo, branch)
4192
4193 assert commit_a is not None
4194 assert commit_b is not None
4195 return repo, commit_a, commit_b
4196
4197
4198 class TestCompare:
4199 """Tests for muse code compare."""
4200
4201 # ── basic correctness ────────────────────────────────────────────────────
4202
4203 def test_compare_exits_zero(
4204 self, compare_repo: tuple[pathlib.Path, str, str]
4205 ) -> None:
4206 _, ref_a, ref_b = compare_repo
4207 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4208 assert result.exit_code == 0, result.output
4209
4210 def test_compare_shows_header(
4211 self, compare_repo: tuple[pathlib.Path, str, str]
4212 ) -> None:
4213 _, ref_a, ref_b = compare_repo
4214 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4215 assert result.exit_code == 0, result.output
4216 assert "Semantic comparison" in result.output
4217 assert "From:" in result.output
4218 assert "To:" in result.output
4219
4220 def test_compare_commit_message_first_line_only(
4221 self, compare_repo: tuple[pathlib.Path, str, str]
4222 ) -> None:
4223 """Multi-line commit messages must be truncated to their first line."""
4224 _, ref_a, ref_b = compare_repo
4225 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4226 assert result.exit_code == 0, result.output
4227 # The body of the second commit must not appear in the header.
4228 assert "This is the extended body" not in result.output
4229
4230 def test_compare_same_ref_no_changes(
4231 self, compare_repo: tuple[pathlib.Path, str, str]
4232 ) -> None:
4233 _, ref_a, _ = compare_repo
4234 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a])
4235 assert result.exit_code == 0, result.output
4236 assert "no semantic changes" in result.output
4237
4238 def test_compare_detects_added_symbols(
4239 self, compare_repo: tuple[pathlib.Path, str, str]
4240 ) -> None:
4241 _, ref_a, ref_b = compare_repo
4242 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b])
4243 assert result.exit_code == 0, result.output
4244 # generate_pdf and send_email were added in commit B.
4245 assert "generate_pdf" in result.output or "send_email" in result.output
4246
4247 def test_compare_invalid_ref_exits_nonzero(
4248 self, compare_repo: tuple[pathlib.Path, str, str]
4249 ) -> None:
4250 _, ref_a, _ = compare_repo
4251 result = runner.invoke(cli, ["code", "compare", ref_a, "deadbeefdeadbeef"])
4252 assert result.exit_code != 0
4253
4254 def test_compare_requires_repo(
4255 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4256 ) -> None:
4257 monkeypatch.chdir(tmp_path)
4258 result = runner.invoke(cli, ["code", "compare", "abc", "def"])
4259 assert result.exit_code != 0
4260
4261 # ── JSON schema ──────────────────────────────────────────────────────────
4262
4263 def test_compare_json_schema(
4264 self, compare_repo: tuple[pathlib.Path, str, str]
4265 ) -> None:
4266 _, ref_a, ref_b = compare_repo
4267 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4268 assert result.exit_code == 0, result.output
4269 data = json.loads(result.output)
4270 assert set(data.keys()) >= {"from", "to", "filters", "stat", "ops"}
4271
4272 def test_compare_json_from_to_schema(
4273 self, compare_repo: tuple[pathlib.Path, str, str]
4274 ) -> None:
4275 _, ref_a, ref_b = compare_repo
4276 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4277 assert result.exit_code == 0, result.output
4278 data = json.loads(result.output)
4279 assert "commit_id" in data["from"]
4280 assert "message" in data["from"]
4281 assert "commit_id" in data["to"]
4282 assert "message" in data["to"]
4283
4284 def test_compare_json_message_first_line_only(
4285 self, compare_repo: tuple[pathlib.Path, str, str]
4286 ) -> None:
4287 _, ref_a, ref_b = compare_repo
4288 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4289 assert result.exit_code == 0, result.output
4290 data = json.loads(result.output)
4291 assert "\n" not in data["to"]["message"]
4292 assert "This is the extended body" not in data["to"]["message"]
4293
4294 def test_compare_json_stat_schema(
4295 self, compare_repo: tuple[pathlib.Path, str, str]
4296 ) -> None:
4297 _, ref_a, ref_b = compare_repo
4298 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4299 assert result.exit_code == 0, result.output
4300 stat = json.loads(result.output)["stat"]
4301 assert set(stat.keys()) >= {
4302 "files_changed", "symbols_added", "symbols_removed",
4303 "symbols_modified", "semver_impact",
4304 }
4305 assert isinstance(stat["files_changed"], int)
4306 assert isinstance(stat["symbols_added"], int)
4307 assert stat["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
4308
4309 def test_compare_json_filters_schema(
4310 self, compare_repo: tuple[pathlib.Path, str, str]
4311 ) -> None:
4312 _, ref_a, ref_b = compare_repo
4313 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4314 assert result.exit_code == 0, result.output
4315 filters = json.loads(result.output)["filters"]
4316 assert set(filters.keys()) >= {"kind", "file", "language"}
4317 # No filters applied — all None.
4318 assert filters["kind"] is None
4319 assert filters["file"] is None
4320 assert filters["language"] is None
4321
4322 def test_compare_json_ops_schema(
4323 self, compare_repo: tuple[pathlib.Path, str, str]
4324 ) -> None:
4325 _, ref_a, ref_b = compare_repo
4326 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--json"])
4327 assert result.exit_code == 0, result.output
4328 ops = json.loads(result.output)["ops"]
4329 assert isinstance(ops, list)
4330 assert len(ops) > 0
4331 for op in ops:
4332 assert "op" in op
4333 assert "address" in op
4334 assert "detail" in op
4335
4336 def test_compare_same_ref_json_empty_ops(
4337 self, compare_repo: tuple[pathlib.Path, str, str]
4338 ) -> None:
4339 _, ref_a, _ = compare_repo
4340 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--json"])
4341 assert result.exit_code == 0, result.output
4342 data = json.loads(result.output)
4343 assert data["ops"] == []
4344 assert data["stat"]["semver_impact"] == "NONE"
4345
4346 # ── --stat flag ──────────────────────────────────────────────────────────
4347
4348 def test_compare_stat_shows_counts(
4349 self, compare_repo: tuple[pathlib.Path, str, str]
4350 ) -> None:
4351 _, ref_a, ref_b = compare_repo
4352 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4353 assert result.exit_code == 0, result.output
4354 assert "Files changed:" in result.output
4355 assert "Symbols added:" in result.output
4356 assert "Symbols removed:" in result.output
4357 assert "Symbols modified:" in result.output
4358 assert "SemVer impact:" in result.output
4359
4360 def test_compare_stat_no_per_symbol_listing(
4361 self, compare_repo: tuple[pathlib.Path, str, str]
4362 ) -> None:
4363 _, ref_a, ref_b = compare_repo
4364 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--stat"])
4365 assert result.exit_code == 0, result.output
4366 # --stat should not include per-symbol listing lines ("added …", "removed …").
4367 assert " added " not in result.output
4368 assert " removed " not in result.output
4369 assert " modified " not in result.output
4370
4371 def test_compare_stat_same_ref_semver_none(
4372 self, compare_repo: tuple[pathlib.Path, str, str]
4373 ) -> None:
4374 _, ref_a, _ = compare_repo
4375 result = runner.invoke(cli, ["code", "compare", ref_a, ref_a, "--stat"])
4376 assert result.exit_code == 0, result.output
4377 assert "NONE" in result.output
4378
4379 # ── --semver flag ────────────────────────────────────────────────────────
4380
4381 def test_compare_semver_appended_to_full_output(
4382 self, compare_repo: tuple[pathlib.Path, str, str]
4383 ) -> None:
4384 _, ref_a, ref_b = compare_repo
4385 result = runner.invoke(cli, ["code", "compare", ref_a, ref_b, "--semver"])
4386 assert result.exit_code == 0, result.output
4387 assert "SemVer impact:" in result.output
4388
4389 # ── --file filter ────────────────────────────────────────────────────────
4390
4391 def test_compare_file_filter_restricts_output(
4392 self, compare_repo: tuple[pathlib.Path, str, str]
4393 ) -> None:
4394 _, ref_a, ref_b = compare_repo
4395 result = runner.invoke(
4396 cli, ["code", "compare", ref_a, ref_b, "--file", "billing.py"]
4397 )
4398 assert result.exit_code == 0, result.output
4399
4400 def test_compare_file_filter_nonexistent_no_ops(
4401 self, compare_repo: tuple[pathlib.Path, str, str]
4402 ) -> None:
4403 _, ref_a, ref_b = compare_repo
4404 result = runner.invoke(
4405 cli, ["code", "compare", ref_a, ref_b, "--file", "nonexistent.py"]
4406 )
4407 assert result.exit_code == 0, result.output
4408 assert "no semantic changes" in result.output
4409
4410 def test_compare_file_filter_in_json(
4411 self, compare_repo: tuple[pathlib.Path, str, str]
4412 ) -> None:
4413 _, ref_a, ref_b = compare_repo
4414 result = runner.invoke(
4415 cli,
4416 ["code", "compare", ref_a, ref_b, "--file", "billing.py", "--json"],
4417 )
4418 assert result.exit_code == 0, result.output
4419 data = json.loads(result.output)
4420 assert data["filters"]["file"] == "billing.py"
4421
4422 # ── --kind filter ────────────────────────────────────────────────────────
4423
4424 def test_compare_kind_filter_case_insensitive(
4425 self, compare_repo: tuple[pathlib.Path, str, str]
4426 ) -> None:
4427 _, ref_a, ref_b = compare_repo
4428 r_lower = runner.invoke(
4429 cli, ["code", "compare", ref_a, ref_b, "--kind", "function"]
4430 )
4431 r_upper = runner.invoke(
4432 cli, ["code", "compare", ref_a, ref_b, "--kind", "Function"]
4433 )
4434 assert r_lower.exit_code == 0
4435 assert r_upper.exit_code == 0
4436 # Both produce the same ops list.
4437 assert r_lower.output == r_upper.output
4438
4439 def test_compare_kind_filter_in_json(
4440 self, compare_repo: tuple[pathlib.Path, str, str]
4441 ) -> None:
4442 _, ref_a, ref_b = compare_repo
4443 result = runner.invoke(
4444 cli, ["code", "compare", ref_a, ref_b, "--kind", "function", "--json"]
4445 )
4446 assert result.exit_code == 0, result.output
4447 data = json.loads(result.output)
4448 assert data["filters"]["kind"] == "function"
4449
4450 # ── --language filter ────────────────────────────────────────────────────
4451
4452 def test_compare_language_filter_python(
4453 self, compare_repo: tuple[pathlib.Path, str, str]
4454 ) -> None:
4455 _, ref_a, ref_b = compare_repo
4456 result = runner.invoke(
4457 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4458 )
4459 assert result.exit_code == 0, result.output
4460
4461 def test_compare_language_filter_case_insensitive(
4462 self, compare_repo: tuple[pathlib.Path, str, str]
4463 ) -> None:
4464 _, ref_a, ref_b = compare_repo
4465 r_lower = runner.invoke(
4466 cli, ["code", "compare", ref_a, ref_b, "--language", "python"]
4467 )
4468 r_upper = runner.invoke(
4469 cli, ["code", "compare", ref_a, ref_b, "--language", "Python"]
4470 )
4471 assert r_lower.exit_code == 0
4472 assert r_upper.exit_code == 0
4473 assert r_lower.output == r_upper.output
4474
4475 def test_compare_language_filter_in_json(
4476 self, compare_repo: tuple[pathlib.Path, str, str]
4477 ) -> None:
4478 _, ref_a, ref_b = compare_repo
4479 result = runner.invoke(
4480 cli, ["code", "compare", ref_a, ref_b, "--language", "python", "--json"]
4481 )
4482 assert result.exit_code == 0, result.output
4483 data = json.loads(result.output)
4484 assert data["filters"]["language"] == "Python"
4485
4486
4487 # ---------------------------------------------------------------------------
4488 # muse code languages
4489 # ---------------------------------------------------------------------------
4490
4491
4492 @pytest.fixture
4493 def lang_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str]:
4494 """Two-commit repo; returns (path, commit_id_a, commit_id_b).
4495
4496 Commit A — billing.py (Python) only.
4497 Commit B — billing.py extended + README.md added.
4498 """
4499 (repo / "billing.py").write_text(textwrap.dedent("""\
4500 import os
4501 import json
4502
4503 class Invoice:
4504 def compute_total(self, items: list[float]) -> float:
4505 return sum(items)
4506
4507 def process_order(invoice: Invoice, items: list[float]) -> float:
4508 return invoice.compute_total(items)
4509 """))
4510 r = runner.invoke(cli, ["commit", "-m", "Add billing module"])
4511 assert r.exit_code == 0, r.output
4512 from muse.core.store import read_current_branch
4513 branch = read_current_branch(repo)
4514 commit_a = get_head_commit_id(repo, branch)
4515
4516 (repo / "billing.py").write_text(textwrap.dedent("""\
4517 import os
4518 import json
4519
4520 class Invoice:
4521 def compute_total(self, items: list[float]) -> float:
4522 return sum(items)
4523
4524 def generate_pdf(self) -> bytes:
4525 return b"pdf"
4526
4527 def process_order(invoice: Invoice, items: list[float]) -> float:
4528 return invoice.compute_total(items)
4529
4530 def send_email(address: str) -> None:
4531 pass
4532 """))
4533 (repo / "README.md").write_text("# My Project\n\nA billing module.\n")
4534 r = runner.invoke(cli, ["commit", "-m", "Add generate_pdf, send_email, README"])
4535 assert r.exit_code == 0, r.output
4536 commit_b = get_head_commit_id(repo, branch)
4537
4538 assert commit_a is not None
4539 assert commit_b is not None
4540 return repo, commit_a, commit_b
4541
4542
4543 class TestLanguages:
4544 """Tests for muse code languages."""
4545
4546 # ── basic correctness ────────────────────────────────────────────────────
4547
4548 def test_languages_exits_zero(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4549 result = runner.invoke(cli, ["code", "languages"])
4550 assert result.exit_code == 0, result.output
4551
4552 def test_languages_shows_header(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4553 result = runner.invoke(cli, ["code", "languages"])
4554 assert result.exit_code == 0, result.output
4555 assert "Language breakdown" in result.output
4556 assert "Total" in result.output
4557
4558 def test_languages_shows_python(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4559 result = runner.invoke(cli, ["code", "languages"])
4560 assert result.exit_code == 0, result.output
4561 assert "Python" in result.output
4562
4563 def test_languages_shows_markdown(self, lang_repo: tuple[pathlib.Path, str, str]) -> None:
4564 result = runner.invoke(cli, ["code", "languages"])
4565 assert result.exit_code == 0, result.output
4566 assert "Markdown" in result.output
4567
4568 def test_languages_excludes_imports_by_default(
4569 self, lang_repo: tuple[pathlib.Path, str, str]
4570 ) -> None:
4571 """Import pseudo-symbols must not inflate the count by default."""
4572 r_default = runner.invoke(cli, ["code", "languages", "--json"])
4573 assert r_default.exit_code == 0, r_default.output
4574 r_imports = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4575 assert r_imports.exit_code == 0, r_imports.output
4576
4577 class _LangEntry(TypedDict):
4578 language: str
4579 files: int
4580 symbols: int
4581 kinds: _KindsMap
4582
4583 class _LangsJson(TypedDict):
4584 languages: list[_LangEntry]
4585
4586 data_default: _LangsJson = json.loads(r_default.output)
4587 data_imports: _LangsJson = json.loads(r_imports.output)
4588
4589 def _py_syms(data: _LangsJson) -> int:
4590 for e in data["languages"]:
4591 if e["language"] == "Python":
4592 return e["symbols"]
4593 return 0
4594
4595 syms_default = _py_syms(data_default)
4596 syms_imports = _py_syms(data_imports)
4597 # With imports included the symbol count must be strictly higher.
4598 assert syms_imports > syms_default
4599
4600 def test_languages_requires_repo(
4601 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4602 ) -> None:
4603 monkeypatch.chdir(tmp_path)
4604 result = runner.invoke(cli, ["code", "languages"])
4605 assert result.exit_code != 0
4606
4607 def test_languages_invalid_commit_exits_nonzero(
4608 self, lang_repo: tuple[pathlib.Path, str, str]
4609 ) -> None:
4610 result = runner.invoke(cli, ["code", "languages", "--commit", "deadbeefdeadbeef"])
4611 assert result.exit_code != 0
4612
4613 # ── JSON schema ──────────────────────────────────────────────────────────
4614
4615 def test_languages_json_schema(
4616 self, lang_repo: tuple[pathlib.Path, str, str]
4617 ) -> None:
4618 result = runner.invoke(cli, ["code", "languages", "--json"])
4619 assert result.exit_code == 0, result.output
4620 data = json.loads(result.output)
4621 assert set(data.keys()) >= {"commit", "include_imports", "languages"}
4622
4623 def test_languages_json_commit_block(
4624 self, lang_repo: tuple[pathlib.Path, str, str]
4625 ) -> None:
4626 result = runner.invoke(cli, ["code", "languages", "--json"])
4627 assert result.exit_code == 0, result.output
4628 data = json.loads(result.output)
4629 commit = data["commit"]
4630 assert "commit_id" in commit
4631 assert "message" in commit
4632 # message is first line only — no newlines.
4633 assert "\n" not in commit["message"]
4634
4635 def test_languages_json_entry_schema(
4636 self, lang_repo: tuple[pathlib.Path, str, str]
4637 ) -> None:
4638 result = runner.invoke(cli, ["code", "languages", "--json"])
4639 assert result.exit_code == 0, result.output
4640 langs = json.loads(result.output)["languages"]
4641 assert isinstance(langs, list)
4642 assert len(langs) > 0
4643 for entry in langs:
4644 assert "language" in entry
4645 assert "files" in entry
4646 assert "symbols" in entry
4647 assert "kinds" in entry
4648 assert isinstance(entry["files"], int)
4649 assert isinstance(entry["symbols"], int)
4650 assert isinstance(entry["kinds"], dict)
4651
4652 def test_languages_json_include_imports_flag(
4653 self, lang_repo: tuple[pathlib.Path, str, str]
4654 ) -> None:
4655 result = runner.invoke(cli, ["code", "languages", "--include-imports", "--json"])
4656 assert result.exit_code == 0, result.output
4657 data = json.loads(result.output)
4658 assert data["include_imports"] is True
4659
4660 # ── --sort flag ──────────────────────────────────────────────────────────
4661
4662 def test_languages_sort_name(
4663 self, lang_repo: tuple[pathlib.Path, str, str]
4664 ) -> None:
4665 result = runner.invoke(cli, ["code", "languages", "--sort", "name"])
4666 assert result.exit_code == 0, result.output
4667
4668 def test_languages_sort_symbols(
4669 self, lang_repo: tuple[pathlib.Path, str, str]
4670 ) -> None:
4671 result = runner.invoke(cli, ["code", "languages", "--sort", "symbols"])
4672 assert result.exit_code == 0, result.output
4673 # Python should appear before Markdown when sorted by symbols desc.
4674 lines = result.output.splitlines()
4675 py_line = next((i for i, l in enumerate(lines) if "Python" in l), None)
4676 md_line = next((i for i, l in enumerate(lines) if "Markdown" in l), None)
4677 # Both might not exist if the repo only has Python; at least ensure no crash.
4678 assert py_line is not None
4679
4680 def test_languages_sort_files(
4681 self, lang_repo: tuple[pathlib.Path, str, str]
4682 ) -> None:
4683 result = runner.invoke(cli, ["code", "languages", "--sort", "files"])
4684 assert result.exit_code == 0, result.output
4685
4686 def test_languages_invalid_sort_exits_nonzero(
4687 self, lang_repo: tuple[pathlib.Path, str, str]
4688 ) -> None:
4689 result = runner.invoke(cli, ["code", "languages", "--sort", "bad"])
4690 assert result.exit_code != 0
4691
4692 # ── --diff flag ──────────────────────────────────────────────────────────
4693
4694 def test_languages_diff_exits_zero(
4695 self, lang_repo: tuple[pathlib.Path, str, str]
4696 ) -> None:
4697 _, commit_a, _ = lang_repo
4698 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4699 assert result.exit_code == 0, result.output
4700
4701 def test_languages_diff_shows_header(
4702 self, lang_repo: tuple[pathlib.Path, str, str]
4703 ) -> None:
4704 _, commit_a, _ = lang_repo
4705 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4706 assert result.exit_code == 0, result.output
4707 assert "Language change" in result.output
4708 assert "Net" in result.output
4709
4710 def test_languages_diff_detects_new_symbols(
4711 self, lang_repo: tuple[pathlib.Path, str, str]
4712 ) -> None:
4713 """Commit B added generate_pdf and send_email — Python symbol count must grow."""
4714 _, commit_a, _ = lang_repo
4715 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a])
4716 assert result.exit_code == 0, result.output
4717 # Python line should show a positive delta.
4718 lines = result.output.splitlines()
4719 py_line = next((l for l in lines if "Python" in l), "")
4720 assert "+" in py_line
4721
4722 def test_languages_diff_unchanged_label(
4723 self, lang_repo: tuple[pathlib.Path, str, str]
4724 ) -> None:
4725 """Comparing a commit to itself must show all languages as unchanged."""
4726 _, _, commit_b = lang_repo
4727 result = runner.invoke(cli, ["code", "languages", "--diff", commit_b])
4728 assert result.exit_code == 0, result.output
4729 assert "unchanged" in result.output
4730
4731 def test_languages_diff_invalid_ref_exits_nonzero(
4732 self, lang_repo: tuple[pathlib.Path, str, str]
4733 ) -> None:
4734 result = runner.invoke(cli, ["code", "languages", "--diff", "deadbeefdeadbeef"])
4735 assert result.exit_code != 0
4736
4737 def test_languages_diff_json_schema(
4738 self, lang_repo: tuple[pathlib.Path, str, str]
4739 ) -> None:
4740 _, commit_a, _ = lang_repo
4741 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4742 assert result.exit_code == 0, result.output
4743 data = json.loads(result.output)
4744 assert set(data.keys()) >= {"from", "to", "include_imports", "diff"}
4745 assert "commit_id" in data["from"]
4746 assert "message" in data["to"]
4747
4748 def test_languages_diff_json_entry_schema(
4749 self, lang_repo: tuple[pathlib.Path, str, str]
4750 ) -> None:
4751 _, commit_a, _ = lang_repo
4752 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4753 assert result.exit_code == 0, result.output
4754 diff = json.loads(result.output)["diff"]
4755 assert isinstance(diff, list)
4756 assert len(diff) > 0
4757 for entry in diff:
4758 assert "language" in entry
4759 assert "delta_files" in entry
4760 assert "delta_symbols" in entry
4761 assert "files_before" in entry
4762 assert "files_after" in entry
4763 assert "symbols_before" in entry
4764 assert "symbols_after" in entry
4765 assert "status" in entry
4766 assert entry["status"] in ("added", "removed", "changed", "unchanged")
4767
4768 def test_languages_diff_json_python_delta_positive(
4769 self, lang_repo: tuple[pathlib.Path, str, str]
4770 ) -> None:
4771 _, commit_a, _ = lang_repo
4772 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4773 assert result.exit_code == 0, result.output
4774 diff = json.loads(result.output)["diff"]
4775 py = next((e for e in diff if e["language"] == "Python"), None)
4776 assert py is not None
4777 assert py["delta_symbols"] > 0
4778 assert py["status"] == "changed"
4779
4780 def test_languages_diff_json_markdown_added(
4781 self, lang_repo: tuple[pathlib.Path, str, str]
4782 ) -> None:
4783 """README.md was added in commit B — Markdown status should be 'added'."""
4784 _, commit_a, _ = lang_repo
4785 result = runner.invoke(cli, ["code", "languages", "--diff", commit_a, "--json"])
4786 assert result.exit_code == 0, result.output
4787 diff = json.loads(result.output)["diff"]
4788 md = next((e for e in diff if e["language"] == "Markdown"), None)
4789 assert md is not None
4790 assert md["status"] == "added"
4791 assert md["files_before"] == 0
4792 assert md["files_after"] == 1
4793
4794
4795 # ---------------------------------------------------------------------------
4796 # muse code rename
4797 # ---------------------------------------------------------------------------
4798
4799
4800 @pytest.fixture
4801 def rename_repo(repo: pathlib.Path) -> pathlib.Path:
4802 """Repo with billing.py and a test file that imports and calls its symbols."""
4803 (repo / "billing.py").write_text(textwrap.dedent("""\
4804 import os
4805
4806 class Invoice:
4807 def compute_total(self, items):
4808 return sum(items)
4809
4810 def apply_discount(self, total, pct):
4811 return total * (1 - pct)
4812
4813 def process_order(invoice, items):
4814 total = compute_total(items)
4815 return total
4816 """))
4817 (repo / "test_billing.py").write_text(textwrap.dedent("""\
4818 from billing import compute_total, Invoice
4819
4820 def test_compute_total():
4821 inv = Invoice()
4822 result = inv.compute_total([1, 2, 3])
4823 assert compute_total([1, 2, 3]) == 6
4824 """))
4825 r = runner.invoke(cli, ["commit", "-m", "Initial billing + tests"])
4826 assert r.exit_code == 0, r.output
4827 return repo
4828
4829
4830 class TestRename:
4831 """Tests for muse code rename."""
4832
4833 # ── basic correctness ────────────────────────────────────────────────────
4834
4835 def test_rename_dry_run_exits_zero(self, rename_repo: pathlib.Path) -> None:
4836 result = runner.invoke(
4837 cli,
4838 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4839 )
4840 assert result.exit_code == 0, result.output
4841
4842 def test_rename_dry_run_shows_preview(self, rename_repo: pathlib.Path) -> None:
4843 result = runner.invoke(
4844 cli,
4845 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4846 )
4847 assert result.exit_code == 0, result.output
4848 assert "Renaming" in result.output
4849 assert "process_order" in result.output
4850 assert "handle_order" in result.output
4851
4852 def test_rename_dry_run_does_not_write(self, rename_repo: pathlib.Path) -> None:
4853 before = (rename_repo / "billing.py").read_text()
4854 runner.invoke(
4855 cli,
4856 ["code", "rename", "billing.py::process_order", "handle_order", "--dry-run"],
4857 )
4858 assert (rename_repo / "billing.py").read_text() == before
4859
4860 def test_rename_applies_definition(self, rename_repo: pathlib.Path) -> None:
4861 result = runner.invoke(
4862 cli,
4863 ["code", "rename", "billing.py::process_order", "handle_order",
4864 "--scope", "definition", "--yes"],
4865 )
4866 assert result.exit_code == 0, result.output
4867 content = (rename_repo / "billing.py").read_text()
4868 assert "def handle_order(" in content
4869 assert "def process_order(" not in content
4870
4871 def test_rename_only_def_token_not_string_literal(
4872 self, rename_repo: pathlib.Path
4873 ) -> None:
4874 """The rename must not touch string literals containing the old name."""
4875 # Add a docstring with the old name.
4876 billing = (rename_repo / "billing.py").read_text()
4877 billing += '\nDOC = "compute_total is a function"\n'
4878 (rename_repo / "billing.py").write_text(billing)
4879 runner.invoke(cli, ["commit", "-m", "add docstring"])
4880
4881 runner.invoke(
4882 cli,
4883 ["code", "rename", "billing.py::Invoice.compute_total",
4884 "compute_invoice_total", "--scope", "definition", "--yes"],
4885 )
4886 content = (rename_repo / "billing.py").read_text()
4887 # The string literal must be untouched.
4888 assert '"compute_total is a function"' in content
4889
4890 def test_rename_method_definition_scoped_to_class(
4891 self, rename_repo: pathlib.Path
4892 ) -> None:
4893 """billing.py::Invoice.compute_total must rename the method inside Invoice."""
4894 result = runner.invoke(
4895 cli,
4896 ["code", "rename", "billing.py::Invoice.compute_total",
4897 "compute_invoice_total", "--scope", "definition", "--yes"],
4898 )
4899 assert result.exit_code == 0, result.output
4900 content = (rename_repo / "billing.py").read_text()
4901 assert "def compute_invoice_total(self" in content
4902 # The module-level bare call in process_order stays unchanged.
4903 assert "compute_total(items)" in content
4904
4905 def test_rename_updates_import_sites(self, rename_repo: pathlib.Path) -> None:
4906 result = runner.invoke(
4907 cli,
4908 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
4909 "--scope", "imports", "--yes"],
4910 )
4911 assert result.exit_code == 0, result.output
4912 content = (rename_repo / "test_billing.py").read_text()
4913 assert "compute_invoice_total" in content
4914 assert "from billing import" in content
4915
4916 def test_rename_requires_repo(
4917 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
4918 ) -> None:
4919 monkeypatch.chdir(tmp_path)
4920 result = runner.invoke(
4921 cli, ["code", "rename", "billing.py::foo", "bar", "--yes"]
4922 )
4923 assert result.exit_code != 0
4924
4925 def test_rename_rejects_same_name(self, rename_repo: pathlib.Path) -> None:
4926 result = runner.invoke(
4927 cli,
4928 ["code", "rename", "billing.py::process_order", "process_order", "--yes"],
4929 )
4930 assert result.exit_code != 0
4931
4932 def test_rename_rejects_invalid_identifier(self, rename_repo: pathlib.Path) -> None:
4933 result = runner.invoke(
4934 cli,
4935 ["code", "rename", "billing.py::process_order", "123invalid", "--yes"],
4936 )
4937 assert result.exit_code != 0
4938
4939 def test_rename_rejects_address_without_double_colon(
4940 self, rename_repo: pathlib.Path
4941 ) -> None:
4942 result = runner.invoke(
4943 cli, ["code", "rename", "billing.py", "new_name", "--yes"]
4944 )
4945 assert result.exit_code != 0
4946
4947 def test_rename_rejects_nonexistent_symbol(self, rename_repo: pathlib.Path) -> None:
4948 result = runner.invoke(
4949 cli,
4950 ["code", "rename", "billing.py::nonexistent_func", "new_name",
4951 "--scope", "definition", "--yes"],
4952 )
4953 assert result.exit_code != 0
4954
4955 def test_rename_rejects_path_traversal(self, rename_repo: pathlib.Path) -> None:
4956 result = runner.invoke(
4957 cli,
4958 ["code", "rename", "../../etc/passwd::foo", "bar", "--yes"],
4959 )
4960 assert result.exit_code != 0
4961
4962 def test_rename_rejects_dunder_without_force(self, rename_repo: pathlib.Path) -> None:
4963 result = runner.invoke(
4964 cli,
4965 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__", "--yes"],
4966 )
4967 assert result.exit_code != 0
4968
4969 def test_rename_allows_dunder_with_force(self, rename_repo: pathlib.Path) -> None:
4970 result = runner.invoke(
4971 cli,
4972 ["code", "rename", "billing.py::Invoice.compute_total", "__compute__",
4973 "--scope", "definition", "--yes", "--force"],
4974 )
4975 assert result.exit_code == 0, result.output
4976 content = (rename_repo / "billing.py").read_text()
4977 assert "def __compute__(self" in content
4978
4979 # ── JSON output ──────────────────────────────────────────────────────────
4980
4981 def test_rename_json_schema(self, rename_repo: pathlib.Path) -> None:
4982 result = runner.invoke(
4983 cli,
4984 ["code", "rename", "billing.py::process_order", "handle_order",
4985 "--dry-run", "--json"],
4986 )
4987 assert result.exit_code == 0, result.output
4988 data = json.loads(result.output)
4989 assert set(data.keys()) >= {
4990 "from_address", "to_address", "from_name", "to_name",
4991 "scope", "dry_run", "files_to_modify", "total_edit_sites", "edit_sites",
4992 }
4993
4994 def test_rename_json_dry_run_empty_files_to_modify(
4995 self, rename_repo: pathlib.Path
4996 ) -> None:
4997 result = runner.invoke(
4998 cli,
4999 ["code", "rename", "billing.py::process_order", "handle_order",
5000 "--dry-run", "--json"],
5001 )
5002 assert result.exit_code == 0, result.output
5003 data = json.loads(result.output)
5004 assert data["dry_run"] is True
5005 assert data["files_to_modify"] == []
5006
5007 def test_rename_json_edit_site_schema(self, rename_repo: pathlib.Path) -> None:
5008 result = runner.invoke(
5009 cli,
5010 ["code", "rename", "billing.py::process_order", "handle_order",
5011 "--dry-run", "--json"],
5012 )
5013 assert result.exit_code == 0, result.output
5014 sites = json.loads(result.output)["edit_sites"]
5015 assert isinstance(sites, list)
5016 assert len(sites) > 0
5017 for site in sites:
5018 assert "file" in site
5019 assert "line" in site
5020 assert "col_start" in site
5021 assert "col_end" in site
5022 assert "kind" in site
5023 assert "context" in site
5024 assert site["kind"] in ("definition", "import", "reference")
5025 assert site["col_start"] < site["col_end"]
5026
5027 def test_rename_json_definition_site_present(self, rename_repo: pathlib.Path) -> None:
5028 result = runner.invoke(
5029 cli,
5030 ["code", "rename", "billing.py::process_order", "handle_order",
5031 "--dry-run", "--json"],
5032 )
5033 assert result.exit_code == 0, result.output
5034 sites = json.loads(result.output)["edit_sites"]
5035 def_sites = [s for s in sites if s["kind"] == "definition"]
5036 assert len(def_sites) == 1
5037 assert def_sites[0]["file"] == "billing.py"
5038 assert "process_order" in def_sites[0]["context"]
5039
5040 def test_rename_json_apply_writes_files(self, rename_repo: pathlib.Path) -> None:
5041 result = runner.invoke(
5042 cli,
5043 ["code", "rename", "billing.py::process_order", "handle_order",
5044 "--yes", "--json", "--scope", "definition"],
5045 )
5046 assert result.exit_code == 0, result.output
5047 content = (rename_repo / "billing.py").read_text()
5048 assert "def handle_order(" in content
5049
5050 # ── --scope flag ─────────────────────────────────────────────────────────
5051
5052 def test_rename_scope_definition_only(self, rename_repo: pathlib.Path) -> None:
5053 """--scope definition should only touch the def token."""
5054 runner.invoke(
5055 cli,
5056 ["code", "rename", "billing.py::process_order", "handle_order",
5057 "--scope", "definition", "--yes"],
5058 )
5059 billing = (rename_repo / "billing.py").read_text()
5060 test = (rename_repo / "test_billing.py").read_text()
5061 assert "def handle_order(" in billing
5062 # The import in test_billing.py must be untouched.
5063 assert "process_order" not in test or "import" in test
5064
5065 def test_rename_scope_imports_only(self, rename_repo: pathlib.Path) -> None:
5066 runner.invoke(
5067 cli,
5068 ["code", "rename", "billing.py::compute_total", "compute_invoice_total",
5069 "--scope", "imports", "--yes"],
5070 )
5071 billing = (rename_repo / "billing.py").read_text()
5072 # The definition in billing.py must be untouched.
5073 assert "def compute_total(" in billing
5074
5075 def test_rename_json_scope_reflected(self, rename_repo: pathlib.Path) -> None:
5076 result = runner.invoke(
5077 cli,
5078 ["code", "rename", "billing.py::process_order", "handle_order",
5079 "--scope", "definition", "--dry-run", "--json"],
5080 )
5081 assert result.exit_code == 0, result.output
5082 assert json.loads(result.output)["scope"] == "definition"
5083
5084 # ── --max-files guard ────────────────────────────────────────────────────
5085
5086 def test_rename_max_files_validation(self, rename_repo: pathlib.Path) -> None:
5087 result = runner.invoke(
5088 cli,
5089 ["code", "rename", "billing.py::process_order", "handle_order",
5090 "--max-files", "0", "--dry-run"],
5091 )
5092 assert result.exit_code != 0
5093
5094 # ── edit precision ───────────────────────────────────────────────────────
5095
5096 def test_rename_preserves_surrounding_code(self, rename_repo: pathlib.Path) -> None:
5097 """Renaming process_order must not touch apply_discount or compute_total."""
5098 runner.invoke(
5099 cli,
5100 ["code", "rename", "billing.py::process_order", "handle_order",
5101 "--scope", "definition", "--yes"],
5102 )
5103 content = (rename_repo / "billing.py").read_text()
5104 assert "def apply_discount(" in content
5105 assert "def compute_total(" in content
5106
5107 def test_rename_col_precision_correct(self, rename_repo: pathlib.Path) -> None:
5108 """The definition rename must produce syntactically valid Python."""
5109 runner.invoke(
5110 cli,
5111 ["code", "rename", "billing.py::process_order", "handle_order",
5112 "--scope", "definition", "--yes"],
5113 )
5114 import ast as _ast
5115 content = (rename_repo / "billing.py").read_text()
5116 # Must parse without SyntaxError.
5117 try:
5118 _ast.parse(content)
5119 except SyntaxError as e:
5120 pytest.fail(f"Renamed file has a syntax error: {e}")
5121
5122
5123 # ---------------------------------------------------------------------------
5124 # blast-risk
5125 # ---------------------------------------------------------------------------
5126
5127
5128 @pytest.fixture
5129 def blast_repo(repo: pathlib.Path) -> pathlib.Path:
5130 """Repo with two commits: a production module and a test file.
5131
5132 billing.py defines Invoice.compute_total and process_order.
5133 test_billing.py imports and calls both — so they have at least one
5134 test caller. A second commit modifies compute_total so churn > 0.
5135 """
5136 (repo / "billing.py").write_text(textwrap.dedent("""\
5137 class Invoice:
5138 def compute_total(self, items):
5139 return sum(items)
5140
5141 def apply_discount(self, total, pct):
5142 return total * (1 - pct)
5143
5144 def process_order(invoice, items):
5145 return invoice.compute_total(items)
5146 """))
5147 (repo / "test_billing.py").write_text(textwrap.dedent("""\
5148 from billing import Invoice, process_order
5149
5150 def test_compute_total():
5151 inv = Invoice()
5152 assert inv.compute_total([1, 2, 3]) == 6
5153
5154 def test_process_order():
5155 inv = Invoice()
5156 assert process_order(inv, [10]) == 10
5157 """))
5158 r = runner.invoke(cli, ["commit", "-m", "Add billing module and tests"])
5159 assert r.exit_code == 0, r.output
5160
5161 # Second commit: modify compute_total so churn count > 0.
5162 (repo / "billing.py").write_text(textwrap.dedent("""\
5163 class Invoice:
5164 def compute_total(self, items):
5165 # round to two decimal places
5166 return round(sum(items), 2)
5167
5168 def apply_discount(self, total, pct):
5169 return total * (1 - pct)
5170
5171 def process_order(invoice, items):
5172 return invoice.compute_total(items)
5173 """))
5174 r2 = runner.invoke(cli, ["commit", "-m", "Round compute_total result"])
5175 assert r2.exit_code == 0, r2.output
5176
5177 return repo
5178
5179
5180 class TestBlastRisk:
5181 """Tests for muse code blast-risk."""
5182
5183 # ── basic correctness ────────────────────────────────────────────────────
5184
5185 def test_blast_risk_exits_zero(self, blast_repo: pathlib.Path) -> None:
5186 result = runner.invoke(cli, ["code", "blast-risk"])
5187 assert result.exit_code == 0, result.output
5188
5189 def test_blast_risk_shows_header(self, blast_repo: pathlib.Path) -> None:
5190 result = runner.invoke(cli, ["code", "blast-risk"])
5191 assert result.exit_code == 0
5192 assert "blast-risk" in result.output
5193 assert "commits" in result.output
5194
5195 def test_blast_risk_shows_scoring_line(self, blast_repo: pathlib.Path) -> None:
5196 result = runner.invoke(cli, ["code", "blast-risk"])
5197 assert result.exit_code == 0
5198 assert "Scoring:" in result.output
5199 assert "impact" in result.output
5200 assert "churn" in result.output
5201 assert "test-gap" in result.output
5202 assert "coupling" in result.output
5203
5204 def test_blast_risk_shows_table_columns(self, blast_repo: pathlib.Path) -> None:
5205 result = runner.invoke(cli, ["code", "blast-risk"])
5206 assert result.exit_code == 0
5207 assert "RISK" in result.output
5208 assert "IMPACT" in result.output
5209 assert "CHURN" in result.output
5210 assert "TEST-GAP" in result.output
5211
5212 def test_blast_risk_lists_symbols(self, blast_repo: pathlib.Path) -> None:
5213 result = runner.invoke(cli, ["code", "blast-risk"])
5214 assert result.exit_code == 0
5215 # At least one symbol from billing.py should appear.
5216 assert "billing.py" in result.output
5217
5218 def test_blast_risk_risk_scores_in_range(self, blast_repo: pathlib.Path) -> None:
5219 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5220 assert result.exit_code == 0, result.output
5221 data = json.loads(result.output)
5222 for sym in data["symbols"]:
5223 assert 0 <= sym["risk"] <= 100
5224 assert 0 <= sym["impact_score"] <= 100
5225 assert 0 <= sym["churn_score"] <= 100
5226 assert 0 <= sym["test_gap_score"] <= 100
5227 assert 0 <= sym["coupling_score"] <= 100
5228
5229 # ── JSON schema ──────────────────────────────────────────────────────────
5230
5231 def test_blast_risk_json_top_level_keys(self, blast_repo: pathlib.Path) -> None:
5232 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5233 assert result.exit_code == 0, result.output
5234 data = json.loads(result.output)
5235 assert "ref" in data
5236 assert "commits_analysed" in data
5237 assert "truncated" in data
5238 assert "filters" in data
5239 assert "weights" in data
5240 assert "symbols" in data
5241
5242 def test_blast_risk_json_weights_sum_to_one(self, blast_repo: pathlib.Path) -> None:
5243 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5244 data = json.loads(result.output)
5245 total = sum(data["weights"].values())
5246 assert abs(total - 1.0) < 1e-6
5247
5248 def test_blast_risk_json_symbol_schema(self, blast_repo: pathlib.Path) -> None:
5249 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5250 data = json.loads(result.output)
5251 assert len(data["symbols"]) > 0
5252 sym = data["symbols"][0]
5253 for key in ("address", "kind", "file", "risk",
5254 "impact_raw", "churn_raw", "test_gap_raw",
5255 "coupling_raw", "impact_score", "churn_score",
5256 "test_gap_score", "coupling_score"):
5257 assert key in sym, f"missing key: {key}"
5258
5259 def test_blast_risk_json_sorted_by_risk_desc(self, blast_repo: pathlib.Path) -> None:
5260 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5261 data = json.loads(result.output)
5262 risks = [s["risk"] for s in data["symbols"]]
5263 assert risks == sorted(risks, reverse=True)
5264
5265 def test_blast_risk_json_no_import_pseudosymbols(self, blast_repo: pathlib.Path) -> None:
5266 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5267 data = json.loads(result.output)
5268 for sym in data["symbols"]:
5269 assert "::import::" not in sym["address"]
5270
5271 def test_blast_risk_json_filters_reflected(self, blast_repo: pathlib.Path) -> None:
5272 result = runner.invoke(
5273 cli, ["code", "blast-risk", "--json", "--kind", "function", "--min-risk", "10"]
5274 )
5275 data = json.loads(result.output)
5276 assert data["filters"]["kind"] == "function"
5277 assert data["filters"]["min_risk"] == 10
5278
5279 # ── --top flag ───────────────────────────────────────────────────────────
5280
5281 def test_blast_risk_top_limits_output(self, blast_repo: pathlib.Path) -> None:
5282 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--top", "2"])
5283 data = json.loads(result.output)
5284 assert len(data["symbols"]) <= 2
5285
5286 def test_blast_risk_top_validation(self, blast_repo: pathlib.Path) -> None:
5287 result = runner.invoke(cli, ["code", "blast-risk", "--top", "0"])
5288 assert result.exit_code != 0
5289
5290 # ── --kind filter ────────────────────────────────────────────────────────
5291
5292 def test_blast_risk_kind_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5293 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "class"])
5294 data = json.loads(result.output)
5295 for sym in data["symbols"]:
5296 assert sym["kind"] == "class"
5297
5298 def test_blast_risk_kind_filter_function(self, blast_repo: pathlib.Path) -> None:
5299 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--kind", "function"])
5300 assert result.exit_code == 0
5301 data = json.loads(result.output)
5302 for sym in data["symbols"]:
5303 assert sym["kind"] in ("function", "method")
5304
5305 # ── --file filter ────────────────────────────────────────────────────────
5306
5307 def test_blast_risk_file_filter_restricts(self, blast_repo: pathlib.Path) -> None:
5308 result = runner.invoke(
5309 cli, ["code", "blast-risk", "--json", "--file", "billing.py"]
5310 )
5311 data = json.loads(result.output)
5312 for sym in data["symbols"]:
5313 assert "billing.py" in sym["file"]
5314
5315 def test_blast_risk_file_filter_nonexistent_returns_empty(
5316 self, blast_repo: pathlib.Path
5317 ) -> None:
5318 result = runner.invoke(
5319 cli, ["code", "blast-risk", "--json", "--file", "no_such_file.py"]
5320 )
5321 assert result.exit_code == 0
5322 data = json.loads(result.output)
5323 assert data["symbols"] == []
5324
5325 # ── --min-risk filter ────────────────────────────────────────────────────
5326
5327 def test_blast_risk_min_risk_filters(self, blast_repo: pathlib.Path) -> None:
5328 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "80"])
5329 data = json.loads(result.output)
5330 for sym in data["symbols"]:
5331 assert sym["risk"] >= 80
5332
5333 def test_blast_risk_min_risk_100_all_excluded(self, blast_repo: pathlib.Path) -> None:
5334 result = runner.invoke(cli, ["code", "blast-risk", "--json", "--min-risk", "100"])
5335 assert result.exit_code == 0
5336
5337 def test_blast_risk_min_risk_validation(self, blast_repo: pathlib.Path) -> None:
5338 result = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "101"])
5339 assert result.exit_code != 0
5340 result2 = runner.invoke(cli, ["code", "blast-risk", "--min-risk", "-1"])
5341 assert result2.exit_code != 0
5342
5343 # ── --explain flag ───────────────────────────────────────────────────────
5344
5345 def test_blast_risk_explain_exits_zero(self, blast_repo: pathlib.Path) -> None:
5346 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5347 data = json.loads(result.output)
5348 if not data["symbols"]:
5349 pytest.skip("no symbols")
5350 addr = data["symbols"][0]["address"]
5351 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5352 assert result2.exit_code == 0, result2.output
5353
5354 def test_blast_risk_explain_shows_breakdown(self, blast_repo: pathlib.Path) -> None:
5355 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5356 data = json.loads(result.output)
5357 if not data["symbols"]:
5358 pytest.skip("no symbols")
5359 addr = data["symbols"][0]["address"]
5360 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr])
5361 assert "Risk score:" in result2.output
5362 assert "Impact" in result2.output
5363 assert "Churn" in result2.output
5364 assert "Test gap" in result2.output
5365 assert "Coupling" in result2.output
5366
5367 def test_blast_risk_explain_nonexistent_errors(self, blast_repo: pathlib.Path) -> None:
5368 result = runner.invoke(
5369 cli, ["code", "blast-risk", "--explain", "no_file.py::no_symbol"]
5370 )
5371 assert result.exit_code != 0
5372
5373 def test_blast_risk_explain_json(self, blast_repo: pathlib.Path) -> None:
5374 result = runner.invoke(cli, ["code", "blast-risk", "--json"])
5375 data = json.loads(result.output)
5376 if not data["symbols"]:
5377 pytest.skip("no symbols")
5378 addr = data["symbols"][0]["address"]
5379 result2 = runner.invoke(cli, ["code", "blast-risk", "--explain", addr, "--json"])
5380 assert result2.exit_code == 0, result2.output
5381 detail = json.loads(result2.output)
5382 assert detail["address"] == addr
5383 assert "risk" in detail
5384
5385 # ── --max-commits ────────────────────────────────────────────────────────
5386
5387 def test_blast_risk_max_commits_validation(self, blast_repo: pathlib.Path) -> None:
5388 result = runner.invoke(cli, ["code", "blast-risk", "--max-commits", "0"])
5389 assert result.exit_code != 0
5390
5391 def test_blast_risk_max_commits_respected(self, blast_repo: pathlib.Path) -> None:
5392 # With max-commits=1, commits_analysed <= 1.
5393 result = runner.invoke(
5394 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5395 )
5396 assert result.exit_code == 0
5397 data = json.loads(result.output)
5398 assert data["commits_analysed"] <= 1
5399
5400 def test_blast_risk_max_commits_truncated_flag(self, blast_repo: pathlib.Path) -> None:
5401 result = runner.invoke(
5402 cli, ["code", "blast-risk", "--json", "--max-commits", "1"]
5403 )
5404 data = json.loads(result.output)
5405 # Two commits exist so truncated should be True with cap=1.
5406 assert isinstance(data["truncated"], bool)
5407
5408 # ── --since ──────────────────────────────────────────────────────────────
5409
5410 def test_blast_risk_since_invalid_ref(self, blast_repo: pathlib.Path) -> None:
5411 result = runner.invoke(cli, ["code", "blast-risk", "--since", "nonexistent_ref"])
5412 assert result.exit_code != 0
5413
5414 # ── requires repo ────────────────────────────────────────────────────────
5415
5416 def test_blast_risk_requires_repo(self, tmp_path: pathlib.Path) -> None:
5417 import os
5418 old = os.getcwd()
5419 try:
5420 os.chdir(tmp_path)
5421 result = runner.invoke(cli, ["code", "blast-risk"])
5422 assert result.exit_code != 0
5423 finally:
5424 os.chdir(old)
5425
5426
5427 # ---------------------------------------------------------------------------
5428 # velocity
5429 # ---------------------------------------------------------------------------
5430
5431
5432 @pytest.fixture
5433 def velocity_repo(repo: pathlib.Path) -> pathlib.Path:
5434 """Repo with two modules across several commits to exercise velocity metrics.
5435
5436 Module layout:
5437 core/store.py — grows across commits (inserts)
5438 shrink/util.py — has a delete later (net negative at some point)
5439
5440 Commit structure (window=2):
5441 1: create core/store.py with 2 functions
5442 2: add a third function to core/store.py → current window: +3 added
5443 3: add shrink/util.py with one function → also in current window
5444 4: delete the function in shrink/util.py → shrink net = 0 (1 added, 1 deleted)
5445 """
5446 (repo / "core").mkdir(exist_ok=True)
5447 (repo / "shrink").mkdir(exist_ok=True)
5448
5449 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5450 def read_object(path):
5451 return path.read_bytes()
5452
5453 def write_object(path, data):
5454 path.write_bytes(data)
5455 """))
5456 r = runner.invoke(cli, ["commit", "-m", "core: initial store"])
5457 assert r.exit_code == 0, r.output
5458
5459 (repo / "core" / "store.py").write_text(textwrap.dedent("""\
5460 def read_object(path):
5461 return path.read_bytes()
5462
5463 def write_object(path, data):
5464 path.write_bytes(data)
5465
5466 def delete_object(path):
5467 path.unlink()
5468 """))
5469 r2 = runner.invoke(cli, ["commit", "-m", "core: add delete_object"])
5470 assert r2.exit_code == 0, r2.output
5471
5472 (repo / "shrink" / "util.py").write_text(textwrap.dedent("""\
5473 def helper():
5474 return True
5475 """))
5476 r3 = runner.invoke(cli, ["commit", "-m", "shrink: add helper"])
5477 assert r3.exit_code == 0, r3.output
5478
5479 return repo
5480
5481
5482 class TestVelocity:
5483 """Tests for muse code velocity."""
5484
5485 # ── basic correctness ────────────────────────────────────────────────────
5486
5487 def test_velocity_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5488 result = runner.invoke(cli, ["code", "velocity"])
5489 assert result.exit_code == 0, result.output
5490
5491 def test_velocity_shows_header(self, velocity_repo: pathlib.Path) -> None:
5492 result = runner.invoke(cli, ["code", "velocity"])
5493 assert "velocity" in result.output.lower()
5494
5495 def test_velocity_shows_column_headers(self, velocity_repo: pathlib.Path) -> None:
5496 result = runner.invoke(cli, ["code", "velocity"])
5497 assert "ADD" in result.output
5498 assert "NET" in result.output
5499
5500 def test_velocity_shows_modules(self, velocity_repo: pathlib.Path) -> None:
5501 result = runner.invoke(cli, ["code", "velocity"])
5502 # Both modules should appear.
5503 assert "core/" in result.output or "store" in result.output
5504
5505 # ── JSON schema ──────────────────────────────────────────────────────────
5506
5507 def test_velocity_json_exits_zero(self, velocity_repo: pathlib.Path) -> None:
5508 result = runner.invoke(cli, ["code", "velocity", "--json"])
5509 assert result.exit_code == 0, result.output
5510 json.loads(result.output)
5511
5512 def test_velocity_json_top_level_keys(self, velocity_repo: pathlib.Path) -> None:
5513 result = runner.invoke(cli, ["code", "velocity", "--json"])
5514 data = json.loads(result.output)
5515 for key in (
5516 "ref", "window_size", "commits_analysed", "truncated",
5517 "filters", "modules", "predictions",
5518 ):
5519 assert key in data, f"missing key: {key}"
5520
5521 def test_velocity_json_module_schema(self, velocity_repo: pathlib.Path) -> None:
5522 result = runner.invoke(cli, ["code", "velocity", "--json"])
5523 data = json.loads(result.output)
5524 if not data["modules"]:
5525 pytest.skip("no modules")
5526 mod = data["modules"][0]
5527 for key in ("module", "current", "prior", "acceleration", "stagnant_commits"):
5528 assert key in mod, f"missing key: {key}"
5529 for key in ("added", "removed", "net", "modified", "active_commits"):
5530 assert key in mod["current"], f"missing current key: {key}"
5531 assert key in mod["prior"], f"missing prior key: {key}"
5532
5533 def test_velocity_json_acceleration_is_net_delta(
5534 self, velocity_repo: pathlib.Path
5535 ) -> None:
5536 result = runner.invoke(cli, ["code", "velocity", "--json"])
5537 data = json.loads(result.output)
5538 for mod in data["modules"]:
5539 expected = mod["current"]["net"] - mod["prior"]["net"]
5540 assert mod["acceleration"] == expected
5541
5542 def test_velocity_json_filters_reflected(self, velocity_repo: pathlib.Path) -> None:
5543 result = runner.invoke(
5544 cli, ["code", "velocity", "--json", "--window", "5", "--top", "3"]
5545 )
5546 data = json.loads(result.output)
5547 assert data["window_size"] == 5
5548 assert data["filters"]["top"] == 3
5549
5550 def test_velocity_json_no_import_pseudosymbols_in_counts(
5551 self, velocity_repo: pathlib.Path
5552 ) -> None:
5553 # Modules should not be "(root)" due to import pseudo-symbols
5554 # (import:: addresses should be filtered out).
5555 result = runner.invoke(cli, ["code", "velocity", "--json"])
5556 data = json.loads(result.output)
5557 # We can't assert 0 imports in the module list, but we can assert
5558 # that '::import::' doesn't appear as a module name.
5559 for mod in data["modules"]:
5560 assert "import" not in mod["module"].lower() or "/" in mod["module"]
5561
5562 # ── --window ─────────────────────────────────────────────────────────────
5563
5564 def test_velocity_window_1_runs(self, velocity_repo: pathlib.Path) -> None:
5565 result = runner.invoke(cli, ["code", "velocity", "--window", "1"])
5566 assert result.exit_code == 0, result.output
5567
5568 def test_velocity_window_validation(self, velocity_repo: pathlib.Path) -> None:
5569 result = runner.invoke(cli, ["code", "velocity", "--window", "0"])
5570 assert result.exit_code != 0
5571
5572 def test_velocity_window_reflected_in_json(
5573 self, velocity_repo: pathlib.Path
5574 ) -> None:
5575 result = runner.invoke(cli, ["code", "velocity", "--json", "--window", "1"])
5576 data = json.loads(result.output)
5577 assert data["window_size"] == 1
5578
5579 # ── --top ─────────────────────────────────────────────────────────────────
5580
5581 def test_velocity_top_limits(self, velocity_repo: pathlib.Path) -> None:
5582 result = runner.invoke(cli, ["code", "velocity", "--json", "--top", "1"])
5583 data = json.loads(result.output)
5584 assert len(data["modules"]) <= 1
5585
5586 def test_velocity_top_validation(self, velocity_repo: pathlib.Path) -> None:
5587 result = runner.invoke(cli, ["code", "velocity", "--top", "0"])
5588 assert result.exit_code != 0
5589
5590 # ── --predict ─────────────────────────────────────────────────────────────
5591
5592 def test_velocity_predict_0_empty(self, velocity_repo: pathlib.Path) -> None:
5593 result = runner.invoke(cli, ["code", "velocity", "--json", "--predict", "0"])
5594 data = json.loads(result.output)
5595 assert data["predictions"] == []
5596
5597 def test_velocity_predict_returns_results(self, velocity_repo: pathlib.Path) -> None:
5598 result = runner.invoke(
5599 cli, ["code", "velocity", "--json", "--predict", "5"]
5600 )
5601 data = json.loads(result.output)
5602 # There are symbols in the window so predictions should be non-empty.
5603 assert isinstance(data["predictions"], list)
5604 if data["predictions"]:
5605 pred = data["predictions"][0]
5606 for key in ("address", "module", "score", "frequency", "last_commit_rank"):
5607 assert key in pred, f"missing key: {key}"
5608
5609 def test_velocity_predict_scores_descending(
5610 self, velocity_repo: pathlib.Path
5611 ) -> None:
5612 result = runner.invoke(
5613 cli, ["code", "velocity", "--json", "--predict", "10"]
5614 )
5615 data = json.loads(result.output)
5616 scores = [p["score"] for p in data["predictions"]]
5617 assert scores == sorted(scores, reverse=True)
5618
5619 def test_velocity_predict_validation(self, velocity_repo: pathlib.Path) -> None:
5620 result = runner.invoke(cli, ["code", "velocity", "--predict", "-1"])
5621 assert result.exit_code != 0
5622
5623 def test_velocity_predict_shown_in_human_output(
5624 self, velocity_repo: pathlib.Path
5625 ) -> None:
5626 result = runner.invoke(
5627 cli, ["code", "velocity", "--predict", "3"]
5628 )
5629 assert result.exit_code == 0
5630 if "predictions" in result.output.lower() or "score" in result.output:
5631 # Just check it doesn't crash.
5632 pass
5633
5634 # ── --max-commits ─────────────────────────────────────────────────────────
5635
5636 def test_velocity_max_commits_validation(self, velocity_repo: pathlib.Path) -> None:
5637 result = runner.invoke(cli, ["code", "velocity", "--max-commits", "0"])
5638 assert result.exit_code != 0
5639
5640 def test_velocity_max_commits_respected(self, velocity_repo: pathlib.Path) -> None:
5641 # With --window 1 and --max-commits 1, effective_max = max(1, 1*2) = 2.
5642 # The 3-commit repo should be capped at 2 commits analysed.
5643 result = runner.invoke(
5644 cli, ["code", "velocity", "--json", "--window", "1", "--max-commits", "1"]
5645 )
5646 assert result.exit_code == 0
5647 data = json.loads(result.output)
5648 assert data["commits_analysed"] <= 2
5649
5650 # ── --since ───────────────────────────────────────────────────────────────
5651
5652 def test_velocity_since_invalid_ref(self, velocity_repo: pathlib.Path) -> None:
5653 result = runner.invoke(cli, ["code", "velocity", "--since", "bad_ref"])
5654 assert result.exit_code != 0
5655
5656 # ── stagnation detection ──────────────────────────────────────────────────
5657
5658 def test_velocity_stagnant_commits_non_negative(
5659 self, velocity_repo: pathlib.Path
5660 ) -> None:
5661 result = runner.invoke(cli, ["code", "velocity", "--json"])
5662 data = json.loads(result.output)
5663 for mod in data["modules"]:
5664 assert mod["stagnant_commits"] >= 0
5665
5666 # ── net counts are consistent ─────────────────────────────────────────────
5667
5668 def test_velocity_net_equals_added_minus_removed(
5669 self, velocity_repo: pathlib.Path
5670 ) -> None:
5671 result = runner.invoke(cli, ["code", "velocity", "--json"])
5672 data = json.loads(result.output)
5673 for mod in data["modules"]:
5674 assert mod["current"]["net"] == (
5675 mod["current"]["added"] - mod["current"]["removed"]
5676 )
5677 assert mod["prior"]["net"] == (
5678 mod["prior"]["added"] - mod["prior"]["removed"]
5679 )
5680
5681 # ── requires repo ─────────────────────────────────────────────────────────
5682
5683 def test_velocity_requires_repo(self, tmp_path: pathlib.Path) -> None:
5684 import os
5685 old = os.getcwd()
5686 try:
5687 os.chdir(tmp_path)
5688 result = runner.invoke(cli, ["code", "velocity"])
5689 assert result.exit_code != 0
5690 finally:
5691 os.chdir(old)
5692
5693
5694 # ---------------------------------------------------------------------------
5695 # age
5696 # ---------------------------------------------------------------------------
5697
5698
5699 @pytest.fixture
5700 def age_repo(repo: pathlib.Path) -> pathlib.Path:
5701 """Repo with several commits to exercise evolutionary-age metrics.
5702
5703 Commit 1: create billing.py (Invoice class + compute_total + stable_fn)
5704 Commit 2: modify compute_total body → 1 impl change
5705 Commit 3: modify compute_total body → 2 impl changes
5706 Commit 4: modify compute_total signature only (add type hint)
5707
5708 stable_fn is created in commit 1 and never touched again.
5709 """
5710 (repo / "billing.py").write_text(textwrap.dedent("""\
5711 class Invoice:
5712 def compute_total(self, items):
5713 return sum(items)
5714
5715 def stable_fn():
5716 return 42
5717 """))
5718 r = runner.invoke(cli, ["commit", "-m", "initial billing"])
5719 assert r.exit_code == 0, r.output
5720
5721 # Commit 2: impl change to compute_total
5722 (repo / "billing.py").write_text(textwrap.dedent("""\
5723 class Invoice:
5724 def compute_total(self, items):
5725 return round(sum(items), 2)
5726
5727 def stable_fn():
5728 return 42
5729 """))
5730 r2 = runner.invoke(cli, ["commit", "-m", "round result"])
5731 assert r2.exit_code == 0, r2.output
5732
5733 # Commit 3: second impl change to compute_total
5734 (repo / "billing.py").write_text(textwrap.dedent("""\
5735 class Invoice:
5736 def compute_total(self, items):
5737 total = sum(items)
5738 return round(total, 4)
5739
5740 def stable_fn():
5741 return 42
5742 """))
5743 r3 = runner.invoke(cli, ["commit", "-m", "higher precision"])
5744 assert r3.exit_code == 0, r3.output
5745
5746 return repo
5747
5748
5749 class TestAge:
5750 """Tests for muse code age."""
5751
5752 # ── basic correctness ────────────────────────────────────────────────────
5753
5754 def test_age_exits_zero(self, age_repo: pathlib.Path) -> None:
5755 result = runner.invoke(cli, ["code", "age"])
5756 assert result.exit_code == 0, result.output
5757
5758 def test_age_shows_header(self, age_repo: pathlib.Path) -> None:
5759 result = runner.invoke(cli, ["code", "age"])
5760 assert "evolutionary age" in result.output.lower()
5761
5762 def test_age_shows_sort_line(self, age_repo: pathlib.Path) -> None:
5763 result = runner.invoke(cli, ["code", "age"])
5764 assert "Sorted by" in result.output
5765
5766 def test_age_shows_table_columns(self, age_repo: pathlib.Path) -> None:
5767 result = runner.invoke(cli, ["code", "age"])
5768 assert "BORN" in result.output
5769 assert "REWRITES" in result.output
5770 assert "GENETIC" in result.output
5771
5772 def test_age_lists_symbols(self, age_repo: pathlib.Path) -> None:
5773 result = runner.invoke(cli, ["code", "age"])
5774 assert "billing.py" in result.output
5775
5776 # ── JSON schema ──────────────────────────────────────────────────────────
5777
5778 def test_age_json_exits_zero(self, age_repo: pathlib.Path) -> None:
5779 result = runner.invoke(cli, ["code", "age", "--json"])
5780 assert result.exit_code == 0, result.output
5781 json.loads(result.output)
5782
5783 def test_age_json_top_level_keys(self, age_repo: pathlib.Path) -> None:
5784 result = runner.invoke(cli, ["code", "age", "--json"])
5785 data = json.loads(result.output)
5786 for key in ("ref", "as_of", "commits_analysed", "truncated", "filters", "symbols"):
5787 assert key in data, f"missing key: {key}"
5788
5789 def test_age_json_symbol_schema(self, age_repo: pathlib.Path) -> None:
5790 result = runner.invoke(cli, ["code", "age", "--json"])
5791 data = json.loads(result.output)
5792 if not data["symbols"]:
5793 pytest.skip("no symbols with history")
5794 sym = data["symbols"][0]
5795 for key in (
5796 "address", "kind", "file",
5797 "born_commit", "born_date",
5798 "last_impl_commit", "last_impl_date",
5799 "last_change_commit", "last_change_date",
5800 "calendar_age_days", "genetic_age_days",
5801 "impl_changes", "sig_changes", "renames", "est_survival_pct",
5802 ):
5803 assert key in sym, f"missing key: {key}"
5804
5805 def test_age_json_survival_pct_in_range(self, age_repo: pathlib.Path) -> None:
5806 result = runner.invoke(cli, ["code", "age", "--json"])
5807 data = json.loads(result.output)
5808 for sym in data["symbols"]:
5809 assert 0 <= sym["est_survival_pct"] <= 100
5810
5811 def test_age_json_filters_reflected(self, age_repo: pathlib.Path) -> None:
5812 result = runner.invoke(
5813 cli, ["code", "age", "--json", "--sort", "calendar", "--kind", "function"]
5814 )
5815 data = json.loads(result.output)
5816 assert data["filters"]["sort"] == "calendar"
5817 assert data["filters"]["kind"] == "function"
5818
5819 def test_age_json_no_import_pseudosymbols(self, age_repo: pathlib.Path) -> None:
5820 result = runner.invoke(cli, ["code", "age", "--json"])
5821 data = json.loads(result.output)
5822 for sym in data["symbols"]:
5823 assert "::import::" not in sym["address"]
5824
5825 # ── impl_changes recorded correctly ─────────────────────────────────────
5826
5827 def test_age_compute_total_has_impl_changes(self, age_repo: pathlib.Path) -> None:
5828 """compute_total was modified twice — should have impl_changes >= 1."""
5829 result = runner.invoke(cli, ["code", "age", "--json"])
5830 data = json.loads(result.output)
5831 totals = [
5832 s for s in data["symbols"]
5833 if "compute_total" in s["address"]
5834 ]
5835 # If history was recorded, impl_changes should be positive.
5836 if totals:
5837 assert totals[0]["impl_changes"] >= 0 # at least recorded
5838
5839 def test_age_stable_fn_lower_impl_changes(self, age_repo: pathlib.Path) -> None:
5840 """stable_fn was never modified — should have 0 impl_changes."""
5841 result = runner.invoke(cli, ["code", "age", "--json"])
5842 data = json.loads(result.output)
5843 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5844 if stables:
5845 assert stables[0]["impl_changes"] == 0
5846
5847 def test_age_stable_fn_100pct_survival(self, age_repo: pathlib.Path) -> None:
5848 result = runner.invoke(cli, ["code", "age", "--json"])
5849 data = json.loads(result.output)
5850 stables = [s for s in data["symbols"] if "stable_fn" in s["address"]]
5851 if stables:
5852 assert stables[0]["est_survival_pct"] == 100
5853
5854 # ── --top ────────────────────────────────────────────────────────────────
5855
5856 def test_age_top_limits(self, age_repo: pathlib.Path) -> None:
5857 result = runner.invoke(cli, ["code", "age", "--json", "--top", "1"])
5858 data = json.loads(result.output)
5859 assert len(data["symbols"]) <= 1
5860
5861 def test_age_top_validation(self, age_repo: pathlib.Path) -> None:
5862 result = runner.invoke(cli, ["code", "age", "--top", "0"])
5863 assert result.exit_code != 0
5864
5865 # ── --sort ───────────────────────────────────────────────────────────────
5866
5867 def test_age_sort_rewrites(self, age_repo: pathlib.Path) -> None:
5868 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "rewrites"])
5869 assert result.exit_code == 0, result.output
5870 data = json.loads(result.output)
5871 impl_counts = [s["impl_changes"] for s in data["symbols"]]
5872 assert impl_counts == sorted(impl_counts, reverse=True)
5873
5874 def test_age_sort_calendar(self, age_repo: pathlib.Path) -> None:
5875 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "calendar"])
5876 assert result.exit_code == 0, result.output
5877 data = json.loads(result.output)
5878 ages = [s["calendar_age_days"] for s in data["symbols"]]
5879 assert ages == sorted(ages, reverse=True)
5880
5881 def test_age_sort_genetic(self, age_repo: pathlib.Path) -> None:
5882 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "genetic"])
5883 assert result.exit_code == 0, result.output
5884 data = json.loads(result.output)
5885 ages = [s["genetic_age_days"] for s in data["symbols"]]
5886 assert ages == sorted(ages, reverse=True)
5887
5888 def test_age_sort_survival(self, age_repo: pathlib.Path) -> None:
5889 result = runner.invoke(cli, ["code", "age", "--json", "--sort", "survival"])
5890 assert result.exit_code == 0, result.output
5891 data = json.loads(result.output)
5892 survivals = [s["est_survival_pct"] for s in data["symbols"]]
5893 assert survivals == sorted(survivals)
5894
5895 def test_age_sort_invalid(self, age_repo: pathlib.Path) -> None:
5896 result = runner.invoke(cli, ["code", "age", "--sort", "bogus"])
5897 assert result.exit_code != 0
5898
5899 # ── --kind filter ────────────────────────────────────────────────────────
5900
5901 def test_age_kind_filter(self, age_repo: pathlib.Path) -> None:
5902 result = runner.invoke(cli, ["code", "age", "--json", "--kind", "function"])
5903 data = json.loads(result.output)
5904 for sym in data["symbols"]:
5905 assert sym["kind"] in ("function", "method")
5906
5907 # ── --file filter ─────────────────────────────────────────────────────────
5908
5909 def test_age_file_filter(self, age_repo: pathlib.Path) -> None:
5910 result = runner.invoke(
5911 cli, ["code", "age", "--json", "--file", "billing.py"]
5912 )
5913 data = json.loads(result.output)
5914 for sym in data["symbols"]:
5915 assert "billing.py" in sym["file"]
5916
5917 def test_age_file_filter_nonexistent(self, age_repo: pathlib.Path) -> None:
5918 result = runner.invoke(
5919 cli, ["code", "age", "--json", "--file", "no_such_file.py"]
5920 )
5921 assert result.exit_code == 0
5922 data = json.loads(result.output)
5923 assert data["symbols"] == []
5924
5925 # ── --explain ─────────────────────────────────────────────────────────────
5926
5927 def test_age_explain_exits_zero(self, age_repo: pathlib.Path) -> None:
5928 result = runner.invoke(cli, ["code", "age", "--json"])
5929 data = json.loads(result.output)
5930 if not data["symbols"]:
5931 pytest.skip("no symbols")
5932 addr = data["symbols"][0]["address"]
5933 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
5934 assert r2.exit_code == 0, r2.output
5935
5936 def test_age_explain_shows_breakdown(self, age_repo: pathlib.Path) -> None:
5937 result = runner.invoke(cli, ["code", "age", "--json"])
5938 data = json.loads(result.output)
5939 if not data["symbols"]:
5940 pytest.skip("no symbols")
5941 addr = data["symbols"][0]["address"]
5942 r2 = runner.invoke(cli, ["code", "age", "--explain", addr])
5943 assert "Implementation changes" in r2.output
5944 assert "Signature changes" in r2.output
5945 assert "Est. survival" in r2.output
5946
5947 def test_age_explain_requires_double_colon(self, age_repo: pathlib.Path) -> None:
5948 result = runner.invoke(cli, ["code", "age", "--explain", "billing.py"])
5949 assert result.exit_code != 0
5950
5951 def test_age_explain_nonexistent_errors(self, age_repo: pathlib.Path) -> None:
5952 result = runner.invoke(cli, ["code", "age", "--explain", "no.py::nonexistent"])
5953 assert result.exit_code != 0
5954
5955 def test_age_explain_json(self, age_repo: pathlib.Path) -> None:
5956 result = runner.invoke(cli, ["code", "age", "--json"])
5957 data = json.loads(result.output)
5958 if not data["symbols"]:
5959 pytest.skip("no symbols")
5960 addr = data["symbols"][0]["address"]
5961 r2 = runner.invoke(cli, ["code", "age", "--explain", addr, "--json"])
5962 assert r2.exit_code == 0, r2.output
5963 detail = json.loads(r2.output)
5964 assert detail["address"] == addr
5965 assert "events" in detail
5966
5967 # ── --max-commits ─────────────────────────────────────────────────────────
5968
5969 def test_age_max_commits_validation(self, age_repo: pathlib.Path) -> None:
5970 result = runner.invoke(cli, ["code", "age", "--max-commits", "0"])
5971 assert result.exit_code != 0
5972
5973 def test_age_max_commits_respected(self, age_repo: pathlib.Path) -> None:
5974 result = runner.invoke(cli, ["code", "age", "--json", "--max-commits", "1"])
5975 assert result.exit_code == 0
5976 data = json.loads(result.output)
5977 assert data["commits_analysed"] <= 1
5978
5979 # ── --since ───────────────────────────────────────────────────────────────
5980
5981 def test_age_since_invalid_ref(self, age_repo: pathlib.Path) -> None:
5982 result = runner.invoke(cli, ["code", "age", "--since", "bad_ref"])
5983 assert result.exit_code != 0
5984
5985 # ── requires repo ─────────────────────────────────────────────────────────
5986
5987 def test_age_requires_repo(self, tmp_path: pathlib.Path) -> None:
5988 import os
5989 old = os.getcwd()
5990 try:
5991 os.chdir(tmp_path)
5992 result = runner.invoke(cli, ["code", "age"])
5993 assert result.exit_code != 0
5994 finally:
5995 os.chdir(old)
5996
5997
5998 # ---------------------------------------------------------------------------
5999 # entangle
6000 # ---------------------------------------------------------------------------
6001
6002
6003 @pytest.fixture
6004 def entangle_repo(repo: pathlib.Path) -> pathlib.Path:
6005 """Repo that has two files with no import link but symbols that co-change.
6006
6007 Commit 1: create billing.py (Invoice class) and serializers.py (to_json).
6008 Commit 2: modify Invoice.compute_total AND to_json together — they
6009 co-change with no import link.
6010 Commit 3: same again — both change again.
6011
6012 billing.py does NOT import serializers.py, so the pair should be
6013 flagged as entangled.
6014 """
6015 (repo / "billing.py").write_text(textwrap.dedent("""\
6016 class Invoice:
6017 def compute_total(self, items):
6018 return sum(items)
6019 """))
6020 (repo / "serializers.py").write_text(textwrap.dedent("""\
6021 def to_json(obj):
6022 return str(obj)
6023 """))
6024 r = runner.invoke(cli, ["commit", "-m", "initial"])
6025 assert r.exit_code == 0, r.output
6026
6027 # Commit 2: both change.
6028 (repo / "billing.py").write_text(textwrap.dedent("""\
6029 class Invoice:
6030 def compute_total(self, items):
6031 return round(sum(items), 2)
6032 """))
6033 (repo / "serializers.py").write_text(textwrap.dedent("""\
6034 def to_json(obj):
6035 import json
6036 return json.dumps(obj)
6037 """))
6038 r2 = runner.invoke(cli, ["commit", "-m", "update both"])
6039 assert r2.exit_code == 0, r2.output
6040
6041 # Commit 3: both change again.
6042 (repo / "billing.py").write_text(textwrap.dedent("""\
6043 class Invoice:
6044 def compute_total(self, items):
6045 return round(sum(items), 4)
6046 """))
6047 (repo / "serializers.py").write_text(textwrap.dedent("""\
6048 def to_json(obj):
6049 import json
6050 return json.dumps(obj, indent=2)
6051 """))
6052 r3 = runner.invoke(cli, ["commit", "-m", "tweak both again"])
6053 assert r3.exit_code == 0, r3.output
6054
6055 return repo
6056
6057
6058 class TestEntangle:
6059 """Tests for muse code entangle."""
6060
6061 # ── basic correctness ────────────────────────────────────────────────────
6062
6063 def test_entangle_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6064 result = runner.invoke(cli, ["code", "entangle"])
6065 assert result.exit_code == 0, result.output
6066
6067 def test_entangle_shows_header(self, entangle_repo: pathlib.Path) -> None:
6068 result = runner.invoke(cli, ["code", "entangle"])
6069 assert result.exit_code == 0
6070 assert "entanglement" in result.output.lower()
6071
6072 def test_entangle_detects_unlinked_pair(self, entangle_repo: pathlib.Path) -> None:
6073 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6074 assert result.exit_code == 0
6075 # Both files should appear in the output.
6076 assert "billing.py" in result.output or "serializers.py" in result.output
6077
6078 def test_entangle_shows_rate(self, entangle_repo: pathlib.Path) -> None:
6079 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "1"])
6080 assert result.exit_code == 0
6081 # Rate column should show a percentage.
6082 assert "%" in result.output
6083
6084 # ── JSON schema ──────────────────────────────────────────────────────────
6085
6086 def test_entangle_json_exits_zero(self, entangle_repo: pathlib.Path) -> None:
6087 result = runner.invoke(cli, ["code", "entangle", "--json"])
6088 assert result.exit_code == 0, result.output
6089 json.loads(result.output) # must be valid JSON
6090
6091 def test_entangle_json_top_level_keys(self, entangle_repo: pathlib.Path) -> None:
6092 result = runner.invoke(cli, ["code", "entangle", "--json"])
6093 data = json.loads(result.output)
6094 for key in ("ref", "commits_analysed", "truncated", "filters", "pairs"):
6095 assert key in data, f"missing key: {key}"
6096
6097 def test_entangle_json_pair_schema(self, entangle_repo: pathlib.Path) -> None:
6098 result = runner.invoke(
6099 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6100 )
6101 data = json.loads(result.output)
6102 if not data["pairs"]:
6103 pytest.skip("no pairs detected")
6104 pair = data["pairs"][0]
6105 for key in (
6106 "symbol_a", "symbol_b", "file_a", "file_b", "same_file",
6107 "structurally_linked", "co_changes", "commits_both_active",
6108 "co_change_rate", "a_in_test", "b_in_test",
6109 ):
6110 assert key in pair, f"missing key: {key}"
6111
6112 def test_entangle_json_co_change_rate_in_range(
6113 self, entangle_repo: pathlib.Path
6114 ) -> None:
6115 result = runner.invoke(
6116 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6117 )
6118 data = json.loads(result.output)
6119 for pair in data["pairs"]:
6120 assert 0.0 <= pair["co_change_rate"] <= 1.0
6121
6122 def test_entangle_json_filters_reflected(
6123 self, entangle_repo: pathlib.Path
6124 ) -> None:
6125 result = runner.invoke(
6126 cli, ["code", "entangle", "--json", "--min-co-changes", "3", "--min-rate", "0.5"]
6127 )
6128 data = json.loads(result.output)
6129 assert data["filters"]["min_co_changes"] == 3
6130 assert data["filters"]["min_rate"] == 0.5
6131
6132 def test_entangle_json_sorted_by_rate_desc(
6133 self, entangle_repo: pathlib.Path
6134 ) -> None:
6135 result = runner.invoke(
6136 cli, ["code", "entangle", "--json", "--min-co-changes", "1"]
6137 )
6138 data = json.loads(result.output)
6139 rates = [p["co_change_rate"] for p in data["pairs"]]
6140 assert rates == sorted(rates, reverse=True)
6141
6142 # ── --top ────────────────────────────────────────────────────────────────
6143
6144 def test_entangle_top_limits(self, entangle_repo: pathlib.Path) -> None:
6145 result = runner.invoke(
6146 cli, ["code", "entangle", "--json", "--top", "1", "--min-co-changes", "1"]
6147 )
6148 data = json.loads(result.output)
6149 assert len(data["pairs"]) <= 1
6150
6151 def test_entangle_top_validation(self, entangle_repo: pathlib.Path) -> None:
6152 result = runner.invoke(cli, ["code", "entangle", "--top", "0"])
6153 assert result.exit_code != 0
6154
6155 # ── --min-co-changes ─────────────────────────────────────────────────────
6156
6157 def test_entangle_min_co_changes_filters(
6158 self, entangle_repo: pathlib.Path
6159 ) -> None:
6160 result = runner.invoke(
6161 cli, ["code", "entangle", "--json", "--min-co-changes", "100"]
6162 )
6163 data = json.loads(result.output)
6164 # No pair can have co-changed 100 times in a 3-commit repo.
6165 assert data["pairs"] == []
6166
6167 def test_entangle_min_co_changes_validation(
6168 self, entangle_repo: pathlib.Path
6169 ) -> None:
6170 result = runner.invoke(cli, ["code", "entangle", "--min-co-changes", "0"])
6171 assert result.exit_code != 0
6172
6173 # ── --min-rate ───────────────────────────────────────────────────────────
6174
6175 def test_entangle_min_rate_1_may_return_results(
6176 self, entangle_repo: pathlib.Path
6177 ) -> None:
6178 result = runner.invoke(
6179 cli, ["code", "entangle", "--json", "--min-rate", "1.0", "--min-co-changes", "1"]
6180 )
6181 assert result.exit_code == 0
6182 data = json.loads(result.output)
6183 for pair in data["pairs"]:
6184 assert pair["co_change_rate"] == 1.0
6185
6186 def test_entangle_min_rate_validation(self, entangle_repo: pathlib.Path) -> None:
6187 result = runner.invoke(cli, ["code", "entangle", "--min-rate", "1.5"])
6188 assert result.exit_code != 0
6189 result2 = runner.invoke(cli, ["code", "entangle", "--min-rate", "-0.1"])
6190 assert result2.exit_code != 0
6191
6192 # ── --symbol filter ──────────────────────────────────────────────────────
6193
6194 def test_entangle_symbol_requires_double_colon(
6195 self, entangle_repo: pathlib.Path
6196 ) -> None:
6197 result = runner.invoke(cli, ["code", "entangle", "--symbol", "billing.py"])
6198 assert result.exit_code != 0
6199
6200 def test_entangle_symbol_exits_zero_valid(
6201 self, entangle_repo: pathlib.Path
6202 ) -> None:
6203 result = runner.invoke(
6204 cli,
6205 ["code", "entangle", "--symbol", "billing.py::Invoice",
6206 "--min-co-changes", "1"],
6207 )
6208 assert result.exit_code == 0, result.output
6209
6210 def test_entangle_symbol_filters_pairs(
6211 self, entangle_repo: pathlib.Path
6212 ) -> None:
6213 result = runner.invoke(
6214 cli,
6215 ["code", "entangle", "--json", "--symbol", "billing.py::Invoice",
6216 "--min-co-changes", "1"],
6217 )
6218 data = json.loads(result.output)
6219 for pair in data["pairs"]:
6220 assert (
6221 "billing.py" in pair["symbol_a"]
6222 or "billing.py" in pair["symbol_b"]
6223 )
6224
6225 # ── --include-same-file ──────────────────────────────────────────────────
6226
6227 def test_entangle_include_same_file_flag(
6228 self, entangle_repo: pathlib.Path
6229 ) -> None:
6230 # Should not crash, and may return same-file pairs.
6231 result = runner.invoke(
6232 cli,
6233 ["code", "entangle", "--json", "--include-same-file",
6234 "--min-co-changes", "1"],
6235 )
6236 assert result.exit_code == 0, result.output
6237 data = json.loads(result.output)
6238 assert data["filters"]["include_same_file"] is True
6239
6240 # ── --max-commits ─────────────────────────────────────────────────────────
6241
6242 def test_entangle_max_commits_validation(
6243 self, entangle_repo: pathlib.Path
6244 ) -> None:
6245 result = runner.invoke(cli, ["code", "entangle", "--max-commits", "0"])
6246 assert result.exit_code != 0
6247
6248 def test_entangle_max_commits_respected(
6249 self, entangle_repo: pathlib.Path
6250 ) -> None:
6251 result = runner.invoke(
6252 cli, ["code", "entangle", "--json", "--max-commits", "1"]
6253 )
6254 assert result.exit_code == 0
6255 data = json.loads(result.output)
6256 assert data["commits_analysed"] <= 1
6257
6258 # ── --since ───────────────────────────────────────────────────────────────
6259
6260 def test_entangle_since_invalid_ref(self, entangle_repo: pathlib.Path) -> None:
6261 result = runner.invoke(cli, ["code", "entangle", "--since", "no_such_ref"])
6262 assert result.exit_code != 0
6263
6264 # ── requires repo ─────────────────────────────────────────────────────────
6265
6266 def test_entangle_requires_repo(self, tmp_path: pathlib.Path) -> None:
6267 import os
6268 old = os.getcwd()
6269 try:
6270 os.chdir(tmp_path)
6271 result = runner.invoke(cli, ["code", "entangle"])
6272 assert result.exit_code != 0
6273 finally:
6274 os.chdir(old)
6275
6276
6277 # ---------------------------------------------------------------------------
6278 # muse code semantic-test-coverage
6279 # ---------------------------------------------------------------------------
6280
6281
6282 @pytest.fixture
6283 def stc_repo(repo: pathlib.Path) -> pathlib.Path:
6284 """Repo with production code and a test file for semantic-test-coverage.
6285
6286 Layout::
6287
6288 billing.py — compute_total (function), Invoice (class),
6289 Invoice.apply_discount (method),
6290 Invoice.generate_pdf (method) ← never called by tests
6291 services.py — process_order (calls compute_total transitively)
6292 tests/test_billing.py — test_compute_total, test_apply_discount,
6293 test_process_order (direct calls)
6294
6295 Direct coverage expected:
6296 compute_total ← test_compute_total, test_process_order (via bare name)
6297 Invoice ← test_compute_total (instantiation)
6298 apply_discount ← test_apply_discount
6299 generate_pdf ← NOT covered
6300 process_order ← test_process_order
6301
6302 Transitive (depth 2) additionally covers:
6303 compute_total ← test_process_order (because process_order calls it)
6304 """
6305 (repo / "tests").mkdir(exist_ok=True)
6306
6307 (repo / "billing.py").write_text(textwrap.dedent("""\
6308 class Invoice:
6309 def apply_discount(self, rate):
6310 return self.total * (1 - rate)
6311
6312 def generate_pdf(self):
6313 return b"PDF"
6314
6315 def compute_total(items):
6316 return sum(i["price"] for i in items)
6317 """))
6318
6319 (repo / "services.py").write_text(textwrap.dedent("""\
6320 from billing import compute_total
6321
6322 def process_order(order):
6323 return compute_total(order["items"])
6324 """))
6325
6326 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
6327 from billing import compute_total, Invoice
6328 from services import process_order
6329
6330 def test_compute_total():
6331 inv = Invoice()
6332 assert compute_total([{"price": 10}]) == 10
6333
6334 def test_apply_discount():
6335 inv = Invoice()
6336 inv.total = 100
6337 assert inv.apply_discount(0.1) == 90
6338
6339 def test_process_order():
6340 result = process_order({"items": [{"price": 5}]})
6341 assert result == 5
6342 """))
6343
6344 r = runner.invoke(cli, ["commit", "-m", "stc: initial repo"])
6345 assert r.exit_code == 0, r.output
6346 return repo
6347
6348
6349 class TestSemanticTestCoverage:
6350 """Tests for ``muse code semantic-test-coverage``."""
6351
6352 CMD = ["code", "semantic-test-coverage"]
6353
6354 # ── basic correctness ────────────────────────────────────────────────────
6355
6356 def test_stc_exits_zero(self, stc_repo: pathlib.Path) -> None:
6357 result = runner.invoke(cli, self.CMD)
6358 assert result.exit_code == 0, result.output
6359
6360 def test_stc_shows_header(self, stc_repo: pathlib.Path) -> None:
6361 result = runner.invoke(cli, self.CMD)
6362 assert "Semantic test coverage" in result.output
6363 assert "HEAD" in result.output
6364
6365 def test_stc_shows_test_function_count(self, stc_repo: pathlib.Path) -> None:
6366 result = runner.invoke(cli, self.CMD)
6367 # 3 test functions in the repo
6368 assert "test functions" in result.output
6369
6370 def test_stc_shows_total_line(self, stc_repo: pathlib.Path) -> None:
6371 result = runner.invoke(cli, self.CMD)
6372 assert "TOTAL:" in result.output
6373
6374 def test_stc_covered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6375 result = runner.invoke(cli, self.CMD)
6376 assert "compute_total" in result.output
6377
6378 def test_stc_uncovered_symbol_shown(self, stc_repo: pathlib.Path) -> None:
6379 result = runner.invoke(cli, self.CMD)
6380 assert "generate_pdf" in result.output
6381
6382 def test_stc_covered_has_check_icon(self, stc_repo: pathlib.Path) -> None:
6383 result = runner.invoke(cli, self.CMD)
6384 assert "✅" in result.output
6385
6386 def test_stc_uncovered_has_cross_icon(self, stc_repo: pathlib.Path) -> None:
6387 result = runner.invoke(cli, self.CMD)
6388 assert "❌" in result.output
6389
6390 # ── JSON output ──────────────────────────────────────────────────────────
6391
6392 def test_stc_json_exits_zero(self, stc_repo: pathlib.Path) -> None:
6393 result = runner.invoke(cli, self.CMD + ["--json"])
6394 assert result.exit_code == 0, result.output
6395
6396 def test_stc_json_is_valid(self, stc_repo: pathlib.Path) -> None:
6397 result = runner.invoke(cli, self.CMD + ["--json"])
6398 data = json.loads(result.output)
6399 assert isinstance(data, dict)
6400
6401 def test_stc_json_top_level_keys(self, stc_repo: pathlib.Path) -> None:
6402 result = runner.invoke(cli, self.CMD + ["--json"])
6403 data = json.loads(result.output)
6404 for key in ("ref", "snapshot_id", "depth", "transitive", "filters",
6405 "summary", "files"):
6406 assert key in data, f"missing key: {key}"
6407
6408 def test_stc_json_ref_is_head(self, stc_repo: pathlib.Path) -> None:
6409 result = runner.invoke(cli, self.CMD + ["--json"])
6410 data = json.loads(result.output)
6411 assert data["ref"] == "HEAD"
6412
6413 def test_stc_json_depth_default(self, stc_repo: pathlib.Path) -> None:
6414 result = runner.invoke(cli, self.CMD + ["--json"])
6415 data = json.loads(result.output)
6416 assert data["depth"] == 1
6417
6418 def test_stc_json_transitive_default_false(self, stc_repo: pathlib.Path) -> None:
6419 result = runner.invoke(cli, self.CMD + ["--json"])
6420 data = json.loads(result.output)
6421 assert data["transitive"] is False
6422
6423 def test_stc_json_summary_schema(self, stc_repo: pathlib.Path) -> None:
6424 result = runner.invoke(cli, self.CMD + ["--json"])
6425 data = json.loads(result.output)
6426 summary = data["summary"]
6427 for key in ("total_symbols", "covered_symbols", "uncovered_symbols",
6428 "coverage_pct", "total_test_functions", "total_production_files"):
6429 assert key in summary, f"summary missing: {key}"
6430
6431 def test_stc_json_summary_counts_consistent(self, stc_repo: pathlib.Path) -> None:
6432 result = runner.invoke(cli, self.CMD + ["--json"])
6433 data = json.loads(result.output)
6434 s = data["summary"]
6435 assert s["covered_symbols"] + s["uncovered_symbols"] == s["total_symbols"]
6436
6437 def test_stc_json_summary_test_fn_count(self, stc_repo: pathlib.Path) -> None:
6438 result = runner.invoke(cli, self.CMD + ["--json"])
6439 data = json.loads(result.output)
6440 # 3 test functions: test_compute_total, test_apply_discount, test_process_order
6441 assert data["summary"]["total_test_functions"] >= 3
6442
6443 def test_stc_json_file_schema(self, stc_repo: pathlib.Path) -> None:
6444 result = runner.invoke(cli, self.CMD + ["--json"])
6445 data = json.loads(result.output)
6446 assert len(data["files"]) > 0
6447 fc = data["files"][0]
6448 for key in ("file", "total_symbols", "covered_symbols",
6449 "uncovered_symbols", "coverage_pct", "symbols"):
6450 assert key in fc, f"file record missing: {key}"
6451
6452 def test_stc_json_symbol_schema(self, stc_repo: pathlib.Path) -> None:
6453 result = runner.invoke(cli, self.CMD + ["--json"])
6454 data = json.loads(result.output)
6455 # Find a file with at least one symbol
6456 sym = data["files"][0]["symbols"][0]
6457 for key in ("address", "name", "kind", "covered", "test_functions"):
6458 assert key in sym, f"symbol record missing: {key}"
6459
6460 def test_stc_json_covered_symbol_has_test_functions(
6461 self, stc_repo: pathlib.Path
6462 ) -> None:
6463 result = runner.invoke(cli, self.CMD + ["--json"])
6464 data = json.loads(result.output)
6465 covered = [
6466 sym
6467 for fc in data["files"]
6468 for sym in fc["symbols"]
6469 if sym["covered"]
6470 ]
6471 assert covered, "expected at least one covered symbol"
6472 assert any(len(sym["test_functions"]) > 0 for sym in covered)
6473
6474 def test_stc_json_uncovered_symbol_empty_test_fns(
6475 self, stc_repo: pathlib.Path
6476 ) -> None:
6477 result = runner.invoke(cli, self.CMD + ["--json"])
6478 data = json.loads(result.output)
6479 uncovered = [
6480 sym
6481 for fc in data["files"]
6482 for sym in fc["symbols"]
6483 if not sym["covered"]
6484 ]
6485 assert uncovered, "expected generate_pdf to be uncovered"
6486 assert all(sym["test_functions"] == [] for sym in uncovered)
6487
6488 def test_stc_json_generate_pdf_uncovered(self, stc_repo: pathlib.Path) -> None:
6489 result = runner.invoke(cli, self.CMD + ["--json"])
6490 data = json.loads(result.output)
6491 found = next(
6492 (
6493 sym
6494 for fc in data["files"]
6495 for sym in fc["symbols"]
6496 if sym["name"] == "generate_pdf"
6497 ),
6498 None,
6499 )
6500 assert found is not None, "generate_pdf symbol not found"
6501 assert found["covered"] is False
6502
6503 def test_stc_json_compute_total_covered(self, stc_repo: pathlib.Path) -> None:
6504 result = runner.invoke(cli, self.CMD + ["--json"])
6505 data = json.loads(result.output)
6506 found = next(
6507 (
6508 sym
6509 for fc in data["files"]
6510 for sym in fc["symbols"]
6511 if sym["name"] == "compute_total"
6512 ),
6513 None,
6514 )
6515 assert found is not None
6516 assert found["covered"] is True
6517
6518 def test_stc_json_coverage_pct_between_0_and_100(
6519 self, stc_repo: pathlib.Path
6520 ) -> None:
6521 result = runner.invoke(cli, self.CMD + ["--json"])
6522 data = json.loads(result.output)
6523 for fc in data["files"]:
6524 assert 0.0 <= fc["coverage_pct"] <= 100.0
6525
6526 def test_stc_json_filter_reflected(self, stc_repo: pathlib.Path) -> None:
6527 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6528 data = json.loads(result.output)
6529 assert data["filters"]["kind"] == "method"
6530
6531 def test_stc_json_no_import_pseudosymbols(self, stc_repo: pathlib.Path) -> None:
6532 result = runner.invoke(cli, self.CMD + ["--json"])
6533 data = json.loads(result.output)
6534 for fc in data["files"]:
6535 for sym in fc["symbols"]:
6536 assert sym["kind"] != "import"
6537
6538 # ── --file filter ────────────────────────────────────────────────────────
6539
6540 def test_stc_file_filter_scopes_output(self, stc_repo: pathlib.Path) -> None:
6541 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6542 data = json.loads(result.output)
6543 for fc in data["files"]:
6544 assert "billing.py" in fc["file"]
6545
6546 def test_stc_file_filter_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6547 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6548 data = json.loads(result.output)
6549 assert data["filters"]["file"] == "billing.py"
6550
6551 def test_stc_file_filter_billing_has_generate_pdf(
6552 self, stc_repo: pathlib.Path
6553 ) -> None:
6554 result = runner.invoke(cli, self.CMD + ["--json", "--file", "billing.py"])
6555 data = json.loads(result.output)
6556 names = [
6557 sym["name"] for fc in data["files"] for sym in fc["symbols"]
6558 ]
6559 assert "generate_pdf" in names
6560
6561 # ── --kind filter ────────────────────────────────────────────────────────
6562
6563 def test_stc_kind_method_only_methods(self, stc_repo: pathlib.Path) -> None:
6564 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "method"])
6565 data = json.loads(result.output)
6566 for fc in data["files"]:
6567 for sym in fc["symbols"]:
6568 assert sym["kind"] == "method"
6569
6570 def test_stc_kind_function_only_functions(self, stc_repo: pathlib.Path) -> None:
6571 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function"])
6572 data = json.loads(result.output)
6573 for fc in data["files"]:
6574 for sym in fc["symbols"]:
6575 assert sym["kind"] == "function"
6576
6577 def test_stc_kind_invalid_rejected(self, stc_repo: pathlib.Path) -> None:
6578 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6579 assert result.exit_code != 0
6580
6581 # ── --uncovered-only ─────────────────────────────────────────────────────
6582
6583 def test_stc_uncovered_only_exits_zero(self, stc_repo: pathlib.Path) -> None:
6584 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6585 assert result.exit_code == 0, result.output
6586
6587 def test_stc_uncovered_only_hides_covered(self, stc_repo: pathlib.Path) -> None:
6588 result = runner.invoke(cli, self.CMD + ["--uncovered-only"])
6589 # generate_pdf should appear; compute_total should not appear
6590 assert "generate_pdf" in result.output
6591
6592 def test_stc_uncovered_only_json_symbols_all_uncovered(
6593 self, stc_repo: pathlib.Path
6594 ) -> None:
6595 result = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6596 data = json.loads(result.output)
6597 for fc in data["files"]:
6598 for sym in fc["symbols"]:
6599 assert sym["covered"] is False
6600
6601 def test_stc_uncovered_only_json_stats_still_full(
6602 self, stc_repo: pathlib.Path
6603 ) -> None:
6604 result_all = runner.invoke(cli, self.CMD + ["--json"])
6605 result_uncov = runner.invoke(cli, self.CMD + ["--json", "--uncovered-only"])
6606 data_all = json.loads(result_all.output)
6607 data_uncov = json.loads(result_uncov.output)
6608 # Total symbol count should be the same (stats reflect full picture)
6609 assert (
6610 data_all["summary"]["total_symbols"]
6611 == data_uncov["summary"]["total_symbols"]
6612 )
6613
6614 # ── --show-tests ─────────────────────────────────────────────────────────
6615
6616 def test_stc_show_tests_exits_zero(self, stc_repo: pathlib.Path) -> None:
6617 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6618 assert result.exit_code == 0, result.output
6619
6620 def test_stc_show_tests_lists_test_addr(self, stc_repo: pathlib.Path) -> None:
6621 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6622 # Should include a ← prefix followed by a test address
6623 assert "←" in result.output
6624
6625 def test_stc_show_tests_references_test_file(
6626 self, stc_repo: pathlib.Path
6627 ) -> None:
6628 result = runner.invoke(cli, self.CMD + ["--show-tests"])
6629 assert "test_billing" in result.output
6630
6631 # ── --transitive / --depth ───────────────────────────────────────────────
6632
6633 def test_stc_transitive_exits_zero(self, stc_repo: pathlib.Path) -> None:
6634 result = runner.invoke(cli, self.CMD + ["--transitive"])
6635 assert result.exit_code == 0, result.output
6636
6637 def test_stc_transitive_json_flag_true(self, stc_repo: pathlib.Path) -> None:
6638 result = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6639 data = json.loads(result.output)
6640 assert data["transitive"] is True
6641
6642 def test_stc_depth_2_implies_transitive(self, stc_repo: pathlib.Path) -> None:
6643 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
6644 data = json.loads(result.output)
6645 assert data["transitive"] is True
6646 assert data["depth"] == 2
6647
6648 def test_stc_depth_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6649 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "3"])
6650 data = json.loads(result.output)
6651 assert data["depth"] == 3
6652
6653 def test_stc_transitive_does_not_reduce_coverage(
6654 self, stc_repo: pathlib.Path
6655 ) -> None:
6656 result_direct = runner.invoke(cli, self.CMD + ["--json"])
6657 result_trans = runner.invoke(cli, self.CMD + ["--json", "--transitive"])
6658 data_direct = json.loads(result_direct.output)
6659 data_trans = json.loads(result_trans.output)
6660 # Transitive coverage must be >= direct coverage
6661 assert (
6662 data_trans["summary"]["covered_symbols"]
6663 >= data_direct["summary"]["covered_symbols"]
6664 )
6665
6666 def test_stc_depth_0_invalid(self, stc_repo: pathlib.Path) -> None:
6667 result = runner.invoke(cli, self.CMD + ["--depth", "0"])
6668 assert result.exit_code != 0
6669
6670 def test_stc_depth_exceeds_max_invalid(self, stc_repo: pathlib.Path) -> None:
6671 result = runner.invoke(cli, self.CMD + ["--depth", "11"])
6672 assert result.exit_code != 0
6673
6674 # ── --min-coverage ───────────────────────────────────────────────────────
6675
6676 def test_stc_min_coverage_0_exits_zero(self, stc_repo: pathlib.Path) -> None:
6677 result = runner.invoke(cli, self.CMD + ["--min-coverage", "0"])
6678 assert result.exit_code == 0, result.output
6679
6680 def test_stc_min_coverage_100_exits_nonzero(self, stc_repo: pathlib.Path) -> None:
6681 # generate_pdf is never covered, so 100% is unachievable.
6682 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6683 assert result.exit_code != 0
6684
6685 def test_stc_min_coverage_shows_warning(self, stc_repo: pathlib.Path) -> None:
6686 result = runner.invoke(cli, self.CMD + ["--min-coverage", "100"])
6687 assert "⚠️" in result.output or "below" in result.output.lower()
6688
6689 def test_stc_min_coverage_reflected_in_json(self, stc_repo: pathlib.Path) -> None:
6690 result = runner.invoke(cli, self.CMD + ["--json", "--min-coverage", "80"])
6691 data = json.loads(result.output)
6692 assert data["filters"]["min_coverage"] == 80
6693
6694 def test_stc_min_coverage_none_when_0(self, stc_repo: pathlib.Path) -> None:
6695 result = runner.invoke(cli, self.CMD + ["--json"])
6696 data = json.loads(result.output)
6697 assert data["filters"]["min_coverage"] is None
6698
6699 def test_stc_min_coverage_invalid_over_100(self, stc_repo: pathlib.Path) -> None:
6700 result = runner.invoke(cli, self.CMD + ["--min-coverage", "101"])
6701 assert result.exit_code != 0
6702
6703 def test_stc_min_coverage_invalid_negative(self, stc_repo: pathlib.Path) -> None:
6704 result = runner.invoke(cli, self.CMD + ["--min-coverage", "-1"])
6705 assert result.exit_code != 0
6706
6707 # ── test-file exclusion ──────────────────────────────────────────────────
6708
6709 def test_stc_test_files_not_in_production_symbols(
6710 self, stc_repo: pathlib.Path
6711 ) -> None:
6712 result = runner.invoke(cli, self.CMD + ["--json"])
6713 data = json.loads(result.output)
6714 for fc in data["files"]:
6715 assert "test_" not in pathlib.PurePosixPath(fc["file"]).name.split(".")[0][:5] or \
6716 not fc["file"].startswith("tests/"), \
6717 f"test file appeared in production symbols: {fc['file']}"
6718
6719 def test_stc_no_test_file_in_prod_files(self, stc_repo: pathlib.Path) -> None:
6720 result = runner.invoke(cli, self.CMD + ["--json"])
6721 data = json.loads(result.output)
6722 for fc in data["files"]:
6723 assert "tests/" not in fc["file"] or fc["file"].startswith("tests/") is False, \
6724 fc["file"]
6725
6726 # ── requires repo ────────────────────────────────────────────────────────
6727
6728 def test_stc_requires_repo(self, tmp_path: pathlib.Path) -> None:
6729 import os
6730 old = os.getcwd()
6731 try:
6732 os.chdir(tmp_path)
6733 result = runner.invoke(cli, self.CMD)
6734 assert result.exit_code != 0
6735 finally:
6736 os.chdir(old)
6737
6738 # ── empty repo ───────────────────────────────────────────────────────────
6739
6740 def test_stc_empty_repo_exits_zero(self, repo: pathlib.Path) -> None:
6741 """An empty repo (no commits yet) should not crash."""
6742 # The base repo fixture has no commits — must handle gracefully.
6743 # First commit something minimal so HEAD exists.
6744 (repo / "empty.py").write_text("")
6745 r = runner.invoke(cli, ["commit", "-m", "seed"])
6746 if r.exit_code != 0:
6747 pytest.skip("could not create initial commit")
6748 result = runner.invoke(cli, self.CMD)
6749 assert result.exit_code == 0, result.output
6750
6751
6752 # ---------------------------------------------------------------------------
6753 # muse code gravity
6754 # ---------------------------------------------------------------------------
6755
6756
6757 @pytest.fixture
6758 def gravity_repo(repo: pathlib.Path) -> pathlib.Path:
6759 """Repo whose call graph creates a clear gravity hierarchy.
6760
6761 Layout::
6762
6763 core.py — read_object (called by everything)
6764 mid.py — process (calls read_object)
6765 top.py — handle (calls process, which calls read_object)
6766 leaf.py — leaf_fn (calls handle)
6767
6768 Expected gravity (transitive dependents):
6769 read_object: 3 (process, handle, leaf_fn) → high gravity
6770 process: 2 (handle, leaf_fn)
6771 handle: 1 (leaf_fn)
6772 leaf_fn: 0 → lowest gravity
6773 """
6774 (repo / "core.py").write_text(textwrap.dedent("""\
6775 def read_object(path):
6776 return path.read_bytes()
6777 """))
6778 r1 = runner.invoke(cli, ["commit", "-m", "core: add read_object"])
6779 assert r1.exit_code == 0, r1.output
6780
6781 (repo / "mid.py").write_text(textwrap.dedent("""\
6782 from core import read_object
6783
6784 def process(path):
6785 return read_object(path)
6786 """))
6787 r2 = runner.invoke(cli, ["commit", "-m", "mid: add process"])
6788 assert r2.exit_code == 0, r2.output
6789
6790 (repo / "top.py").write_text(textwrap.dedent("""\
6791 from mid import process
6792
6793 def handle(path):
6794 return process(path)
6795 """))
6796 r3 = runner.invoke(cli, ["commit", "-m", "top: add handle"])
6797 assert r3.exit_code == 0, r3.output
6798
6799 (repo / "leaf.py").write_text(textwrap.dedent("""\
6800 from top import handle
6801
6802 def leaf_fn(path):
6803 return handle(path)
6804 """))
6805 r4 = runner.invoke(cli, ["commit", "-m", "leaf: add leaf_fn"])
6806 assert r4.exit_code == 0, r4.output
6807
6808 return repo
6809
6810
6811 class TestGravity:
6812 """Tests for ``muse code gravity``."""
6813
6814 CMD = ["code", "gravity"]
6815
6816 # ── basic correctness ─────────────────────────────────────────────────────
6817
6818 def test_gravity_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6819 result = runner.invoke(cli, self.CMD)
6820 assert result.exit_code == 0, result.output
6821
6822 def test_gravity_shows_header(self, gravity_repo: pathlib.Path) -> None:
6823 result = runner.invoke(cli, self.CMD)
6824 assert "Symbol gravity" in result.output
6825
6826 def test_gravity_shows_head(self, gravity_repo: pathlib.Path) -> None:
6827 result = runner.invoke(cli, self.CMD)
6828 assert "HEAD" in result.output
6829
6830 def test_gravity_shows_column_headers(self, gravity_repo: pathlib.Path) -> None:
6831 result = runner.invoke(cli, self.CMD)
6832 assert "GRAVITY" in result.output
6833 assert "DIRECT" in result.output
6834 assert "DEPTH" in result.output
6835
6836 def test_gravity_shows_symbols(self, gravity_repo: pathlib.Path) -> None:
6837 result = runner.invoke(cli, self.CMD)
6838 # At least one symbol should appear.
6839 assert "read_object" in result.output or "process" in result.output
6840
6841 def test_gravity_shows_percentage(self, gravity_repo: pathlib.Path) -> None:
6842 result = runner.invoke(cli, self.CMD)
6843 assert "%" in result.output
6844
6845 # ── --top ─────────────────────────────────────────────────────────────────
6846
6847 def test_gravity_top_limits_output(self, gravity_repo: pathlib.Path) -> None:
6848 result1 = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6849 result3 = runner.invoke(cli, self.CMD + ["--json", "--top", "3"])
6850 data1 = json.loads(result1.output)
6851 data3 = json.loads(result3.output)
6852 assert len(data1["symbols"]) <= 1
6853 assert len(data3["symbols"]) <= 3
6854
6855 def test_gravity_top_0_returns_all(self, gravity_repo: pathlib.Path) -> None:
6856 result_all = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6857 result_lim = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
6858 data_all = json.loads(result_all.output)
6859 data_lim = json.loads(result_lim.output)
6860 assert len(data_all["symbols"]) >= len(data_lim["symbols"])
6861
6862 def test_gravity_top_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6863 result = runner.invoke(cli, self.CMD + ["--top", "-1"])
6864 assert result.exit_code != 0
6865
6866 # ── --sort ────────────────────────────────────────────────────────────────
6867
6868 def test_gravity_sort_gravity_default(self, gravity_repo: pathlib.Path) -> None:
6869 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
6870 data = json.loads(result.output)
6871 if len(data["symbols"]) >= 2:
6872 pcts = [s["gravity_pct"] for s in data["symbols"]]
6873 assert pcts == sorted(pcts, reverse=True)
6874
6875 def test_gravity_sort_direct(self, gravity_repo: pathlib.Path) -> None:
6876 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "direct", "--top", "0"])
6877 data = json.loads(result.output)
6878 assert result.exit_code == 0
6879 if len(data["symbols"]) >= 2:
6880 directs = [s["direct_dependents"] for s in data["symbols"]]
6881 assert directs == sorted(directs, reverse=True)
6882
6883 def test_gravity_sort_depth(self, gravity_repo: pathlib.Path) -> None:
6884 result = runner.invoke(cli, self.CMD + ["--json", "--sort", "depth", "--top", "0"])
6885 data = json.loads(result.output)
6886 assert result.exit_code == 0
6887 if len(data["symbols"]) >= 2:
6888 depths = [s["max_depth"] for s in data["symbols"]]
6889 assert depths == sorted(depths, reverse=True)
6890
6891 def test_gravity_sort_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
6892 result = runner.invoke(cli, self.CMD + ["--sort", "invalid"])
6893 assert result.exit_code != 0
6894
6895 # ── --depth cap ───────────────────────────────────────────────────────────
6896
6897 def test_gravity_depth_0_unlimited(self, gravity_repo: pathlib.Path) -> None:
6898 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "0"])
6899 data = json.loads(result.output)
6900 assert result.exit_code == 0
6901 assert data["max_depth"] == 0
6902
6903 def test_gravity_depth_1_direct_only(self, gravity_repo: pathlib.Path) -> None:
6904 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "1", "--top", "0"])
6905 data = json.loads(result.output)
6906 assert result.exit_code == 0
6907 # With depth=1, max_depth for any symbol should be at most 1.
6908 for sym in data["symbols"]:
6909 assert sym["max_depth"] <= 1
6910
6911 def test_gravity_depth_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6912 result = runner.invoke(cli, self.CMD + ["--depth", "-1"])
6913 assert result.exit_code != 0
6914
6915 # ── --kind filter ─────────────────────────────────────────────────────────
6916
6917 def test_gravity_kind_function_only(self, gravity_repo: pathlib.Path) -> None:
6918 result = runner.invoke(cli, self.CMD + ["--json", "--kind", "function", "--top", "0"])
6919 data = json.loads(result.output)
6920 assert result.exit_code == 0
6921 for sym in data["symbols"]:
6922 assert sym["kind"] == "function"
6923
6924 def test_gravity_kind_invalid_rejected(self, gravity_repo: pathlib.Path) -> None:
6925 result = runner.invoke(cli, self.CMD + ["--kind", "not_a_kind"])
6926 assert result.exit_code != 0
6927
6928 # ── --file filter ─────────────────────────────────────────────────────────
6929
6930 def test_gravity_file_filter_scopes(self, gravity_repo: pathlib.Path) -> None:
6931 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py", "--top", "0"])
6932 data = json.loads(result.output)
6933 assert result.exit_code == 0
6934 for sym in data["symbols"]:
6935 assert "core.py" in sym["file"]
6936
6937 def test_gravity_file_filter_reflected_in_json(self, gravity_repo: pathlib.Path) -> None:
6938 result = runner.invoke(cli, self.CMD + ["--json", "--file", "core.py"])
6939 data = json.loads(result.output)
6940 assert data["filters"]["file"] == "core.py"
6941
6942 # ── --min-gravity ─────────────────────────────────────────────────────────
6943
6944 def test_gravity_min_gravity_filters_low(self, gravity_repo: pathlib.Path) -> None:
6945 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "50.0", "--top", "0"])
6946 data = json.loads(result.output)
6947 for sym in data["symbols"]:
6948 assert sym["gravity_pct"] >= 50.0
6949
6950 def test_gravity_min_gravity_100_returns_few(self, gravity_repo: pathlib.Path) -> None:
6951 result = runner.invoke(cli, self.CMD + ["--json", "--min-gravity", "100.0"])
6952 assert result.exit_code == 0
6953
6954 def test_gravity_min_gravity_invalid_over_100(self, gravity_repo: pathlib.Path) -> None:
6955 result = runner.invoke(cli, self.CMD + ["--min-gravity", "101.0"])
6956 assert result.exit_code != 0
6957
6958 def test_gravity_min_gravity_invalid_negative(self, gravity_repo: pathlib.Path) -> None:
6959 result = runner.invoke(cli, self.CMD + ["--min-gravity", "-1.0"])
6960 assert result.exit_code != 0
6961
6962 # ── --explain ─────────────────────────────────────────────────────────────
6963
6964 def test_gravity_explain_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6965 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
6966 assert result.exit_code == 0, result.output
6967
6968 def test_gravity_explain_shows_breakdown(self, gravity_repo: pathlib.Path) -> None:
6969 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
6970 assert "Gravity breakdown" in result.output
6971
6972 def test_gravity_explain_shows_depth_distribution(
6973 self, gravity_repo: pathlib.Path
6974 ) -> None:
6975 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
6976 assert "Depth distribution" in result.output
6977
6978 def test_gravity_explain_shows_deepest_callers(
6979 self, gravity_repo: pathlib.Path
6980 ) -> None:
6981 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::read_object"])
6982 assert "Deepest callers" in result.output
6983
6984 def test_gravity_explain_missing_address_format(
6985 self, gravity_repo: pathlib.Path
6986 ) -> None:
6987 result = runner.invoke(cli, self.CMD + ["--explain", "no_double_colon"])
6988 assert result.exit_code != 0
6989
6990 def test_gravity_explain_unknown_symbol_exits_nonzero(
6991 self, gravity_repo: pathlib.Path
6992 ) -> None:
6993 result = runner.invoke(cli, self.CMD + ["--explain", "core.py::no_such_fn"])
6994 assert result.exit_code != 0
6995
6996 def test_gravity_explain_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
6997 result = runner.invoke(
6998 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
6999 )
7000 assert result.exit_code == 0, result.output
7001
7002 def test_gravity_explain_json_schema(self, gravity_repo: pathlib.Path) -> None:
7003 result = runner.invoke(
7004 cli, self.CMD + ["--explain", "core.py::read_object", "--json"]
7005 )
7006 data = json.loads(result.output)
7007 for key in (
7008 "address", "name", "kind", "file",
7009 "gravity_pct", "direct_dependents", "transitive_dependents",
7010 "max_depth", "depth_distribution",
7011 ):
7012 assert key in data, f"missing key: {key}"
7013
7014 # ── JSON leaderboard ──────────────────────────────────────────────────────
7015
7016 def test_gravity_json_exits_zero(self, gravity_repo: pathlib.Path) -> None:
7017 result = runner.invoke(cli, self.CMD + ["--json"])
7018 assert result.exit_code == 0, result.output
7019
7020 def test_gravity_json_top_level_keys(self, gravity_repo: pathlib.Path) -> None:
7021 result = runner.invoke(cli, self.CMD + ["--json"])
7022 data = json.loads(result.output)
7023 for key in (
7024 "ref", "snapshot_id", "total_production_symbols",
7025 "max_depth", "include_tests", "filters", "symbols",
7026 ):
7027 assert key in data, f"missing key: {key}"
7028
7029 def test_gravity_json_symbol_schema(self, gravity_repo: pathlib.Path) -> None:
7030 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7031 data = json.loads(result.output)
7032 if data["symbols"]:
7033 sym = data["symbols"][0]
7034 for key in (
7035 "address", "name", "kind", "file",
7036 "gravity_pct", "direct_dependents",
7037 "transitive_dependents", "max_depth", "depth_distribution",
7038 ):
7039 assert key in sym, f"symbol missing key: {key}"
7040
7041 def test_gravity_json_gravity_pct_range(self, gravity_repo: pathlib.Path) -> None:
7042 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7043 data = json.loads(result.output)
7044 for sym in data["symbols"]:
7045 assert 0.0 <= sym["gravity_pct"] <= 100.0
7046
7047 def test_gravity_json_read_object_has_highest_gravity(
7048 self, gravity_repo: pathlib.Path
7049 ) -> None:
7050 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7051 data = json.loads(result.output)
7052 # read_object is called transitively by everything — should be near top.
7053 names = [s["name"] for s in data["symbols"]]
7054 if "read_object" in names and len(names) > 1:
7055 ro_idx = names.index("read_object")
7056 # read_object should be in the top half.
7057 assert ro_idx <= len(names) // 2 + 1
7058
7059 def test_gravity_json_leaf_fn_lower_gravity(
7060 self, gravity_repo: pathlib.Path
7061 ) -> None:
7062 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7063 data = json.loads(result.output)
7064 syms = {s["name"]: s for s in data["symbols"]}
7065 if "leaf_fn" in syms and "read_object" in syms:
7066 assert syms["leaf_fn"]["gravity_pct"] <= syms["read_object"]["gravity_pct"]
7067
7068 def test_gravity_json_include_tests_flag(self, gravity_repo: pathlib.Path) -> None:
7069 result = runner.invoke(cli, self.CMD + ["--json", "--include-tests"])
7070 data = json.loads(result.output)
7071 assert data["include_tests"] is True
7072
7073 def test_gravity_json_depth_reflected(self, gravity_repo: pathlib.Path) -> None:
7074 result = runner.invoke(cli, self.CMD + ["--json", "--depth", "2"])
7075 data = json.loads(result.output)
7076 assert data["max_depth"] == 2
7077
7078 def test_gravity_json_filters_reflected(self, gravity_repo: pathlib.Path) -> None:
7079 result = runner.invoke(
7080 cli,
7081 self.CMD + ["--json", "--kind", "function", "--min-gravity", "5.0", "--top", "10"],
7082 )
7083 data = json.loads(result.output)
7084 assert data["filters"]["kind"] == "function"
7085 assert data["filters"]["min_gravity"] == 5.0
7086 assert data["filters"]["top"] == 10
7087
7088 def test_gravity_json_depth_distribution_is_dict(
7089 self, gravity_repo: pathlib.Path
7090 ) -> None:
7091 result = runner.invoke(cli, self.CMD + ["--json", "--top", "0"])
7092 data = json.loads(result.output)
7093 for sym in data["symbols"]:
7094 assert isinstance(sym["depth_distribution"], dict)
7095
7096 # ── requires repo ─────────────────────────────────────────────────────────
7097
7098 def test_gravity_requires_repo(self, tmp_path: pathlib.Path) -> None:
7099 import os
7100 old = os.getcwd()
7101 try:
7102 os.chdir(tmp_path)
7103 result = runner.invoke(cli, self.CMD)
7104 assert result.exit_code != 0
7105 finally:
7106 os.chdir(old)
7107
7108
7109 # ---------------------------------------------------------------------------
7110 # muse code narrative
7111 # ---------------------------------------------------------------------------
7112
7113
7114 @pytest.fixture
7115 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
7116 """Repo with a symbol that has a rich multi-event history.
7117
7118 billing.py::compute_total goes through:
7119 commit 1: seed commit (different file — gives billing.py a parent context)
7120 commit 2: created (insert — billing.py added, compute_total appears as new symbol)
7121 commit 3: body rewritten (replace with impl keywords)
7122 commit 4: signature changed (replace with signature keywords)
7123 """
7124 # Commit 1 — seed so billing.py's creation is a delta, not the initial commit.
7125 (repo / "readme.txt").write_text("MuseHub billing module\n")
7126 r0 = runner.invoke(cli, ["commit", "-m", "chore: initial seed"])
7127 assert r0.exit_code == 0, r0.output
7128
7129 # Commit 2 — create billing.py (compute_total becomes a new symbol in delta).
7130 (repo / "billing.py").write_text(textwrap.dedent("""\
7131 def compute_total(items):
7132 total = 0
7133 for item in items:
7134 total += item["price"]
7135 return total
7136 """))
7137 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7138 assert r1.exit_code == 0, r1.output
7139
7140 # Commit 3 — body rewrite: implementation changed.
7141 (repo / "billing.py").write_text(textwrap.dedent("""\
7142 def compute_total(items):
7143 return sum(i["price"] for i in items)
7144 """))
7145 r2 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
7146 assert r2.exit_code == 0, r2.output
7147
7148 # Commit 4 — signature change.
7149 (repo / "billing.py").write_text(textwrap.dedent("""\
7150 def compute_total(items, currency="USD"):
7151 return sum(i["price"] for i in items)
7152 """))
7153 r3 = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
7154 assert r3.exit_code == 0, r3.output
7155
7156 return repo
7157
7158
7159 class TestNarrative:
7160 """Tests for ``muse code narrative``."""
7161
7162 CMD = ["code", "narrative"]
7163 ADDR = "billing.py::compute_total"
7164
7165 # ── basic correctness ─────────────────────────────────────────────────────
7166
7167 def test_narrative_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7168 result = runner.invoke(cli, self.CMD + [self.ADDR])
7169 assert result.exit_code == 0, result.output
7170
7171 def test_narrative_shows_symbol_name(self, narrative_repo: pathlib.Path) -> None:
7172 result = runner.invoke(cli, self.CMD + [self.ADDR])
7173 assert "compute_total" in result.output
7174
7175 def test_narrative_shows_file(self, narrative_repo: pathlib.Path) -> None:
7176 result = runner.invoke(cli, self.CMD + [self.ADDR])
7177 assert "billing.py" in result.output
7178
7179 def test_narrative_shows_born_event(self, narrative_repo: pathlib.Path) -> None:
7180 result = runner.invoke(cli, self.CMD + [self.ADDR])
7181 assert "Born" in result.output or "born" in result.output
7182
7183 def test_narrative_shows_life_summary(self, narrative_repo: pathlib.Path) -> None:
7184 result = runner.invoke(cli, self.CMD + [self.ADDR])
7185 assert "Life summary" in result.output or "Survival" in result.output
7186
7187 def test_narrative_shows_commit_id(self, narrative_repo: pathlib.Path) -> None:
7188 result = runner.invoke(cli, self.CMD + [self.ADDR])
7189 assert "commit" in result.output
7190
7191 def test_narrative_shows_survival(self, narrative_repo: pathlib.Path) -> None:
7192 result = runner.invoke(cli, self.CMD + [self.ADDR])
7193 assert "%" in result.output
7194
7195 # ── missing symbol ────────────────────────────────────────────────────────
7196
7197 def test_narrative_missing_symbol_exits_nonzero(
7198 self, narrative_repo: pathlib.Path
7199 ) -> None:
7200 result = runner.invoke(
7201 cli, self.CMD + ["billing.py::does_not_exist"]
7202 )
7203 assert result.exit_code != 0
7204
7205 def test_narrative_bad_address_no_colons_exits_nonzero(
7206 self, narrative_repo: pathlib.Path
7207 ) -> None:
7208 result = runner.invoke(cli, self.CMD + ["no_double_colon"])
7209 assert result.exit_code != 0
7210
7211 # ── --format prose ────────────────────────────────────────────────────────
7212
7213 def test_narrative_prose_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7214 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7215 assert result.exit_code == 0, result.output
7216
7217 def test_narrative_prose_contains_name(self, narrative_repo: pathlib.Path) -> None:
7218 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7219 assert "compute_total" in result.output
7220
7221 def test_narrative_prose_contains_content(self, narrative_repo: pathlib.Path) -> None:
7222 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7223 # Symbol name or some indication of the symbol's life should appear.
7224 assert "compute_total" in result.output or "rewritten" in result.output or "born" in result.output.lower()
7225
7226 def test_narrative_prose_no_timeline_label(
7227 self, narrative_repo: pathlib.Path
7228 ) -> None:
7229 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "prose"])
7230 # Timeline labels like "Born " should not appear in prose.
7231 assert "Life summary" not in result.output
7232
7233 def test_narrative_format_invalid_rejected(
7234 self, narrative_repo: pathlib.Path
7235 ) -> None:
7236 result = runner.invoke(cli, self.CMD + [self.ADDR, "--format", "invalid"])
7237 assert result.exit_code != 0
7238
7239 # ── --json ────────────────────────────────────────────────────────────────
7240
7241 def test_narrative_json_exits_zero(self, narrative_repo: pathlib.Path) -> None:
7242 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7243 assert result.exit_code == 0, result.output
7244
7245 def test_narrative_json_is_valid(self, narrative_repo: pathlib.Path) -> None:
7246 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7247 data = json.loads(result.output)
7248 assert isinstance(data, dict)
7249
7250 def test_narrative_json_top_level_keys(self, narrative_repo: pathlib.Path) -> None:
7251 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7252 data = json.loads(result.output)
7253 for key in (
7254 "address", "name", "kind", "file", "status",
7255 "born_date", "born_commit", "last_change_date", "last_change_commit",
7256 "calendar_age_days", "genetic_age_days",
7257 "impl_changes", "sig_changes", "renames",
7258 "est_survival_pct", "commits_analysed", "truncated", "events",
7259 ):
7260 assert key in data, f"missing key: {key}"
7261
7262 def test_narrative_json_address_matches(self, narrative_repo: pathlib.Path) -> None:
7263 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7264 data = json.loads(result.output)
7265 assert data["address"] == self.ADDR
7266
7267 def test_narrative_json_name_is_bare(self, narrative_repo: pathlib.Path) -> None:
7268 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7269 data = json.loads(result.output)
7270 assert data["name"] == "compute_total"
7271
7272 def test_narrative_json_file_is_file_part(self, narrative_repo: pathlib.Path) -> None:
7273 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7274 data = json.loads(result.output)
7275 assert data["file"] == "billing.py"
7276
7277 def test_narrative_json_status_alive(self, narrative_repo: pathlib.Path) -> None:
7278 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7279 data = json.loads(result.output)
7280 assert data["status"] == "alive"
7281
7282 def test_narrative_json_events_list(self, narrative_repo: pathlib.Path) -> None:
7283 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7284 data = json.loads(result.output)
7285 assert isinstance(data["events"], list)
7286 assert len(data["events"]) >= 1
7287
7288 def test_narrative_json_event_schema(self, narrative_repo: pathlib.Path) -> None:
7289 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7290 data = json.loads(result.output)
7291 ev = data["events"][0]
7292 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
7293 assert key in ev, f"event missing key: {key}"
7294
7295 def test_narrative_json_born_commit_set(self, narrative_repo: pathlib.Path) -> None:
7296 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7297 data = json.loads(result.output)
7298 assert data["born_commit"] != ""
7299
7300 def test_narrative_json_born_date_format(self, narrative_repo: pathlib.Path) -> None:
7301 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7302 data = json.loads(result.output)
7303 import re
7304 assert re.match(r"\d{4}-\d{2}-\d{2}", data["born_date"])
7305
7306 def test_narrative_json_impl_changes_at_least_one(
7307 self, narrative_repo: pathlib.Path
7308 ) -> None:
7309 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7310 data = json.loads(result.output)
7311 # We made at least one body rewrite commit.
7312 assert data["impl_changes"] >= 1
7313
7314 def test_narrative_json_commits_analysed_positive(
7315 self, narrative_repo: pathlib.Path
7316 ) -> None:
7317 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7318 data = json.loads(result.output)
7319 assert data["commits_analysed"] > 0
7320
7321 def test_narrative_json_survival_between_0_and_100(
7322 self, narrative_repo: pathlib.Path
7323 ) -> None:
7324 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7325 data = json.loads(result.output)
7326 assert 0 <= data["est_survival_pct"] <= 100
7327
7328 def test_narrative_json_calendar_age_nonnegative(
7329 self, narrative_repo: pathlib.Path
7330 ) -> None:
7331 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7332 data = json.loads(result.output)
7333 assert data["calendar_age_days"] >= 0
7334
7335 def test_narrative_json_events_oldest_first(
7336 self, narrative_repo: pathlib.Path
7337 ) -> None:
7338 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7339 data = json.loads(result.output)
7340 dates = [ev["date"] for ev in data["events"]]
7341 assert dates == sorted(dates)
7342
7343 def test_narrative_json_create_event_present(
7344 self, narrative_repo: pathlib.Path
7345 ) -> None:
7346 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7347 data = json.loads(result.output)
7348 types = [ev["event_type"] for ev in data["events"]]
7349 assert "create" in types
7350
7351 # ── --since ───────────────────────────────────────────────────────────────
7352
7353 def test_narrative_since_invalid_ref_exits_nonzero(
7354 self, narrative_repo: pathlib.Path
7355 ) -> None:
7356 result = runner.invoke(
7357 cli, self.CMD + [self.ADDR, "--since", "no_such_ref_xyz"]
7358 )
7359 assert result.exit_code != 0
7360
7361 # ── --max-commits ─────────────────────────────────────────────────────────
7362
7363 def test_narrative_max_commits_validation(
7364 self, narrative_repo: pathlib.Path
7365 ) -> None:
7366 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7367 assert result.exit_code != 0
7368
7369 def test_narrative_max_commits_1_finds_head_event(
7370 self, narrative_repo: pathlib.Path
7371 ) -> None:
7372 result = runner.invoke(
7373 cli, self.CMD + [self.ADDR, "--json", "--max-commits", "1"]
7374 )
7375 # With max-commits=1 we only see the HEAD commit; it must still succeed
7376 # if the HEAD commit touched our symbol, or fail gracefully if not.
7377 assert result.exit_code in (0, 1)
7378
7379 # ── --show-source ─────────────────────────────────────────────────────────
7380
7381 def test_narrative_show_source_exits_zero(
7382 self, narrative_repo: pathlib.Path
7383 ) -> None:
7384 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7385 assert result.exit_code == 0, result.output
7386
7387 def test_narrative_show_source_contains_def(
7388 self, narrative_repo: pathlib.Path
7389 ) -> None:
7390 result = runner.invoke(cli, self.CMD + [self.ADDR, "--show-source"])
7391 # HEAD source should contain the function definition.
7392 assert "def compute_total" in result.output
7393
7394 # ── requires repo ─────────────────────────────────────────────────────────
7395
7396 def test_narrative_requires_repo(self, tmp_path: pathlib.Path) -> None:
7397 import os
7398 old = os.getcwd()
7399 try:
7400 os.chdir(tmp_path)
7401 result = runner.invoke(cli, self.CMD + [self.ADDR])
7402 assert result.exit_code != 0
7403 finally:
7404 os.chdir(old)
7405
7406
7407 # ---------------------------------------------------------------------------
7408 # contract
7409 # ---------------------------------------------------------------------------
7410
7411
7412 @pytest.fixture()
7413 def contract_repo(repo: pathlib.Path) -> pathlib.Path:
7414 """Repo designed to exercise every dimension of ``muse code contract``.
7415
7416 Layout::
7417
7418 billing.py — compute_total(items, currency="USD") → float
7419 services.py — place_order() calls compute_total with currency="EUR" → stored
7420 report.py — generate_report() calls compute_total(items) → stored (omits currency)
7421 audit.py — run_audit() calls compute_total(items) → discarded (bad caller)
7422 tests/test_billing.py — tests with assertions about compute_total
7423
7424 Commit history::
7425
7426 1. seed commit — readme.txt so symbol events are real insert ops
7427 2. billing.py added — compute_total created
7428 3. services.py, report.py, audit.py, tests/ added — callers in place
7429 4. billing.py updated — body rewrite (PATCH)
7430 5. billing.py updated — add currency param (MINOR)
7431 """
7432 import os
7433
7434 (repo / "readme.txt").write_text("# contract test repo\n")
7435 r0 = runner.invoke(cli, ["commit", "-m", "seed: initial readme"])
7436 assert r0.exit_code == 0, r0.output
7437
7438 (repo / "billing.py").write_text(textwrap.dedent("""\
7439 def compute_total(items):
7440 return sum(i["price"] for i in items)
7441 """))
7442 r1 = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
7443 assert r1.exit_code == 0, r1.output
7444
7445 os.makedirs(repo / "tests", exist_ok=True)
7446 (repo / "services.py").write_text(textwrap.dedent("""\
7447 from billing import compute_total
7448
7449 def place_order(items):
7450 total = compute_total(items, currency="EUR")
7451 return total
7452 """))
7453 (repo / "report.py").write_text(textwrap.dedent("""\
7454 from billing import compute_total
7455
7456 def generate_report(items):
7457 result = compute_total(items)
7458 return result
7459 """))
7460 (repo / "audit.py").write_text(textwrap.dedent("""\
7461 from billing import compute_total
7462
7463 def run_audit(items):
7464 compute_total(items)
7465 """))
7466 (repo / "tests" / "test_billing.py").write_text(textwrap.dedent("""\
7467 from billing import compute_total
7468
7469 def test_compute_total_basic():
7470 result = compute_total([{"price": 10}, {"price": 5}])
7471 assert result == 15
7472 assert result > 0
7473 assert isinstance(result, (int, float))
7474
7475 def test_compute_total_empty():
7476 result = compute_total([])
7477 assert result == 0
7478 """))
7479 r2 = runner.invoke(cli, ["commit", "-m", "feat: add callers and tests"])
7480 assert r2.exit_code == 0, r2.output
7481
7482 # body rewrite — PATCH
7483 (repo / "billing.py").write_text(textwrap.dedent("""\
7484 def compute_total(items):
7485 total = 0.0
7486 for item in items:
7487 total += float(item["price"])
7488 return total
7489 """))
7490 r3 = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total"])
7491 assert r3.exit_code == 0, r3.output
7492
7493 # add currency param — MINOR
7494 (repo / "billing.py").write_text(textwrap.dedent("""\
7495 def compute_total(items, currency="USD"):
7496 total = 0.0
7497 for item in items:
7498 total += float(item["price"])
7499 return total
7500 """))
7501 r4 = runner.invoke(cli, ["commit", "-m", "feat: add optional currency param"])
7502 assert r4.exit_code == 0, r4.output
7503
7504 return repo
7505
7506
7507 class TestContract:
7508 """Tests for ``muse code contract``."""
7509
7510 CMD = ["code", "contract"]
7511 ADDR = "billing.py::compute_total"
7512
7513 # ── basic correctness ─────────────────────────────────────────────────────
7514
7515 def test_contract_exits_zero(self, contract_repo: pathlib.Path) -> None:
7516 result = runner.invoke(cli, self.CMD + [self.ADDR])
7517 assert result.exit_code == 0, result.output
7518
7519 def test_contract_shows_address(self, contract_repo: pathlib.Path) -> None:
7520 result = runner.invoke(cli, self.CMD + [self.ADDR])
7521 assert "compute_total" in result.output
7522
7523 def test_contract_shows_signature_section(self, contract_repo: pathlib.Path) -> None:
7524 result = runner.invoke(cli, self.CMD + [self.ADDR])
7525 assert "Signature" in result.output
7526
7527 def test_contract_shows_def_keyword(self, contract_repo: pathlib.Path) -> None:
7528 result = runner.invoke(cli, self.CMD + [self.ADDR])
7529 assert "def compute_total" in result.output
7530
7531 def test_contract_shows_stability_section(self, contract_repo: pathlib.Path) -> None:
7532 result = runner.invoke(cli, self.CMD + [self.ADDR])
7533 assert "Stability" in result.output
7534
7535 def test_contract_shows_commits_analysed(self, contract_repo: pathlib.Path) -> None:
7536 result = runner.invoke(cli, self.CMD + [self.ADDR])
7537 assert "commits" in result.output
7538
7539 def test_contract_shows_assessment(self, contract_repo: pathlib.Path) -> None:
7540 result = runner.invoke(cli, self.CMD + [self.ADDR])
7541 assert "Assessment" in result.output
7542
7543 def test_contract_shows_return_section(self, contract_repo: pathlib.Path) -> None:
7544 result = runner.invoke(cli, self.CMD + [self.ADDR])
7545 assert "Return value" in result.output
7546
7547 def test_contract_shows_parameters_section(self, contract_repo: pathlib.Path) -> None:
7548 result = runner.invoke(cli, self.CMD + [self.ADDR])
7549 assert "Parameters" in result.output
7550
7551 # ── call-site disposition detection ──────────────────────────────────────
7552
7553 def test_contract_detects_stored(self, contract_repo: pathlib.Path) -> None:
7554 result = runner.invoke(cli, self.CMD + [self.ADDR])
7555 assert "stored" in result.output
7556
7557 def test_contract_detects_discarded(self, contract_repo: pathlib.Path) -> None:
7558 result = runner.invoke(cli, self.CMD + [self.ADDR])
7559 assert "discarded" in result.output
7560
7561 def test_contract_warns_on_discarded(self, contract_repo: pathlib.Path) -> None:
7562 result = runner.invoke(cli, self.CMD + [self.ADDR])
7563 # audit.py discards the return — should surface a warning.
7564 assert "⚠" in result.output
7565
7566 # ── test assertions ───────────────────────────────────────────────────────
7567
7568 def test_contract_shows_test_assertions(self, contract_repo: pathlib.Path) -> None:
7569 result = runner.invoke(cli, self.CMD + [self.ADDR])
7570 assert "assert" in result.output.lower()
7571
7572 def test_contract_shows_assert_result_positive(
7573 self, contract_repo: pathlib.Path
7574 ) -> None:
7575 result = runner.invoke(cli, self.CMD + [self.ADDR])
7576 assert "result > 0" in result.output or "assert" in result.output
7577
7578 # ── --json ────────────────────────────────────────────────────────────────
7579
7580 def test_contract_json_exits_zero(self, contract_repo: pathlib.Path) -> None:
7581 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7582 assert result.exit_code == 0, result.output
7583
7584 def test_contract_json_is_valid(self, contract_repo: pathlib.Path) -> None:
7585 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7586 data = json.loads(result.output)
7587 assert isinstance(data, dict)
7588
7589 def test_contract_json_top_level_keys(self, contract_repo: pathlib.Path) -> None:
7590 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7591 data = json.loads(result.output)
7592 for key in (
7593 "address", "name", "kind", "signature", "parameters",
7594 "return_annotation", "call_sites", "caller_files",
7595 "return_dispositions", "arg_observations",
7596 "test_assertions", "commit_signals", "history",
7597 "preconditions", "postconditions", "warnings", "stability",
7598 ):
7599 assert key in data, f"missing top-level key: {key}"
7600
7601 def test_contract_json_address_matches(self, contract_repo: pathlib.Path) -> None:
7602 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7603 data = json.loads(result.output)
7604 assert data["address"] == self.ADDR
7605
7606 def test_contract_json_name_is_bare(self, contract_repo: pathlib.Path) -> None:
7607 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7608 data = json.loads(result.output)
7609 assert data["name"] == "compute_total"
7610
7611 def test_contract_json_kind_is_function(self, contract_repo: pathlib.Path) -> None:
7612 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7613 data = json.loads(result.output)
7614 assert data["kind"] in {"function", "async_function", "method", "async_method"}
7615
7616 def test_contract_json_signature_contains_def(
7617 self, contract_repo: pathlib.Path
7618 ) -> None:
7619 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7620 data = json.loads(result.output)
7621 assert "def compute_total" in data["signature"]
7622
7623 def test_contract_json_parameters_is_list(self, contract_repo: pathlib.Path) -> None:
7624 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7625 data = json.loads(result.output)
7626 assert isinstance(data["parameters"], list)
7627
7628 def test_contract_json_parameters_not_empty(self, contract_repo: pathlib.Path) -> None:
7629 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7630 data = json.loads(result.output)
7631 assert len(data["parameters"]) >= 1
7632
7633 def test_contract_json_parameters_schema(self, contract_repo: pathlib.Path) -> None:
7634 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7635 data = json.loads(result.output)
7636 p = data["parameters"][0]
7637 for key in ("name", "annotation", "has_default", "default_str"):
7638 assert key in p, f"parameter missing key: {key}"
7639
7640 def test_contract_json_items_param_present(self, contract_repo: pathlib.Path) -> None:
7641 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7642 data = json.loads(result.output)
7643 names = [p["name"] for p in data["parameters"]]
7644 assert "items" in names
7645
7646 def test_contract_json_currency_param_present(
7647 self, contract_repo: pathlib.Path
7648 ) -> None:
7649 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7650 data = json.loads(result.output)
7651 names = [p["name"] for p in data["parameters"]]
7652 assert "currency" in names
7653
7654 def test_contract_json_currency_has_default(self, contract_repo: pathlib.Path) -> None:
7655 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7656 data = json.loads(result.output)
7657 params = {p["name"]: p for p in data["parameters"]}
7658 assert params["currency"]["has_default"] is True
7659
7660 def test_contract_json_currency_default_str(self, contract_repo: pathlib.Path) -> None:
7661 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7662 data = json.loads(result.output)
7663 params = {p["name"]: p for p in data["parameters"]}
7664 assert params["currency"]["default_str"] == "'USD'"
7665
7666 def test_contract_json_call_sites_positive(self, contract_repo: pathlib.Path) -> None:
7667 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7668 data = json.loads(result.output)
7669 assert data["call_sites"] >= 1
7670
7671 def test_contract_json_caller_files_positive(self, contract_repo: pathlib.Path) -> None:
7672 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7673 data = json.loads(result.output)
7674 assert data["caller_files"] >= 1
7675
7676 def test_contract_json_return_dispositions_is_dict(
7677 self, contract_repo: pathlib.Path
7678 ) -> None:
7679 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7680 data = json.loads(result.output)
7681 assert isinstance(data["return_dispositions"], dict)
7682
7683 def test_contract_json_return_dispositions_keys(
7684 self, contract_repo: pathlib.Path
7685 ) -> None:
7686 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7687 data = json.loads(result.output)
7688 rd = data["return_dispositions"]
7689 for key in ("stored", "discarded", "returned", "asserted", "compared"):
7690 assert key in rd, f"return_dispositions missing: {key}"
7691
7692 def test_contract_json_discarded_count_at_least_one(
7693 self, contract_repo: pathlib.Path
7694 ) -> None:
7695 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7696 data = json.loads(result.output)
7697 # audit.py discards the return value
7698 assert data["return_dispositions"].get("discarded", 0) >= 1
7699
7700 def test_contract_json_stored_count_at_least_one(
7701 self, contract_repo: pathlib.Path
7702 ) -> None:
7703 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7704 data = json.loads(result.output)
7705 assert data["return_dispositions"].get("stored", 0) >= 1
7706
7707 def test_contract_json_test_assertions_is_list(
7708 self, contract_repo: pathlib.Path
7709 ) -> None:
7710 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7711 data = json.loads(result.output)
7712 assert isinstance(data["test_assertions"], list)
7713
7714 def test_contract_json_test_assertions_not_empty(
7715 self, contract_repo: pathlib.Path
7716 ) -> None:
7717 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7718 data = json.loads(result.output)
7719 assert len(data["test_assertions"]) >= 1
7720
7721 def test_contract_json_test_assertions_are_strings(
7722 self, contract_repo: pathlib.Path
7723 ) -> None:
7724 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7725 data = json.loads(result.output)
7726 for assertion in data["test_assertions"]:
7727 assert isinstance(assertion, str)
7728
7729 def test_contract_json_history_schema(self, contract_repo: pathlib.Path) -> None:
7730 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7731 data = json.loads(result.output)
7732 h = data["history"]
7733 for key in (
7734 "commits_analysed", "truncated", "major_bumps",
7735 "minor_bumps", "patch_bumps", "sig_changes",
7736 "impl_changes", "est_survival_pct",
7737 ):
7738 assert key in h, f"history missing key: {key}"
7739
7740 def test_contract_json_history_commits_positive(
7741 self, contract_repo: pathlib.Path
7742 ) -> None:
7743 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7744 data = json.loads(result.output)
7745 assert data["history"]["commits_analysed"] > 0
7746
7747 def test_contract_json_history_survival_0_to_100(
7748 self, contract_repo: pathlib.Path
7749 ) -> None:
7750 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7751 data = json.loads(result.output)
7752 pct = data["history"]["est_survival_pct"]
7753 assert 0 <= pct <= 100
7754
7755 def test_contract_json_commit_signals_is_list(
7756 self, contract_repo: pathlib.Path
7757 ) -> None:
7758 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7759 data = json.loads(result.output)
7760 assert isinstance(data["commit_signals"], list)
7761
7762 def test_contract_json_preconditions_is_list(
7763 self, contract_repo: pathlib.Path
7764 ) -> None:
7765 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7766 data = json.loads(result.output)
7767 assert isinstance(data["preconditions"], list)
7768
7769 def test_contract_json_postconditions_is_list(
7770 self, contract_repo: pathlib.Path
7771 ) -> None:
7772 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7773 data = json.loads(result.output)
7774 assert isinstance(data["postconditions"], list)
7775
7776 def test_contract_json_warnings_is_list(self, contract_repo: pathlib.Path) -> None:
7777 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7778 data = json.loads(result.output)
7779 assert isinstance(data["warnings"], list)
7780
7781 def test_contract_json_stability_valid_value(
7782 self, contract_repo: pathlib.Path
7783 ) -> None:
7784 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7785 data = json.loads(result.output)
7786 assert data["stability"] in {"stable", "evolving", "volatile", "dormant"}
7787
7788 def test_contract_json_arg_observations_is_list(
7789 self, contract_repo: pathlib.Path
7790 ) -> None:
7791 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7792 data = json.loads(result.output)
7793 assert isinstance(data["arg_observations"], list)
7794
7795 # ── input validation ──────────────────────────────────────────────────────
7796
7797 def test_contract_missing_address_exits_nonzero(
7798 self, contract_repo: pathlib.Path
7799 ) -> None:
7800 result = runner.invoke(cli, self.CMD)
7801 assert result.exit_code != 0
7802
7803 def test_contract_bad_address_format_exits_nonzero(
7804 self, contract_repo: pathlib.Path
7805 ) -> None:
7806 result = runner.invoke(cli, self.CMD + ["billing_no_colon"])
7807 assert result.exit_code != 0
7808
7809 def test_contract_unknown_address_exits_nonzero(
7810 self, contract_repo: pathlib.Path
7811 ) -> None:
7812 result = runner.invoke(cli, self.CMD + ["billing.py::nonexistent_fn_xyz"])
7813 assert result.exit_code != 0
7814
7815 def test_contract_max_commits_zero_rejected(
7816 self, contract_repo: pathlib.Path
7817 ) -> None:
7818 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "0"])
7819 assert result.exit_code != 0
7820
7821 def test_contract_max_commits_one_succeeds(self, contract_repo: pathlib.Path) -> None:
7822 result = runner.invoke(cli, self.CMD + [self.ADDR, "--max-commits", "1"])
7823 assert result.exit_code == 0, result.output
7824
7825 # ── requires repo ─────────────────────────────────────────────────────────
7826
7827 def test_contract_requires_repo(self, tmp_path: pathlib.Path) -> None:
7828 import os
7829
7830 old = os.getcwd()
7831 try:
7832 os.chdir(tmp_path)
7833 result = runner.invoke(cli, self.CMD + [self.ADDR])
7834 assert result.exit_code != 0
7835 finally:
7836 os.chdir(old)
7837
7838 # ── history accuracy ──────────────────────────────────────────────────────
7839
7840 def test_contract_json_impl_changes_nonzero(
7841 self, contract_repo: pathlib.Path
7842 ) -> None:
7843 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7844 data = json.loads(result.output)
7845 # We made 2 body changes (perf rewrite + currency add).
7846 assert data["history"]["impl_changes"] >= 1
7847
7848 def test_contract_json_truncated_false_small_repo(
7849 self, contract_repo: pathlib.Path
7850 ) -> None:
7851 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7852 data = json.loads(result.output)
7853 assert data["history"]["truncated"] is False
7854
7855 def test_contract_json_postconditions_nonempty(
7856 self, contract_repo: pathlib.Path
7857 ) -> None:
7858 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7859 data = json.loads(result.output)
7860 # Should infer at least one postcondition (return value is stored).
7861 assert len(data["postconditions"]) >= 1
7862
7863 def test_contract_json_warnings_nonempty(self, contract_repo: pathlib.Path) -> None:
7864 result = runner.invoke(cli, self.CMD + [self.ADDR, "--json"])
7865 data = json.loads(result.output)
7866 # Missing type annotations + discarded return should generate warnings.
7867 assert len(data["warnings"]) >= 1
7868
7869
7870 # ---------------------------------------------------------------------------
7871 # predict
7872 # ---------------------------------------------------------------------------
7873
7874
7875 @pytest.fixture()
7876 def predict_repo(repo: pathlib.Path) -> pathlib.Path:
7877 """Repo with commit history that produces clear prediction signals.
7878
7879 billing.py::compute_total — changed in every commit (high frequency)
7880 billing.py::apply_discount — always co-changes with compute_total (entanglement)
7881 services.py::place_order — changed only once (low confidence)
7882
7883 5 commits are made so that recency, frequency, and co-change signals
7884 are all detectable within the default horizon.
7885 """
7886 # Commit 1 — establish both symbols
7887 (repo / "billing.py").write_text(textwrap.dedent("""\
7888 def compute_total(items):
7889 return sum(i["price"] for i in items)
7890
7891 def apply_discount(total, rate):
7892 return total * (1 - rate)
7893 """))
7894 (repo / "services.py").write_text(textwrap.dedent("""\
7895 def place_order(items):
7896 return True
7897 """))
7898 r1 = runner.invoke(cli, ["commit", "-m", "feat: initial billing"])
7899 assert r1.exit_code == 0, r1.output
7900
7901 # Commits 2-5 — co-evolve compute_total and apply_discount together
7902 for i in range(2, 6):
7903 (repo / "billing.py").write_text(textwrap.dedent(f"""\
7904 def compute_total(items, rev={i}):
7905 total = 0.0
7906 for item in items:
7907 total += float(item["price"])
7908 return total
7909
7910 def apply_discount(total, rate, rev={i}):
7911 return max(0.0, total * (1 - rate))
7912 """))
7913 r = runner.invoke(cli, ["commit", "-m", f"refactor: billing revision {i}"])
7914 assert r.exit_code == 0, r.output
7915
7916 return repo
7917
7918
7919 class TestPredict:
7920 """Tests for ``muse code predict``."""
7921
7922 CMD = ["code", "predict"]
7923
7924 # ── basic correctness ─────────────────────────────────────────────────────
7925
7926 def test_predict_exits_zero(self, predict_repo: pathlib.Path) -> None:
7927 result = runner.invoke(cli, self.CMD)
7928 assert result.exit_code == 0, result.output
7929
7930 def test_predict_shows_header(self, predict_repo: pathlib.Path) -> None:
7931 result = runner.invoke(cli, self.CMD)
7932 assert "Predicted changes" in result.output
7933
7934 def test_predict_shows_horizon(self, predict_repo: pathlib.Path) -> None:
7935 result = runner.invoke(cli, self.CMD)
7936 assert "horizon:" in result.output
7937
7938 def test_predict_shows_commits_analysed(self, predict_repo: pathlib.Path) -> None:
7939 result = runner.invoke(cli, self.CMD)
7940 assert "analysed" in result.output
7941
7942 def test_predict_shows_compute_total(self, predict_repo: pathlib.Path) -> None:
7943 result = runner.invoke(cli, self.CMD)
7944 assert "compute_total" in result.output
7945
7946 def test_predict_shows_apply_discount(self, predict_repo: pathlib.Path) -> None:
7947 result = runner.invoke(cli, self.CMD)
7948 assert "apply_discount" in result.output
7949
7950 def test_predict_shows_score(self, predict_repo: pathlib.Path) -> None:
7951 result = runner.invoke(cli, self.CMD)
7952 # Scores are in N.NN format at the start of each prediction line.
7953 import re
7954 assert re.search(r"0\.\d{2}", result.output)
7955
7956 def test_predict_shows_reasons(self, predict_repo: pathlib.Path) -> None:
7957 result = runner.invoke(cli, self.CMD)
7958 assert "↳" in result.output
7959
7960 def test_predict_high_confidence_band_present(
7961 self, predict_repo: pathlib.Path
7962 ) -> None:
7963 result = runner.invoke(cli, self.CMD)
7964 # compute_total changed 4/5 commits — should be HIGH or MEDIUM.
7965 assert "CONFIDENCE" in result.output
7966
7967 def test_predict_entanglement_signal(self, predict_repo: pathlib.Path) -> None:
7968 result = runner.invoke(cli, self.CMD + ["--horizon", "10"])
7969 # compute_total and apply_discount co-change → entanglement reason expected.
7970 assert "entangled" in result.output or "co-change" in result.output
7971
7972 # ── --top ─────────────────────────────────────────────────────────────────
7973
7974 def test_predict_top_1_shows_one_prediction(
7975 self, predict_repo: pathlib.Path
7976 ) -> None:
7977 result = runner.invoke(cli, self.CMD + ["--top", "1"])
7978 assert result.exit_code == 0, result.output
7979 # With --top 1 there is exactly one score line.
7980 import re
7981 scores = re.findall(r"^\s+0\.\d{2}\s+", result.output, re.MULTILINE)
7982 assert len(scores) == 1
7983
7984 def test_predict_top_0_shows_all(self, predict_repo: pathlib.Path) -> None:
7985 result = runner.invoke(cli, self.CMD + ["--top", "0"])
7986 assert result.exit_code == 0, result.output
7987 assert "compute_total" in result.output
7988
7989 # ── --min-confidence ──────────────────────────────────────────────────────
7990
7991 def test_predict_min_confidence_1_empty(self, predict_repo: pathlib.Path) -> None:
7992 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.0"])
7993 assert result.exit_code == 0, result.output
7994 # Nothing should reach score 1.0 exactly.
7995 assert "No predictions" in result.output or "compute_total" not in result.output
7996
7997 def test_predict_min_confidence_invalid_rejected(
7998 self, predict_repo: pathlib.Path
7999 ) -> None:
8000 result = runner.invoke(cli, self.CMD + ["--min-confidence", "1.5"])
8001 assert result.exit_code != 0
8002
8003 def test_predict_min_confidence_zero_shows_all(
8004 self, predict_repo: pathlib.Path
8005 ) -> None:
8006 result = runner.invoke(cli, self.CMD + ["--min-confidence", "0.0"])
8007 assert result.exit_code == 0, result.output
8008 assert "compute_total" in result.output
8009
8010 # ── --horizon ─────────────────────────────────────────────────────────────
8011
8012 def test_predict_horizon_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8013 result = runner.invoke(cli, self.CMD + ["--horizon", "1"])
8014 assert result.exit_code == 0, result.output
8015
8016 def test_predict_horizon_invalid_rejected(self, predict_repo: pathlib.Path) -> None:
8017 result = runner.invoke(cli, self.CMD + ["--horizon", "0"])
8018 assert result.exit_code != 0
8019
8020 def test_predict_max_commits_1_exits_zero(self, predict_repo: pathlib.Path) -> None:
8021 result = runner.invoke(cli, self.CMD + ["--max-commits", "1"])
8022 assert result.exit_code == 0, result.output
8023
8024 def test_predict_max_commits_invalid_rejected(
8025 self, predict_repo: pathlib.Path
8026 ) -> None:
8027 result = runner.invoke(cli, self.CMD + ["--max-commits", "0"])
8028 assert result.exit_code != 0
8029
8030 # ── --file ────────────────────────────────────────────────────────────────
8031
8032 def test_predict_file_filter_billing(self, predict_repo: pathlib.Path) -> None:
8033 result = runner.invoke(cli, self.CMD + ["--file", "billing.py"])
8034 assert result.exit_code == 0, result.output
8035 # Should show billing symbols.
8036 if "compute_total" in result.output or "apply_discount" in result.output:
8037 pass # expected
8038 # Should NOT show services.py symbols.
8039 assert "place_order" not in result.output
8040
8041 def test_predict_file_filter_nonexistent_empty(
8042 self, predict_repo: pathlib.Path
8043 ) -> None:
8044 result = runner.invoke(cli, self.CMD + ["--file", "nonexistent_xyz.py"])
8045 assert result.exit_code == 0, result.output
8046 assert "No predictions" in result.output
8047
8048 # ── --explain ─────────────────────────────────────────────────────────────
8049
8050 def test_predict_explain_exits_zero(self, predict_repo: pathlib.Path) -> None:
8051 result = runner.invoke(
8052 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8053 )
8054 assert result.exit_code == 0, result.output
8055
8056 def test_predict_explain_shows_signal_breakdown(
8057 self, predict_repo: pathlib.Path
8058 ) -> None:
8059 result = runner.invoke(
8060 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8061 )
8062 assert "signal breakdown" in result.output
8063
8064 def test_predict_explain_shows_all_signals(
8065 self, predict_repo: pathlib.Path
8066 ) -> None:
8067 result = runner.invoke(
8068 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8069 )
8070 for signal in ("recency", "frequency", "co_change", "sig_instability",
8071 "module_velocity"):
8072 assert signal in result.output, f"missing signal: {signal}"
8073
8074 def test_predict_explain_shows_bar(self, predict_repo: pathlib.Path) -> None:
8075 result = runner.invoke(
8076 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8077 )
8078 assert "█" in result.output or "░" in result.output
8079
8080 def test_predict_explain_shows_score(self, predict_repo: pathlib.Path) -> None:
8081 result = runner.invoke(
8082 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8083 )
8084 assert "Score:" in result.output
8085
8086 def test_predict_explain_shows_reasons(self, predict_repo: pathlib.Path) -> None:
8087 result = runner.invoke(
8088 cli, self.CMD + ["--explain", "billing.py::compute_total"]
8089 )
8090 assert "Reasons" in result.output
8091
8092 def test_predict_explain_bad_format_rejected(
8093 self, predict_repo: pathlib.Path
8094 ) -> None:
8095 result = runner.invoke(cli, self.CMD + ["--explain", "no_colon_here"])
8096 assert result.exit_code != 0
8097
8098 def test_predict_explain_unknown_addr_rejected(
8099 self, predict_repo: pathlib.Path
8100 ) -> None:
8101 result = runner.invoke(
8102 cli, self.CMD + ["--explain", "billing.py::nonexistent_fn_xyz"]
8103 )
8104 assert result.exit_code != 0
8105
8106 # ── --json ────────────────────────────────────────────────────────────────
8107
8108 def test_predict_json_exits_zero(self, predict_repo: pathlib.Path) -> None:
8109 result = runner.invoke(cli, self.CMD + ["--json"])
8110 assert result.exit_code == 0, result.output
8111
8112 def test_predict_json_is_valid(self, predict_repo: pathlib.Path) -> None:
8113 result = runner.invoke(cli, self.CMD + ["--json"])
8114 data = json.loads(result.output)
8115 assert isinstance(data, dict)
8116
8117 def test_predict_json_top_level_keys(self, predict_repo: pathlib.Path) -> None:
8118 result = runner.invoke(cli, self.CMD + ["--json"])
8119 data = json.loads(result.output)
8120 for key in (
8121 "generated_at", "horizon_commits", "max_commits",
8122 "commits_analysed", "truncated", "predictions",
8123 ):
8124 assert key in data, f"missing key: {key}"
8125
8126 def test_predict_json_predictions_is_list(self, predict_repo: pathlib.Path) -> None:
8127 result = runner.invoke(cli, self.CMD + ["--json"])
8128 data = json.loads(result.output)
8129 assert isinstance(data["predictions"], list)
8130
8131 def test_predict_json_predictions_not_empty(
8132 self, predict_repo: pathlib.Path
8133 ) -> None:
8134 result = runner.invoke(cli, self.CMD + ["--json"])
8135 data = json.loads(result.output)
8136 assert len(data["predictions"]) >= 1
8137
8138 def test_predict_json_prediction_schema(self, predict_repo: pathlib.Path) -> None:
8139 result = runner.invoke(cli, self.CMD + ["--json"])
8140 data = json.loads(result.output)
8141 pred = data["predictions"][0]
8142 for key in (
8143 "address", "name", "kind", "file", "score", "confidence",
8144 "reasons", "signals", "last_changed_commit", "last_changed_date",
8145 "top_partners",
8146 ):
8147 assert key in pred, f"prediction missing key: {key}"
8148
8149 def test_predict_json_signals_schema(self, predict_repo: pathlib.Path) -> None:
8150 result = runner.invoke(cli, self.CMD + ["--json"])
8151 data = json.loads(result.output)
8152 signals = data["predictions"][0]["signals"]
8153 for key in ("recency", "frequency", "co_change", "sig_instability",
8154 "module_velocity"):
8155 assert key in signals, f"signals missing key: {key}"
8156
8157 def test_predict_json_score_is_float(self, predict_repo: pathlib.Path) -> None:
8158 result = runner.invoke(cli, self.CMD + ["--json"])
8159 data = json.loads(result.output)
8160 assert isinstance(data["predictions"][0]["score"], float)
8161
8162 def test_predict_json_score_in_range(self, predict_repo: pathlib.Path) -> None:
8163 result = runner.invoke(cli, self.CMD + ["--json"])
8164 data = json.loads(result.output)
8165 for pred in data["predictions"]:
8166 assert 0.0 <= pred["score"] <= 1.0, (
8167 f"score out of range: {pred['score']}"
8168 )
8169
8170 def test_predict_json_confidence_valid(self, predict_repo: pathlib.Path) -> None:
8171 result = runner.invoke(cli, self.CMD + ["--json"])
8172 data = json.loads(result.output)
8173 for pred in data["predictions"]:
8174 assert pred["confidence"] in {"high", "medium", "low"}, (
8175 f"invalid confidence: {pred['confidence']}"
8176 )
8177
8178 def test_predict_json_sorted_by_score_desc(self, predict_repo: pathlib.Path) -> None:
8179 result = runner.invoke(cli, self.CMD + ["--json"])
8180 data = json.loads(result.output)
8181 scores = [p["score"] for p in data["predictions"]]
8182 assert scores == sorted(scores, reverse=True)
8183
8184 def test_predict_json_commits_analysed_positive(
8185 self, predict_repo: pathlib.Path
8186 ) -> None:
8187 result = runner.invoke(cli, self.CMD + ["--json"])
8188 data = json.loads(result.output)
8189 assert data["commits_analysed"] > 0
8190
8191 def test_predict_json_truncated_false_small_repo(
8192 self, predict_repo: pathlib.Path
8193 ) -> None:
8194 result = runner.invoke(cli, self.CMD + ["--json"])
8195 data = json.loads(result.output)
8196 assert data["truncated"] is False
8197
8198 def test_predict_json_top_partners_is_list(self, predict_repo: pathlib.Path) -> None:
8199 result = runner.invoke(cli, self.CMD + ["--json"])
8200 data = json.loads(result.output)
8201 for pred in data["predictions"]:
8202 assert isinstance(pred["top_partners"], list)
8203
8204 def test_predict_json_partner_schema(self, predict_repo: pathlib.Path) -> None:
8205 result = runner.invoke(cli, self.CMD + ["--json"])
8206 data = json.loads(result.output)
8207 # Find a prediction that has partners.
8208 for pred in data["predictions"]:
8209 if pred["top_partners"]:
8210 p = pred["top_partners"][0]
8211 for key in ("address", "co_change_rate", "co_change_commits"):
8212 assert key in p, f"partner missing key: {key}"
8213 break
8214
8215 def test_predict_json_co_change_rate_in_range(
8216 self, predict_repo: pathlib.Path
8217 ) -> None:
8218 result = runner.invoke(cli, self.CMD + ["--json"])
8219 data = json.loads(result.output)
8220 for pred in data["predictions"]:
8221 for p in pred["top_partners"]:
8222 assert 0.0 <= p["co_change_rate"] <= 1.0
8223
8224 def test_predict_json_top_1_returns_one(self, predict_repo: pathlib.Path) -> None:
8225 result = runner.invoke(cli, self.CMD + ["--json", "--top", "1"])
8226 data = json.loads(result.output)
8227 assert len(data["predictions"]) == 1
8228
8229 def test_predict_json_horizon_matches_arg(self, predict_repo: pathlib.Path) -> None:
8230 result = runner.invoke(cli, self.CMD + ["--json", "--horizon", "3"])
8231 data = json.loads(result.output)
8232 assert data["horizon_commits"] == 3
8233
8234 def test_predict_json_reasons_is_list(self, predict_repo: pathlib.Path) -> None:
8235 result = runner.invoke(cli, self.CMD + ["--json"])
8236 data = json.loads(result.output)
8237 for pred in data["predictions"]:
8238 assert isinstance(pred["reasons"], list)
8239 assert all(isinstance(r, str) for r in pred["reasons"])
8240
8241 # ── requires repo ─────────────────────────────────────────────────────────
8242
8243 def test_predict_requires_repo(self, tmp_path: pathlib.Path) -> None:
8244 import os
8245
8246 old = os.getcwd()
8247 try:
8248 os.chdir(tmp_path)
8249 result = runner.invoke(cli, self.CMD)
8250 assert result.exit_code != 0
8251 finally:
8252 os.chdir(old)
8253
8254
8255 # ---------------------------------------------------------------------------
8256 # Helpers
8257 # ---------------------------------------------------------------------------
8258
8259
8260 def _all_commit_ids(repo: pathlib.Path) -> list[str]:
8261 """Return all commit IDs from the store, newest-first (by log order)."""
8262 from muse.core.store import get_all_commits
8263 commits = get_all_commits(repo)
8264 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 14 days ago