cgcardona / muse public
deps.py python
296 lines 10.6 KB
25a0c523 feat(code): add call-graph tier — impact, dead, coverage commands (#60) Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """muse deps — import graph and call-graph analysis.
2
3 Answers two questions that Git cannot:
4
5 **File mode** (``muse deps src/billing.py``):
6 What does this file import, and what files in the repo import it?
7
8 **Symbol mode** (``muse deps "src/billing.py::compute_invoice_total"``):
9 What does this function call? (Python only; uses stdlib ``ast``.)
10 With ``--reverse``: what symbols in the repo call this function?
11
12 These relationships are *structural impossibilities* in Git: Git stores files
13 as blobs of text with no concept of imports or call-sites. Muse reads the
14 typed symbol graph produced at commit time and the AST of the working tree
15 to answer these questions in milliseconds.
16
17 Usage::
18
19 muse deps src/billing.py # import graph (file)
20 muse deps src/billing.py --reverse # who imports this file?
21 muse deps "src/billing.py::compute_invoice_total" # call graph (Python)
22 muse deps "src/billing.py::compute_invoice_total" --reverse # callers
23
24 Flags:
25
26 ``--commit, -c REF``
27 Inspect a historical snapshot instead of HEAD (import graph mode only).
28
29 ``--reverse``
30 Invert the query: show callers instead of callees, or importers instead
31 of imports.
32
33 ``--json``
34 Emit results as JSON.
35 """
36 from __future__ import annotations
37
38 import json
39 import logging
40 import pathlib
41
42 import typer
43
44 from muse.core.errors import ExitCode
45 from muse.core.object_store import read_object
46 from muse.core.repo import require_repo
47 from muse.core.store import get_commit_snapshot_manifest, resolve_commit_ref
48 from muse.plugins.code._callgraph import build_reverse_graph, callees_for_symbol
49 from muse.plugins.code._query import language_of, symbols_for_snapshot
50 from muse.plugins.code.ast_parser import SEMANTIC_EXTENSIONS, SymbolTree, parse_symbols
51
52 logger = logging.getLogger(__name__)
53
54 app = typer.Typer()
55
56
57 def _read_repo_id(root: pathlib.Path) -> str:
58 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
59
60
61 def _read_branch(root: pathlib.Path) -> str:
62 head_ref = (root / ".muse" / "HEAD").read_text().strip()
63 return head_ref.removeprefix("refs/heads/").strip()
64
65
66 # ---------------------------------------------------------------------------
67 # Import graph helpers
68 # ---------------------------------------------------------------------------
69
70
71 def _imports_in_tree(tree: SymbolTree) -> list[str]:
72 """Return the list of module/symbol names imported by symbols in *tree*."""
73 return sorted(
74 rec["qualified_name"]
75 for rec in tree.values()
76 if rec["kind"] == "import"
77 )
78
79
80 def _file_imports(
81 root: pathlib.Path,
82 manifest: dict[str, str],
83 target_file: str,
84 ) -> list[str]:
85 """Return import names declared in *target_file* within *manifest*."""
86 obj_id = manifest.get(target_file)
87 if obj_id is None:
88 return []
89 raw = read_object(root, obj_id)
90 if raw is None:
91 return []
92 tree = parse_symbols(raw, target_file)
93 return _imports_in_tree(tree)
94
95
96 def _reverse_imports(
97 root: pathlib.Path,
98 manifest: dict[str, str],
99 target_file: str,
100 ) -> list[str]:
101 """Return files in *manifest* that import a name matching *target_file*.
102
103 The heuristic: the target file's stem (e.g. ``billing`` for
104 ``src/billing.py``) is matched against each other file's import names.
105 This catches ``import billing``, ``from billing import X``, and fully-
106 qualified paths like ``src.billing``.
107 """
108 target_stem = pathlib.PurePosixPath(target_file).stem
109 target_module = pathlib.PurePosixPath(target_file).with_suffix("").as_posix().replace("/", ".")
110 importers: list[str] = []
111 for file_path, obj_id in manifest.items():
112 if file_path == target_file:
113 continue
114 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
115 if suffix not in SEMANTIC_EXTENSIONS:
116 continue
117 raw = read_object(root, obj_id)
118 if raw is None:
119 continue
120 tree = parse_symbols(raw, file_path)
121 for imp_name in _imports_in_tree(tree):
122 # Match stem or any suffix of the dotted module path.
123 if (
124 imp_name == target_stem
125 or imp_name == target_module
126 or imp_name.endswith(f".{target_stem}")
127 or imp_name.endswith(f".{target_module}")
128 or target_stem in imp_name.split(".")
129 ):
130 importers.append(file_path)
131 break
132 return sorted(importers)
133
134
135 # ---------------------------------------------------------------------------
136 # Call-graph helpers (Python only) — thin wrappers over _callgraph
137 # ---------------------------------------------------------------------------
138
139
140 def _python_callers(
141 root: pathlib.Path,
142 manifest: dict[str, str],
143 target_name: str,
144 ) -> list[str]:
145 """Return addresses of Python symbols that call *target_name*."""
146 reverse = build_reverse_graph(root, manifest)
147 return reverse.get(target_name, [])
148
149
150 # ---------------------------------------------------------------------------
151 # Command
152 # ---------------------------------------------------------------------------
153
154
155 @app.callback(invoke_without_command=True)
156 def deps(
157 ctx: typer.Context,
158 target: str = typer.Argument(
159 ..., metavar="TARGET",
160 help=(
161 'File path (e.g. "src/billing.py") for import graph, or '
162 'symbol address (e.g. "src/billing.py::compute_invoice_total") for call graph.'
163 ),
164 ),
165 reverse: bool = typer.Option(
166 False, "--reverse", "-r",
167 help="Show importers (file mode) or callers (symbol mode) instead.",
168 ),
169 ref: str | None = typer.Option(
170 None, "--commit", "-c", metavar="REF",
171 help="Inspect a historical commit instead of HEAD (import graph mode only).",
172 ),
173 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
174 ) -> None:
175 """Show the import graph or call graph for a file or symbol.
176
177 **File mode** — pass a file path::
178
179 muse deps src/billing.py # what does billing.py import?
180 muse deps src/billing.py --reverse # what files import billing.py?
181
182 **Symbol mode** — pass a symbol address (Python only for call graph)::
183
184 muse deps "src/billing.py::compute_invoice_total"
185 muse deps "src/billing.py::compute_invoice_total" --reverse
186
187 Call-graph analysis uses the live working tree for symbol mode.
188 Import-graph analysis uses the committed snapshot (``--commit`` to pin).
189 """
190 root = require_repo()
191 repo_id = _read_repo_id(root)
192 branch = _read_branch(root)
193
194 is_symbol_mode = "::" in target
195
196 # ----------------------------------------------------------------
197 # Symbol mode: call-graph (Python only)
198 # ----------------------------------------------------------------
199 if is_symbol_mode:
200 file_rel, sym_qualified = target.split("::", 1)
201 lang = language_of(file_rel)
202 if lang != "Python":
203 typer.echo(
204 f"⚠️ Call-graph analysis is currently Python-only. "
205 f"'{file_rel}' is {lang}.",
206 err=True,
207 )
208 raise typer.Exit(code=ExitCode.USER_ERROR)
209
210 # Read from working tree.
211 candidates = [root / "muse-work" / file_rel, root / file_rel]
212 src_path: pathlib.Path | None = None
213 for c in candidates:
214 if c.exists():
215 src_path = c
216 break
217 if src_path is None:
218 typer.echo(f"❌ File '{file_rel}' not found in working tree.", err=True)
219 raise typer.Exit(code=ExitCode.USER_ERROR)
220
221 source = src_path.read_bytes()
222
223 if not reverse:
224 callees = callees_for_symbol(source, target)
225 if as_json:
226 typer.echo(json.dumps({"address": target, "calls": callees}, indent=2))
227 return
228 typer.echo(f"\nCallees of {target}")
229 typer.echo("─" * 62)
230 if not callees:
231 typer.echo(" (no function calls detected)")
232 else:
233 for name in callees:
234 typer.echo(f" {name}")
235 typer.echo(f"\n{len(callees)} callee(s)")
236 else:
237 target_name = sym_qualified.split(".")[-1]
238 commit = resolve_commit_ref(root, repo_id, branch, None)
239 if commit is None:
240 typer.echo("❌ No commits found.", err=True)
241 raise typer.Exit(code=ExitCode.USER_ERROR)
242 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
243 callers = _python_callers(root, manifest, target_name)
244 if as_json:
245 typer.echo(json.dumps(
246 {"address": target, "target_name": target_name, "called_by": callers},
247 indent=2,
248 ))
249 return
250 typer.echo(f"\nCallers of {target}")
251 typer.echo(f" (matching bare name: {target_name!r})")
252 typer.echo("─" * 62)
253 if not callers:
254 typer.echo(" (no callers found in committed snapshot)")
255 else:
256 for addr in callers:
257 typer.echo(f" {addr}")
258 typer.echo(f"\n{len(callers)} caller(s) found")
259 return
260
261 # ----------------------------------------------------------------
262 # File mode: import graph
263 # ----------------------------------------------------------------
264 commit = resolve_commit_ref(root, repo_id, branch, ref)
265 if commit is None:
266 typer.echo(f"❌ Commit '{ref or 'HEAD'}' not found.", err=True)
267 raise typer.Exit(code=ExitCode.USER_ERROR)
268
269 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
270
271 if not reverse:
272 imports = _file_imports(root, manifest, target)
273 if as_json:
274 typer.echo(json.dumps({"file": target, "imports": imports}, indent=2))
275 return
276 typer.echo(f"\nImports declared in {target}")
277 typer.echo("─" * 62)
278 if not imports:
279 typer.echo(" (no imports found)")
280 else:
281 for name in imports:
282 typer.echo(f" {name}")
283 typer.echo(f"\n{len(imports)} import(s)")
284 else:
285 importers = _reverse_imports(root, manifest, target)
286 if as_json:
287 typer.echo(json.dumps({"file": target, "imported_by": importers}, indent=2))
288 return
289 typer.echo(f"\nFiles that import {target}")
290 typer.echo("─" * 62)
291 if not importers:
292 typer.echo(" (no files import this module in the committed snapshot)")
293 else:
294 for fp in importers:
295 typer.echo(f" {fp}")
296 typer.echo(f"\n{len(importers)} importer(s) found")