gabriel / muse public
test_cmd_codemap.py python
871 lines 31.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 145 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 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 a short_id: either "sha256:<12hex>" or bare 12-char hex.
485 import re
486 assert re.search(r"commit (sha256:[0-9a-f]{12}|[0-9a-f]{12})", 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_short_id(self, code_repo: pathlib.Path) -> None:
554 data = _codemap_json()
555 commit = data["commit"]
556 assert isinstance(commit, str)
557 # short_id returns "sha256:<12hex>" for sha256-prefixed IDs.
558 assert commit.startswith("sha256:")
559 hex_part = commit[len("sha256:"):]
560 assert len(hex_part) == 12
561 assert all(c in "0123456789abcdef" for c in hex_part)
562
563 def test_json_language_filter_none_when_unset(self, code_repo: pathlib.Path) -> None:
564 data = _codemap_json()
565 assert data["language_filter"] is None
566
567 def test_json_module_entry_has_required_fields(self, code_repo: pathlib.Path) -> None:
568 data = _codemap_json()
569 modules: list[_ModuleEntry] = data["modules"]
570 if modules:
571 entry = modules[0]
572 assert "file" in entry
573 assert "symbol_count" in entry
574 assert "importers" in entry
575 assert "imports" in entry
576
577 def test_json_schema_version_matches_package(self, code_repo: pathlib.Path) -> None:
578 from muse._version import __version__
579 data = _codemap_json()
580 assert data["schema_version"] == __version__
581
582
583 # ---------------------------------------------------------------------------
584 # Integration — --top flag
585 # ---------------------------------------------------------------------------
586
587
588 class TestCodemapTopFlag:
589 def test_top_1_limits_modules_to_1(self, code_repo: pathlib.Path) -> None:
590 data = _codemap_json(["--top", "1"])
591 assert len(data["modules"]) <= 1
592
593 def test_top_3_limits_sections(self, code_repo: pathlib.Path) -> None:
594 data = _codemap_json(["--top", "3"])
595 assert len(data["modules"]) <= 3
596 assert len(data["high_centrality"]) <= 3
597 assert len(data["boundary_files"]) <= 3
598 assert len(data["agent_safe_zones"]) <= 3
599
600 def test_top_zero_exits_nonzero(self, code_repo: pathlib.Path) -> None:
601 result = runner.invoke(cli, ["code", "codemap", "--top", "0"])
602 assert result.exit_code != 0
603
604 def test_top_negative_exits_nonzero(self, code_repo: pathlib.Path) -> None:
605 result = runner.invoke(cli, ["code", "codemap", "--top", "-1"])
606 assert result.exit_code != 0
607
608 def test_top_text_mode_respected(self, code_repo: pathlib.Path) -> None:
609 result = runner.invoke(cli, ["code", "codemap", "--top", "1"])
610 assert result.exit_code == 0
611
612
613 # ---------------------------------------------------------------------------
614 # Integration — --min-importers flag
615 # ---------------------------------------------------------------------------
616
617
618 class TestCodemapMinImporters:
619 def test_min_importers_zero_includes_all(self, multi_file_repo: pathlib.Path) -> None:
620 data_all = _codemap_json(["--min-importers", "0"])
621 data_zero = _codemap_json() # default = 0
622 assert len(data_all["modules"]) == len(data_zero["modules"])
623
624 def test_min_importers_1_excludes_unimported(self, multi_file_repo: pathlib.Path) -> None:
625 data_all = _codemap_json()
626 data_filtered = _codemap_json(["--min-importers", "1"])
627 modules_all: list[_ModuleEntry] = data_all["modules"]
628 modules_filtered: list[_ModuleEntry] = data_filtered["modules"]
629 # Every module in the filtered list must have importers >= 1.
630 for mod in modules_filtered:
631 assert mod["importers"] >= 1
632 # Filtered list cannot be larger than unfiltered.
633 assert len(modules_filtered) <= len(modules_all)
634
635 def test_min_importers_very_large_returns_empty_modules(
636 self, code_repo: pathlib.Path
637 ) -> None:
638 data = _codemap_json(["--min-importers", "9999"])
639 assert data["modules"] == []
640
641 def test_min_importers_negative_exits_nonzero(self, code_repo: pathlib.Path) -> None:
642 result = runner.invoke(cli, ["code", "codemap", "--min-importers", "-1"])
643 assert result.exit_code != 0
644
645 def test_min_importers_label_in_text_output(self, code_repo: pathlib.Path) -> None:
646 result = runner.invoke(cli, ["code", "codemap", "--min-importers", "2"])
647 assert result.exit_code == 0
648 assert "min-importers" in result.output
649
650
651 # ---------------------------------------------------------------------------
652 # Integration — --language flag
653 # ---------------------------------------------------------------------------
654
655
656 class TestCodemapLanguageFlag:
657 def test_language_python_exits_zero(self, code_repo: pathlib.Path) -> None:
658 result = runner.invoke(cli, ["code", "codemap", "--language", "Python"])
659 assert result.exit_code == 0
660
661 def test_language_filter_in_json(self, code_repo: pathlib.Path) -> None:
662 data = _codemap_json(["--language", "Python"])
663 assert data["language_filter"] == "Python"
664
665 def test_language_no_match_exits_zero_empty_modules(
666 self, code_repo: pathlib.Path
667 ) -> None:
668 data = _codemap_json(["--language", "COBOL"])
669 assert data["modules"] == []
670
671 def test_language_text_header_shown(self, code_repo: pathlib.Path) -> None:
672 result = runner.invoke(cli, ["code", "codemap", "--language", "Python"])
673 assert result.exit_code == 0
674 assert "language: Python" in result.output
675
676
677 # ---------------------------------------------------------------------------
678 # Integration — --commit flag
679 # ---------------------------------------------------------------------------
680
681
682 class TestCodemapCommitFlag:
683 def test_commit_head_is_default(self, code_repo: pathlib.Path) -> None:
684 data_head = _codemap_json(["--commit", "HEAD"])
685 data_default = _codemap_json()
686 assert data_head["commit"] == data_default["commit"]
687
688 def test_commit_head_minus_1(self, code_repo: pathlib.Path) -> None:
689 result = runner.invoke(cli, ["code", "codemap", "--commit", "HEAD~1", "--json"])
690 assert result.exit_code == 0
691 data = json.loads(result.output)
692 data_head = _codemap_json()
693 # Historical snapshot commit id must differ from HEAD.
694 assert data["commit"] != data_head["commit"]
695
696 def test_commit_invalid_ref_exits_nonzero(self, code_repo: pathlib.Path) -> None:
697 result = runner.invoke(cli, ["code", "codemap", "--commit", "totally_bogus_ref_xyz"])
698 assert result.exit_code != 0
699
700
701 # ---------------------------------------------------------------------------
702 # Integration — empty repo
703 # ---------------------------------------------------------------------------
704
705
706 class TestCodemapEmptyRepo:
707 def test_no_commits_exits_nonzero(self, repo: pathlib.Path) -> None:
708 """Repo with no commits: HEAD does not resolve — must exit non-zero."""
709 result = runner.invoke(cli, ["code", "codemap"])
710 assert result.exit_code != 0
711
712
713 # ---------------------------------------------------------------------------
714 # E2E — cycle detection
715 # ---------------------------------------------------------------------------
716
717
718 class TestCodemapCycleE2E:
719 def test_circular_import_detected(self, cycle_repo: pathlib.Path) -> None:
720 data = _codemap_json()
721 cycles: list[list[str]] = data["import_cycles"]
722 assert len(cycles) >= 1
723 involved = {node for cycle in cycles for node in cycle}
724 # alpha.py and beta.py should both appear in the cycle paths.
725 assert any("alpha" in node for node in involved)
726 assert any("beta" in node for node in involved)
727
728 def test_acyclic_repo_has_no_cycles(self, multi_file_repo: pathlib.Path) -> None:
729 data = _codemap_json()
730 assert data["import_cycles"] == []
731
732 def test_cycle_paths_are_closed_rings(self, cycle_repo: pathlib.Path) -> None:
733 data = _codemap_json()
734 for cycle in data["import_cycles"]:
735 assert isinstance(cycle, list)
736 assert len(cycle) >= 2
737 assert cycle[0] == cycle[-1], "cycle path must start and end at the same node"
738
739
740 # ---------------------------------------------------------------------------
741 # E2E — agent-safe zones
742 # ---------------------------------------------------------------------------
743
744
745 class TestCodemapAgentSafeZones:
746 def test_isolated_file_appears_in_agent_safe_zones(
747 self, multi_file_repo: pathlib.Path
748 ) -> None:
749 """standalone.py imports nothing and is imported by nothing."""
750 data = _codemap_json()
751 safe: list[str] = data["agent_safe_zones"]
752 assert any("standalone" in fp for fp in safe)
753
754 def test_imported_file_not_in_agent_safe_zones(
755 self, multi_file_repo: pathlib.Path
756 ) -> None:
757 """utils.py is imported by models.py and api.py — not isolated."""
758 data = _codemap_json()
759 safe: list[str] = data["agent_safe_zones"]
760 assert not any("utils" in fp for fp in safe)
761
762 def test_agent_safe_zones_are_sorted(self, multi_file_repo: pathlib.Path) -> None:
763 data = _codemap_json()
764 safe: list[str] = data["agent_safe_zones"]
765 assert safe == sorted(safe)
766
767
768 # ---------------------------------------------------------------------------
769 # E2E — boundary files
770 # ---------------------------------------------------------------------------
771
772
773 class TestCodemapBoundaryFiles:
774 def test_boundary_entry_has_required_fields(self, multi_file_repo: pathlib.Path) -> None:
775 data = _codemap_json()
776 for boundary in data["boundary_files"]:
777 assert "file" in boundary
778 assert "fan_out" in boundary
779 assert "fan_in" in boundary
780
781 def test_boundary_file_fan_in_is_zero(self, multi_file_repo: pathlib.Path) -> None:
782 data = _codemap_json()
783 for boundary in data["boundary_files"]:
784 assert boundary["fan_in"] == 0
785
786 def test_boundary_file_fan_out_at_least_3(self, multi_file_repo: pathlib.Path) -> None:
787 data = _codemap_json()
788 for boundary in data["boundary_files"]:
789 assert boundary["fan_out"] >= 3
790
791
792 # ---------------------------------------------------------------------------
793 # Stress — performance and determinism
794 # ---------------------------------------------------------------------------
795
796
797 class TestCodemapStress:
798 def test_find_cycles_1000_node_linear_chain_no_recursion_error(self) -> None:
799 depth = 1_000
800 nodes = [f"file_{i}.py" for i in range(depth)]
801 g: _AdjacencyMap = {nodes[i]: [nodes[i + 1]] for i in range(depth - 1)}
802 g[nodes[-1]] = []
803 cycles = _find_cycles(g)
804 assert cycles == []
805
806 def test_find_cycles_500_nodes_50_embedded_3_cycles(self) -> None:
807 """500-node graph with 50 explicit A→B→C→A triangles plus 350 isolated nodes."""
808 g: _AdjacencyMap = {}
809 expected_min = 50
810 for i in range(50):
811 a, b, c = f"a_{i}", f"b_{i}", f"c_{i}"
812 g[a] = [b]
813 g[b] = [c]
814 g[c] = [a]
815 for i in range(350):
816 g[f"iso_{i}"] = []
817 cycles = _find_cycles(g)
818 assert len(cycles) >= expected_min
819
820 def test_build_import_graph_large_sym_map_with_duplicates(self) -> None:
821 """1 000 files each importing the same hub file via 10 duplicate records."""
822 hub = "src/hub.py"
823 sym_map: _SymbolMap = {hub: {}}
824 for i in range(999):
825 fp = f"src/module_{i}.py"
826 tree: SymbolTree = {}
827 for j in range(10):
828 rec: SymbolRecord = {
829 "kind": "import",
830 "name": "hub", # same bare module — all edges to hub
831 "qualified_name": f"import::hub_alias_{j}",
832 "lineno": j + 1,
833 "end_lineno": j + 1,
834 "content_id": "",
835 "body_hash": "",
836 "signature_id": "",
837 "metadata_id": "",
838 "canonical_key": "",
839 }
840 tree[f"import::hub_alias_{j}"] = rec
841 sym_map[fp] = tree
842
843 imports_out, in_degree = _build_import_graph(sym_map)
844
845 assert in_degree[hub] == 999
846 for fp in sym_map:
847 if fp == hub:
848 continue
849 assert imports_out[fp].count(hub) == 1, "each file must have exactly one edge to hub"
850
851 def test_repeated_runs_produce_identical_json(self, code_repo: pathlib.Path) -> None:
852 """Two back-to-back invocations must produce the exact same JSON."""
853 result_a = runner.invoke(cli, ["code", "codemap", "--json"])
854 result_b = runner.invoke(cli, ["code", "codemap", "--json"])
855 assert result_a.exit_code == 0
856 assert result_b.exit_code == 0
857 da = json.loads(result_a.output)
858 db = json.loads(result_b.output)
859 da.pop("duration_ms", None)
860 db.pop("duration_ms", None)
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"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 145 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 148 days ago