gabriel / muse public

impact.py file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:6 chore(timeline): remove unused RationalRate import in entity.py · · Jul 10, 2026
1 """muse code 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 code 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 With ``--compare REF``, the command diffs the blast radius between two commits,
15 showing exactly which callers were *added* (new risk) and which were *removed*
16 (coupling reduced) β€” the ideal pre-merge signal for any proposal that touches a
17 widely-called symbol.
18
19 With ``--forward``, the direction reverses: instead of "who calls this?", it
20 answers "what does this call?" β€” the full transitive dependency fan-out of the
21 target symbol.
22
23 This is structurally impossible in Git. Git stores files as blobs β€” it has
24 no concept of call relationships between functions. You would need an
25 external static-analysis tool and a separate dependency graph. In Muse,
26 the symbol graph is a first-class citizen of every committed snapshot.
27
28 Usage::
29
30 muse code impact "src/billing.py::compute_invoice_total"
31 muse code impact "src/billing.py::compute_invoice_total" --depth 2
32 muse code impact "src/auth.py::validate_token" --commit HEAD~5
33 muse code impact "src/core.py::content_hash" --json
34 muse code impact "src/billing.py::process_order" --compare HEAD~10
35 muse code impact "src/billing.py::process_order" --forward --depth 3
36 muse code impact "src/api.py::handle_request" --file src/billing.py
37 muse code impact "src/core.py::content_hash" --count
38
39 Output::
40
41 Impact analysis: src/billing.py::compute_invoice_total
42 ──────────────────────────────────────────────────────────────
43
44 Depth 1 β€” direct callers (2):
45 src/api.py::create_invoice
46 src/billing.py::process_order
47
48 Depth 2 β€” callers of callers (1):
49 src/api.py::handle_request
50
51 ──────────────────────────────────────────────────────────────
52 Total blast radius: 3 symbols across 2 files
53 πŸ”΄ High impact β€” add tests before changing this symbol.
54
55 Flags:
56
57 ``--depth, -d N``
58 Stop BFS after N levels (default: 0 = unlimited).
59
60 ``--commit, -c REF``
61 Analyse a historical snapshot instead of HEAD.
62
63 ``--compare REF``
64 Diff blast radius between HEAD (or ``--commit``) and REF.
65 Shows added callers (new risk) and removed callers (reduced coupling).
66
67 ``--forward``
68 Show callees (dependencies) instead of callers. Answers: "what does
69 this symbol depend on?" rather than "what depends on this symbol?"
70
71 ``--file PATH``
72 Restrict the blast-radius output to callers from this file only.
73
74 ``--count``
75 Print only the total count of callers (scriptable).
76
77 ``--json``
78 Emit the full blast-radius map as JSON.
79 """
80
81 import argparse
82 import json
83 import logging
84 import pathlib
85 import sys
86
87 from muse.core.envelope import EnvelopeJson, make_envelope
88 from muse.core.errors import ExitCode
89 from muse.core.repo import require_repo
90 from muse.core.timing import start_timer
91 from typing import TypedDict
92
93 from muse.core.refs import read_current_branch
94 from muse.core.commits import (
95 CommitRecord,
96 resolve_commit_ref,
97 )
98 from muse.core.snapshots import get_commit_snapshot_manifest
99 from muse.core.symbol_cache import load_symbol_cache
100 from muse.core.callgraph_cache import load_callgraph_cache
101 from muse.core.implicit_edge_cache import load_implicit_edge_cache
102 from muse.plugins.code._callgraph import (
103 ForwardGraph,
104 ReverseGraph,
105 build_forward_graph,
106 build_reverse_graph,
107 transitive_callees,
108 transitive_callers,
109 )
110 from muse.plugins.code._framework import (
111 ImplicitEntryEdge,
112 ImplicitEdgeGraph,
113 build_implicit_edge_graph,
114 )
115 from muse.plugins.code._query import dir_of, language_of
116 from muse.core.validation import clamp_int, sanitize_display
117
118 type _BlastRadius = dict[str, list[str]]
119 type _StrMeta = dict[str, str]
120 logger = logging.getLogger(__name__)
121
122 class _EntryPointJson(TypedDict):
123 """One framework entry-point edge in JSON output."""
124
125 framework_id: str
126 kind: str
127 metadata: _StrMeta
128
129 class _ImpactJsonBase(EnvelopeJson):
130 """Required fields for ``muse code impact`` JSON output."""
131
132 mode: str
133 address: str
134 target_name: str
135 commit_id: str
136 depth_limit: int
137 total: int
138
139 class _ImpactJson(_ImpactJsonBase, total=False):
140 """Full JSON payload for ``muse code impact`` output.
141
142 Inherits required envelope + domain fields from :class:`_ImpactJsonBase`.
143
144 Fields (all optional)
145 ---------------------
146 file_filter File scope applied, or ``None``.
147 blast_radius Depth-keyed map of caller addresses (reverse mode).
148 callees Depth-keyed map of callee names (forward mode).
149 entry_points Framework entry-point edges, if any.
150 compare_commit_id Commit ID of the ``--compare`` snapshot.
151 added_callers Callers present in HEAD but not in compare.
152 removed_callers Callers present in compare but not in HEAD.
153 net_change ``len(added_callers) - len(removed_callers)``.
154 """
155
156 file_filter: str | None
157 blast_radius: _BlastRadius
158 callees: dict[str, list[str]]
159 entry_points: list[_EntryPointJson]
160 compare_commit_id: str
161 added_callers: list[str]
162 removed_callers: list[str]
163 net_change: int
164
165 def _flat_addrs(blast: dict[int, list[str]]) -> set[str]:
166 """Return the flat set of all addresses across all depths."""
167 return {addr for addrs in blast.values() for addr in addrs}
168
169 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
170 """Register the impact subcommand."""
171 parser = subparsers.add_parser(
172 "impact",
173 help="Show the transitive blast-radius of changing a symbol.",
174 description=__doc__,
175 formatter_class=argparse.RawDescriptionHelpFormatter,
176 )
177 parser.add_argument(
178 "address", metavar="ADDRESS",
179 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
180 )
181 parser.add_argument(
182 "--depth", "-d", type=int, default=None, metavar="N",
183 help="Maximum BFS depth (default: 5, max: 50). Use --transitive for full traversal.",
184 )
185 parser.add_argument(
186 "--commit", "-c", default=None, metavar="REF", dest="ref",
187 help="Analyse a historical snapshot instead of HEAD.",
188 )
189 parser.add_argument(
190 "--compare", default=None, metavar="REF", dest="compare_ref",
191 help="Diff blast radius against this commit reference.",
192 )
193 parser.add_argument(
194 "--forward", action="store_true", dest="forward",
195 help="Show callees (dependencies) instead of callers.",
196 )
197 parser.add_argument(
198 "--file", "-f", default=None, metavar="PATH", dest="file_filter",
199 help="Restrict blast-radius output to callers from this file.",
200 )
201 parser.add_argument(
202 "--count", action="store_true", dest="count_only",
203 help="Print only the total count of matching symbols.",
204 )
205 parser.add_argument(
206 "--roll-up-to",
207 default=None, choices=("directory",), metavar="LEVEL", dest="roll_up_to",
208 help="Roll up blast-radius addresses to the given granularity (e.g. 'directory').",
209 )
210 parser.add_argument(
211 "--json", "-j", action="store_true", dest="json_out",
212 help="Emit results as JSON.",
213 )
214 parser.set_defaults(func=run)
215
216 def run(args: argparse.Namespace) -> None:
217 """Show the transitive blast-radius of changing a symbol.
218
219 Builds the reverse call graph for the committed snapshot, then BFS-walks
220 it outward from the target symbol. Depth 1 = direct callers; depth 2 =
221 callers of callers; and so on. Use ``--compare`` to diff the blast radius
222 against another commit; ``--forward`` to walk callees instead of callers.
223
224 Agent quickstart
225 ----------------
226 ::
227
228 muse code impact "src/billing.py::compute_total" --json
229 muse code impact "src/billing.py::compute_total" --depth 3 --json
230 muse code impact "src/billing.py::compute_total" --forward --json
231 muse code impact "src/billing.py::compute_total" --compare HEAD~10 --json
232
233 JSON fields (reverse / blast-radius)
234 -------------------------------------
235 mode ``"reverse"``.
236 address Symbol address analysed.
237 commit_id Commit snapshot used.
238 blast_radius Depth-keyed map of caller addresses (strings ``"1"``, ``"2"``…).
239 total Total caller count across all depths.
240
241 JSON fields (``--forward`` / callees)
242 ---------------------------------------
243 mode ``"forward"``.
244 callees Depth-keyed map of callee names.
245 total Total callee count.
246
247 Exit codes
248 ----------
249 0 Analysis complete.
250 1 Symbol not found or invalid arguments.
251 2 Not inside a Muse repository.
252 """
253 elapsed = start_timer()
254 address: str = args.address
255 _depth_raw: int | None = args.depth
256 depth: int = clamp_int(_depth_raw, 1, 50, "depth") if _depth_raw is not None else 5
257 ref: str | None = args.ref
258 compare_ref: str | None = args.compare_ref
259 forward: bool = args.forward
260 file_filter: str | None = args.file_filter
261 count_only: bool = args.count_only
262 json_out: bool = args.json_out
263 roll_up_to: str | None = getattr(args, "roll_up_to", None)
264
265 if forward and compare_ref:
266 print("❌ --forward and --compare are mutually exclusive.", file=sys.stderr)
267 raise SystemExit(ExitCode.USER_ERROR)
268
269 root = require_repo()
270 branch = read_current_branch(root)
271
272 lang = language_of(address.split("::")[0]) if "::" in address else ""
273 if lang and lang != "Python":
274 print(
275 f"⚠️ Impact analysis is currently Python-only. '{address}' is {lang}.",
276 file=sys.stderr,
277 )
278 raise SystemExit(ExitCode.USER_ERROR)
279
280 commit = resolve_commit_ref(root, branch, ref)
281 if commit is None:
282 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
283 raise SystemExit(ExitCode.USER_ERROR)
284
285 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
286
287 cache = load_symbol_cache(root)
288 cg_cache = load_callgraph_cache(root)
289 implicit_cache = load_implicit_edge_cache(root)
290
291 target_name = address.split("::")[-1].split(".")[-1] if "::" in address else address
292
293 if forward:
294 fwd: ForwardGraph = build_forward_graph(root, manifest, cache=cache, callgraph_cache=cg_cache)
295 callee_map = transitive_callees(address, fwd, max_depth=depth)
296 cache.save()
297 cg_cache.save()
298 _render_forward(
299 address=address,
300 target_name=target_name,
301 commit=commit,
302 callee_map=callee_map,
303 depth_limit=depth,
304 count_only=count_only,
305 json_out=json_out,
306 duration_ms=elapsed(),
307 )
308 return
309
310 rev: ReverseGraph = build_reverse_graph(root, manifest, cache=cache, callgraph_cache=cg_cache)
311 implicit: ImplicitEdgeGraph = build_implicit_edge_graph(root, manifest, cache=cache, implicit_cache=implicit_cache)
312 blast = transitive_callers(target_name, rev, max_depth=depth)
313
314 if file_filter:
315 blast = {
316 d: [a for a in addrs if "::" in a and a.split("::")[0] == file_filter]
317 for d, addrs in blast.items()
318 }
319 blast = {d: addrs for d, addrs in blast.items() if addrs}
320
321 compare_commit: CommitRecord | None = None
322 added: set[str] = set()
323 removed: set[str] = set()
324
325 if compare_ref:
326 compare_commit = resolve_commit_ref(root, branch, compare_ref)
327 if compare_commit is None:
328 print(f"❌ --compare commit '{compare_ref}' not found.", file=sys.stderr)
329 raise SystemExit(ExitCode.USER_ERROR)
330 compare_manifest = get_commit_snapshot_manifest(root, compare_commit.commit_id) or {}
331 compare_rev: ReverseGraph = build_reverse_graph(root, compare_manifest, cache=cache, callgraph_cache=cg_cache)
332 compare_blast = transitive_callers(target_name, compare_rev, max_depth=depth)
333
334 if file_filter:
335 compare_blast = {
336 d: [a for a in addrs if "::" in a and a.split("::")[0] == file_filter]
337 for d, addrs in compare_blast.items()
338 }
339 compare_blast = {d: addrs for d, addrs in compare_blast.items() if addrs}
340
341 current_flat = _flat_addrs(blast)
342 compare_flat = _flat_addrs(compare_blast)
343 added = current_flat - compare_flat
344 removed = compare_flat - current_flat
345
346 cache.save()
347 cg_cache.save()
348 implicit_cache.save()
349
350 entry_points = implicit.get(address, [])
351
352 _render_reverse(
353 address=address,
354 target_name=target_name,
355 commit=commit,
356 blast=blast,
357 entry_points=entry_points,
358 depth_limit=depth,
359 file_filter=file_filter,
360 compare_commit=compare_commit,
361 added=added,
362 removed=removed,
363 count_only=count_only,
364 json_out=json_out,
365 roll_up_to=roll_up_to,
366 duration_ms=elapsed(),
367 )
368
369 # ---------------------------------------------------------------------------
370 # Renderers
371 # ---------------------------------------------------------------------------
372
373 def _render_forward(
374 *,
375 address: str,
376 target_name: str,
377 commit: CommitRecord,
378 callee_map: dict[int, list[str]],
379 depth_limit: int,
380 count_only: bool,
381 json_out: bool,
382 duration_ms: float = 0.0,
383 ) -> None:
384 """Render forward (callees) analysis."""
385 total = sum(len(v) for v in callee_map.values())
386
387 if count_only and not json_out:
388 print(total)
389 return
390
391 if json_out:
392 out = _ImpactJson(
393 **make_envelope(lambda: duration_ms),
394 mode="forward",
395 address=address,
396 target_name=target_name,
397 commit_id=commit.commit_id,
398 depth_limit=depth_limit,
399 total=total,
400 )
401 out["callees"] = {str(d): names for d, names in sorted(callee_map.items())}
402 print(json.dumps(out))
403 return
404
405 print(f"\nDependency fan-out: {sanitize_display(address)}")
406 print("─" * 62)
407
408 if not callee_map:
409 print(f"\n ('{target_name}' calls nothing detectable β€” leaf function or dynamic dispatch)")
410 return
411
412 for d in sorted(callee_map.keys()):
413 label = "direct callees" if d == 1 else f"depth-{d} callees"
414 print(f"\nDepth {d} β€” {label} ({len(callee_map[d])}):")
415 for name in sorted(callee_map[d]):
416 print(f" {sanitize_display(name)}")
417
418 print(f"\n{'─' * 62}")
419 print(f"Total dependency fan-out: {total} callee(s)")
420 print("\nNote: analysis covers Python call-sites only.")
421
422 def _render_reverse(
423 *,
424 address: str,
425 target_name: str,
426 commit: CommitRecord,
427 blast: dict[int, list[str]],
428 entry_points: list[ImplicitEntryEdge],
429 depth_limit: int,
430 file_filter: str | None,
431 compare_commit: CommitRecord | None,
432 added: set[str],
433 removed: set[str],
434 count_only: bool,
435 json_out: bool,
436 roll_up_to: str | None = None,
437 duration_ms: float = 0.0,
438 ) -> None:
439 """Render reverse (callers / blast-radius) analysis."""
440 total = sum(len(v) for v in blast.values())
441
442 if count_only and not json_out:
443 print(total)
444 return
445
446 if json_out:
447 payload = _ImpactJson(
448 **make_envelope(lambda: duration_ms),
449 mode="reverse",
450 address=address,
451 target_name=target_name,
452 commit_id=commit.commit_id,
453 depth_limit=depth_limit,
454 total=total,
455 )
456 payload["file_filter"] = file_filter
457 payload["blast_radius"] = {str(d): list(addrs) for d, addrs in sorted(blast.items())}
458 if roll_up_to == "directory":
459 dir_counts: dict[str, int] = {}
460 for addrs in blast.values():
461 for addr in addrs:
462 d = dir_of(addr.split("::")[0]) if "::" in addr else dir_of(addr)
463 dir_counts[d] = dir_counts.get(d, 0) + 1
464 payload["directory_blast_radius"] = dir_counts
465 if entry_points:
466 payload["entry_points"] = [
467 _EntryPointJson(
468 framework_id=ep.framework_id,
469 kind=ep.kind,
470 metadata=ep.metadata,
471 )
472 for ep in entry_points
473 ]
474 if compare_commit is not None:
475 payload["compare_commit_id"] = compare_commit.commit_id
476 payload["added_callers"] = sorted(added)
477 payload["removed_callers"] = sorted(removed)
478 payload["net_change"] = len(added) - len(removed)
479 print(json.dumps(payload))
480 return
481
482 print(f"\nImpact analysis: {sanitize_display(address)}")
483 if file_filter:
484 print(f"(filtered to callers in: {file_filter})")
485 print("─" * 62)
486
487 if entry_points:
488 print("\n Framework entry point β€” externally reachable via:")
489 for ep in entry_points:
490 meta_str = " ".join(f"{k}={v}" for k, v in ep.metadata.items() if v)
491 print(f" [{ep.framework_id}] kind={ep.kind} {meta_str}")
492
493 if not blast:
494 if entry_points:
495 print("\n (no explicit callers in user code β€” wired by the framework above)")
496 else:
497 print(
498 f"\n (no callers detected β€” '{target_name}' may be an entry point or dead code)"
499 )
500 print("\n Note: analysis covers Python only; external callers are not detected.")
501 else:
502 all_files: set[str] = set()
503 for d in sorted(blast.keys()):
504 callers = blast[d]
505 if d == 1:
506 label = "direct callers"
507 elif d == 2:
508 label = "callers of callers"
509 else:
510 label = f"depth-{d} callers"
511 print(f"\nDepth {d} β€” {label} ({len(callers)}):")
512 for addr in sorted(callers):
513 marker = " ← NEW" if addr in added else ""
514 print(f" {sanitize_display(addr)}{marker}")
515 if "::" in addr:
516 all_files.add(addr.split("::")[0])
517
518 print(f"\n{'─' * 62}")
519 file_label = "file" if len(all_files) == 1 else "files"
520 print(f"Total blast radius: {total} symbol(s) across {len(all_files)} {file_label}")
521 if total >= 10:
522 print("πŸ”΄ High impact β€” add tests before changing this symbol.")
523 elif total >= 3:
524 print("🟑 Medium impact β€” review callers before changing this symbol.")
525 else:
526 print("🟒 Low impact β€” change is well-contained.")
527 print(
528 "\nNote: analysis covers Python call-sites only."
529 " Dynamic dispatch (getattr, decorators) is not detected."
530 )
531
532 if compare_commit is not None:
533 print(f"\nBlast-radius diff vs {compare_commit.commit_id}:")
534 print("─" * 62)
535 if not added and not removed:
536 print(" No change in blast radius.")
537 else:
538 net = len(added) - len(removed)
539 sign = "+" if net >= 0 else ""
540 print(f" Net change: {sign}{net} caller(s)")
541 if added:
542 print(f"\n Added ({len(added)}):")
543 for a in sorted(added):
544 print(f" + {a}")
545 if removed:
546 print(f"\n Removed ({len(removed)}):")
547 for r in sorted(removed):
548 print(f" - {r}")