blast_risk.py
python
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