"""muse code codemap — repository semantic topology. Generates a structural map of the codebase from committed snapshot data: * **Modules ranked by size** — symbol count and lines of code per file * **Import in-degree** — how many other files import each module * **Import cycles** — circular dependency chains detected via iterative DFS * **High-centrality symbols** — functions called from the most callers * **Boundary files** — high fan-out (imports many) but low fan-in (few import it) * **Agent-safe zones** — completely isolated files with no import coupling This is a semantic topology view, not a file-system listing. It reveals the actual shape of a codebase — where the load-bearing columns are, where the cycles hide, and where parallel agents can safely work without collision. Usage:: muse code codemap muse code codemap --commit HEAD~10 muse code codemap --language Python muse code codemap --top 20 muse code codemap --min-importers 2 muse code codemap --json Output:: Semantic codemap — commit a1b2c3d4 Top modules by size: src/billing.py 42 symbols (12 importers) ⬛ HIGH CENTRALITY src/models.py 31 symbols (8 importers) src/auth.py 18 symbols (5 importers) Import cycles (2): src/billing.py → src/utils.py → src/billing.py src/api.py → src/auth.py → src/api.py High-centrality symbols (most callers): src/billing.py::compute_total 14 callers src/auth.py::validate_token 9 callers Boundary files (high fan-out, low fan-in): src/cli.py imports 8 modules ← imported by 0 Agent-safe zones (no import coupling — safe for parallel work): src/utils.py src/constants.py Flags: ``--commit, -c REF`` Analyse a historical snapshot instead of HEAD. ``--language LANG`` Restrict analysis to files of this language. ``--top N`` Show top N entries in each section (default: 15, must be ≥ 1). ``--min-importers N`` Only include modules imported by at least N other files in the ranked module list (default: 0 = show all). ``--json`` Emit the full codemap as JSON. """ from __future__ import annotations import argparse import json import logging import pathlib from muse._version import __version__ from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import Manifest, get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref from muse.core.symbol_cache import load_symbol_cache from muse.plugins.code._callgraph import build_reverse_graph from muse.plugins.code._query import symbols_for_snapshot from muse.plugins.code.ast_parser import SymbolTree from muse.core.validation import clamp_int, sanitize_display type _SymbolTreeMap = dict[str, SymbolTree] type _ImportOut = dict[str, list[str]] type _CounterMap = dict[str, int] logger = logging.getLogger(__name__) def _build_import_graph( sym_map: _SymbolTreeMap, ) -> tuple[dict[str, list[str]], dict[str, int]]: """Return ``(imports_out, in_degree)`` for the files in *sym_map*. Builds a stem-based heuristic import graph: for each import symbol in each file, check whether the imported module stem matches a known file in the map. Edges are deduplicated so a file importing the same module via multiple ``from X import a, b`` statements counts as one edge. Args: sym_map: Pre-parsed symbol trees, keyed by file path. Already filtered by language when the caller applies a filter. Returns: ``imports_out`` — adjacency list: file → list of files it imports. ``in_degree`` — import fan-in count per file. """ stem_to_file: Manifest = { pathlib.PurePosixPath(fp).stem: fp for fp in sym_map } imports_out: _ImportOut = {fp: [] for fp in sym_map} in_degree: _CounterMap = {fp: 0 for fp in sym_map} for file_path, tree in sym_map.items(): seen_targets: set[str] = set() for rec in tree.values(): if rec["kind"] != "import": continue # rec["name"] is the bare module name (e.g. "utils" for both # `import utils` and `from utils import X`). target = stem_to_file.get(rec["name"]) if target and target != file_path and target not in seen_targets: seen_targets.add(target) imports_out[file_path].append(target) in_degree[target] += 1 return imports_out, in_degree def _find_cycles(imports_out: _ImportOut) -> list[list[str]]: """Detect import cycles via iterative DFS. Returns cycle paths. Uses an explicit stack instead of recursion so that deeply nested import graphs (thousands of files in a chain) cannot exhaust Python's call stack. O(V+E) — every node is visited at most once globally. The ``in_stack`` dict maps each node on the current DFS path to its index in ``path``, giving O(1) lookups for both cycle detection and cycle extraction (replacing the previous O(N) ``path.index()`` call). """ cycles: list[list[str]] = [] visited: set[str] = set() for start in imports_out: if start in visited: continue # Stack frame: (node, path-so-far, in_stack: node→index-in-path) stack: list[tuple[str, list[str], dict[str, int]]] = [(start, [], {})] while stack: node, path, in_stack = stack.pop() if node in in_stack: idx = in_stack[node] cycles.append(path[idx:] + [node]) continue if node in visited: continue visited.add(node) new_in_stack = {**in_stack, node: len(path)} new_path = path + [node] for neighbour in imports_out.get(node, []): stack.append((neighbour, new_path, new_in_stack)) return cycles def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the codemap subcommand.""" parser = subparsers.add_parser( "codemap", help="Generate a semantic topology map of the repository.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--commit", "-c", default=None, metavar="REF", dest="ref", help="Analyse this commit instead of HEAD.", ) parser.add_argument( "--language", "-l", default=None, metavar="LANG", dest="language", help="Restrict analysis to this language.", ) parser.add_argument( "--top", "-n", type=int, default=15, metavar="N", dest="top", help="Number of entries to show in each ranked section (must be ≥ 1).", ) parser.add_argument( "--min-importers", type=int, default=0, metavar="N", dest="min_importers", help="Only show modules imported by at least N other files (default: 0 = all).", ) parser.add_argument( "--json", action="store_true", dest="as_json", help="Emit results as JSON.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Generate a semantic topology map of the repository. Ranks modules by size, detects import cycles, finds high-centrality symbols, identifies boundary files (high fan-out, low fan-in), and surfaces agent-safe zones — files with no import coupling that are safe for parallel agent work. All analysis is done from the committed snapshot; the working tree is never read. A shared SymbolCache is used across all three analysis passes so each object blob is parsed at most once. """ ref: str | None = args.ref language: str | None = args.language top: int = clamp_int(args.top, 1, 10_000, 'top') min_importers: int = clamp_int(args.min_importers, 0, 10000, 'min_importers') as_json: bool = args.as_json if top < 1: logger.error("--top must be at least 1, got %d", top) raise SystemExit(ExitCode.USER_ERROR) if min_importers < 0: logger.error("--min-importers must be non-negative, got %d", min_importers) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() try: repo_id = read_repo_id(root) except (FileNotFoundError, json.JSONDecodeError, KeyError) as exc: logger.error("Cannot read repo identity: %s", exc) raise SystemExit(ExitCode.INTERNAL_ERROR) from exc branch = read_current_branch(root) commit = resolve_commit_ref(root, repo_id, branch, ref) if commit is None: logger.error("Commit %r not found.", ref or "HEAD") raise SystemExit(ExitCode.USER_ERROR) manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} # Single shared cache — each blob is parsed at most once across all passes. cache = load_symbol_cache(root) sym_map = symbols_for_snapshot(root, manifest, language_filter=language, cache=cache) if language and not sym_map: logger.warning("No files matched language filter %r — output will be empty.", language) file_sym_counts: _CounterMap = {fp: len(tree) for fp, tree in sym_map.items()} # Import graph built from the pre-parsed sym_map (no second parse pass). imports_out, in_degree = _build_import_graph(sym_map) cycles = _find_cycles(imports_out) # Call graph uses the shared cache (no re-parse of Python blobs). reverse = build_reverse_graph(root, manifest, cache=cache) centrality: list[tuple[str, int]] = sorted( ((name, len(callers)) for name, callers in reverse.items()), key=lambda t: t[1], reverse=True, )[:top] # Boundary files: imports many but is imported by few. fan_out = {fp: len(targets) for fp, targets in imports_out.items() if targets} boundaries: list[tuple[str, int, int]] = sorted( [ (fp, fan_out.get(fp, 0), in_degree.get(fp, 0)) for fp in sym_map if fan_out.get(fp, 0) >= 3 and in_degree.get(fp, 0) == 0 ], key=lambda t: t[1], reverse=True, )[:top] # Agent-safe zones: completely decoupled files (no imports in or out). agent_safe: list[str] = sorted( fp for fp in sym_map if in_degree.get(fp, 0) == 0 and not imports_out.get(fp) )[:top] # Ranked modules — optionally filtered by --min-importers. ranked: list[tuple[str, int]] = sorted( ( (fp, cnt) for fp, cnt in file_sym_counts.items() if in_degree.get(fp, 0) >= min_importers ), key=lambda t: t[1], reverse=True, )[:top] if as_json: print(json.dumps( { "schema_version": __version__, "commit": commit.commit_id[:8], "branch": branch, "language_filter": language, "modules": [ { "file": fp, "symbol_count": cnt, "importers": in_degree.get(fp, 0), "imports": len(imports_out.get(fp, [])), } for fp, cnt in ranked ], "import_cycles": cycles, "high_centrality": [ {"name": name, "callers": cnt} for name, cnt in centrality ], "boundary_files": [ {"file": fp, "fan_out": fo, "fan_in": fi} for fp, fo, fi in boundaries ], "agent_safe_zones": agent_safe, }, indent=2, )) return print(f"\nSemantic codemap — commit {commit.commit_id[:8]}") if language: print(f" (language: {language})") if min_importers: print(f" (min-importers: {min_importers})") print("─" * 62) print(f"\nTop modules by size (top {min(top, len(ranked))}):") if ranked: max_fp = max(len(fp) for fp, _ in ranked) for fp, cnt in ranked: imp = in_degree.get(fp, 0) imp_label = f"({imp} importers)" if imp else "(not imported)" print(f" {sanitize_display(fp):<{max_fp}} {cnt:>3} symbols {imp_label}") else: print(" (no files match the current filters)") print(f"\nImport cycles ({len(cycles)}):") if cycles: for cycle in cycles[:top]: print(" " + " → ".join(cycle)) else: print(" ✅ No import cycles detected") print(f"\nHigh-centrality symbols — most callers (Python):") if centrality: for name, cnt in centrality: print(f" {sanitize_display(name):<40} {cnt} caller(s)") else: print(" (no Python call graph available)") print(f"\nBoundary files — high fan-out, zero fan-in:") if boundaries: for fp, fo, fi in boundaries: print(f" {sanitize_display(fp)} imports {fo} ← imported by {fi}") else: print(" (none detected)") print(f"\nAgent-safe zones — no import coupling ({len(agent_safe)}):") if agent_safe: for fp in agent_safe: print(f" {sanitize_display(fp)}") else: print(" (all files are coupled via imports)")