codemap.py python
367 lines 13.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """muse code codemap — repository semantic topology.
2
3 Generates a structural map of the codebase from committed snapshot data:
4
5 * **Modules ranked by size** — symbol count and lines of code per file
6 * **Import in-degree** — how many other files import each module
7 * **Import cycles** — circular dependency chains detected via iterative DFS
8 * **High-centrality symbols** — functions called from the most callers
9 * **Boundary files** — high fan-out (imports many) but low fan-in (few import it)
10 * **Agent-safe zones** — completely isolated files with no import coupling
11
12 This is a semantic topology view, not a file-system listing. It reveals the
13 actual shape of a codebase — where the load-bearing columns are, where the
14 cycles hide, and where parallel agents can safely work without collision.
15
16 Usage::
17
18 muse code codemap
19 muse code codemap --commit HEAD~10
20 muse code codemap --language Python
21 muse code codemap --top 20
22 muse code codemap --min-importers 2
23 muse code codemap --json
24
25 Output::
26
27 Semantic codemap — commit a1b2c3d4
28
29 Top modules by size:
30 src/billing.py 42 symbols (12 importers) ⬛ HIGH CENTRALITY
31 src/models.py 31 symbols (8 importers)
32 src/auth.py 18 symbols (5 importers)
33
34 Import cycles (2):
35 src/billing.py → src/utils.py → src/billing.py
36 src/api.py → src/auth.py → src/api.py
37
38 High-centrality symbols (most callers):
39 src/billing.py::compute_total 14 callers
40 src/auth.py::validate_token 9 callers
41
42 Boundary files (high fan-out, low fan-in):
43 src/cli.py imports 8 modules ← imported by 0
44
45 Agent-safe zones (no import coupling — safe for parallel work):
46 src/utils.py
47 src/constants.py
48
49 Flags:
50
51 ``--commit, -c REF``
52 Analyse a historical snapshot instead of HEAD.
53
54 ``--language LANG``
55 Restrict analysis to files of this language.
56
57 ``--top N``
58 Show top N entries in each section (default: 15, must be ≥ 1).
59
60 ``--min-importers N``
61 Only include modules imported by at least N other files in the ranked
62 module list (default: 0 = show all).
63
64 ``--json``
65 Emit the full codemap as JSON.
66 """
67
68 from __future__ import annotations
69
70 import argparse
71 import json
72 import logging
73 import pathlib
74
75 from muse._version import __version__
76 from muse.core.errors import ExitCode
77 from muse.core.repo import read_repo_id, require_repo
78 from muse.core.store import Manifest, get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
79 from muse.core.symbol_cache import load_symbol_cache
80 from muse.plugins.code._callgraph import build_reverse_graph
81 from muse.plugins.code._query import symbols_for_snapshot
82 from muse.plugins.code.ast_parser import SymbolTree
83 from muse.core.validation import clamp_int, sanitize_display
84
85 type _SymbolTreeMap = dict[str, SymbolTree]
86 type _ImportOut = dict[str, list[str]]
87 type _CounterMap = dict[str, int]
88
89 logger = logging.getLogger(__name__)
90
91
92 def _build_import_graph(
93 sym_map: _SymbolTreeMap,
94 ) -> tuple[dict[str, list[str]], dict[str, int]]:
95 """Return ``(imports_out, in_degree)`` for the files in *sym_map*.
96
97 Builds a stem-based heuristic import graph: for each import symbol in
98 each file, check whether the imported module stem matches a known file in
99 the map. Edges are deduplicated so a file importing the same module via
100 multiple ``from X import a, b`` statements counts as one edge.
101
102 Args:
103 sym_map: Pre-parsed symbol trees, keyed by file path. Already
104 filtered by language when the caller applies a filter.
105
106 Returns:
107 ``imports_out`` — adjacency list: file → list of files it imports.
108 ``in_degree`` — import fan-in count per file.
109 """
110 stem_to_file: Manifest = {
111 pathlib.PurePosixPath(fp).stem: fp for fp in sym_map
112 }
113
114 imports_out: _ImportOut = {fp: [] for fp in sym_map}
115 in_degree: _CounterMap = {fp: 0 for fp in sym_map}
116
117 for file_path, tree in sym_map.items():
118 seen_targets: set[str] = set()
119 for rec in tree.values():
120 if rec["kind"] != "import":
121 continue
122 # rec["name"] is the bare module name (e.g. "utils" for both
123 # `import utils` and `from utils import X`).
124 target = stem_to_file.get(rec["name"])
125 if target and target != file_path and target not in seen_targets:
126 seen_targets.add(target)
127 imports_out[file_path].append(target)
128 in_degree[target] += 1
129
130 return imports_out, in_degree
131
132
133 def _find_cycles(imports_out: _ImportOut) -> list[list[str]]:
134 """Detect import cycles via iterative DFS. Returns cycle paths.
135
136 Uses an explicit stack instead of recursion so that deeply nested import
137 graphs (thousands of files in a chain) cannot exhaust Python's call stack.
138 O(V+E) — every node is visited at most once globally.
139
140 The ``in_stack`` dict maps each node on the current DFS path to its index
141 in ``path``, giving O(1) lookups for both cycle detection and cycle
142 extraction (replacing the previous O(N) ``path.index()`` call).
143 """
144 cycles: list[list[str]] = []
145 visited: set[str] = set()
146
147 for start in imports_out:
148 if start in visited:
149 continue
150 # Stack frame: (node, path-so-far, in_stack: node→index-in-path)
151 stack: list[tuple[str, list[str], dict[str, int]]] = [(start, [], {})]
152 while stack:
153 node, path, in_stack = stack.pop()
154 if node in in_stack:
155 idx = in_stack[node]
156 cycles.append(path[idx:] + [node])
157 continue
158 if node in visited:
159 continue
160 visited.add(node)
161 new_in_stack = {**in_stack, node: len(path)}
162 new_path = path + [node]
163 for neighbour in imports_out.get(node, []):
164 stack.append((neighbour, new_path, new_in_stack))
165
166 return cycles
167
168
169 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
170 """Register the codemap subcommand."""
171 parser = subparsers.add_parser(
172 "codemap",
173 help="Generate a semantic topology map of the repository.",
174 description=__doc__,
175 formatter_class=argparse.RawDescriptionHelpFormatter,
176 )
177 parser.add_argument(
178 "--commit", "-c", default=None, metavar="REF", dest="ref",
179 help="Analyse this commit instead of HEAD.",
180 )
181 parser.add_argument(
182 "--language", "-l", default=None, metavar="LANG", dest="language",
183 help="Restrict analysis to this language.",
184 )
185 parser.add_argument(
186 "--top", "-n", type=int, default=15, metavar="N", dest="top",
187 help="Number of entries to show in each ranked section (must be ≥ 1).",
188 )
189 parser.add_argument(
190 "--min-importers", type=int, default=0, metavar="N", dest="min_importers",
191 help="Only show modules imported by at least N other files (default: 0 = all).",
192 )
193 parser.add_argument(
194 "--json", action="store_true", dest="as_json",
195 help="Emit results as JSON.",
196 )
197 parser.set_defaults(func=run)
198
199
200 def run(args: argparse.Namespace) -> None:
201 """Generate a semantic topology map of the repository.
202
203 Ranks modules by size, detects import cycles, finds high-centrality
204 symbols, identifies boundary files (high fan-out, low fan-in), and
205 surfaces agent-safe zones — files with no import coupling that are safe
206 for parallel agent work.
207
208 All analysis is done from the committed snapshot; the working tree is
209 never read. A shared SymbolCache is used across all three analysis
210 passes so each object blob is parsed at most once.
211 """
212 ref: str | None = args.ref
213 language: str | None = args.language
214 top: int = clamp_int(args.top, 1, 10_000, 'top')
215 min_importers: int = clamp_int(args.min_importers, 0, 10000, 'min_importers')
216 as_json: bool = args.as_json
217
218 if top < 1:
219 logger.error("--top must be at least 1, got %d", top)
220 raise SystemExit(ExitCode.USER_ERROR)
221 if min_importers < 0:
222 logger.error("--min-importers must be non-negative, got %d", min_importers)
223 raise SystemExit(ExitCode.USER_ERROR)
224
225 root = require_repo()
226
227 try:
228 repo_id = read_repo_id(root)
229 except (FileNotFoundError, json.JSONDecodeError, KeyError) as exc:
230 logger.error("Cannot read repo identity: %s", exc)
231 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
232
233 branch = read_current_branch(root)
234 commit = resolve_commit_ref(root, repo_id, branch, ref)
235 if commit is None:
236 logger.error("Commit %r not found.", ref or "HEAD")
237 raise SystemExit(ExitCode.USER_ERROR)
238
239 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
240
241 # Single shared cache — each blob is parsed at most once across all passes.
242 cache = load_symbol_cache(root)
243
244 sym_map = symbols_for_snapshot(root, manifest, language_filter=language, cache=cache)
245
246 if language and not sym_map:
247 logger.warning("No files matched language filter %r — output will be empty.", language)
248
249 file_sym_counts: _CounterMap = {fp: len(tree) for fp, tree in sym_map.items()}
250
251 # Import graph built from the pre-parsed sym_map (no second parse pass).
252 imports_out, in_degree = _build_import_graph(sym_map)
253
254 cycles = _find_cycles(imports_out)
255
256 # Call graph uses the shared cache (no re-parse of Python blobs).
257 reverse = build_reverse_graph(root, manifest, cache=cache)
258 centrality: list[tuple[str, int]] = sorted(
259 ((name, len(callers)) for name, callers in reverse.items()),
260 key=lambda t: t[1],
261 reverse=True,
262 )[:top]
263
264 # Boundary files: imports many but is imported by few.
265 fan_out = {fp: len(targets) for fp, targets in imports_out.items() if targets}
266 boundaries: list[tuple[str, int, int]] = sorted(
267 [
268 (fp, fan_out.get(fp, 0), in_degree.get(fp, 0))
269 for fp in sym_map
270 if fan_out.get(fp, 0) >= 3 and in_degree.get(fp, 0) == 0
271 ],
272 key=lambda t: t[1],
273 reverse=True,
274 )[:top]
275
276 # Agent-safe zones: completely decoupled files (no imports in or out).
277 agent_safe: list[str] = sorted(
278 fp for fp in sym_map
279 if in_degree.get(fp, 0) == 0 and not imports_out.get(fp)
280 )[:top]
281
282 # Ranked modules — optionally filtered by --min-importers.
283 ranked: list[tuple[str, int]] = sorted(
284 (
285 (fp, cnt) for fp, cnt in file_sym_counts.items()
286 if in_degree.get(fp, 0) >= min_importers
287 ),
288 key=lambda t: t[1],
289 reverse=True,
290 )[:top]
291
292 if as_json:
293 print(json.dumps(
294 {
295 "schema_version": __version__,
296 "commit": commit.commit_id[:8],
297 "branch": branch,
298 "language_filter": language,
299 "modules": [
300 {
301 "file": fp,
302 "symbol_count": cnt,
303 "importers": in_degree.get(fp, 0),
304 "imports": len(imports_out.get(fp, [])),
305 }
306 for fp, cnt in ranked
307 ],
308 "import_cycles": cycles,
309 "high_centrality": [
310 {"name": name, "callers": cnt}
311 for name, cnt in centrality
312 ],
313 "boundary_files": [
314 {"file": fp, "fan_out": fo, "fan_in": fi}
315 for fp, fo, fi in boundaries
316 ],
317 "agent_safe_zones": agent_safe,
318 },
319 indent=2,
320 ))
321 return
322
323 print(f"\nSemantic codemap — commit {commit.commit_id[:8]}")
324 if language:
325 print(f" (language: {language})")
326 if min_importers:
327 print(f" (min-importers: {min_importers})")
328 print("─" * 62)
329
330 print(f"\nTop modules by size (top {min(top, len(ranked))}):")
331 if ranked:
332 max_fp = max(len(fp) for fp, _ in ranked)
333 for fp, cnt in ranked:
334 imp = in_degree.get(fp, 0)
335 imp_label = f"({imp} importers)" if imp else "(not imported)"
336 print(f" {sanitize_display(fp):<{max_fp}} {cnt:>3} symbols {imp_label}")
337 else:
338 print(" (no files match the current filters)")
339
340 print(f"\nImport cycles ({len(cycles)}):")
341 if cycles:
342 for cycle in cycles[:top]:
343 print(" " + " → ".join(cycle))
344 else:
345 print(" ✅ No import cycles detected")
346
347 print(f"\nHigh-centrality symbols — most callers (Python):")
348 if centrality:
349 for name, cnt in centrality:
350 print(f" {sanitize_display(name):<40} {cnt} caller(s)")
351 else:
352 print(" (no Python call graph available)")
353
354 print(f"\nBoundary files — high fan-out, zero fan-in:")
355 if boundaries:
356 for fp, fo, fi in boundaries:
357 print(f" {sanitize_display(fp)} imports {fo} ← imported by {fi}")
358 else:
359 print(" (none detected)")
360
361 print(f"\nAgent-safe zones — no import coupling ({len(agent_safe)}):")
362 if agent_safe:
363 for fp in agent_safe:
364 print(f" {sanitize_display(fp)}")
365 else:
366 print(" (all files are coupled via imports)")
367
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago