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