deps.py
python
| 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") |