gabriel / muse public
blast_risk.py python
768 lines 27.6 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
1 """muse code blast-risk — composite pre-release risk leaderboard.
2
3 Synthesises four dimensions that Muse tracks independently into a single
4 ranked risk score for every symbol in the snapshot:
5
6 Impact (40 %) — how many *other* symbols directly reference this one
7 via import, bare name, or attribute access. A bug in a
8 highly-referenced symbol touches many callsites.
9
10 Churn (30 %) — how many commits in the analysed range modified this
11 symbol. Frequently-changing code has higher defect
12 probability (same signal as ``muse code hotspots``).
13
14 Test gap (20 %) — fraction of callers that live in *production* files.
15 Symbols whose callers are untested carry hidden risk.
16
17 Coupling (10 %) — how many other files co-change with this symbol's file.
18 High coupling amplifies the blast radius.
19
20 Each dimension is normalised 0–100 across the full symbol set, then
21 combined with the weights above into a final risk score 0–100.
22
23 No other tool can produce this score because no other tool simultaneously
24 knows the call graph, the churn history, which callers are tests, and the
25 file co-change graph.
26
27 Usage::
28
29 muse code blast-risk
30 muse code blast-risk --top 20
31 muse code blast-risk --since a3f2c9
32 muse code blast-risk --kind function
33 muse code blast-risk --file billing.py
34 muse code blast-risk --explain billing.py::Invoice.compute_total
35 muse code blast-risk --min-risk 50
36 muse code blast-risk --json
37
38 Output::
39
40 Symbol blast-risk — HEAD (304 commits · 378 symbols)
41 Scoring: impact×40% + churn×30% + test-gap×20% + coupling×10%
42
43 # ADDRESS RISK IMPACT CHURN TEST-GAP COUPLING
44 1 muse/core/store.py::resolve_commit_ref 94 high 12 87 % high
45 2 muse/core/errors.py::ExitCode 87 high 8 72 % med
46 3 billing.py::Invoice.compute_total 81 med 15 100 % low
47
48 ⚠️ High-risk symbols are prime targets for test coverage and refactoring.
49
50 JSON output (--json)::
51
52 {
53 "ref": "HEAD",
54 "commits_analysed": 304,
55 "truncated": false,
56 "filters": { "kind": null, "file": null, "min_risk": 0, "since": null },
57 "weights": { "impact": 0.40, "churn": 0.30, "test_gap": 0.20, "coupling": 0.10 },
58 "symbols": [
59 {
60 "address": "muse/core/store.py::resolve_commit_ref",
61 "kind": "function",
62 "file": "muse/core/store.py",
63 "risk": 94,
64 "impact_raw": 47,
65 "churn_raw": 12,
66 "test_gap_raw": 0.87,
67 "coupling_raw": 9,
68 "impact_score": 100,
69 "churn_score": 80,
70 "test_gap_score": 87,
71 "coupling_score": 75
72 }
73 ]
74 }
75
76 --explain output::
77
78 Blast-risk breakdown — muse/core/store.py::resolve_commit_ref
79
80 Risk score: 94 / 100
81 Kind: function
82
83 Impact (40 %) score: 100 / 100 raw: 47 direct callers
84 Churn (30 %) score: 80 / 100 raw: 12 changes in 304 commits
85 Test gap (20 %) score: 87 / 100 raw: 41 / 47 callers are production (not test)
86 Coupling (10 %) score: 75 / 100 raw: 9 files co-change with muse/core/store.py
87
88 Top callers (production):
89 muse/cli/commands/compare.py::run
90 muse/cli/commands/blame.py::run
91 muse/plugins/code/_query.py::walk_commits_bfs
92 … 38 more
93 """
94
95 from __future__ import annotations
96
97 import ast
98 import argparse
99 import json
100 import logging
101 import pathlib
102 import re
103 import sys
104 from typing import TypedDict
105
106 from muse.core.errors import ExitCode
107 from muse.core.repo import read_repo_id, require_repo
108 from muse.core.store import (
109 Manifest,
110 get_commit_snapshot_manifest,
111 read_current_branch,
112 resolve_commit_ref,
113 )
114 from muse.core.symbol_cache import load_symbol_cache
115 from muse.domain import DomainOp
116 from muse.plugins.code._query import (
117 file_pairs,
118 flat_symbol_ops,
119 language_of,
120 symbols_for_snapshot,
121 touched_files,
122 walk_commits_bfs,
123 )
124 from muse.core.validation import MAX_AST_BYTES, clamp_int, sanitize_display
125
126 logger = logging.getLogger(__name__)
127
128 type CallerMap = dict[str, list[str]] # name → list of caller file_paths
129 type ChurnMap = dict[str, int] # file_path or address → churn count
130 type CouplingMap = dict[str, int] # file_path → co-change partner count
131 type SymbolRecordMap = dict[str, tuple[str, str]] # address → (name, kind)
132 type CallerListMap = dict[str, list[str]] # address → list of caller files
133 type TestGapMap = dict[str, float] # address → test gap fraction
134
135 # ── Constants ──────────────────────────────────────────────────────────────────
136
137 _DEFAULT_TOP = 20
138 _DEFAULT_MAX_COMMITS = 10_000
139 _MAX_FILES_PER_COMMIT = 50 # skip mass-edit commits (noisy for coupling)
140
141 _W_IMPACT: float = 0.40
142 _W_CHURN: float = 0.30
143 _W_TEST_GAP: float = 0.20
144 _W_COUPLING: float = 0.10
145
146 # Test-file heuristics — callers in these files are considered "tested".
147 _TEST_PATTERNS: tuple[re.Pattern[str], ...] = (
148 re.compile(r"(^|/)test_"),
149 re.compile(r"_test\.py$"),
150 re.compile(r"(^|/)tests/"),
151 re.compile(r"(^|/)spec/"),
152 re.compile(r"(^|/)conftest\.py$"),
153 )
154
155 # ── TypedDicts ─────────────────────────────────────────────────────────────────
156
157
158 class _SymbolRisk(TypedDict):
159 """Fully-scored risk record for one symbol."""
160
161 address: str
162 kind: str
163 file: str
164 risk: int # final composite score 0-100
165 impact_raw: int # direct caller count
166 churn_raw: int # commit-change count
167 test_gap_raw: float # fraction of callers that are production (0-1)
168 coupling_raw: int # co-change partner count for the file
169 impact_score: int # normalised 0-100
170 churn_score: int # normalised 0-100
171 test_gap_score: int # = round(test_gap_raw * 100)
172 coupling_score: int # normalised 0-100
173 # Only present in --explain output.
174 production_callers: list[str]
175 test_callers: list[str]
176
177
178 class _SymbolRiskJson(TypedDict):
179 """JSON-serialisable risk record — ``production_callers``/``test_callers`` excluded."""
180
181 address: str
182 kind: str
183 file: str
184 risk: int
185 impact_raw: int
186 churn_raw: int
187 test_gap_raw: float
188 coupling_raw: int
189 impact_score: int
190 churn_score: int
191 test_gap_score: int
192 coupling_score: int
193
194
195 class _BlastRiskFilters(TypedDict):
196 kind: str | None
197 file: str | None
198 min_risk: int
199 since: str | None
200 top: int
201 max_commits: int
202
203
204 class _BlastRiskWeights(TypedDict):
205 impact: float
206 churn: float
207 test_gap: float
208 coupling: float
209
210
211 class _BlastRiskOutput(TypedDict):
212 """JSON payload for ``muse code blast-risk`` output."""
213
214 ref: str
215 commits_analysed: int
216 truncated: bool
217 filters: _BlastRiskFilters
218 weights: _BlastRiskWeights
219 symbols: list[_SymbolRiskJson]
220
221
222 # ── Helpers ────────────────────────────────────────────────────────────────────
223
224
225
226 def _is_test_file(path: str) -> bool:
227 return any(p.search(path) for p in _TEST_PATTERNS)
228
229
230 def _normalise_score(raw: int | float, maximum: int | float) -> int:
231 if maximum <= 0:
232 return 0
233 return round(min(100, (raw / maximum) * 100))
234
235
236 # ── Phase 1: caller map ────────────────────────────────────────────────────────
237
238
239 def _build_caller_map(
240 root: pathlib.Path,
241 manifest: Manifest,
242 known_names: frozenset[str],
243 ) -> CallerMap:
244 """Return ``name → [caller_file_path, ...]`` by scanning all Python files.
245
246 Uses AST-level ``ast.Name`` and ``ast.Attribute`` nodes so only
247 identifier references are counted — string literals and comments are
248 never included.
249
250 Only names that appear in *known_names* are tracked to avoid building an
251 arbitrarily large map.
252 """
253 caller_map: CallerMap = {}
254 for file_path in sorted(manifest):
255 if language_of(file_path) != "Python":
256 continue
257 disk_path = root / file_path
258 if not disk_path.is_file():
259 continue
260 try:
261 source = disk_path.read_bytes()
262 if len(source) > MAX_AST_BYTES:
263 return {}
264 tree = ast.parse(source, filename=file_path)
265 except (OSError, SyntaxError):
266 continue
267 for node in ast.walk(tree):
268 name: str | None = None
269 if isinstance(node, ast.Name) and node.id in known_names:
270 name = node.id
271 elif isinstance(node, ast.Attribute) and node.attr in known_names:
272 name = node.attr
273 if name is not None:
274 caller_map.setdefault(name, []).append(file_path)
275 return caller_map
276
277
278 # ── Phase 2: churn + coupling in one BFS pass ─────────────────────────────────
279
280
281 def _collect_churn_and_coupling(
282 root: pathlib.Path,
283 head_commit_id: str,
284 stop_at: str | None,
285 max_commits: int,
286 ) -> tuple[dict[str, int], dict[str, int], int, bool]:
287 """Single BFS pass collecting churn and file-coupling simultaneously.
288
289 Returns:
290 churn — ``address → commit-change count``
291 file_coupling — ``file_path → number of distinct co-change partners``
292 commits_analysed — number of commits walked
293 truncated — whether the BFS hit max_commits
294 """
295 commits, truncated = walk_commits_bfs(
296 root, head_commit_id, max_commits, stop_at_commit_id=stop_at
297 )
298
299 churn: ChurnMap = {}
300 pair_counts: dict[tuple[str, str], int] = {}
301
302 for commit in commits:
303 if commit.structured_delta is None:
304 continue
305 ops: list[DomainOp] = commit.structured_delta["ops"]
306
307 # ── churn ──────────────────────────────────────────────────────────
308 for op in flat_symbol_ops(ops):
309 addr: str = op["address"]
310 if "::import::" not in addr: # exclude import pseudo-symbols
311 churn[addr] = churn.get(addr, 0) + 1
312
313 # ── coupling ───────────────────────────────────────────────────────
314 files = touched_files(ops)
315 if 2 <= len(files) <= _MAX_FILES_PER_COMMIT:
316 for fa, fb in file_pairs(files):
317 pair_counts[(fa, fb)] = pair_counts.get((fa, fb), 0) + 1
318
319 # Collapse pair counts to per-file partner counts.
320 file_coupling: CouplingMap = {}
321 for (fa, fb), cnt in pair_counts.items():
322 if cnt >= 1:
323 file_coupling[fa] = file_coupling.get(fa, 0) + 1
324 file_coupling[fb] = file_coupling.get(fb, 0) + 1
325
326 return churn, file_coupling, len(commits), truncated
327
328
329 # ── Phase 3: score + rank ──────────────────────────────────────────────────────
330
331
332 def _score_symbols(
333 symbol_records: SymbolRecordMap,
334 caller_map: CallerMap,
335 churn: ChurnMap,
336 file_coupling: CouplingMap,
337 kind_filter: str | None,
338 file_filter: str | None,
339 min_risk: int,
340 top: int,
341 include_callers: bool,
342 ) -> list[_SymbolRisk]:
343 """Compute normalised scores and return the ranked list."""
344
345 # ── Gather raw values ──────────────────────────────────────────────────
346 raw_impacts: ChurnMap = {}
347 raw_test_gaps: TestGapMap = {}
348 prod_callers_map: CallerListMap = {}
349 test_callers_map: CallerListMap = {}
350
351 for address, (name, kind) in symbol_records.items():
352 file_path = address.split("::")[0]
353 callers = caller_map.get(name, [])
354
355 prod = [c for c in callers if not _is_test_file(c) and c != file_path]
356 tests = [c for c in callers if _is_test_file(c)]
357 total = len(prod) + len(tests)
358
359 raw_impacts[address] = total
360 raw_test_gaps[address] = len(prod) / total if total > 0 else 0.0
361 prod_callers_map[address] = sorted(set(prod))
362 test_callers_map[address] = sorted(set(tests))
363
364 # ── Normalisation maximums ─────────────────────────────────────────────
365 max_impact = max(raw_impacts.values(), default=1) or 1
366 max_churn = max(churn.values(), default=1) or 1
367 max_coupling = max(file_coupling.values(), default=1) or 1
368
369 # ── Score every symbol ─────────────────────────────────────────────────
370 scored: list[_SymbolRisk] = []
371 for address, (name, kind) in symbol_records.items():
372 file_path = address.split("::")[0]
373
374 if kind_filter and kind.lower() != kind_filter:
375 continue
376 if file_filter and not (
377 file_path == file_filter or file_path.endswith("/" + file_filter)
378 ):
379 continue
380
381 impact_raw = raw_impacts[address]
382 churn_raw = churn.get(address, 0)
383 test_gap_raw = raw_test_gaps[address]
384 coupling_raw = file_coupling.get(file_path, 0)
385
386 impact_s = _normalise_score(impact_raw, max_impact)
387 churn_s = _normalise_score(churn_raw, max_churn)
388 test_gap_s = round(test_gap_raw * 100)
389 coupling_s = _normalise_score(coupling_raw, max_coupling)
390
391 risk = round(
392 _W_IMPACT * impact_s
393 + _W_CHURN * churn_s
394 + _W_TEST_GAP * test_gap_s
395 + _W_COUPLING * coupling_s
396 )
397
398 if risk < min_risk:
399 continue
400
401 scored.append(_SymbolRisk(
402 address=address,
403 kind=kind,
404 file=file_path,
405 risk=risk,
406 impact_raw=impact_raw,
407 churn_raw=churn_raw,
408 test_gap_raw=test_gap_raw,
409 coupling_raw=coupling_raw,
410 impact_score=impact_s,
411 churn_score=churn_s,
412 test_gap_score=test_gap_s,
413 coupling_score=coupling_s,
414 production_callers=prod_callers_map[address] if include_callers else [],
415 test_callers=test_callers_map[address] if include_callers else [],
416 ))
417
418 scored.sort(key=lambda r: (-r["risk"], r["address"]))
419 return scored[:top]
420
421
422 # ── Formatters ─────────────────────────────────────────────────────────────────
423
424
425 def _level(score: int) -> str:
426 if score >= 70:
427 return "high"
428 if score >= 35:
429 return " med"
430 return " low"
431
432
433 def _print_table(
434 records: list[_SymbolRisk],
435 ref: str,
436 commits_analysed: int,
437 truncated: bool,
438 since: str | None,
439 ) -> None:
440 scope = f"{since}..{ref}" if since else ref
441 trunc_note = " ⚠️ truncated" if truncated else ""
442 print(
443 f"\nSymbol blast-risk — {scope}"
444 f" ({commits_analysed} commits{trunc_note})"
445 )
446 print(
447 f"Scoring: impact×{int(_W_IMPACT*100)}%"
448 f" + churn×{int(_W_CHURN*100)}%"
449 f" + test-gap×{int(_W_TEST_GAP*100)}%"
450 f" + coupling×{int(_W_COUPLING*100)}%\n"
451 )
452
453 if not records:
454 print(" (no symbols meet the filter criteria)")
455 return
456
457 max_addr = max(len(r["address"]) for r in records)
458 hdr = (
459 f" {'#':>3} {'ADDRESS':<{max_addr}} {'RISK':>4} "
460 f"{'IMPACT':>6} {'CHURN':>5} {'TEST-GAP':>8} {'COUPLING':>8}"
461 )
462 print(hdr)
463 print(" " + "─" * (len(hdr) - 2))
464
465 for i, rec in enumerate(records, 1):
466 tg = f"{rec['test_gap_score']:>3} %"
467 print(
468 f" {i:>3} {rec['address']:<{max_addr}} {rec['risk']:>4} "
469 f"{_level(rec['impact_score']):>6} {rec['churn_raw']:>5} "
470 f"{tg:>8} {_level(rec['coupling_score']):>8}"
471 )
472
473 print(
474 "\n⚠️ High-risk symbols are prime targets for test coverage "
475 "and refactoring."
476 )
477
478
479 def _print_explain(rec: _SymbolRisk, commits_analysed: int) -> None:
480 addr = rec["address"]
481 print(f"\nBlast-risk breakdown — {sanitize_display(addr)}\n")
482 print(f" Risk score: {rec['risk']} / 100")
483 print(f" Kind: {rec['kind']}")
484 print()
485
486 rows = [
487 ("Impact", _W_IMPACT, rec["impact_score"], f"{rec['impact_raw']} direct caller(s)"),
488 ("Churn", _W_CHURN, rec["churn_score"], f"{rec['churn_raw']} change(s) in {commits_analysed} commits"),
489 ("Test gap", _W_TEST_GAP, rec["test_gap_score"], f"{rec['test_gap_score']}% of callers are production (no test)"),
490 ("Coupling", _W_COUPLING, rec["coupling_score"], f"{rec['coupling_raw']} file(s) co-change with {rec['file']}"),
491 ]
492 for label, weight, score, detail in rows:
493 pct = f"{int(weight*100)}%"
494 print(f" {label:<10} ({pct:>4}) score: {score:>3} / 100 {detail}")
495
496 prod = rec["production_callers"]
497 if prod:
498 print(f"\n Production callers ({len(prod)}):")
499 for c in prod[:10]:
500 print(f" {c}")
501 if len(prod) > 10:
502 print(f" … {len(prod) - 10} more")
503
504 tests = rec["test_callers"]
505 if tests:
506 print(f"\n Test callers ({len(tests)}):")
507 for c in tests[:5]:
508 print(f" {c}")
509 if len(tests) > 5:
510 print(f" … {len(tests) - 5} more")
511
512
513 # ── CLI ────────────────────────────────────────────────────────────────────────
514
515
516 def register(
517 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
518 ) -> None:
519 """Register the blast-risk subcommand."""
520 parser = subparsers.add_parser(
521 "blast-risk",
522 help=(
523 "Composite pre-release risk leaderboard: impact × churn × "
524 "test-gap × coupling."
525 ),
526 description=__doc__,
527 formatter_class=argparse.RawDescriptionHelpFormatter,
528 )
529 parser.add_argument(
530 "--top", "-t",
531 type=int, default=_DEFAULT_TOP, metavar="N",
532 help=f"Show the top N riskiest symbols (default: {_DEFAULT_TOP}).",
533 )
534 parser.add_argument(
535 "--since", "-s",
536 default=None, metavar="REF", dest="since",
537 help=(
538 "Limit churn and coupling analysis to commits reachable from HEAD "
539 "but not from REF. Useful for release-scoped risk: "
540 "--since v1.0"
541 ),
542 )
543 parser.add_argument(
544 "--max-commits",
545 type=int, default=_DEFAULT_MAX_COMMITS, metavar="N",
546 help=f"Maximum commits to scan for churn/coupling (default: {_DEFAULT_MAX_COMMITS}).",
547 )
548 parser.add_argument(
549 "--kind", "-k",
550 default=None, metavar="KIND", dest="kind_filter",
551 help="Restrict to symbols of this kind (function, class, method, …).",
552 )
553 parser.add_argument(
554 "--file", "-f",
555 default=None, metavar="FILE", dest="file_filter",
556 help="Restrict to symbols in this file (accepts a path suffix).",
557 )
558 parser.add_argument(
559 "--min-risk",
560 type=int, default=0, metavar="N", dest="min_risk",
561 help="Only show symbols with a composite risk score >= N (default: 0).",
562 )
563 parser.add_argument(
564 "--explain",
565 default=None, metavar="ADDRESS", dest="explain",
566 help=(
567 "Print a detailed per-dimension breakdown for a single symbol "
568 "instead of the leaderboard table."
569 ),
570 )
571 parser.add_argument(
572 "--json",
573 action="store_true", dest="as_json",
574 help="Emit results as JSON.",
575 )
576 parser.set_defaults(func=run)
577
578
579 def run(args: argparse.Namespace) -> None:
580 """Rank every symbol by composite blast-risk.
581
582 The score combines four dimensions that Muse tracks natively: how many
583 symbols reference this one (impact), how often it changes (churn), how
584 many of its callers are untested (test gap), and how many files
585 co-change with its file (coupling). Each dimension is normalised
586 0–100 then weighted.
587 """
588 top: int = clamp_int(args.top, 1, 10_000, 'top')
589 since: str | None = args.since
590 max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits')
591 kind_filter: str | None = args.kind_filter
592 file_filter: str | None = args.file_filter
593 min_risk: int = clamp_int(args.min_risk, 0, 100, 'min_risk')
594 explain_addr: str | None = args.explain
595 as_json: bool = args.as_json
596
597 # ── Validation ────────────────────────────────────────────────────────────
598
599 if top < 1:
600 print("❌ --top must be >= 1.", file=sys.stderr)
601 raise SystemExit(ExitCode.USER_ERROR)
602 if max_commits < 1:
603 print("❌ --max-commits must be >= 1.", file=sys.stderr)
604 raise SystemExit(ExitCode.USER_ERROR)
605 if not (0 <= min_risk <= 100):
606 print("❌ --min-risk must be between 0 and 100.", file=sys.stderr)
607 raise SystemExit(ExitCode.USER_ERROR)
608 if kind_filter is not None:
609 kind_filter = kind_filter.strip().lower()
610
611 # ── Repo setup ────────────────────────────────────────────────────────────
612
613 root = require_repo()
614 repo_id = read_repo_id(root)
615 branch = read_current_branch(root)
616
617 head = resolve_commit_ref(root, repo_id, branch, None)
618 if head is None:
619 print("❌ HEAD commit not found.", file=sys.stderr)
620 raise SystemExit(ExitCode.USER_ERROR)
621
622 stop_at: str | None = None
623 if since is not None:
624 since_commit = resolve_commit_ref(root, repo_id, branch, since)
625 if since_commit is None:
626 print(f"❌ Commit '{since}' not found.", file=sys.stderr)
627 raise SystemExit(ExitCode.USER_ERROR)
628 stop_at = since_commit.commit_id
629
630 manifest: Manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
631
632 # ── Phase 1: load symbol trees (cached) ──────────────────────────────────
633
634 shared_cache = load_symbol_cache(root)
635 all_trees = symbols_for_snapshot(root, manifest, cache=shared_cache)
636
637 # Build: address → (name, kind) and name → [addresses]
638 symbol_records: SymbolRecordMap = {}
639 known_names: set[str] = set()
640 for file_path, tree in all_trees.items():
641 for addr, sym in tree.items():
642 sym_name: str = sym["name"]
643 sym_kind: str = sym["kind"]
644 if "::import::" in addr:
645 continue
646 symbol_records[addr] = (sym_name, sym_kind)
647 known_names.add(sym_name)
648
649 if not symbol_records:
650 _emit_empty(as_json, branch, 0, False, since, kind_filter, file_filter, min_risk)
651 return
652
653 # ── Phase 2: caller map (AST scan of all Python files) ───────────────────
654
655 caller_map = _build_caller_map(root, manifest, frozenset(known_names))
656
657 # ── Phase 3: churn + coupling in one BFS pass ─────────────────────────────
658
659 churn, file_coupling, commits_analysed, truncated = _collect_churn_and_coupling(
660 root, head.commit_id, stop_at, max_commits
661 )
662
663 # ── Phase 4: --explain mode ───────────────────────────────────────────────
664
665 if explain_addr is not None:
666 if explain_addr not in symbol_records:
667 print(
668 f"❌ Symbol '{explain_addr}' not found in HEAD snapshot.",
669 file=sys.stderr,
670 )
671 print(" Hint: muse code symbols --json | grep address", file=sys.stderr)
672 raise SystemExit(ExitCode.USER_ERROR)
673
674 # Score just this one symbol with full caller lists.
675 ranked = _score_symbols(
676 {explain_addr: symbol_records[explain_addr]},
677 caller_map, churn, file_coupling,
678 kind_filter=None, file_filter=None,
679 min_risk=0, top=1,
680 include_callers=True,
681 )
682 if not ranked:
683 print(f" (no risk data available for '{explain_addr}')")
684 return
685 rec = ranked[0]
686 if as_json:
687 print(json.dumps(dict(rec), indent=2))
688 else:
689 _print_explain(rec, commits_analysed)
690 return
691
692 # ── Phase 5: score + rank all symbols ─────────────────────────────────────
693
694 ranked = _score_symbols(
695 symbol_records, caller_map, churn, file_coupling,
696 kind_filter=kind_filter, file_filter=file_filter,
697 min_risk=min_risk, top=top,
698 include_callers=False,
699 )
700
701 # ── Output ────────────────────────────────────────────────────────────────
702
703 ref = branch
704 filters = _BlastRiskFilters(
705 kind=kind_filter,
706 file=file_filter,
707 min_risk=min_risk,
708 since=since,
709 top=top,
710 max_commits=max_commits,
711 )
712
713 if as_json:
714 symbols: list[_SymbolRiskJson] = [
715 _SymbolRiskJson(
716 address=r["address"], kind=r["kind"], file=r["file"],
717 risk=r["risk"], impact_raw=r["impact_raw"], churn_raw=r["churn_raw"],
718 test_gap_raw=r["test_gap_raw"], coupling_raw=r["coupling_raw"],
719 impact_score=r["impact_score"], churn_score=r["churn_score"],
720 test_gap_score=r["test_gap_score"], coupling_score=r["coupling_score"],
721 )
722 for r in ranked
723 ]
724 print(json.dumps(_BlastRiskOutput(
725 ref=ref,
726 commits_analysed=commits_analysed,
727 truncated=truncated,
728 filters=filters,
729 weights=_BlastRiskWeights(
730 impact=_W_IMPACT,
731 churn=_W_CHURN,
732 test_gap=_W_TEST_GAP,
733 coupling=_W_COUPLING,
734 ),
735 symbols=symbols,
736 ), indent=2))
737 return
738
739 _print_table(ranked, ref, commits_analysed, truncated, since)
740
741
742 def _emit_empty(
743 as_json: bool,
744 ref: str,
745 commits_analysed: int,
746 truncated: bool,
747 since: str | None,
748 kind_filter: str | None,
749 file_filter: str | None,
750 min_risk: int,
751 ) -> None:
752 if as_json:
753 print(json.dumps({
754 "ref": ref,
755 "commits_analysed": commits_analysed,
756 "truncated": truncated,
757 "filters": {
758 "kind": kind_filter, "file": file_filter,
759 "min_risk": min_risk, "since": since,
760 },
761 "weights": {
762 "impact": _W_IMPACT, "churn": _W_CHURN,
763 "test_gap": _W_TEST_GAP, "coupling": _W_COUPLING,
764 },
765 "symbols": [],
766 }, indent=2))
767 else:
768 print("\n(no symbols found in the current snapshot)")
File History 4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago