gabriel / muse public
impact.py python
184 lines 6.6 KB
bda49bdb feat: redesign .museignore as TOML with domain-scoped sections (#100) 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
54 from __future__ import annotations
55
56 import json
57 import logging
58 import pathlib
59
60 import typer
61
62 from muse.core.errors import ExitCode
63 from muse.core.repo import require_repo
64 from muse.core.store import get_commit_snapshot_manifest, resolve_commit_ref
65 from muse.plugins.code._callgraph import build_reverse_graph, transitive_callers
66 from muse.plugins.code._query import language_of
67
68 logger = logging.getLogger(__name__)
69
70 app = typer.Typer()
71
72
73 def _read_repo_id(root: pathlib.Path) -> str:
74 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
75
76
77 def _read_branch(root: pathlib.Path) -> str:
78 head_ref = (root / ".muse" / "HEAD").read_text().strip()
79 return head_ref.removeprefix("refs/heads/").strip()
80
81
82 @app.callback(invoke_without_command=True)
83 def impact(
84 ctx: typer.Context,
85 address: str = typer.Argument(
86 ..., metavar="ADDRESS",
87 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
88 ),
89 depth: int = typer.Option(
90 0, "--depth", "-d", metavar="N",
91 help="Maximum BFS depth (0 = unlimited).",
92 ),
93 ref: str | None = typer.Option(
94 None, "--commit", "-c", metavar="REF",
95 help="Analyse a historical snapshot instead of HEAD.",
96 ),
97 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
98 ) -> None:
99 """Show the transitive blast-radius of changing a symbol.
100
101 Builds the reverse call graph for the committed snapshot, then BFS-walks
102 it from the target symbol outwards. Depth 1 = direct callers; depth 2 =
103 callers of callers; and so on until no new callers are found.
104
105 The blast-radius map reveals exactly how far a change propagates through
106 the codebase — information that is impossible to derive from Git alone.
107
108 Python only (call-graph analysis uses stdlib ``ast``).
109 """
110 root = require_repo()
111 repo_id = _read_repo_id(root)
112 branch = _read_branch(root)
113 lang = language_of(address.split("::")[0]) if "::" in address else ""
114 if lang and lang != "Python":
115 typer.echo(
116 f"⚠️ Impact analysis is currently Python-only. '{address}' is {lang}.",
117 err=True,
118 )
119 raise typer.Exit(code=ExitCode.USER_ERROR)
120
121 commit = resolve_commit_ref(root, repo_id, branch, ref)
122 if commit is None:
123 typer.echo(f"❌ Commit '{ref or 'HEAD'}' not found.", err=True)
124 raise typer.Exit(code=ExitCode.USER_ERROR)
125
126 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
127 reverse = build_reverse_graph(root, manifest)
128
129 target_name = address.split("::")[-1].split(".")[-1] if "::" in address else address
130 blast = transitive_callers(target_name, reverse, max_depth=depth)
131
132 if as_json:
133 typer.echo(json.dumps(
134 {
135 "address": address,
136 "target_name": target_name,
137 "commit": commit.commit_id[:8],
138 "depth_limit": depth,
139 "blast_radius": {
140 str(d): addrs for d, addrs in sorted(blast.items())
141 },
142 "total": sum(len(v) for v in blast.values()),
143 },
144 indent=2,
145 ))
146 return
147
148 typer.echo(f"\nImpact analysis: {address}")
149 typer.echo("─" * 62)
150
151 if not blast:
152 typer.echo(
153 f"\n (no callers detected — '{target_name}' may be an entry point or dead code)"
154 )
155 typer.echo(
156 "\n Note: analysis covers Python only; external callers are not detected."
157 )
158 return
159
160 total = sum(len(v) for v in blast.values())
161 all_files: set[str] = set()
162
163 for d in sorted(blast.keys()):
164 callers = blast[d]
165 label = "direct callers" if d == 1 else "callers of callers" if d == 2 else f"depth-{d} callers"
166 typer.echo(f"\nDepth {d} — {label} ({len(callers)}):")
167 for addr in sorted(callers):
168 typer.echo(f" {addr}")
169 if "::" in addr:
170 all_files.add(addr.split("::")[0])
171
172 typer.echo("\n" + "─" * 62)
173 file_label = "file" if len(all_files) == 1 else "files"
174 typer.echo(f"Total blast radius: {total} symbol(s) across {len(all_files)} {file_label}")
175 if total >= 10:
176 typer.echo("🔴 High impact — add tests before changing this symbol.")
177 elif total >= 3:
178 typer.echo("🟡 Medium impact — review callers before changing this symbol.")
179 else:
180 typer.echo("🟢 Low impact — change is well-contained.")
181 typer.echo(
182 "\nNote: analysis covers Python call-sites only."
183 " Dynamic dispatch (getattr, decorators) is not detected."
184 )