gabriel / muse public
gravity.py python
844 lines 31.1 KB
Raw
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 1 day ago
1 """muse code gravity — structural weight of every symbol.
2
3 "Gravity" answers the question every architect dreads:
4
5 **"If I change the contract of this symbol, how much of the codebase breaks?"**
6
7 Not just the direct callers — the full transitive closure. If ``read_object``
8 changes, that breaks ``read_commit``, which breaks ``resolve_commit_ref``, which
9 breaks every route handler, which breaks every CLI command that touches the
10 repo. That is gravity: the fraction of the production codebase that lives
11 downstream of a symbol in the call graph.
12
13 Why this matters
14 ----------------
15 * High-gravity symbols are **structural pillars**. Treat their public
16 contracts as APIs — design changes carefully, communicate broadly, bump
17 MAJOR.
18 * Low-gravity symbols are **safe to refactor**. They sit at the edge of the
19 dependency graph with few downstream effects.
20 * **Gravity ≠ hotspot.** A frequently-changed symbol with low gravity is a
21 quick iteration loop. A rarely-changed symbol with gravity 90% is a time
22 bomb — the moment its contract shifts, everything breaks.
23
24 How it works
25 ------------
26 1. Loads the committed HEAD snapshot and builds the production call graph
27 (AST-based, same approach as ``blast-risk`` and ``semantic-test-coverage``).
28 2. Inverts the graph: for each symbol, BFS through the reverse call graph to
29 find every symbol that transitively depends on it.
30 3. ``gravity_pct = transitive_dependents / total_production_symbols × 100``.
31
32 Test files are excluded from the dependency graph by default (a function being
33 called by 50 test functions does not mean it has production structural weight).
34 Pass ``--include-tests`` to count test callers as well.
35
36 Usage::
37
38 muse code gravity
39 muse code gravity --top 20
40 muse code gravity --min-gravity 50
41 muse code gravity --kind function
42 muse code gravity --file core/store.py
43 muse code gravity --sort depth
44 muse code gravity --depth 3
45 muse code gravity --explain "muse/core/object_store.py::read_object"
46 muse code gravity --include-tests
47 muse code gravity --json
48
49 Default output::
50
51 Symbol gravity — HEAD (378 production symbols)
52 Structural weight: fraction of production codebase that transitively depends on each symbol.
53
54 # ADDRESS GRAVITY DIRECT DEPTH
55 1 muse/core/object_store.py::read_object 94.2% 28 8
56 2 muse/core/store.py::resolve_commit_ref 87.1% 47 6
57 3 muse/core/errors.py::ExitCode 75.4% 31 5
58
59 ⚠️ High-gravity symbols are structural pillars. Treat their contracts as APIs.
60
61 ``--explain`` output::
62
63 Gravity breakdown — muse/core/object_store.py::read_object
64
65 Kind: function
66 Total gravity: 356 / 378 (94.2%)
67 Direct callers: 28
68 Max depth: 8
69
70 Depth distribution:
71 depth 1 (direct): 28 callers ████████████████████
72 depth 2: 89 callers ████████████████████████████████████████
73 depth 3: 127 callers ████████████████████████████████████████████████████
74 depth 4: 82 callers ██████████████████████████████████
75 depth 5+: 30 callers ████████████
76
77 Deepest callers (depth 8):
78 muse/api/routes/wire.py::push_handler
79 muse/api/routes/wire.py::pull_handler
80 muse/cli/commands/log.py::run
81
82 A breaking change here propagates through 8 layers of the codebase.
83
84 JSON output (``--json``)::
85
86 {
87 "ref": "HEAD",
88 "snapshot_id": "a3f2c9e1ab2c",
89 "total_production_symbols": 378,
90 "max_depth": 0,
91 "include_tests": false,
92 "filters": { "kind": null, "file": null, "min_gravity": 0.0, "top": 20 },
93 "symbols": [
94 {
95 "address": "muse/core/object_store.py::read_object",
96 "name": "read_object",
97 "kind": "function",
98 "file": "muse/core/object_store.py",
99 "gravity_pct": 94.2,
100 "direct_dependents": 28,
101 "transitive_dependents": 356,
102 "max_depth": 8,
103 "depth_distribution": { "1": 28, "2": 89, "3": 127, "4": 82, "5": 30 }
104 }
105 ]
106 }
107
108 Security note
109 -------------
110 Symbol addresses are validated to contain ``::`` before any store access.
111 File paths from the manifest are not passed to the shell — all access goes
112 through the object store's content-addressed lookup.
113 """
114
115 import argparse
116 import ast
117 import json
118 import logging
119 import pathlib
120 import sys
121 from typing import Literal, TypedDict
122
123 from muse.core.envelope import EnvelopeJson, make_envelope
124 from muse.core.errors import ExitCode
125 from muse.core.repo import require_repo
126 from muse.core.timing import start_timer
127 from muse.core.types import Manifest
128 from muse.core.refs import read_current_branch
129 from muse.core.commits import resolve_commit_ref
130 from muse.core.snapshots import get_commit_snapshot_manifest
131 from muse.plugins.code._callgraph import (
132 ForwardGraph,
133 ReverseGraph,
134 call_name,
135 find_func_node,
136 transitive_callers,
137 )
138 from muse.plugins.code._query import is_test_file, symbols_for_snapshot
139 from muse.plugins.code.ast_parser import SymbolRecord, parse_symbols
140 from muse.core.validation import clamp_int, MAX_AST_BYTES, sanitize_display
141
142 type _BlobMap = dict[str, bytes]
143 type _SymbolIndex = dict[str, SymbolRecord]
144 type _CounterMap = dict[str, int]
145
146 logger = logging.getLogger(__name__)
147
148 # ── Constants ──────────────────────────────────────────────────────────────────
149
150 _DEFAULT_TOP = 20
151 _DEFAULT_DEPTH = 0 # 0 = unlimited
152 _PY_SUFFIXES: frozenset[str] = frozenset({".py", ".pyi"})
153
154 _IMPORT_KIND = "import"
155 _TRACKED_KINDS: frozenset[str] = frozenset(
156 {"function", "async_function", "method", "async_method", "class"}
157 )
158
159 SortKey = Literal["gravity", "direct", "depth"]
160 _SORT_CHOICES: tuple[SortKey, ...] = ("gravity", "direct", "depth")
161
162 _BAR_MAX_WIDTH = 40
163
164 # ── TypedDicts ─────────────────────────────────────────────────────────────────
165
166 class _SymbolGravity(TypedDict):
167 """Gravity record for a single production symbol.
168
169 Nested inside the ``symbols`` list of :class:`_JsonOut`.
170
171 Fields
172 ------
173 address Full symbol address (``file.py::ClassName.method``).
174 name Short symbol name (last component of address).
175 kind Symbol kind: ``"function"``, ``"class"``, ``"method"``, etc.
176 file Source file path relative to the repo root.
177 gravity_pct Percentage of production symbols that transitively depend
178 on this one (0.0–100.0). Higher = more dangerous to change.
179 direct_dependents Number of symbols that directly call or import this one.
180 transitive_dependents Total count of symbols reachable via the reverse call graph.
181 max_depth Maximum BFS depth reached in the reverse dependency traversal.
182 depth_distribution Count of dependent symbols per BFS depth layer
183 (``{"1": N, "2": M, …}``).
184 """
185
186 address: str
187 name: str
188 kind: str
189 file: str
190 gravity_pct: float
191 direct_dependents: int
192 transitive_dependents: int
193 max_depth: int
194 depth_distribution: _CounterMap
195
196 class _FilterSpec(TypedDict):
197 """Active filter values echoed back in gravity JSON output.
198
199 Fields
200 ------
201 kind Symbol kind filter applied, or ``None`` for all kinds.
202 file File path filter applied, or ``None`` for all files.
203 min_gravity Minimum ``gravity_pct`` threshold — symbols below this were excluded.
204 top Maximum number of symbols returned (leaderboard cap).
205 """
206
207 kind: str | None
208 file: str | None
209 min_gravity: float
210 top: int
211
212 class _JsonOut(EnvelopeJson):
213 """JSON output for ``muse code gravity --json`` (leaderboard mode).
214
215 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
216
217 Fields
218 ------
219 ref Branch ref at which analysis was performed.
220 snapshot_id Short commit ID for the HEAD snapshot.
221 total_production_symbols Total production symbols in the snapshot.
222 max_depth BFS depth cap (0 = unlimited).
223 include_tests Whether test-file callers were counted.
224 filters Active filter values (kind, file, min_gravity, top).
225 symbols Ranked list of symbol gravity records.
226 """
227
228 ref: str
229 snapshot_id: str
230 total_production_symbols: int
231 max_depth: int
232 include_tests: bool
233 filters: _FilterSpec
234 symbols: list[_SymbolGravity]
235
236 class _GravityExplainJson(EnvelopeJson):
237 """JSON output for ``muse code gravity --explain ADDR --json``.
238
239 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
240
241 Fields
242 ------
243 address Symbol address being explained.
244 name Symbol short name.
245 kind Symbol kind (function, class, method, …).
246 file Source file path.
247 gravity_pct Fraction of production codebase that transitively
248 depends on this symbol, expressed as a percentage.
249 direct_dependents Number of symbols that directly call this one.
250 transitive_dependents Total transitive dependent count.
251 max_depth Maximum depth reached in the reverse BFS.
252 depth_distribution Count of dependents per BFS depth layer.
253 """
254
255 address: str
256 name: str
257 kind: str
258 file: str
259 gravity_pct: float
260 direct_dependents: int
261 transitive_dependents: int
262 max_depth: int
263 depth_distribution: _CounterMap
264
265 # ── Helpers ────────────────────────────────────────────────────────────────────
266
267 def _load_py_blobs(
268 root: pathlib.Path,
269 manifest: Manifest,
270 include_tests: bool,
271 ) -> _BlobMap:
272 """Load Python file blobs from the object store into memory.
273
274 Args:
275 root: Repository root.
276 manifest: Snapshot manifest.
277 include_tests: When False (default), test files are excluded.
278
279 Returns:
280 Mapping ``{file_path: raw_bytes}`` for qualifying Python files.
281 """
282 from muse.core.object_store import read_object
283
284 blobs: _BlobMap = {}
285 for file_path, obj_id in manifest.items():
286 if pathlib.PurePosixPath(file_path).suffix.lower() not in _PY_SUFFIXES:
287 continue
288 if not include_tests and is_test_file(file_path):
289 continue
290 raw = read_object(root, obj_id)
291 if raw is not None:
292 blobs[file_path] = raw
293 return blobs
294
295 def _build_forward_graph(
296 blobs: _BlobMap,
297 ) -> ForwardGraph:
298 """Build the forward call graph from pre-loaded blobs.
299
300 Scans every Python file in *blobs*, walking each callable symbol's body
301 for ``ast.Call`` nodes. Returns ``{caller_address: frozenset[callee_bare_name]}``.
302
303 Building from pre-loaded blobs avoids a second object-store round-trip.
304 """
305 graph: ForwardGraph = {}
306 for file_path, raw in blobs.items():
307 try:
308 if len(raw) > MAX_AST_BYTES:
309 return {}
310 tree = ast.parse(raw)
311 except SyntaxError:
312 continue
313 sym_tree = parse_symbols(raw, file_path)
314 for addr, rec in sym_tree.items():
315 if rec["kind"] not in {"function", "async_function", "method", "async_method"}:
316 continue
317 func_node = find_func_node(tree.body, rec["qualified_name"].split("."))
318 if func_node is None:
319 continue
320 callees: set[str] = set()
321 for node in ast.walk(func_node):
322 if isinstance(node, ast.Call):
323 n = call_name(node.func)
324 if n:
325 callees.add(n)
326 graph[addr] = frozenset(callees)
327 return graph
328
329 def _invert_graph(forward: ForwardGraph) -> ReverseGraph:
330 """Invert the forward call graph to get ``{callee_bare_name: [caller_addr, ...]}``.
331
332 Produces a sorted list of callers for each callee name so output is
333 deterministic across runs.
334 """
335 reverse: ReverseGraph = {}
336 for caller_addr, callee_names in forward.items():
337 for name in callee_names:
338 reverse.setdefault(name, []).append(caller_addr)
339 for name in reverse:
340 reverse[name].sort()
341 return reverse
342
343 def _gravity_bar(count: int, max_count: int, width: int = _BAR_MAX_WIDTH) -> str:
344 """Return a unicode block bar proportional to *count* / *max_count*."""
345 if max_count <= 0:
346 return ""
347 filled = round(count / max_count * width)
348 return "█" * filled
349
350 # ── Core algorithm ─────────────────────────────────────────────────────────────
351
352 def _compute_gravity_for_symbol(
353 bare_name: str,
354 reverse: ReverseGraph,
355 max_depth: int,
356 ) -> tuple[dict[int, list[str]], int]:
357 """Compute transitive dependents for a symbol with the given bare name.
358
359 Uses the existing ``transitive_callers`` BFS from ``_callgraph``.
360
361 Args:
362 bare_name: Bare callee name (last component of the symbol address).
363 reverse: Reverse call graph from ``_invert_graph``.
364 max_depth: BFS depth limit; ``0`` = unlimited.
365
366 Returns:
367 ``(depth_map, max_reached_depth)`` where depth_map is
368 ``{depth: [caller_address, ...]}``.
369 """
370 depth_map = transitive_callers(bare_name, reverse, max_depth=max_depth)
371 max_reached = max(depth_map) if depth_map else 0
372 return depth_map, max_reached
373
374 def _build_gravity_records(
375 prod_symbols: _SymbolIndex,
376 reverse: ReverseGraph,
377 max_depth: int,
378 file_filter: str | None,
379 kind_filter: str | None,
380 min_gravity: float,
381 top: int,
382 sort_key: SortKey,
383 ) -> tuple[list[_SymbolGravity], int]:
384 """Compute gravity for every qualifying production symbol.
385
386 Args:
387 prod_symbols: Flat ``{address: SymbolRecord}`` for all production symbols.
388 reverse: Reverse call graph.
389 max_depth: BFS depth cap (``0`` = unlimited).
390 file_filter: Optional path suffix filter.
391 kind_filter: Optional symbol kind filter.
392 min_gravity: Minimum gravity percentage threshold.
393 top: Maximum number of records to return (``0`` = unlimited).
394 sort_key: Sort dimension.
395
396 Returns:
397 ``(records, total_prod_symbols)`` — records are sorted and truncated
398 to *top*. ``total_prod_symbols`` is the unfiltered production symbol
399 count used as the denominator for gravity %.
400 """
401 total = len(prod_symbols)
402 if total == 0:
403 return [], 0
404
405 records: list[_SymbolGravity] = []
406
407 for addr, rec in prod_symbols.items():
408 kind = rec["kind"]
409 if kind == _IMPORT_KIND or kind not in _TRACKED_KINDS:
410 continue
411 file_path = addr.split("::")[0]
412 if file_filter and file_filter not in file_path:
413 continue
414 if kind_filter and kind != kind_filter:
415 continue
416
417 bare_name = rec["name"]
418 depth_map, max_reached = _compute_gravity_for_symbol(bare_name, reverse, max_depth)
419
420 direct = len(depth_map.get(1, []))
421 total_trans = sum(len(v) for v in depth_map.values())
422 denom = max(1, total - 1) # exclude self
423 pct = round(total_trans / denom * 100, 1)
424
425 if pct < min_gravity:
426 continue
427
428 # Flatten depth distribution to str-keyed dict for JSON.
429 dist: _CounterMap = {str(d): len(addrs) for d, addrs in depth_map.items()}
430
431 records.append(
432 _SymbolGravity(
433 address=addr,
434 name=bare_name,
435 kind=kind,
436 file=file_path,
437 gravity_pct=pct,
438 direct_dependents=direct,
439 transitive_dependents=total_trans,
440 max_depth=max_reached,
441 depth_distribution=dist,
442 )
443 )
444
445 # Sort.
446 if sort_key == "direct":
447 records.sort(key=lambda r: (r["direct_dependents"], r["gravity_pct"]), reverse=True)
448 elif sort_key == "depth":
449 records.sort(key=lambda r: (r["max_depth"], r["gravity_pct"]), reverse=True)
450 else: # gravity (default)
451 records.sort(key=lambda r: (r["gravity_pct"], r["transitive_dependents"]), reverse=True)
452
453 if top > 0:
454 records = records[:top]
455
456 return records, total
457
458 def _find_deepest_callers(
459 bare_name: str,
460 reverse: ReverseGraph,
461 max_depth: int,
462 show_count: int = 5,
463 ) -> tuple[list[str], int]:
464 """Return the addresses of the deepest callers (longest dependency chains).
465
466 Args:
467 bare_name: Target symbol's bare name.
468 reverse: Reverse call graph.
469 max_depth: BFS depth cap; 0 = unlimited.
470 show_count: Maximum number of deepest-caller addresses to return.
471
472 Returns:
473 ``(addresses, depth)`` — the deepest addresses found, and the depth at
474 which they were found.
475 """
476 depth_map, max_reached = _compute_gravity_for_symbol(bare_name, reverse, max_depth)
477 if not depth_map:
478 return [], 0
479 deepest = depth_map[max_reached]
480 return deepest[:show_count], max_reached
481
482 # ── Formatters ─────────────────────────────────────────────────────────────────
483
484 def _print_leaderboard(
485 records: list[_SymbolGravity],
486 total_prod: int,
487 max_depth_cap: int,
488 sort_key: SortKey,
489 include_tests: bool,
490 ) -> None:
491 """Print the human-readable gravity leaderboard."""
492 scope = "all symbols" if include_tests else "production symbols"
493 cap_str = f" · depth cap: {max_depth_cap}" if max_depth_cap > 0 else ""
494 print(f"\n Symbol gravity — HEAD ({total_prod} {scope}{cap_str})")
495 print(
496 f" Structural weight: fraction of {scope} that transitively depends"
497 " on each symbol.\n"
498 )
499
500 if not records:
501 print(" No symbols match the current filters.\n")
502 return
503
504 # Column widths.
505 addr_w = min(60, max(len(r["address"]) for r in records) + 2)
506 print(
507 f" {'#':>4} {'ADDRESS':<{addr_w}} {'GRAVITY':>8} {'DIRECT':>7} {'DEPTH':>6}"
508 )
509 print(f" {'─' * 4} {'─' * addr_w} {'─' * 8} {'─' * 7} {'─' * 6}")
510
511 for i, rec in enumerate(records, 1):
512 addr = rec["address"]
513 if len(addr) > addr_w:
514 addr = f"…{addr[-(addr_w - 1):]}"
515 pct_str = f"{rec['gravity_pct']:5.1f}%"
516 print(
517 f" {i:>4} {addr:<{addr_w}} {pct_str:>8}"
518 f" {rec['direct_dependents']:>7}"
519 f" {rec['max_depth']:>6}"
520 )
521
522 print()
523 if records:
524 top = records[0]
525 print(
526 f" ⚠️ {top['name']} carries {top['gravity_pct']:.1f}% structural weight"
527 " — treat its contract as an API."
528 )
529 print()
530
531 def _print_explain(
532 rec: _SymbolGravity,
533 reverse: ReverseGraph,
534 max_depth: int,
535 total_prod: int,
536 ) -> None:
537 """Print the detailed gravity breakdown for a single symbol."""
538 print(f"\n Gravity breakdown — {sanitize_display(rec['address'])}\n")
539 print(f" Kind: {sanitize_display(rec['kind'])}")
540 print(f" File: {sanitize_display(rec['file'])}")
541 print(
542 f" Total gravity: {rec['transitive_dependents']} / {total_prod}"
543 f" ({rec['gravity_pct']:.1f}%)"
544 )
545 print(f" Direct callers: {rec['direct_dependents']}")
546 print(f" Max depth: {rec['max_depth']}")
547 print()
548
549 if rec["depth_distribution"]:
550 # Re-fetch depth_map for the distribution bars.
551 depth_map, _ = _compute_gravity_for_symbol(rec["name"], reverse, max_depth)
552 max_at_level = max(len(v) for v in depth_map.values()) if depth_map else 1
553
554 print(" Depth distribution:")
555 for depth in sorted(depth_map):
556 callers = depth_map[depth]
557 count = len(callers)
558 bar = _gravity_bar(count, max_at_level, width=30)
559 label = f"depth {depth} (direct):" if depth == 1 else f"depth {depth}: "
560 print(f" {label} {count:>5} callers {bar}")
561 print()
562
563 # Deepest callers.
564 deepest_addrs, deepest_depth = _find_deepest_callers(rec["name"], reverse, max_depth)
565 if deepest_addrs:
566 print(f" Deepest callers (depth {deepest_depth}):")
567 for addr in deepest_addrs:
568 print(f" {sanitize_display(addr)}")
569 print()
570 print(
571 f" A breaking change here propagates through"
572 f" {deepest_depth} layers of the codebase."
573 )
574 print()
575
576 # ── Entry point ────────────────────────────────────────────────────────────────
577
578 def run(args: argparse.Namespace) -> None:
579 """Compute structural gravity — transitive downstream weight — for every symbol.
580
581 Builds the AST call graph from HEAD, inverts it, and computes for each
582 production symbol what fraction of the entire codebase transitively depends
583 on it. High-gravity symbols are structural pillars; changing them has broad
584 blast radius. Use ``--explain ADDR`` for a deep-dive into one symbol.
585
586 Agent quickstart
587 ----------------
588 ::
589
590 muse code gravity --json
591 muse code gravity --top 10 --json
592 muse code gravity --explain "src/billing.py::compute_total" --json
593 muse code gravity --kind function --min-gravity 0.1 --json
594
595 JSON fields (leaderboard)
596 -------------------------
597 ref Commit ref analysed (``"HEAD"``).
598 total_production_symbols Total non-test symbols in scope.
599 symbols Ranked list: ``address``, ``gravity_pct``,
600 ``transitive_callers``, ``kind``, ``file``.
601
602 JSON fields (``--explain`` mode)
603 ---------------------------------
604 address Symbol explained.
605 gravity_pct Fraction of codebase depending on this symbol.
606 depth_distribution Map of depth → caller count.
607 deepest_callers Farthest callers by hop distance.
608
609 Exit codes
610 ----------
611 0 Analysis complete.
612 1 Symbol not found or invalid arguments.
613 2 Not inside a Muse repository.
614 """
615 elapsed = start_timer()
616 root = require_repo()
617
618 # ── Argument validation ────────────────────────────────────────────────────
619 top: int = clamp_int(args.top, 0, 10000, 'top')
620 if top < 0:
621 print("❌ --top must be >= 0 (0 = unlimited).", file=sys.stderr)
622 raise SystemExit(ExitCode.USER_ERROR)
623
624 max_depth: int = clamp_int(args.depth, 0, 50, 'depth')
625 if max_depth < 0:
626 print("❌ --depth must be >= 0 (0 = unlimited).", file=sys.stderr)
627 raise SystemExit(ExitCode.USER_ERROR)
628
629 min_gravity: float = args.min_gravity
630 if not (0.0 <= min_gravity <= 100.0):
631 print("❌ --min-gravity must be between 0.0 and 100.0.", file=sys.stderr)
632 raise SystemExit(ExitCode.USER_ERROR)
633
634 sort_key: SortKey = args.sort
635 kind_filter: str | None = args.kind or None
636 file_filter: str | None = args.file or None
637 include_tests: bool = args.include_tests
638 explain_addr: str | None = args.explain or None
639
640 if explain_addr is not None and "::" not in explain_addr:
641 print(
642 "❌ --explain ADDRESS must be in ``file::Symbol`` format.",
643 file=sys.stderr,
644 )
645 raise SystemExit(ExitCode.USER_ERROR)
646
647 # ── Resolve HEAD ───────────────────────────────────────────────────────────
648 branch = read_current_branch(root)
649
650 head = resolve_commit_ref(root, branch, None)
651 if head is None:
652 print("❌ HEAD commit not found — is this an empty repository?", file=sys.stderr)
653 raise SystemExit(ExitCode.USER_ERROR)
654
655 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
656
657 # ── Single bulk read of all qualifying Python blobs ────────────────────────
658 blobs = _load_py_blobs(root, manifest, include_tests)
659
660 # ── Symbol extraction ──────────────────────────────────────────────────────
661 all_trees = symbols_for_snapshot(root, manifest)
662
663 # Partition: test files vs. production (for symbol scope).
664 prod_trees = (
665 all_trees
666 if include_tests
667 else {fp: t for fp, t in all_trees.items() if not is_test_file(fp)}
668 )
669
670 # Flat production symbol index (imports excluded).
671 prod_symbols: _SymbolIndex = {}
672 for sym_tree in prod_trees.values():
673 for addr, rec in sym_tree.items():
674 if rec["kind"] != _IMPORT_KIND and rec["kind"] in _TRACKED_KINDS:
675 prod_symbols[addr] = rec
676
677 if not prod_symbols:
678 print(" No production symbols found in HEAD snapshot.\n")
679 return
680
681 # ── Build call graphs ──────────────────────────────────────────────────────
682 forward = _build_forward_graph(blobs)
683 reverse = _invert_graph(forward)
684
685 # ── --explain: single-symbol deep dive ────────────────────────────────────
686 if explain_addr is not None:
687 if explain_addr not in prod_symbols:
688 print(
689 f"❌ {explain_addr!r} not found in HEAD production symbols.",
690 file=sys.stderr,
691 )
692 print(
693 " Use ``muse code symbols`` to list available addresses.",
694 file=sys.stderr,
695 )
696 raise SystemExit(ExitCode.USER_ERROR)
697
698 rec_explain = prod_symbols[explain_addr]
699 bare = rec_explain["name"]
700 depth_map, max_reached = _compute_gravity_for_symbol(bare, reverse, max_depth)
701 direct = len(depth_map.get(1, []))
702 total_trans = sum(len(v) for v in depth_map.values())
703 denom = max(1, len(prod_symbols) - 1)
704 pct = round(total_trans / denom * 100, 1)
705 dist = {str(d): len(addrs) for d, addrs in depth_map.items()}
706
707 single_rec = _SymbolGravity(
708 address=explain_addr,
709 name=bare,
710 kind=rec_explain["kind"],
711 file=explain_addr.split("::")[0],
712 gravity_pct=pct,
713 direct_dependents=direct,
714 transitive_dependents=total_trans,
715 max_depth=max_reached,
716 depth_distribution=dist,
717 )
718
719 if args.json_out:
720 print(json.dumps(_GravityExplainJson(**make_envelope(elapsed), **single_rec)))
721 return
722
723 _print_explain(single_rec, reverse, max_depth, len(prod_symbols))
724 return
725
726 # ── Leaderboard ────────────────────────────────────────────────────────────
727 records, total_prod = _build_gravity_records(
728 prod_symbols,
729 reverse,
730 max_depth,
731 file_filter,
732 kind_filter,
733 min_gravity,
734 top,
735 sort_key,
736 )
737
738 if args.json_out:
739 print(json.dumps(_JsonOut(
740 **make_envelope(elapsed),
741 ref="HEAD",
742 snapshot_id=head.commit_id,
743 total_production_symbols=total_prod,
744 max_depth=max_depth,
745 include_tests=include_tests,
746 filters=_FilterSpec(
747 kind=kind_filter,
748 file=file_filter,
749 min_gravity=min_gravity,
750 top=top,
751 ),
752 symbols=records,
753 )))
754 return
755
756 _print_leaderboard(records, total_prod, max_depth, sort_key, include_tests)
757
758 # ── CLI registration ───────────────────────────────────────────────────────────
759
760 def register(
761 sub: argparse._SubParsersAction[argparse.ArgumentParser],
762 ) -> None:
763 """Register ``gravity`` under the ``code`` subcommand group.
764
765 Args:
766 sub: The subparser action from the ``code`` command group.
767 """
768 p = sub.add_parser(
769 "gravity",
770 help=(
771 "Structural weight — how much of the production codebase"
772 " transitively depends on each symbol."
773 ),
774 description=__doc__,
775 formatter_class=argparse.RawDescriptionHelpFormatter,
776 )
777 p.add_argument(
778 "--explain",
779 metavar="ADDRESS",
780 help=(
781 "Deep-dive into a specific symbol's gravity:"
782 " depth distribution and deepest callers."
783 ),
784 )
785 p.add_argument(
786 "--top",
787 type=int,
788 default=_DEFAULT_TOP,
789 metavar="N",
790 help=f"Show only the top N symbols by gravity (default: {_DEFAULT_TOP}; 0 = all).",
791 )
792 p.add_argument(
793 "--sort",
794 metavar="KEY",
795 choices=list(_SORT_CHOICES),
796 default="gravity",
797 help=(
798 "Sort dimension: ``gravity`` (default, transitive %%)"
799 ", ``direct`` (direct callers)"
800 ", ``depth`` (max dependency chain length)."
801 ),
802 )
803 p.add_argument(
804 "--depth",
805 type=int,
806 default=_DEFAULT_DEPTH,
807 metavar="D",
808 help=(
809 f"Maximum BFS depth for transitive analysis (default: {_DEFAULT_DEPTH} = unlimited)."
810 " Use a small value (e.g. 3) for large repos to bound runtime."
811 ),
812 )
813 p.add_argument(
814 "--kind",
815 metavar="KIND",
816 choices=["function", "async_function", "method", "async_method", "class"],
817 help="Filter symbols by kind.",
818 )
819 p.add_argument(
820 "--file",
821 metavar="SUFFIX",
822 help="Scope analysis to symbols in files whose path contains SUFFIX.",
823 )
824 p.add_argument(
825 "--min-gravity",
826 type=float,
827 default=0.0,
828 metavar="PCT",
829 help="Show only symbols with gravity >= PCT%%.",
830 )
831 p.add_argument(
832 "--include-tests",
833 action="store_true",
834 help=(
835 "Count test-file callers in the dependency graph"
836 " (default: test callers are excluded)."
837 ),
838 )
839 p.add_argument(
840 "--json", "-j",
841 action="store_true", dest="json_out",
842 help="Emit JSON instead of human-readable text.",
843 )
844 p.set_defaults(func=run)
File History 7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e chore: remove blob-debug test marker file Sonnet 4.6 1 day ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago