gabriel / muse public
test_cmd_codemap.py python
900 lines 32.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """Tests for ``muse code codemap``.
2
3 Coverage layers
4 ---------------
5 Unit
6 _build_import_graph — edge deduplication, self-loop exclusion, empty maps.
7 _find_cycles — empty graph, linear chain, simple cycle, multi-cycle,
8 overlapping cycles, self-loop, deep chain (no stack
9 overflow), disconnected components, O(1) index lookup.
10
11 Integration (live repo via CliRunner)
12 Exits zero for valid invocations.
13 JSON schema: all required top-level keys present.
14 JSON schema: new fields — ``branch``, ``agent_safe_zones``.
15 ``--top`` flag limits each ranked section.
16 ``--top 0`` and ``--top -1`` are rejected with a non-zero exit.
17 ``--min-importers`` filters ranked module list correctly.
18 ``--language`` filter restricts analysis to the named language.
19 ``--language`` with no match exits zero but emits empty modules list.
20 ``--commit REF`` analyses a historical snapshot.
21 Text output contains all expected section headers.
22 No-repo invocation exits non-zero.
23 Empty repo (no commits yet) exits non-zero or returns gracefully.
24
25 E2E (real cross-file import cycles in a live repo)
26 Cycle is detected when two Python files import each other.
27 No false-positive cycle when imports are acyclic.
28 Agent-safe zone reported for a fully isolated file.
29
30 Stress
31 1 000-node linear chain: completes without RecursionError.
32 500-node graph with 50 embedded 3-cycles: all cycles found, no crash.
33 Repeated runs produce identical output (determinism).
34 Large sym_map with duplicate import records: edges deduplicated.
35 """
36
37 from __future__ import annotations
38
39 import json
40 import pathlib
41 import textwrap
42 import time
43 from typing import TypedDict
44
45 import pytest
46
47 from tests.cli_test_helper import CliRunner
48
49 from muse.cli.commands.codemap import _build_import_graph, _find_cycles
50 from muse.plugins.code.ast_parser import SymbolTree, SymbolRecord
51
52 type _SymbolMap = dict[str, SymbolTree]
53 type _AdjacencyMap = dict[str, list[str]]
54
55 cli = None # argparse migration — CliRunner ignores this arg
56
57 runner = CliRunner()
58
59
60 # ---------------------------------------------------------------------------
61 # Typed payload for JSON assertions — mirrors the codemap JSON schema.
62 # ---------------------------------------------------------------------------
63
64
65 class _ModuleEntry(TypedDict):
66 file: str
67 symbol_count: int
68 importers: int
69 imports: int
70
71
72 class _CentralityEntry(TypedDict):
73 name: str
74 callers: int
75
76
77 class _BoundaryEntry(TypedDict):
78 file: str
79 fan_out: int
80 fan_in: int
81
82
83 class _CodemapPayload(TypedDict):
84 schema_version: str
85 commit: str
86 branch: str
87 language_filter: str | None
88 modules: list[_ModuleEntry]
89 import_cycles: list[list[str]]
90 high_centrality: list[_CentralityEntry]
91 boundary_files: list[_BoundaryEntry]
92 agent_safe_zones: list[str]
93
94
95 # ---------------------------------------------------------------------------
96 # Fixtures
97 # ---------------------------------------------------------------------------
98
99
100 @pytest.fixture
101 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
102 """Fresh code-domain Muse repo, no commits."""
103 monkeypatch.chdir(tmp_path)
104 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
105 result = runner.invoke(cli, ["init", "--domain", "code"])
106 assert result.exit_code == 0, result.output
107 return tmp_path
108
109
110 @pytest.fixture
111 def code_repo(repo: pathlib.Path) -> pathlib.Path:
112 """Repo with two Python commits — same fixture shape as other code tests."""
113 work = repo
114 (work / "billing.py").write_text(textwrap.dedent("""\
115 class Invoice:
116 def compute_total(self, items):
117 return sum(items)
118
119 def apply_discount(self, total, pct):
120 return total * (1 - pct)
121
122 def process_order(invoice, items):
123 return invoice.compute_total(items)
124 """))
125 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
126 assert r.exit_code == 0, r.output
127
128 (work / "billing.py").write_text(textwrap.dedent("""\
129 class Invoice:
130 def compute_invoice_total(self, items):
131 return sum(items)
132
133 def apply_discount(self, total, pct):
134 return total * (1 - pct)
135
136 def generate_pdf(self):
137 return b"pdf"
138
139 def process_order(invoice, items):
140 return invoice.compute_invoice_total(items)
141
142 def send_email(address):
143 pass
144 """))
145 r = runner.invoke(cli, ["commit", "-m", "Rename + add generate_pdf, send_email"])
146 assert r.exit_code == 0, r.output
147 return repo
148
149
150 @pytest.fixture
151 def multi_file_repo(repo: pathlib.Path) -> pathlib.Path:
152 """Repo with multiple files to exercise import-graph and cycle detection."""
153 work = repo
154
155 (work / "utils.py").write_text(textwrap.dedent("""\
156 def helper():
157 pass
158 """))
159 (work / "models.py").write_text(textwrap.dedent("""\
160 import utils
161
162 class User:
163 pass
164 """))
165 (work / "api.py").write_text(textwrap.dedent("""\
166 import models
167 import utils
168
169 def handle():
170 pass
171 """))
172 (work / "standalone.py").write_text(textwrap.dedent("""\
173 def isolated():
174 pass
175 """))
176 r = runner.invoke(cli, ["commit", "-m", "Multi-file layout"])
177 assert r.exit_code == 0, r.output
178 return repo
179
180
181 @pytest.fixture
182 def cycle_repo(repo: pathlib.Path) -> pathlib.Path:
183 """Repo with a deliberate circular import: alpha ↔ beta."""
184 work = repo
185
186 (work / "alpha.py").write_text(textwrap.dedent("""\
187 import beta
188
189 def do_alpha():
190 pass
191 """))
192 (work / "beta.py").write_text(textwrap.dedent("""\
193 import alpha
194
195 def do_beta():
196 pass
197 """))
198 r = runner.invoke(cli, ["commit", "-m", "Introduce alpha-beta cycle"])
199 assert r.exit_code == 0, r.output
200 return repo
201
202
203 # ---------------------------------------------------------------------------
204 # Helpers
205 # ---------------------------------------------------------------------------
206
207
208 def _make_sym_tree(*import_names: str) -> SymbolTree:
209 """Build a minimal SymbolTree matching the real parse_symbols format.
210
211 ``name`` holds the bare module name (e.g. ``"utils"``).
212 ``qualified_name`` uses the ``import::NAME`` format that ``parse_symbols``
213 actually produces.
214 """
215 tree: SymbolTree = {}
216 for name in import_names:
217 rec: SymbolRecord = {
218 "kind": "import",
219 "name": name,
220 "qualified_name": f"import::{name}",
221 "lineno": 1,
222 "end_lineno": 1,
223 "content_id": "",
224 "body_hash": "",
225 "signature_id": "",
226 "metadata_id": "",
227 "canonical_key": "",
228 }
229 tree[f"import::{name}"] = rec
230 return tree
231
232
233 def _codemap_json(args: list[str] | None = None) -> _CodemapPayload:
234 """Invoke codemap --json and return the typed payload."""
235 cmd = ["code", "codemap", "--json"] + (args or [])
236 result = runner.invoke(cli, cmd)
237 assert result.exit_code == 0, result.output
238 raw: _CodemapPayload = json.loads(result.output)
239 return raw
240
241
242 # ---------------------------------------------------------------------------
243 # Unit — _build_import_graph
244 # ---------------------------------------------------------------------------
245
246
247 class TestBuildImportGraph:
248 def test_empty_sym_map_returns_empty_dicts(self) -> None:
249 sym_map: _SymbolMap = {}
250 imports_out, in_degree = _build_import_graph(sym_map)
251 assert imports_out == {}
252 assert in_degree == {}
253
254 def test_single_file_no_imports(self) -> None:
255 sym_map: _SymbolMap = {"src/a.py": {}}
256 imports_out, in_degree = _build_import_graph(sym_map)
257 assert imports_out == {"src/a.py": []}
258 assert in_degree == {"src/a.py": 0}
259
260 def test_simple_import_edge(self) -> None:
261 sym_map: _SymbolMap = {
262 "src/a.py": _make_sym_tree("b"),
263 "src/b.py": {},
264 }
265 imports_out, in_degree = _build_import_graph(sym_map)
266 assert "src/b.py" in imports_out["src/a.py"]
267 assert in_degree["src/b.py"] == 1
268 assert in_degree["src/a.py"] == 0
269
270 def test_self_loop_excluded(self) -> None:
271 """A file importing its own stem must not create a self-edge."""
272 sym_map: _SymbolMap = {
273 "src/a.py": _make_sym_tree("a"),
274 }
275 imports_out, in_degree = _build_import_graph(sym_map)
276 assert imports_out["src/a.py"] == []
277
278 def test_duplicate_import_records_produce_single_edge(self) -> None:
279 """Multiple import records for the same target count as one edge."""
280 tree: SymbolTree = {}
281 for i in range(5):
282 rec: SymbolRecord = {
283 "kind": "import",
284 "name": "b", # same bare module name — all edges to src/b.py
285 "qualified_name": f"import::b_alias_{i}",
286 "lineno": i + 1,
287 "end_lineno": i + 1,
288 "content_id": "",
289 "body_hash": "",
290 "signature_id": "",
291 "metadata_id": "",
292 "canonical_key": "",
293 }
294 tree[f"import::b_alias_{i}"] = rec
295
296 sym_map: _SymbolMap = {
297 "src/a.py": tree,
298 "src/b.py": {},
299 }
300 imports_out, in_degree = _build_import_graph(sym_map)
301 assert imports_out["src/a.py"].count("src/b.py") == 1
302 assert in_degree["src/b.py"] == 1
303
304 def test_unknown_import_ignored(self) -> None:
305 """Imports with no matching stem in the map are silently skipped."""
306 sym_map: _SymbolMap = {
307 "src/a.py": _make_sym_tree("nonexistent_module"),
308 }
309 imports_out, in_degree = _build_import_graph(sym_map)
310 assert imports_out["src/a.py"] == []
311
312 def test_non_import_records_ignored(self) -> None:
313 tree: SymbolTree = {}
314 fn_rec: SymbolRecord = {
315 "kind": "function",
316 "name": "b",
317 "qualified_name": "b",
318 "lineno": 1,
319 "end_lineno": 3,
320 "content_id": "",
321 "body_hash": "",
322 "signature_id": "",
323 "metadata_id": "",
324 "canonical_key": "",
325 }
326 tree["function::b"] = fn_rec
327
328 sym_map: _SymbolMap = {
329 "src/a.py": tree,
330 "src/b.py": {},
331 }
332 imports_out, _ = _build_import_graph(sym_map)
333 assert imports_out["src/a.py"] == []
334
335 def test_fan_out_multiple_targets(self) -> None:
336 sym_map: _SymbolMap = {
337 "src/a.py": _make_sym_tree("b", "c", "d"),
338 "src/b.py": {},
339 "src/c.py": {},
340 "src/d.py": {},
341 }
342 imports_out, in_degree = _build_import_graph(sym_map)
343 assert len(imports_out["src/a.py"]) == 3
344 assert in_degree["src/b.py"] == 1
345 assert in_degree["src/c.py"] == 1
346 assert in_degree["src/d.py"] == 1
347
348 def test_fan_in_multiple_importers(self) -> None:
349 sym_map: _SymbolMap = {
350 "src/a.py": _make_sym_tree("c"),
351 "src/b.py": _make_sym_tree("c"),
352 "src/c.py": {},
353 }
354 imports_out, in_degree = _build_import_graph(sym_map)
355 assert in_degree["src/c.py"] == 2
356
357
358 # ---------------------------------------------------------------------------
359 # Unit — _find_cycles
360 # ---------------------------------------------------------------------------
361
362
363 class TestFindCycles:
364 def test_empty_graph(self) -> None:
365 assert _find_cycles({}) == []
366
367 def test_single_node_no_edges(self) -> None:
368 assert _find_cycles({"A": []}) == []
369
370 def test_linear_chain_no_cycle(self) -> None:
371 g: _AdjacencyMap = {"A": ["B"], "B": ["C"], "C": []}
372 assert _find_cycles(g) == []
373
374 def test_self_loop(self) -> None:
375 g: _AdjacencyMap = {"A": ["A"]}
376 cycles = _find_cycles(g)
377 assert len(cycles) == 1
378 assert cycles[0][0] == cycles[0][-1] == "A"
379
380 def test_simple_two_node_cycle(self) -> None:
381 g: _AdjacencyMap = {"A": ["B"], "B": ["A"]}
382 cycles = _find_cycles(g)
383 assert any("A" in c and "B" in c for c in cycles)
384
385 def test_three_node_cycle(self) -> None:
386 g: _AdjacencyMap = {"A": ["B"], "B": ["C"], "C": ["A"]}
387 cycles = _find_cycles(g)
388 assert len(cycles) >= 1
389 cycle = cycles[0]
390 assert cycle[0] == cycle[-1]
391 assert len(cycle) == 4 # A→B→C→A
392
393 def test_two_independent_cycles(self) -> None:
394 g: _AdjacencyMap = {
395 "A": ["B"], "B": ["A"], # cycle 1
396 "C": ["D"], "D": ["C"], # cycle 2
397 }
398 cycles = _find_cycles(g)
399 assert len(cycles) == 2
400
401 def test_overlapping_cycles_shared_node(self) -> None:
402 """Node B participates in both A→B→A and B→C→B."""
403 g: _AdjacencyMap = {
404 "A": ["B"],
405 "B": ["A", "C"],
406 "C": ["B"],
407 }
408 cycles = _find_cycles(g)
409 assert len(cycles) >= 2
410
411 def test_disconnected_graph_with_cycle_in_one_component(self) -> None:
412 g: _AdjacencyMap = {
413 "X": ["Y"], "Y": [], # acyclic component
414 "A": ["B"], "B": ["A"], # cyclic component
415 }
416 cycles = _find_cycles(g)
417 assert len(cycles) == 1
418
419 def test_cycle_path_forms_closed_ring(self) -> None:
420 g: _AdjacencyMap = {"A": ["B"], "B": ["C"], "C": ["A"]}
421 cycles = _find_cycles(g)
422 for cycle in cycles:
423 assert cycle[0] == cycle[-1], "cycle path must start and end at the same node"
424
425 def test_deep_linear_chain_no_recursion_error(self) -> None:
426 depth = 1_000 # well beyond Python's default recursion limit
427 nodes = [f"mod_{i}" for i in range(depth)]
428 g: _AdjacencyMap = {nodes[i]: [nodes[i + 1]] for i in range(depth - 1)}
429 g[nodes[-1]] = []
430 cycles = _find_cycles(g)
431 assert cycles == []
432
433 def test_deep_cycle_at_end_of_long_chain(self) -> None:
434 depth = 500
435 nodes = [f"mod_{i}" for i in range(depth)]
436 g: _AdjacencyMap = {nodes[i]: [nodes[i + 1]] for i in range(depth - 1)}
437 g[nodes[-1]] = [nodes[-2]] # last two form a cycle
438 cycles = _find_cycles(g)
439 assert any(nodes[-1] in c or nodes[-2] in c for c in cycles)
440
441 def test_no_duplicate_cycles_for_same_back_edge(self) -> None:
442 g: _AdjacencyMap = {"A": ["B"], "B": ["A"]}
443 cycles = _find_cycles(g)
444 # There should be exactly one detected cycle for a simple two-node ring.
445 assert len(cycles) == 1
446
447 def test_fully_connected_triangle(self) -> None:
448 g: _AdjacencyMap = {"A": ["B", "C"], "B": ["A", "C"], "C": ["A", "B"]}
449 cycles = _find_cycles(g)
450 assert len(cycles) >= 1
451
452 def test_index_extraction_is_correct(self) -> None:
453 """Cycle slice starts at the actual back-edge target, not index 0."""
454 g: _AdjacencyMap = {"A": ["B"], "B": ["C"], "C": ["B"]}
455 cycles = _find_cycles(g)
456 # The cycle involves B and C, not A.
457 for cycle in cycles:
458 assert "A" not in cycle, "A is not part of any cycle in this graph"
459
460
461 # ---------------------------------------------------------------------------
462 # Integration — basic CLI invocations
463 # ---------------------------------------------------------------------------
464
465
466 class TestCodemapCLIBasic:
467 def test_exits_zero(self, code_repo: pathlib.Path) -> None:
468 result = runner.invoke(cli, ["code", "codemap"])
469 assert result.exit_code == 0, result.output
470
471 def test_text_output_has_all_sections(self, code_repo: pathlib.Path) -> None:
472 result = runner.invoke(cli, ["code", "codemap"])
473 assert result.exit_code == 0
474 out = result.output
475 assert "Semantic codemap" in out
476 assert "Top modules by size" in out
477 assert "Import cycles" in out
478 assert "High-centrality" in out
479 assert "Boundary files" in out
480 assert "Agent-safe zones" in out
481
482 def test_text_output_contains_commit_hash(self, code_repo: pathlib.Path) -> None:
483 result = runner.invoke(cli, ["code", "codemap"])
484 assert result.exit_code == 0
485 # The commit id is a short_id: either "sha256:<12hex>" or bare 12-char hex.
486 import re
487 assert re.search(r"commit (sha256:[0-9a-f]{12}|[0-9a-f]{12})", result.output)
488
489 def test_no_repo_exits_nonzero(
490 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
491 ) -> None:
492 monkeypatch.chdir(tmp_path)
493 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
494 result = runner.invoke(cli, ["code", "codemap"])
495 assert result.exit_code != 0
496
497
498 # ---------------------------------------------------------------------------
499 # Integration — JSON schema
500 # ---------------------------------------------------------------------------
501
502
503 class TestCodemapJSONSchema:
504 def test_json_exits_zero(self, code_repo: pathlib.Path) -> None:
505 result = runner.invoke(cli, ["code", "codemap", "--json"])
506 assert result.exit_code == 0, result.output
507
508 def test_json_is_valid(self, code_repo: pathlib.Path) -> None:
509 result = runner.invoke(cli, ["code", "codemap", "--json"])
510 assert result.exit_code == 0
511 data = json.loads(result.output)
512 assert isinstance(data, dict)
513
514 def test_json_required_top_level_keys(self, code_repo: pathlib.Path) -> None:
515 data = _codemap_json()
516 required = {
517 "schema",
518 "commit",
519 "branch",
520 "language_filter",
521 "modules",
522 "import_cycles",
523 "high_centrality",
524 "boundary_files",
525 "agent_safe_zones",
526 }
527 assert required <= data.keys()
528
529 def test_json_modules_is_list(self, code_repo: pathlib.Path) -> None:
530 data = _codemap_json()
531 assert isinstance(data["modules"], list)
532
533 def test_json_import_cycles_is_list(self, code_repo: pathlib.Path) -> None:
534 data = _codemap_json()
535 assert isinstance(data["import_cycles"], list)
536
537 def test_json_high_centrality_is_list(self, code_repo: pathlib.Path) -> None:
538 data = _codemap_json()
539 assert isinstance(data["high_centrality"], list)
540
541 def test_json_boundary_files_is_list(self, code_repo: pathlib.Path) -> None:
542 data = _codemap_json()
543 assert isinstance(data["boundary_files"], list)
544
545 def test_json_agent_safe_zones_is_list(self, code_repo: pathlib.Path) -> None:
546 data = _codemap_json()
547 assert isinstance(data["agent_safe_zones"], list)
548
549 def test_json_branch_field_is_string(self, code_repo: pathlib.Path) -> None:
550 data = _codemap_json()
551 assert isinstance(data["branch"], str)
552 assert data["branch"] # non-empty
553
554 def test_json_commit_is_short_id(self, code_repo: pathlib.Path) -> None:
555 data = _codemap_json()
556 commit = data["commit"]
557 assert isinstance(commit, str)
558 # short_id returns "sha256:<12hex>" for sha256-prefixed IDs.
559 assert commit.startswith("sha256:")
560 hex_part = commit[len("sha256:"):]
561 assert len(hex_part) == 12
562 assert all(c in "0123456789abcdef" for c in hex_part)
563
564 def test_json_language_filter_none_when_unset(self, code_repo: pathlib.Path) -> None:
565 data = _codemap_json()
566 assert data["language_filter"] is None
567
568 def test_json_module_entry_has_required_fields(self, code_repo: pathlib.Path) -> None:
569 data = _codemap_json()
570 modules: list[_ModuleEntry] = data["modules"]
571 if modules:
572 entry = modules[0]
573 assert "file" in entry
574 assert "symbol_count" in entry
575 assert "importers" in entry
576 assert "imports" in entry
577
578 def test_json_schema_version_matches_package(self, code_repo: pathlib.Path) -> None:
579 data = _codemap_json()
580 assert isinstance(data["schema"], int)
581 assert data["schema"] > 0
582
583
584 # ---------------------------------------------------------------------------
585 # Integration — --top flag
586 # ---------------------------------------------------------------------------
587
588
589 class TestCodemapTopFlag:
590 def test_top_1_limits_modules_to_1(self, code_repo: pathlib.Path) -> None:
591 data = _codemap_json(["--top", "1"])
592 assert len(data["modules"]) <= 1
593
594 def test_top_3_limits_sections(self, code_repo: pathlib.Path) -> None:
595 data = _codemap_json(["--top", "3"])
596 assert len(data["modules"]) <= 3
597 assert len(data["high_centrality"]) <= 3
598 assert len(data["boundary_files"]) <= 3
599 assert len(data["agent_safe_zones"]) <= 3
600
601 def test_top_zero_exits_nonzero(self, code_repo: pathlib.Path) -> None:
602 result = runner.invoke(cli, ["code", "codemap", "--top", "0"])
603 assert result.exit_code != 0
604
605 def test_top_negative_exits_nonzero(self, code_repo: pathlib.Path) -> None:
606 result = runner.invoke(cli, ["code", "codemap", "--top", "-1"])
607 assert result.exit_code != 0
608
609 def test_top_text_mode_respected(self, code_repo: pathlib.Path) -> None:
610 result = runner.invoke(cli, ["code", "codemap", "--top", "1"])
611 assert result.exit_code == 0
612
613
614 # ---------------------------------------------------------------------------
615 # Integration — --min-importers flag
616 # ---------------------------------------------------------------------------
617
618
619 class TestCodemapMinImporters:
620 def test_min_importers_zero_includes_all(self, multi_file_repo: pathlib.Path) -> None:
621 data_all = _codemap_json(["--min-importers", "0"])
622 data_zero = _codemap_json() # default = 0
623 assert len(data_all["modules"]) == len(data_zero["modules"])
624
625 def test_min_importers_1_excludes_unimported(self, multi_file_repo: pathlib.Path) -> None:
626 data_all = _codemap_json()
627 data_filtered = _codemap_json(["--min-importers", "1"])
628 modules_all: list[_ModuleEntry] = data_all["modules"]
629 modules_filtered: list[_ModuleEntry] = data_filtered["modules"]
630 # Every module in the filtered list must have importers >= 1.
631 for mod in modules_filtered:
632 assert mod["importers"] >= 1
633 # Filtered list cannot be larger than unfiltered.
634 assert len(modules_filtered) <= len(modules_all)
635
636 def test_min_importers_very_large_returns_empty_modules(
637 self, code_repo: pathlib.Path
638 ) -> None:
639 data = _codemap_json(["--min-importers", "9999"])
640 assert data["modules"] == []
641
642 def test_min_importers_negative_exits_nonzero(self, code_repo: pathlib.Path) -> None:
643 result = runner.invoke(cli, ["code", "codemap", "--min-importers", "-1"])
644 assert result.exit_code != 0
645
646 def test_min_importers_label_in_text_output(self, code_repo: pathlib.Path) -> None:
647 result = runner.invoke(cli, ["code", "codemap", "--min-importers", "2"])
648 assert result.exit_code == 0
649 assert "min-importers" in result.output
650
651
652 # ---------------------------------------------------------------------------
653 # Integration — --language flag
654 # ---------------------------------------------------------------------------
655
656
657 class TestCodemapLanguageFlag:
658 def test_language_python_exits_zero(self, code_repo: pathlib.Path) -> None:
659 result = runner.invoke(cli, ["code", "codemap", "--language", "Python"])
660 assert result.exit_code == 0
661
662 def test_language_filter_in_json(self, code_repo: pathlib.Path) -> None:
663 data = _codemap_json(["--language", "Python"])
664 assert data["language_filter"] == "Python"
665
666 def test_language_no_match_exits_zero_empty_modules(
667 self, code_repo: pathlib.Path
668 ) -> None:
669 data = _codemap_json(["--language", "COBOL"])
670 assert data["modules"] == []
671
672 def test_language_text_header_shown(self, code_repo: pathlib.Path) -> None:
673 result = runner.invoke(cli, ["code", "codemap", "--language", "Python"])
674 assert result.exit_code == 0
675 assert "language: Python" in result.output
676
677
678 # ---------------------------------------------------------------------------
679 # Integration — --commit flag
680 # ---------------------------------------------------------------------------
681
682
683 class TestCodemapCommitFlag:
684 def test_commit_head_is_default(self, code_repo: pathlib.Path) -> None:
685 data_head = _codemap_json(["--commit", "HEAD"])
686 data_default = _codemap_json()
687 assert data_head["commit"] == data_default["commit"]
688
689 def test_commit_head_minus_1(self, code_repo: pathlib.Path) -> None:
690 result = runner.invoke(cli, ["code", "codemap", "--commit", "HEAD~1", "--json"])
691 assert result.exit_code == 0
692 data = json.loads(result.output)
693 data_head = _codemap_json()
694 # Historical snapshot commit id must differ from HEAD.
695 assert data["commit"] != data_head["commit"]
696
697 def test_commit_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
698 result = runner.invoke(cli, ["code", "codemap", "--commit", "totally_bogus_ref_xyz"])
699 assert result.exit_code != 0
700
701
702 # ---------------------------------------------------------------------------
703 # Integration — empty repo
704 # ---------------------------------------------------------------------------
705
706
707 class TestCodemapEmptyRepo:
708 def test_no_commits_exits_nonzero(self, repo: pathlib.Path) -> None:
709 """Repo with no commits: HEAD does not resolve — must exit non-zero."""
710 result = runner.invoke(cli, ["code", "codemap"])
711 assert result.exit_code != 0
712
713
714 # ---------------------------------------------------------------------------
715 # E2E — cycle detection
716 # ---------------------------------------------------------------------------
717
718
719 class TestCodemapCycleE2E:
720 def test_circular_import_detected(self, cycle_repo: pathlib.Path) -> None:
721 data = _codemap_json()
722 cycles: list[list[str]] = data["import_cycles"]
723 assert len(cycles) >= 1
724 involved = {node for cycle in cycles for node in cycle}
725 # alpha.py and beta.py should both appear in the cycle paths.
726 assert any("alpha" in node for node in involved)
727 assert any("beta" in node for node in involved)
728
729 def test_acyclic_repo_has_no_cycles(self, multi_file_repo: pathlib.Path) -> None:
730 data = _codemap_json()
731 assert data["import_cycles"] == []
732
733 def test_cycle_paths_are_closed_rings(self, cycle_repo: pathlib.Path) -> None:
734 data = _codemap_json()
735 for cycle in data["import_cycles"]:
736 assert isinstance(cycle, list)
737 assert len(cycle) >= 2
738 assert cycle[0] == cycle[-1], "cycle path must start and end at the same node"
739
740
741 # ---------------------------------------------------------------------------
742 # E2E — agent-safe zones
743 # ---------------------------------------------------------------------------
744
745
746 class TestCodemapAgentSafeZones:
747 def test_isolated_file_appears_in_agent_safe_zones(
748 self, multi_file_repo: pathlib.Path
749 ) -> None:
750 """standalone.py imports nothing and is imported by nothing."""
751 data = _codemap_json()
752 safe: list[str] = data["agent_safe_zones"]
753 assert any("standalone" in fp for fp in safe)
754
755 def test_imported_file_not_in_agent_safe_zones(
756 self, multi_file_repo: pathlib.Path
757 ) -> None:
758 """utils.py is imported by models.py and api.py — not isolated."""
759 data = _codemap_json()
760 safe: list[str] = data["agent_safe_zones"]
761 assert not any("utils" in fp for fp in safe)
762
763 def test_agent_safe_zones_are_sorted(self, multi_file_repo: pathlib.Path) -> None:
764 data = _codemap_json()
765 safe: list[str] = data["agent_safe_zones"]
766 assert safe == sorted(safe)
767
768
769 # ---------------------------------------------------------------------------
770 # E2E — boundary files
771 # ---------------------------------------------------------------------------
772
773
774 class TestCodemapBoundaryFiles:
775 def test_boundary_entry_has_required_fields(self, multi_file_repo: pathlib.Path) -> None:
776 data = _codemap_json()
777 for boundary in data["boundary_files"]:
778 assert "file" in boundary
779 assert "fan_out" in boundary
780 assert "fan_in" in boundary
781
782 def test_boundary_file_fan_in_is_zero(self, multi_file_repo: pathlib.Path) -> None:
783 data = _codemap_json()
784 for boundary in data["boundary_files"]:
785 assert boundary["fan_in"] == 0
786
787 def test_boundary_file_fan_out_at_least_3(self, multi_file_repo: pathlib.Path) -> None:
788 data = _codemap_json()
789 for boundary in data["boundary_files"]:
790 assert boundary["fan_out"] >= 3
791
792
793 # ---------------------------------------------------------------------------
794 # Stress — performance and determinism
795 # ---------------------------------------------------------------------------
796
797
798 class TestCodemapStress:
799 def test_find_cycles_1000_node_linear_chain_no_recursion_error(self) -> None:
800 depth = 1_000
801 nodes = [f"file_{i}.py" for i in range(depth)]
802 g: _AdjacencyMap = {nodes[i]: [nodes[i + 1]] for i in range(depth - 1)}
803 g[nodes[-1]] = []
804 cycles = _find_cycles(g)
805 assert cycles == []
806
807 def test_find_cycles_500_nodes_50_embedded_3_cycles(self) -> None:
808 """500-node graph with 50 explicit A→B→C→A triangles plus 350 isolated nodes."""
809 g: _AdjacencyMap = {}
810 expected_min = 50
811 for i in range(50):
812 a, b, c = f"a_{i}", f"b_{i}", f"c_{i}"
813 g[a] = [b]
814 g[b] = [c]
815 g[c] = [a]
816 for i in range(350):
817 g[f"iso_{i}"] = []
818 cycles = _find_cycles(g)
819 assert len(cycles) >= expected_min
820
821 def test_build_import_graph_large_sym_map_with_duplicates(self) -> None:
822 """1 000 files each importing the same hub file via 10 duplicate records."""
823 hub = "src/hub.py"
824 sym_map: _SymbolMap = {hub: {}}
825 for i in range(999):
826 fp = f"src/module_{i}.py"
827 tree: SymbolTree = {}
828 for j in range(10):
829 rec: SymbolRecord = {
830 "kind": "import",
831 "name": "hub", # same bare module — all edges to hub
832 "qualified_name": f"import::hub_alias_{j}",
833 "lineno": j + 1,
834 "end_lineno": j + 1,
835 "content_id": "",
836 "body_hash": "",
837 "signature_id": "",
838 "metadata_id": "",
839 "canonical_key": "",
840 }
841 tree[f"import::hub_alias_{j}"] = rec
842 sym_map[fp] = tree
843
844 imports_out, in_degree = _build_import_graph(sym_map)
845
846 assert in_degree[hub] == 999
847 for fp in sym_map:
848 if fp == hub:
849 continue
850 assert imports_out[fp].count(hub) == 1, "each file must have exactly one edge to hub"
851
852 def test_repeated_runs_produce_identical_json(self, code_repo: pathlib.Path) -> None:
853 """Two back-to-back invocations must produce the exact same JSON."""
854 result_a = runner.invoke(cli, ["code", "codemap", "--json"])
855 result_b = runner.invoke(cli, ["code", "codemap", "--json"])
856 assert result_a.exit_code == 0
857 assert result_b.exit_code == 0
858 _volatile = {"duration_ms", "timestamp"}
859 da = {k: v for k, v in json.loads(result_a.output).items() if k not in _volatile}
860 db = {k: v for k, v in json.loads(result_b.output).items() if k not in _volatile}
861 assert da == db
862
863 def test_codemap_completes_within_reasonable_time(
864 self, code_repo: pathlib.Path
865 ) -> None:
866 """Codemap on a small repo must finish within 10 seconds."""
867 start = time.monotonic()
868 result = runner.invoke(cli, ["code", "codemap", "--json"])
869 elapsed = time.monotonic() - start
870 assert result.exit_code == 0
871 assert elapsed < 10.0, f"codemap took {elapsed:.1f}s — too slow"
872
873
874 # ---------------------------------------------------------------------------
875 # Flag registration tests
876 # ---------------------------------------------------------------------------
877
878 import argparse as _argparse
879 from muse.cli.commands.codemap import register as _register_codemap
880
881
882 def _parse_codemap(*args: str) -> _argparse.Namespace:
883 root_p = _argparse.ArgumentParser()
884 subs = root_p.add_subparsers(dest="cmd")
885 _register_codemap(subs)
886 return root_p.parse_args(["codemap", *args])
887
888
889 class TestRegisterFlags:
890 def test_default_json_out_is_false(self) -> None:
891 ns = _parse_codemap()
892 assert ns.json_out is False
893
894 def test_json_flag_sets_json_out(self) -> None:
895 ns = _parse_codemap("--json")
896 assert ns.json_out is True
897
898 def test_j_shorthand_sets_json_out(self) -> None:
899 ns = _parse_codemap("-j")
900 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago