gabriel / muse public
impact.py python
183 lines 6.6 KB
25a0c523 feat(code): add call-graph tier — impact, dead, coverage commands (#60) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """muse impact — transitive blast-radius analysis.
2
3 Answers the question every engineer asks before touching a function:
4 *"If I change this, what else could break?"*
5
6 ``muse impact`` builds the reverse call graph for the committed snapshot,
7 then performs a BFS from the target symbol's bare name through every caller,
8 then every caller's callers, until the full transitive closure is reached.
9
10 The result is a depth-ordered blast-radius map: depth 1 = direct callers,
11 depth 2 = callers of callers, and so on. This tells you exactly how far a
12 change propagates through the codebase.
13
14 This is structurally impossible in Git. Git stores files as blobs — it has
15 no concept of call relationships between functions. You would need an
16 external static-analysis tool and a separate dependency graph. In Muse,
17 the symbol graph is a first-class citizen of every committed snapshot.
18
19 Usage::
20
21 muse impact "src/billing.py::compute_invoice_total"
22 muse impact "src/billing.py::compute_invoice_total" --depth 2
23 muse impact "src/auth.py::validate_token" --commit HEAD~5
24 muse impact "src/core.py::content_hash" --json
25
26 Output::
27
28 Impact analysis: src/billing.py::compute_invoice_total
29 ──────────────────────────────────────────────────────────────
30
31 Depth 1 — direct callers (2):
32 src/api.py::create_invoice
33 src/billing.py::process_order
34
35 Depth 2 — callers of callers (1):
36 src/api.py::handle_request
37
38 ──────────────────────────────────────────────────────────────
39 Total blast radius: 3 symbols across 2 files
40 High impact — consider adding tests before changing this symbol.
41
42 Flags:
43
44 ``--depth, -d N``
45 Stop BFS after N levels (default: 0 = unlimited).
46
47 ``--commit, -c REF``
48 Analyse a historical snapshot instead of HEAD.
49
50 ``--json``
51 Emit the full blast-radius map as JSON.
52 """
53 from __future__ import annotations
54
55 import json
56 import logging
57 import pathlib
58
59 import typer
60
61 from muse.core.errors import ExitCode
62 from muse.core.repo import require_repo
63 from muse.core.store import get_commit_snapshot_manifest, resolve_commit_ref
64 from muse.plugins.code._callgraph import build_reverse_graph, transitive_callers
65 from muse.plugins.code._query import language_of
66
67 logger = logging.getLogger(__name__)
68
69 app = typer.Typer()
70
71
72 def _read_repo_id(root: pathlib.Path) -> str:
73 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
74
75
76 def _read_branch(root: pathlib.Path) -> str:
77 head_ref = (root / ".muse" / "HEAD").read_text().strip()
78 return head_ref.removeprefix("refs/heads/").strip()
79
80
81 @app.callback(invoke_without_command=True)
82 def impact(
83 ctx: typer.Context,
84 address: str = typer.Argument(
85 ..., metavar="ADDRESS",
86 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
87 ),
88 depth: int = typer.Option(
89 0, "--depth", "-d", metavar="N",
90 help="Maximum BFS depth (0 = unlimited).",
91 ),
92 ref: str | None = typer.Option(
93 None, "--commit", "-c", metavar="REF",
94 help="Analyse a historical snapshot instead of HEAD.",
95 ),
96 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
97 ) -> None:
98 """Show the transitive blast-radius of changing a symbol.
99
100 Builds the reverse call graph for the committed snapshot, then BFS-walks
101 it from the target symbol outwards. Depth 1 = direct callers; depth 2 =
102 callers of callers; and so on until no new callers are found.
103
104 The blast-radius map reveals exactly how far a change propagates through
105 the codebase — information that is impossible to derive from Git alone.
106
107 Python only (call-graph analysis uses stdlib ``ast``).
108 """
109 root = require_repo()
110 repo_id = _read_repo_id(root)
111 branch = _read_branch(root)
112 lang = language_of(address.split("::")[0]) if "::" in address else ""
113 if lang and lang != "Python":
114 typer.echo(
115 f"⚠️ Impact analysis is currently Python-only. '{address}' is {lang}.",
116 err=True,
117 )
118 raise typer.Exit(code=ExitCode.USER_ERROR)
119
120 commit = resolve_commit_ref(root, repo_id, branch, ref)
121 if commit is None:
122 typer.echo(f"❌ Commit '{ref or 'HEAD'}' not found.", err=True)
123 raise typer.Exit(code=ExitCode.USER_ERROR)
124
125 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
126 reverse = build_reverse_graph(root, manifest)
127
128 target_name = address.split("::")[-1].split(".")[-1] if "::" in address else address
129 blast = transitive_callers(target_name, reverse, max_depth=depth)
130
131 if as_json:
132 typer.echo(json.dumps(
133 {
134 "address": address,
135 "target_name": target_name,
136 "commit": commit.commit_id[:8],
137 "depth_limit": depth,
138 "blast_radius": {
139 str(d): addrs for d, addrs in sorted(blast.items())
140 },
141 "total": sum(len(v) for v in blast.values()),
142 },
143 indent=2,
144 ))
145 return
146
147 typer.echo(f"\nImpact analysis: {address}")
148 typer.echo("─" * 62)
149
150 if not blast:
151 typer.echo(
152 f"\n (no callers detected — '{target_name}' may be an entry point or dead code)"
153 )
154 typer.echo(
155 "\n Note: analysis covers Python only; external callers are not detected."
156 )
157 return
158
159 total = sum(len(v) for v in blast.values())
160 all_files: set[str] = set()
161
162 for d in sorted(blast.keys()):
163 callers = blast[d]
164 label = "direct callers" if d == 1 else "callers of callers" if d == 2 else f"depth-{d} callers"
165 typer.echo(f"\nDepth {d} — {label} ({len(callers)}):")
166 for addr in sorted(callers):
167 typer.echo(f" {addr}")
168 if "::" in addr:
169 all_files.add(addr.split("::")[0])
170
171 typer.echo("\n" + "─" * 62)
172 file_label = "file" if len(all_files) == 1 else "files"
173 typer.echo(f"Total blast radius: {total} symbol(s) across {len(all_files)} {file_label}")
174 if total >= 10:
175 typer.echo("🔴 High impact — add tests before changing this symbol.")
176 elif total >= 3:
177 typer.echo("🟡 Medium impact — review callers before changing this symbol.")
178 else:
179 typer.echo("🟢 Low impact — change is well-contained.")
180 typer.echo(
181 "\nNote: analysis covers Python call-sites only."
182 " Dynamic dispatch (getattr, decorators) is not detected."
183 )