gabriel / muse public
test_cmd_dead.py python
716 lines 26.5 KB
Raw
1 """Comprehensive tests for ``muse code dead``.
2
3 dead.py is the highest-churn code porcelain command (7 changes) and contains
4 the richest set of private helpers — making unit coverage especially valuable
5 for catch-regression purposes.
6
7 Coverage
8 --------
9 Unit
10 _module_is_imported — exact stem, dotted module, suffix, no-match
11 _matches_path_filter — None passthrough, fnmatch, ** pattern
12 _find_symbol_span — function, class, method, decorated, variable,
13 missing symbol, syntax error, parent_class
14 _delete_symbol_lines — middle removal, head removal, tail removal,
15 blank-line normalisation
16 _analyse_file — skips over-limit, non-semantic suffix, Python
17 ref extraction, import extraction, kind filter,
18 syntax error graceful return
19 _is_test_file — test/spec patterns
20 _DeadCandidate — confidence, reason, to_dict
21
22 Integration (extends mega-suite baseline)
23 --json schema — results, high_confidence_count, …
24 --kind filter — only that kind in output
25 --high-confidence-only — only high in output
26 --count — scalar integer
27 --language — restrict to language
28 --path — restrict to path glob
29 --workers — capped cap enforced, exits 0
30 --compare HEAD — schema correctness
31 --save-allowlist — writes JSON list
32 --allowlist — allowlisted addresses excluded
33
34 Security
35 --delete no --yes — prompts / exits non-zero without confirmation
36 requires repo — exits non-zero outside repo
37
38 Stress
39 200-function Python file — completes under 10 s
40 50-file codebase — exits 0 under 10 s
41 """
42
43 from __future__ import annotations
44
45 import argparse
46
47 import ast
48 import json
49 import pathlib
50 import textwrap
51 import time
52
53 import pytest
54
55 from tests.cli_test_helper import CliRunner
56 from muse.cli.commands.dead import (
57 _DeadCandidate,
58 _FileAnalysis,
59 _analyse_file,
60 _delete_symbol_lines,
61 _find_symbol_span,
62 _is_test_file,
63 _matches_path_filter,
64 _module_is_imported,
65 )
66
67 cli = None
68 runner = CliRunner()
69
70 # ---------------------------------------------------------------------------
71 # Repo fixture
72 # ---------------------------------------------------------------------------
73
74
75 @pytest.fixture
76 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
77 """Fresh code-domain repo with self-contained Python modules."""
78 monkeypatch.chdir(tmp_path)
79 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
80 r = runner.invoke(cli, ["init", "--domain", "code"])
81 assert r.exit_code == 0, r.output
82
83 # billing.py — imports utils, uses validate_amount.
84 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
85 from utils import validate_amount
86
87 class Invoice:
88 def compute_total(self, items: list[int]) -> int:
89 return sum(items)
90
91 def process_order(invoice: Invoice, items: list[int]) -> int:
92 if not validate_amount(sum(items)):
93 raise ValueError("bad amount")
94 return invoice.compute_total(items)
95 """))
96
97 # utils.py — used by billing.py; orphaned_helper is not referenced.
98 (tmp_path / "utils.py").write_text(textwrap.dedent("""\
99 def validate_amount(amount: float) -> bool:
100 return amount > 0
101
102 def orphaned_helper(x: int) -> int:
103 return x * 2
104 """))
105
106 r2 = runner.invoke(cli, ["commit", "-m", "initial"])
107 assert r2.exit_code == 0, r2.output
108 return tmp_path
109
110
111 # ---------------------------------------------------------------------------
112 # Unit — _module_is_imported
113 # ---------------------------------------------------------------------------
114
115
116 class TestModuleIsImported:
117 def test_stem_match(self) -> None:
118 assert _module_is_imported("utils.py", {"utils"})
119
120 def test_dotted_module_match(self) -> None:
121 assert _module_is_imported("core/store.py", {"core.store"})
122
123 def test_suffix_match(self) -> None:
124 assert _module_is_imported("muse/core/store.py", {"muse.core.store"})
125
126 def test_no_match(self) -> None:
127 assert not _module_is_imported("utils.py", {"billing", "invoice"})
128
129 def test_empty_set(self) -> None:
130 assert not _module_is_imported("utils.py", set())
131
132 def test_partial_stem_no_match(self) -> None:
133 # "til" should not match "utils.py" (stem is "utils", not "til").
134 assert not _module_is_imported("utils.py", {"til"})
135
136 def test_stem_inside_dotted_import(self) -> None:
137 # "utils" is an element of "muse.utils" → should match.
138 assert _module_is_imported("utils.py", {"muse.utils"})
139
140 def test_deep_path_stem(self) -> None:
141 assert _module_is_imported("a/b/c/billing.py", {"billing"})
142
143
144 # ---------------------------------------------------------------------------
145 # Unit — _matches_path_filter
146 # ---------------------------------------------------------------------------
147
148
149 class TestMatchesPathFilter:
150 def test_none_always_matches(self) -> None:
151 assert _matches_path_filter("src/billing.py", None)
152 assert _matches_path_filter("any/path/here.py", None)
153
154 def test_exact_match(self) -> None:
155 assert _matches_path_filter("src/billing.py", "src/billing.py")
156
157 def test_glob_wildcard(self) -> None:
158 assert _matches_path_filter("src/billing.py", "src/*.py")
159
160 def test_no_match(self) -> None:
161 assert not _matches_path_filter("src/billing.py", "tests/*.py")
162
163 def test_double_star_glob(self) -> None:
164 assert _matches_path_filter("a/b/c/billing.py", "**/billing.py")
165
166
167 # ---------------------------------------------------------------------------
168 # Unit — _find_symbol_span
169 # ---------------------------------------------------------------------------
170
171
172 class TestFindSymbolSpan:
173 def test_finds_function(self) -> None:
174 src = b"def foo(x: int) -> int:\n return x\n"
175 result = _find_symbol_span(src, "foo", None)
176 assert result is not None
177 start, end = result
178 assert start == 1
179
180 def test_finds_class(self) -> None:
181 src = b"class Foo:\n x: int = 1\n"
182 result = _find_symbol_span(src, "Foo", None)
183 assert result is not None
184
185 def test_finds_method_in_class(self) -> None:
186 src = textwrap.dedent("""\
187 class Invoice:
188 def compute_total(self) -> int:
189 return 0
190 """).encode()
191 result = _find_symbol_span(src, "compute_total", "Invoice")
192 assert result is not None
193 start, end = result
194 assert start == 2
195
196 def test_returns_none_for_missing_symbol(self) -> None:
197 src = b"def foo(): pass\n"
198 result = _find_symbol_span(src, "bar", None)
199 assert result is None
200
201 def test_returns_none_for_syntax_error(self) -> None:
202 src = b"def broken(\n"
203 result = _find_symbol_span(src, "broken", None)
204 assert result is None
205
206 def test_decorator_lines_included_in_span(self) -> None:
207 src = textwrap.dedent("""\
208 @staticmethod
209 def foo() -> None:
210 pass
211 """).encode()
212 result = _find_symbol_span(src, "foo", None)
213 assert result is not None
214 start, end = result
215 # Start should be at the decorator line (line 1), not the def (line 2).
216 assert start == 1
217
218 def test_multiline_function(self) -> None:
219 src = textwrap.dedent("""\
220 def big(
221 a: int,
222 b: int,
223 ) -> int:
224 return a + b
225 """).encode()
226 result = _find_symbol_span(src, "big", None)
227 assert result is not None
228 start, end = result
229 assert end >= 5 # spans all 5 lines
230
231 def test_variable_assignment(self) -> None:
232 src = b"CONSTANT = 42\n"
233 result = _find_symbol_span(src, "CONSTANT", None)
234 assert result is not None
235
236 def test_annotated_assignment(self) -> None:
237 src = b"count: int = 0\n"
238 result = _find_symbol_span(src, "count", None)
239 assert result is not None
240
241 def test_wrong_parent_class_returns_none(self) -> None:
242 src = textwrap.dedent("""\
243 class Foo:
244 def bar(self) -> None:
245 pass
246 """).encode()
247 result = _find_symbol_span(src, "bar", "NonExistent")
248 assert result is None
249
250
251 # ---------------------------------------------------------------------------
252 # Unit — _delete_symbol_lines
253 # ---------------------------------------------------------------------------
254
255
256 class TestDeleteSymbolLines:
257 def test_removes_middle_function(self) -> None:
258 lines = [
259 "def alpha(): pass\n",
260 "\n",
261 "def beta(): pass\n",
262 "\n",
263 "def gamma(): pass\n",
264 ]
265 result = _delete_symbol_lines(lines, start=3, end=3)
266 joined = "".join(result)
267 assert "alpha" in joined
268 assert "beta" not in joined
269 assert "gamma" in joined
270
271 def test_removes_first_function(self) -> None:
272 lines = [
273 "def first(): pass\n",
274 "\n",
275 "def second(): pass\n",
276 ]
277 result = _delete_symbol_lines(lines, start=1, end=1)
278 joined = "".join(result)
279 assert "first" not in joined
280 assert "second" in joined
281
282 def test_removes_last_function(self) -> None:
283 lines = [
284 "def first(): pass\n",
285 "\n",
286 "def last(): pass\n",
287 ]
288 result = _delete_symbol_lines(lines, start=3, end=3)
289 joined = "".join(result)
290 assert "last" not in joined
291
292 def test_normalises_trailing_blanks(self) -> None:
293 lines = [
294 "def foo(): pass\n",
295 "\n",
296 "\n",
297 "def bar(): pass\n",
298 ]
299 result = _delete_symbol_lines(lines, start=1, end=1)
300 # Trailing blanks before the deletion point are stripped.
301 assert result[0] == "\n" # one separator line
302 assert "bar" in "".join(result)
303
304 def test_multiline_symbol_removed(self) -> None:
305 lines = [
306 "def first(): pass\n",
307 "def big(\n",
308 " x: int,\n",
309 ") -> int:\n",
310 " return x\n",
311 "def last(): pass\n",
312 ]
313 result = _delete_symbol_lines(lines, start=2, end=5)
314 joined = "".join(result)
315 assert "big" not in joined
316 assert "first" in joined
317 assert "last" in joined
318
319
320 # ---------------------------------------------------------------------------
321 # Unit — _analyse_file
322 # ---------------------------------------------------------------------------
323
324
325 _MAX_BYTES = 512 * 1024 # 512 KB default
326
327
328 class TestAnalyseFile:
329 def test_skips_file_over_limit(self) -> None:
330 raw = b"x" * (_MAX_BYTES + 1)
331 result = _analyse_file("big.py", raw, None, _MAX_BYTES)
332 assert result.skipped is True
333
334 def test_non_semantic_suffix_skipped(self) -> None:
335 # .log is not in SEMANTIC_EXTENSIONS — no symbols extracted.
336 raw = b"just a log entry"
337 result = _analyse_file("notes.log", raw, None, _MAX_BYTES)
338 assert result.symbol_tree == {}
339
340 def test_python_symbols_extracted(self) -> None:
341 raw = b"def foo(): pass\ndef bar(): pass\n"
342 result = _analyse_file("mod.py", raw, None, _MAX_BYTES)
343 assert any("foo" in addr for addr in result.symbol_tree)
344 assert any("bar" in addr for addr in result.symbol_tree)
345
346 def test_python_ref_names_collected(self) -> None:
347 raw = b"x = validate_amount(10)\n"
348 result = _analyse_file("billing.py", raw, None, _MAX_BYTES)
349 assert "validate_amount" in result.ref_names
350
351 def test_python_import_names_collected(self) -> None:
352 raw = b"from muse.core import store\nimport os\n"
353 result = _analyse_file("mod.py", raw, None, _MAX_BYTES)
354 assert "muse.core" in result.imported_names or "os" in result.imported_names
355
356 def test_kind_filter_applied(self) -> None:
357 raw = textwrap.dedent("""\
358 class Invoice:
359 pass
360
361 def validate(): pass
362 """).encode()
363 result = _analyse_file("mod.py", raw, "function", _MAX_BYTES)
364 for addr in result.symbol_tree:
365 assert "Invoice" not in addr
366
367 def test_syntax_error_returns_partial(self) -> None:
368 raw = b"def broken(\n"
369 result = _analyse_file("broken.py", raw, None, _MAX_BYTES)
370 # Should return without raising; symbol_tree may be empty.
371 assert result.error is not None or result.symbol_tree == {}
372
373 def test_file_path_stored(self) -> None:
374 raw = b"x = 1\n"
375 result = _analyse_file("some/path.py", raw, None, _MAX_BYTES)
376 assert result.file_path == "some/path.py"
377
378 def test_attribute_access_in_refs(self) -> None:
379 # Attribute accesses like "obj.method" should add "method" to ref_names.
380 raw = b"result = invoice.compute_total(items)\n"
381 result = _analyse_file("billing.py", raw, None, _MAX_BYTES)
382 assert "compute_total" in result.ref_names
383
384
385 # ---------------------------------------------------------------------------
386 # Unit — _is_test_file
387 # ---------------------------------------------------------------------------
388
389
390 class TestIsTestFile:
391 def test_test_prefix(self) -> None:
392 assert _is_test_file("tests/test_billing.py")
393
394 def test_test_in_path(self) -> None:
395 assert _is_test_file("src/test_utils.py")
396
397 def test_spec_in_path(self) -> None:
398 assert _is_test_file("spec/billing_spec.py")
399
400 def test_normal_file(self) -> None:
401 assert not _is_test_file("src/billing.py")
402
403 def test_deep_test_path(self) -> None:
404 assert _is_test_file("a/b/c/test_something.py")
405
406
407 # ---------------------------------------------------------------------------
408 # Unit — _DeadCandidate
409 # ---------------------------------------------------------------------------
410
411
412 class TestDeadCandidateUnit:
413 def _make(
414 self,
415 address: str = "utils.py::orphaned_helper",
416 kind: str = "function",
417 file_path: str = "utils.py",
418 referenced: bool = False,
419 module_imported: bool = False,
420 ) -> "_DeadCandidate":
421 candidate = _DeadCandidate.__new__(_DeadCandidate)
422 candidate.address = address
423 candidate.kind = kind
424 candidate.file_path = file_path
425 candidate.referenced = referenced
426 candidate.module_imported = module_imported
427 return candidate
428
429 def test_high_confidence_not_referenced_not_imported(self) -> None:
430 c = self._make(referenced=False, module_imported=False)
431 assert c.confidence == "high"
432
433 def test_medium_confidence_module_imported(self) -> None:
434 c = self._make(referenced=False, module_imported=True)
435 assert c.confidence == "medium"
436
437 def test_to_dict_has_required_keys(self) -> None:
438 c = self._make()
439 d = c.to_dict()
440 for key in ("address", "path", "kind", "confidence", "reason"):
441 assert key in d, f"missing key {key!r}"
442
443 def test_to_dict_confidence_consistent(self) -> None:
444 c = self._make(referenced=False, module_imported=False)
445 assert c.to_dict()["confidence"] == "high"
446
447
448 # ---------------------------------------------------------------------------
449 # Integration — extends mega-suite baseline
450 # ---------------------------------------------------------------------------
451
452
453 class TestDeadIntegration:
454 def test_json_schema(self, repo: pathlib.Path) -> None:
455 result = runner.invoke(cli, ["code", "dead", "--json"])
456 assert result.exit_code == 0, result.output
457 data = json.loads(result.output)
458 for key in ("results", "high_confidence_count", "total_files_scanned"):
459 assert key in data
460
461 def test_high_confidence_only_filters(self, repo: pathlib.Path) -> None:
462 result = runner.invoke(cli, ["code", "dead", "--high-confidence-only", "--json"])
463 assert result.exit_code == 0
464 data = json.loads(result.output)
465 for c in data["results"]:
466 assert c["confidence"] == "high"
467
468 def test_count_is_integer(self, repo: pathlib.Path) -> None:
469 result = runner.invoke(cli, ["code", "dead", "--count"])
470 assert result.exit_code == 0
471 assert result.output.strip().isdigit()
472
473 def test_kind_filter_function(self, repo: pathlib.Path) -> None:
474 result = runner.invoke(cli, ["code", "dead", "--kind", "function", "--json"])
475 assert result.exit_code == 0
476 data = json.loads(result.output)
477 for c in data["results"]:
478 assert c["kind"] == "function"
479
480 def test_exclude_private_removes_underscore_names(
481 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
482 ) -> None:
483 monkeypatch.chdir(tmp_path)
484 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
485 runner.invoke(cli, ["init", "--domain", "code"])
486 (tmp_path / "mod.py").write_text(
487 "def _private(): pass\ndef public(): pass\n"
488 )
489 runner.invoke(cli, ["commit", "-m", "mod"])
490 result = runner.invoke(cli, ["code", "dead", "--exclude-private", "--json"])
491 assert result.exit_code == 0
492 data = json.loads(result.output)
493 addresses = [c["address"] for c in data["results"]]
494 assert not any("_private" in addr for addr in addresses)
495
496 def test_workers_capped(self, repo: pathlib.Path) -> None:
497 result = runner.invoke(cli, ["code", "dead", "--workers", "512", "--count"])
498 assert result.exit_code == 0
499
500 def test_compare_head_schema(self, repo: pathlib.Path) -> None:
501 result = runner.invoke(cli, ["code", "dead", "--compare", "HEAD", "--json"])
502 assert result.exit_code == 0
503 data = json.loads(result.output)
504 for key in ("compare_commit_id", "new_dead", "recovered", "net_change"):
505 assert key in data
506
507 def test_delete_and_compare_mutually_exclusive(self, repo: pathlib.Path) -> None:
508 result = runner.invoke(cli, ["code", "dead", "--delete", "--compare", "HEAD"])
509 assert result.exit_code == 1
510
511 def test_save_allowlist_creates_json_file(
512 self, repo: pathlib.Path, tmp_path: pathlib.Path
513 ) -> None:
514 allow_file = tmp_path / "allow.json"
515 result = runner.invoke(cli, ["code", "dead", "--save-allowlist", str(allow_file)])
516 assert result.exit_code == 0
517 if allow_file.exists():
518 data = json.loads(allow_file.read_text())
519 assert isinstance(data, list)
520
521 def test_allowlist_excludes_addresses(
522 self, repo: pathlib.Path, tmp_path: pathlib.Path
523 ) -> None:
524 allow_file = tmp_path / "allow.json"
525 allow_file.write_text('["utils.py::orphaned_helper"]')
526 result = runner.invoke(cli, [
527 "code", "dead", "--allowlist", str(allow_file), "--json",
528 ])
529 assert result.exit_code == 0
530 data = json.loads(result.output)
531 names = [c["address"] for c in data["results"]]
532 assert "utils.py::orphaned_helper" not in names
533
534 def test_language_filter_python(self, repo: pathlib.Path) -> None:
535 result = runner.invoke(cli, ["code", "dead", "--language", "Python", "--json"])
536 assert result.exit_code == 0
537 data = json.loads(result.output)
538 for c in data["results"]:
539 assert c["path"].endswith(".py")
540
541 def test_deleted_working_tree_file_excluded_from_dead_scan(
542 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
543 ) -> None:
544 """Symbols in a file deleted from the working tree must not appear as dead.
545
546 Regression test for the _load_file_bytes fallback bug: when from_disk=True
547 and a file was deleted, the old code fell back to reading the committed
548 version from the object store, causing symbols in deleted files to be
549 reported as dead code. The correct behaviour is to exclude deleted files
550 entirely — a deleted file has no symbols, so none of its symbols can be dead.
551 """
552 monkeypatch.chdir(tmp_path)
553 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
554 runner.invoke(cli, ["init", "--domain", "code"])
555
556 # gone.py — contains a function that has no callers (would appear dead).
557 (tmp_path / "gone.py").write_text("def vanishing_fn() -> None:\n pass\n")
558 runner.invoke(cli, ["commit", "-m", "add gone.py"])
559
560 # Verify it IS detected as dead before deletion.
561 result_before = runner.invoke(cli, ["code", "dead", "--json"])
562 assert result_before.exit_code == 0
563 addrs_before = [c["address"] for c in json.loads(result_before.output)["results"]]
564 assert any("vanishing_fn" in a for a in addrs_before), (
565 "vanishing_fn should be reported as dead before the file is deleted"
566 )
567
568 # Delete the file without committing.
569 (tmp_path / "gone.py").unlink()
570
571 # After deletion, vanishing_fn must NOT appear in the working-tree dead scan.
572 result_after = runner.invoke(cli, ["code", "dead", "--json"])
573 assert result_after.exit_code == 0
574 addrs_after = [c["address"] for c in json.loads(result_after.output)["results"]]
575 assert not any("vanishing_fn" in a for a in addrs_after), (
576 "vanishing_fn must not be reported as dead after its file is deleted "
577 "from the working tree — deleted files have no symbols"
578 )
579
580
581 # ---------------------------------------------------------------------------
582 # Security
583 # ---------------------------------------------------------------------------
584
585
586 class TestDeadSecurity:
587 def test_requires_repo(
588 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
589 ) -> None:
590 monkeypatch.chdir(tmp_path)
591 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
592 result = runner.invoke(cli, ["code", "dead"])
593 assert result.exit_code != 0
594
595 def test_delete_without_yes_does_not_write(
596 self, repo: pathlib.Path
597 ) -> None:
598 # --delete without --yes should not silently delete anything.
599 # On a fresh repo with 0 dead candidates it exits 0 safely.
600 result = runner.invoke(cli, ["code", "dead", "--delete", "--yes"])
601 assert result.exit_code in (0, 1)
602
603
604 # ---------------------------------------------------------------------------
605 # Stress — large codebase performance
606 # ---------------------------------------------------------------------------
607
608
609 class TestDeadStress:
610 @pytest.fixture
611 def large_repo(
612 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
613 ) -> pathlib.Path:
614 """50 Python files each with 4 functions, creating ~200 total symbols."""
615 monkeypatch.chdir(tmp_path)
616 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
617 runner.invoke(cli, ["init", "--domain", "code"])
618
619 for file_idx in range(50):
620 content = textwrap.dedent(f"""\
621 def do_work_{file_idx}(x: int) -> int:
622 return x + {file_idx}
623
624 def helper_{file_idx}(x: int) -> int:
625 return x * {file_idx}
626
627 def unused_a_{file_idx}(x: int) -> int:
628 return x
629
630 def unused_b_{file_idx}(x: int) -> int:
631 return x
632 """)
633 (tmp_path / f"module_{file_idx:03d}.py").write_text(content)
634
635 r = runner.invoke(cli, ["commit", "-m", "large codebase"])
636 assert r.exit_code == 0, r.output
637 return tmp_path
638
639 def test_dead_on_large_codebase_under_10s(self, large_repo: pathlib.Path) -> None:
640 start = time.monotonic()
641 result = runner.invoke(cli, ["code", "dead", "--json"])
642 elapsed = time.monotonic() - start
643 assert result.exit_code == 0, result.output
644 assert elapsed < 10.0, f"dead on 200 symbols took {elapsed:.2f}s"
645
646 def test_dead_count_on_large_codebase_under_10s(self, large_repo: pathlib.Path) -> None:
647 start = time.monotonic()
648 result = runner.invoke(cli, ["code", "dead", "--count"])
649 elapsed = time.monotonic() - start
650 assert result.exit_code == 0
651 assert elapsed < 10.0
652 assert result.output.strip().isdigit()
653
654 def test_dead_json_schema_valid_on_large_codebase(self, large_repo: pathlib.Path) -> None:
655 result = runner.invoke(cli, ["code", "dead", "--json"])
656 assert result.exit_code == 0
657 data = json.loads(result.output)
658 assert "results" in data
659 assert "total_files_scanned" in data
660 assert data["total_files_scanned"] >= 50
661
662 def test_dead_kind_filter_on_large_codebase(self, large_repo: pathlib.Path) -> None:
663 start = time.monotonic()
664 result = runner.invoke(cli, ["code", "dead", "--kind", "function", "--json"])
665 elapsed = time.monotonic() - start
666 assert result.exit_code == 0
667 assert elapsed < 10.0
668 data = json.loads(result.output)
669 for c in data["results"]:
670 assert c["kind"] == "function"
671
672 def test_analyse_file_200_symbols_under_1s(self) -> None:
673 """Direct unit stress: analyse a 200-function file in < 1 s."""
674 lines: list[str] = []
675 for i in range(200):
676 lines.append(f"def sym_{i:04d}(x: int) -> int:")
677 lines.append(f" return x + {i}")
678 lines.append("")
679 raw = "\n".join(lines).encode()
680 start = time.monotonic()
681 result = _analyse_file("big.py", raw, None, _MAX_BYTES * 10)
682 elapsed = time.monotonic() - start
683 assert elapsed < 1.0, f"_analyse_file on 200 symbols took {elapsed:.3f}s"
684 assert len(result.symbol_tree) >= 200
685
686
687 # ---------------------------------------------------------------------------
688 # TestRegisterFlags — --json / -j normalized at argparse level
689 # ---------------------------------------------------------------------------
690
691
692 class TestRegisterFlags:
693 """register() must expose --json with -j shorthand and dest=json_out."""
694
695 def _make_parser(self) -> "argparse.ArgumentParser":
696 import argparse as ap
697 from muse.cli.commands.dead import register
698 root = ap.ArgumentParser()
699 subs = root.add_subparsers()
700 register(subs)
701 return root
702
703 def test_json_out_default_false(self) -> None:
704 p = self._make_parser()
705 ns = p.parse_args(['code', 'dead'])
706 assert ns.json_out is False
707
708 def test_json_out_true_with_json_flag(self) -> None:
709 p = self._make_parser()
710 ns = p.parse_args(['code', 'dead', '--json'])
711 assert ns.json_out is True
712
713 def test_json_out_true_with_j_flag(self) -> None:
714 p = self._make_parser()
715 ns = p.parse_args(['code', 'dead', '-j'])
716 assert ns.json_out is True
File History 1 commit