deps.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """muse code deps β€” import graph and call-graph analysis.
2
3 Answers questions that Git cannot answer structurally:
4
5 **File mode** (``muse code deps src/billing.py``):
6 What does this file import, and what files in the repo import it?
7
8 **Symbol mode** (``muse code 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 With ``--depth N``: walk the call chain transitively N hops deep.
12 With ``--transitive``: unlimited-depth BFS through the full call graph.
13
14 These relationships are *structural impossibilities* in Git: Git stores files
15 as blobs of text with no concept of imports or call-sites. Muse reads the
16 typed symbol graph and the AST to answer these questions in milliseconds.
17
18 Usage::
19
20 muse code deps src/billing.py # what does billing.py import?
21 muse code deps src/billing.py --reverse # who imports billing.py?
22 muse code deps src/billing.py --reverse --filter tests/ # only importers in tests/
23 muse code deps "src/billing.py::compute_total" # direct callees (Python)
24 muse code deps "src/billing.py::compute_total" --reverse # direct callers
25 muse code deps "src/billing.py::compute_total" --reverse --depth 3 # 3-hop callers
26 muse code deps "src/billing.py::compute_total" --transitive # full blast radius
27 muse code deps "src/billing.py::compute_total" --count # caller count only
28
29 Flags:
30
31 ``--commit, -c REF``
32 Inspect a historical snapshot instead of HEAD.
33
34 ``--reverse``
35 Invert the query: show callers (symbol mode) or importers (file mode).
36
37 ``--depth N``
38 With ``--reverse``: walk transitive callers up to N hops.
39 Without ``--reverse``: walk transitive callees up to N hops.
40 ``0`` means unlimited (same as ``--transitive``).
41
42 ``--transitive``
43 Walk the full call graph without a depth cap (equivalent to ``--depth 0``).
44
45 ``--filter PATTERN``
46 Restrict results to addresses or file paths containing PATTERN.
47
48 ``--count``
49 Emit only the total count of results.
50
51 ``--json``
52 Emit results as JSON.
53 """
54
55 from __future__ import annotations
56
57 import argparse
58 import fnmatch
59 import json
60 import logging
61 import pathlib
62 import sys
63
64 from muse.core.errors import ExitCode
65 from muse.core.repo import read_repo_id, require_repo
66 from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
67 from muse.core.symbol_cache import load_symbol_cache
68 from muse.plugins.registry import read_domain
69 from muse.plugins.code._callgraph import (
70 build_forward_graph,
71 build_reverse_graph,
72 callees_for_symbol,
73 transitive_callees,
74 transitive_callers,
75 )
76 from muse.plugins.code._query import language_of, symbols_for_snapshot
77 from muse.plugins.code.ast_parser import SymbolTree
78 from muse.core.validation import clamp_int, sanitize_display
79 from muse.core._types import Manifest
80
81 logger = logging.getLogger(__name__)
82
83
84
85 def _validate_file_rel(file_rel: str) -> None:
86 """Exit with USER_ERROR if *file_rel* looks like a path traversal attempt."""
87 if not file_rel:
88 print("❌ Target file path cannot be empty.", file=sys.stderr)
89 raise SystemExit(ExitCode.USER_ERROR)
90 p = pathlib.PurePosixPath(file_rel)
91 if p.is_absolute() or ".." in p.parts:
92 print(
93 f"❌ Target path '{file_rel}' must be a relative path"
94 " with no '..' components.",
95 file=sys.stderr,
96 )
97 raise SystemExit(ExitCode.USER_ERROR)
98
99
100 # ---------------------------------------------------------------------------
101 # Import graph helpers
102 # ---------------------------------------------------------------------------
103
104
105 def _imports_in_tree(tree: SymbolTree) -> list[str]:
106 """Return the list of module/symbol names imported by symbols in *tree*."""
107 return sorted(
108 rec["qualified_name"]
109 for rec in tree.values()
110 if rec["kind"] == "import"
111 )
112
113
114 def _file_imports(
115 root: pathlib.Path,
116 manifest: Manifest,
117 target_file: str,
118 *,
119 workdir: pathlib.Path | None = None,
120 ) -> list[str]:
121 """Return import names declared in *target_file* within *manifest*."""
122 cache = load_symbol_cache(root)
123 sym_map = symbols_for_snapshot(
124 root, manifest, file_filter=target_file, workdir=workdir, cache=cache
125 )
126 cache.save()
127 tree = sym_map.get(target_file, {})
128 return _imports_in_tree(tree)
129
130
131 def _reverse_imports(
132 root: pathlib.Path,
133 manifest: Manifest,
134 target_file: str,
135 file_filter: str | None = None,
136 ) -> list[str]:
137 """Return files in *manifest* that import a name matching *target_file*.
138
139 The heuristic: the target file's stem (e.g. ``billing`` for
140 ``src/billing.py``) is matched against each other file's import names.
141 This catches ``import billing``, ``from billing import X``, and fully-
142 qualified paths like ``src.billing``.
143
144 Args:
145 root: Repository root.
146 manifest: Snapshot manifest.
147 target_file: File path to find importers of.
148 file_filter: Optional glob pattern β€” only files matching this pattern
149 are included in the importer list.
150 """
151 target_stem = pathlib.PurePosixPath(target_file).stem
152 target_module = (
153 pathlib.PurePosixPath(target_file)
154 .with_suffix("")
155 .as_posix()
156 .replace("/", ".")
157 )
158 # Use the shared cache β€” one load for the entire manifest scan.
159 cache = load_symbol_cache(root)
160 sym_map = symbols_for_snapshot(root, manifest, cache=cache)
161 cache.save()
162
163 importers: list[str] = []
164 for file_path, tree in sym_map.items():
165 if file_path == target_file:
166 continue
167 if file_filter and not fnmatch.fnmatch(file_path, f"*{file_filter}*"):
168 continue
169 for imp_name in _imports_in_tree(tree):
170 if (
171 imp_name == target_stem
172 or imp_name == target_module
173 or imp_name.endswith(f".{target_stem}")
174 or imp_name.endswith(f".{target_module}")
175 or target_stem in imp_name.split(".")
176 ):
177 importers.append(file_path)
178 break
179 return sorted(importers)
180
181
182 # ---------------------------------------------------------------------------
183 # Knowtation domain path
184 # ---------------------------------------------------------------------------
185
186
187 def _run_knowtation_deps(
188 *,
189 root: pathlib.Path,
190 target: str,
191 reverse: bool,
192 as_json: bool,
193 count_only: bool,
194 file_filter: str | None,
195 ) -> None:
196 """Knowtation-domain path for ``muse code deps``.
197
198 Treats *target* as a vault-relative note path (``.md`` / ``.markdown``).
199 Builds a fresh :class:`~muse.plugins.knowtation.link_index.LinkIndex` for
200 the working tree and returns outgoing / incoming note edges.
201
202 Args:
203 root: Repository root.
204 target: Vault-relative path to the note.
205 reverse: When ``True``, return notes that link **to** *target*.
206 as_json: Emit JSON instead of text.
207 count_only: Emit only the integer count of edges.
208 file_filter: Restrict results to paths containing this substring.
209 """
210 from muse.plugins.knowtation.code_intel import note_deps
211 from muse.plugins.knowtation.link_index import build_link_index
212 from muse.plugins.knowtation._query import is_note
213
214 _validate_file_rel(target)
215
216 if not is_note(target):
217 print(
218 f"❌ '{target}' is not a Markdown note. "
219 "Knowtation deps requires a .md / .markdown / .mdx path.",
220 file=sys.stderr,
221 )
222 raise SystemExit(ExitCode.USER_ERROR)
223
224 candidate = root / target
225 if not candidate.is_file():
226 print(f"❌ Note '{target}' not found in vault.", file=sys.stderr)
227 raise SystemExit(ExitCode.USER_ERROR)
228
229 try:
230 index = build_link_index(root)
231 except ValueError as exc:
232 print(f"❌ Could not index vault: {exc}", file=sys.stderr)
233 raise SystemExit(ExitCode.USER_ERROR)
234
235 result = note_deps(index, target, reverse=reverse)
236 links = result["links"]
237
238 if file_filter:
239 links = [
240 link for link in links
241 if (link["target"] and file_filter in link["target"])
242 or (file_filter in link["source"])
243 ]
244 result["links"] = links
245 result["count"] = len(links)
246
247 if count_only:
248 print(result["count"])
249 return
250
251 if as_json:
252 result["domain"] = "knowtation"
253 print(json.dumps(result, indent=2))
254 return
255
256 direction_label = "Notes that link to" if reverse else "Notes linked from"
257 print(f"\n{direction_label} {sanitize_display(target)}")
258 print("─" * 62)
259 if not links:
260 msg = "no incoming links" if reverse else "no outgoing links"
261 print(f" ({msg})")
262 else:
263 for link in links:
264 other = link["source"] if reverse else (link["target"] or "(broken)")
265 kind = link["kind"]
266 fragment = f"#{link['fragment']}" if link["fragment"] else ""
267 print(f" {sanitize_display(other)}{fragment} [{kind}]")
268 print(f"\n{result['count']} link(s)")
269
270
271 # ---------------------------------------------------------------------------
272 # Registration
273 # ---------------------------------------------------------------------------
274
275
276 def register(
277 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
278 ) -> None:
279 """Register the deps subcommand."""
280 parser = subparsers.add_parser(
281 "deps",
282 help="Show the import graph or call graph for a file or symbol.",
283 description=__doc__,
284 formatter_class=argparse.RawDescriptionHelpFormatter,
285 )
286 parser.add_argument(
287 "target",
288 metavar="TARGET",
289 help=(
290 'File path (e.g. "src/billing.py") for import graph, or '
291 'symbol address (e.g. "src/billing.py::compute_invoice_total")'
292 " for call graph."
293 ),
294 )
295 parser.add_argument(
296 "--reverse", "-r",
297 action="store_true",
298 help="Show importers (file mode) or callers (symbol mode) instead.",
299 )
300 parser.add_argument(
301 "--depth",
302 type=int,
303 default=1,
304 metavar="N",
305 help=(
306 "Walk transitive callers/callees up to N hops"
307 " (symbol mode only; 0 = unlimited)."
308 ),
309 )
310 parser.add_argument(
311 "--transitive",
312 action="store_true",
313 help="Walk the full call graph without a depth cap.",
314 )
315 parser.add_argument(
316 "--filter",
317 dest="file_filter",
318 default=None,
319 metavar="PATTERN",
320 help="Restrict results to addresses/paths containing PATTERN.",
321 )
322 parser.add_argument(
323 "--count",
324 action="store_true",
325 help="Emit only the total count of results.",
326 )
327 parser.add_argument(
328 "--commit", "-c",
329 default=None,
330 metavar="REF",
331 dest="ref",
332 help="Inspect a historical commit instead of HEAD.",
333 )
334 parser.add_argument(
335 "--json",
336 action="store_true",
337 dest="as_json",
338 help="Emit results as JSON.",
339 )
340 parser.set_defaults(func=run)
341
342
343 # ---------------------------------------------------------------------------
344 # Run
345 # ---------------------------------------------------------------------------
346
347
348 def run(args: argparse.Namespace) -> None:
349 """Show the import graph or call graph for a file or symbol.
350
351 **File mode** β€” pass a file path::
352
353 muse code deps src/billing.py # what does billing.py import?
354 muse code deps src/billing.py --reverse # what files import billing.py?
355
356 **Symbol mode** β€” pass a symbol address (Python call-graph)::
357
358 muse code deps "src/billing.py::compute_total"
359 muse code deps "src/billing.py::compute_total" --reverse --depth 3
360 muse code deps "src/billing.py::compute_total" --transitive
361
362 Call-graph analysis uses the committed snapshot (or ``--commit`` to pin).
363 """
364 target: str = args.target
365 reverse: bool = args.reverse
366 ref: str | None = args.ref
367 as_json: bool = args.as_json
368 depth: int = clamp_int(args.depth, 1, 50, "depth")
369 transitive: bool = args.transitive
370 file_filter: str | None = args.file_filter
371 count_only: bool = args.count
372
373 # --transitive overrides --depth to unlimited (0 signals no BFS cap internally).
374 effective_depth = 0 if transitive else depth
375
376 root = require_repo()
377 repo_id = read_repo_id(root)
378 branch = read_current_branch(root)
379
380 try:
381 active_domain = read_domain(root)
382 except Exception:
383 active_domain = "code"
384
385 if active_domain == "knowtation":
386 _run_knowtation_deps(
387 root=root,
388 target=target,
389 reverse=reverse,
390 as_json=as_json,
391 count_only=count_only,
392 file_filter=file_filter,
393 )
394 return
395
396 is_symbol_mode = "::" in target
397
398 # ── Symbol mode: call-graph (Python only) ─────────────────────────────────
399 if is_symbol_mode:
400 file_rel, sym_qualified = target.split("::", 1)
401 _validate_file_rel(file_rel)
402
403 lang = language_of(file_rel)
404 if lang != "Python":
405 print(
406 f"❌ Call-graph analysis is currently Python-only."
407 f" '{file_rel}' is {lang}.",
408 file=sys.stderr,
409 )
410 raise SystemExit(ExitCode.USER_ERROR)
411
412 commit = resolve_commit_ref(root, repo_id, branch, ref)
413 if commit is None:
414 print(
415 f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr
416 )
417 raise SystemExit(ExitCode.USER_ERROR)
418
419 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
420 shared_cache = load_symbol_cache(root)
421
422 if not reverse:
423 # ── Forward: what does this symbol call? ──────────────────────
424 if effective_depth == 1:
425 # Single hop β€” fast path using source bytes.
426 obj_id = manifest.get(file_rel)
427 if obj_id is None:
428 print(
429 f"❌ '{file_rel}' not found in snapshot"
430 f" '{commit.commit_id[:8]}'.",
431 file=sys.stderr,
432 )
433 raise SystemExit(ExitCode.USER_ERROR)
434 from muse.core.object_store import read_object # noqa: PLC0415
435 raw = read_object(root, obj_id)
436 if raw is None:
437 print(
438 f"❌ Object for '{file_rel}' missing from store.",
439 file=sys.stderr,
440 )
441 raise SystemExit(ExitCode.USER_ERROR)
442 callees = callees_for_symbol(raw, target)
443 if file_filter:
444 callees = [c for c in callees if file_filter in c]
445 shared_cache.save()
446
447 if count_only:
448 print(len(callees))
449 return
450 if as_json:
451 print(
452 json.dumps(
453 {"address": target, "depth": 1, "calls": callees},
454 indent=2,
455 )
456 )
457 return
458 print(f"\nDirect callees of {sanitize_display(target)}")
459 print("─" * 62)
460 if not callees:
461 print(" (no function calls detected)")
462 else:
463 for name in callees:
464 print(f" {name}")
465 print(f"\n{len(callees)} callee(s)")
466 else:
467 # Multi-hop: build full forward graph and BFS.
468 forward = build_forward_graph(root, manifest, cache=shared_cache)
469 shared_cache.save()
470 by_depth = transitive_callees(target, forward, effective_depth)
471 flat = [
472 name
473 for d in sorted(by_depth)
474 for name in sorted(by_depth[d])
475 ]
476 if file_filter:
477 flat = [c for c in flat if file_filter in c]
478 total = sum(len(v) for v in by_depth.values())
479
480 if count_only:
481 print(total)
482 return
483 if as_json:
484 print(
485 json.dumps(
486 {
487 "address": target,
488 "depth": effective_depth,
489 "transitive": True,
490 "by_depth": {
491 str(d): sorted(by_depth[d])
492 for d in sorted(by_depth)
493 },
494 },
495 indent=2,
496 )
497 )
498 return
499 depth_label = (
500 "∞" if effective_depth == 0 else str(effective_depth)
501 )
502 print(
503 f"\nTransitive callees of {target}"
504 f" (depth ≀ {depth_label})"
505 )
506 print("─" * 62)
507 if not by_depth:
508 print(" (no callees found)")
509 else:
510 for d in sorted(by_depth):
511 names = sorted(by_depth[d])
512 print(f"\n depth {d}:")
513 for name in names:
514 print(f" {sanitize_display(name)}")
515 print(f"\n{total} callee(s) across {len(by_depth)} depth(s)")
516
517 else:
518 # ── Reverse: who calls this symbol? ───────────────────────────
519 target_name = sym_qualified.split(".")[-1]
520 reverse_graph = build_reverse_graph(root, manifest, cache=shared_cache)
521 shared_cache.save()
522
523 if effective_depth == 1:
524 # Direct callers only.
525 callers = reverse_graph.get(target_name, [])
526 if file_filter:
527 callers = [c for c in callers if file_filter in c]
528 if count_only:
529 print(len(callers))
530 return
531 if as_json:
532 print(
533 json.dumps(
534 {
535 "address": target,
536 "target_name": target_name,
537 "depth": 1,
538 "called_by": callers,
539 },
540 indent=2,
541 )
542 )
543 return
544 print(f"\nDirect callers of {sanitize_display(target)}")
545 print("─" * 62)
546 if not callers:
547 print(" (no callers found in snapshot)")
548 else:
549 for addr in callers:
550 print(f" {sanitize_display(addr)}")
551 print(f"\n{len(callers)} caller(s)")
552 else:
553 # Transitive callers via BFS.
554 by_depth = transitive_callers(target_name, reverse_graph, effective_depth)
555 if file_filter:
556 by_depth = {
557 d: [c for c in callers if file_filter in c]
558 for d, callers in by_depth.items()
559 }
560 by_depth = {d: v for d, v in by_depth.items() if v}
561 total = sum(len(v) for v in by_depth.values())
562
563 if count_only:
564 print(total)
565 return
566 if as_json:
567 print(
568 json.dumps(
569 {
570 "address": target,
571 "target_name": target_name,
572 "depth": effective_depth,
573 "transitive": True,
574 "by_depth": {
575 str(d): sorted(by_depth[d])
576 for d in sorted(by_depth)
577 },
578 },
579 indent=2,
580 )
581 )
582 return
583 depth_label = (
584 "∞" if effective_depth == 0 else str(effective_depth)
585 )
586 print(
587 f"\nTransitive callers of {target}"
588 f" (depth ≀ {depth_label})"
589 )
590 print(f" (matching bare name: {target_name!r})")
591 print("─" * 62)
592 if not by_depth:
593 print(" (no callers found)")
594 else:
595 for d in sorted(by_depth):
596 addrs = sorted(by_depth[d])
597 print(f"\n depth {d}:")
598 for addr in addrs:
599 print(f" {addr}")
600 print(f"\n{total} caller(s) across {len(by_depth)} depth(s)")
601 return
602
603 # ── File mode: import graph ────────────────────────────────────────────────
604 _validate_file_rel(target)
605
606 commit = resolve_commit_ref(root, repo_id, branch, ref)
607 if commit is None:
608 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
609 raise SystemExit(ExitCode.USER_ERROR)
610
611 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
612
613 # When working-tree mode and the target is not yet committed, inject a
614 # synthetic manifest entry so symbols_for_snapshot can read it from disk.
615 working_tree = ref is None
616 if working_tree and target not in manifest:
617 candidate = root / target
618 if candidate.is_file():
619 manifest = dict(manifest)
620 manifest[target] = ""
621
622 if not reverse:
623 imports = _file_imports(root, manifest, target, workdir=root if working_tree else None)
624 if file_filter:
625 imports = [i for i in imports if file_filter in i]
626 if count_only:
627 print(len(imports))
628 return
629 if as_json:
630 print(
631 json.dumps({"file": target, "imports": imports}, indent=2)
632 )
633 return
634 print(f"\nImports declared in {sanitize_display(target)}")
635 print("─" * 62)
636 if not imports:
637 print(" (no imports found)")
638 else:
639 for name in imports:
640 print(f" {sanitize_display(name)}")
641 print(f"\n{len(imports)} import(s)")
642 else:
643 importers = _reverse_imports(root, manifest, target, file_filter)
644 if count_only:
645 print(len(importers))
646 return
647 if as_json:
648 print(
649 json.dumps(
650 {"file": target, "imported_by": importers}, indent=2
651 )
652 )
653 return
654 print(f"\nFiles that import {sanitize_display(target)}")
655 if file_filter:
656 print(f" (filtered to: *{file_filter}*)")
657 print("─" * 62)
658 if not importers:
659 print(" (no files import this module in the committed snapshot)")
660 else:
661 for fp in importers:
662 print(f" {sanitize_display(fp)}")
663 print(f"\n{len(importers)} importer(s) found")