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