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