age.py
python
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b
docs(mwp-master): tick all ACs green; mark MWP-7 complete w…
Sonnet 4.6
20 days ago
| 1 | """muse code age — evolutionary distance of every symbol. |
| 2 | |
| 3 | ``muse code stable`` answers "which symbols haven't changed in a long time?" |
| 4 | ``muse code age`` answers "how much of a symbol's *original* implementation |
| 5 | is still alive — and when was it last fundamentally rewritten?" |
| 6 | |
| 7 | These are different questions. A function that was created 8 months ago but |
| 8 | has been completely rewritten 5 times has a *genetic age* of days, not months. |
| 9 | Knowing this tells you: |
| 10 | |
| 11 | - Don't trust the creation date as a proxy for stability. |
| 12 | - Don't assume institutional knowledge. The person who wrote the original |
| 13 | may have left; the current implementation may have been authored by someone |
| 14 | who joined last month. |
| 15 | - Focus code review attention on symbols with many rewrites and few tests. |
| 16 | |
| 17 | Dimensions |
| 18 | ---------- |
| 19 | ``calendar_age`` |
| 20 | Days since the symbol was first inserted into the repository. |
| 21 | |
| 22 | ``genetic_age`` |
| 23 | Days since the symbol's implementation body last changed. If the body |
| 24 | has never changed (0 rewrites), ``genetic_age == calendar_age``. |
| 25 | A rename or signature change does **not** reset genetic age — only a full |
| 26 | body rewrite does. |
| 27 | |
| 28 | ``impl_changes`` |
| 29 | Total count of commits that changed the function body (replace ops whose |
| 30 | summary indicates an implementation or body change). |
| 31 | |
| 32 | ``sig_changes`` |
| 33 | Total count of commits that changed only the signature (type annotations, |
| 34 | parameter names) without a body rewrite. |
| 35 | |
| 36 | ``renames`` |
| 37 | Total count of rename operations recorded in the delta history. |
| 38 | |
| 39 | ``est_survival`` |
| 40 | Estimated fraction of the original implementation still present: |
| 41 | ``round(100 / (impl_changes + 1)) %``. |
| 42 | 0 rewrites → 100 %. 1 rewrite → 50 %. 3 rewrites → 25 %. This is a |
| 43 | conservative heuristic; the actual fraction requires diffing the original |
| 44 | and current source bytes, which ``--explain`` does for Python files. |
| 45 | |
| 46 | Usage:: |
| 47 | |
| 48 | muse code age |
| 49 | muse code age --top 30 |
| 50 | muse code age --sort rewrites # most-rewritten first |
| 51 | muse code age --sort calendar # oldest symbols first |
| 52 | muse code age --sort genetic # oldest unmodified body first |
| 53 | muse code age --sort survival # most original code remaining first |
| 54 | muse code age --kind function |
| 55 | muse code age --file billing.py |
| 56 | muse code age --since v1.0 |
| 57 | muse code age --explain billing.py::Invoice.compute_total |
| 58 | muse code age --json |
| 59 | |
| 60 | Output:: |
| 61 | |
| 62 | Symbol evolutionary age — HEAD (304 commits · 2026-03-21) |
| 63 | Sorted by: most rewrites first |
| 64 | |
| 65 | # ADDRESS BORN GENETIC REWRITES EST-SURV |
| 66 | 1 muse/core/store.py::resolve_commit_ref 8 mo ago 3 wk ago 4 20 % |
| 67 | 2 muse/core/errors.py::ExitCode 6 mo ago 6 mo ago 0 100 % |
| 68 | 3 billing.py::Invoice.compute_total 3 mo ago 2 wk ago 3 25 % |
| 69 | |
| 70 | ⚠️ Symbols with high rewrites + low genetic age are the most evolved. |
| 71 | They may carry little of their original design intent. |
| 72 | |
| 73 | --explain output:: |
| 74 | |
| 75 | Evolutionary age — billing.py::Invoice.compute_total |
| 76 | |
| 77 | Kind: method |
| 78 | Born: 2026-01-12 (commit a3f2c9e1) — 87 days ago |
| 79 | Last impl change: 2026-03-08 (commit 97fe52ab) — 13 days ago (genetic age) |
| 80 | Last any change: 2026-03-20 (commit b1c2d3e4) — 1 day ago |
| 81 | |
| 82 | Implementation changes: 3 |
| 83 | Signature changes: 1 |
| 84 | Renames: 0 |
| 85 | Est. survival: 25 % (3/4 original generations replaced) |
| 86 | |
| 87 | Change timeline (newest first): |
| 88 | 2026-03-20 b1c2d3e4 signature changed |
| 89 | 2026-03-08 97fe52ab implementation changed |
| 90 | 2026-02-14 cc8d7a90 implementation changed |
| 91 | 2026-01-29 55f2b0e1 implementation changed |
| 92 | 2026-01-12 a3f2c9e1 created |
| 93 | |
| 94 | JSON output (--json):: |
| 95 | |
| 96 | { |
| 97 | "ref": "HEAD", |
| 98 | "as_of": "2026-03-21", |
| 99 | "commits_analysed": 304, |
| 100 | "truncated": false, |
| 101 | "filters": { "kind": null, "file": null, "sort": "rewrites", "top": 20 }, |
| 102 | "symbols": [ |
| 103 | { |
| 104 | "address": "billing.py::Invoice.compute_total", |
| 105 | "kind": "method", |
| 106 | "file": "billing.py", |
| 107 | "born_commit": "a3f2c9e1...", |
| 108 | "born_date": "2026-01-12", |
| 109 | "last_impl_commit": "97fe52ab...", |
| 110 | "last_impl_date": "2026-03-08", |
| 111 | "last_change_commit": "b1c2d3e4...", |
| 112 | "last_change_date": "2026-03-20", |
| 113 | "calendar_age_days": 87, |
| 114 | "genetic_age_days": 13, |
| 115 | "impl_changes": 3, |
| 116 | "sig_changes": 1, |
| 117 | "renames": 0, |
| 118 | "est_survival_pct": 25 |
| 119 | } |
| 120 | ] |
| 121 | } |
| 122 | """ |
| 123 | |
| 124 | import argparse |
| 125 | import datetime |
| 126 | import json |
| 127 | import logging |
| 128 | import pathlib |
| 129 | import sys |
| 130 | from dataclasses import dataclass, field |
| 131 | from typing import Literal, TypedDict |
| 132 | |
| 133 | from muse.core.types import short_id |
| 134 | from muse.core.errors import ExitCode |
| 135 | from muse.core.repo import require_repo |
| 136 | from muse.core.refs import read_current_branch |
| 137 | from muse.core.commits import resolve_commit_ref |
| 138 | from muse.core.snapshots import get_commit_snapshot_manifest |
| 139 | from muse.core.symbol_cache import load_symbol_cache |
| 140 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 141 | from muse.core.timing import start_timer |
| 142 | from muse.domain import DomainOp |
| 143 | from muse.plugins.code._query import ( |
| 144 | flat_symbol_ops, |
| 145 | symbols_for_snapshot, |
| 146 | walk_commits_bfs, |
| 147 | ) |
| 148 | from muse.core.validation import clamp_int, sanitize_display |
| 149 | |
| 150 | logger = logging.getLogger(__name__) |
| 151 | |
| 152 | type _AccMap = dict[str, _Acc] |
| 153 | type _KindMap = dict[str, str] |
| 154 | |
| 155 | # ── Constants ────────────────────────────────────────────────────────────────── |
| 156 | |
| 157 | _DEFAULT_TOP = 20 |
| 158 | _DEFAULT_MAX_COMMITS = 10_000 |
| 159 | |
| 160 | SortKey = Literal["rewrites", "calendar", "genetic", "survival"] |
| 161 | _SORT_CHOICES: tuple[SortKey, ...] = ("rewrites", "calendar", "genetic", "survival") |
| 162 | _DEFAULT_SORT: SortKey = "rewrites" |
| 163 | |
| 164 | # Keywords in op summaries that indicate a body/implementation change. |
| 165 | _IMPL_KEYWORDS: frozenset[str] = frozenset( |
| 166 | {"implementation", "modified", "body", "reformatted"} |
| 167 | ) |
| 168 | # Signature-only change keywords (no body change). |
| 169 | _SIG_KEYWORDS: frozenset[str] = frozenset({"signature"}) |
| 170 | # Rename keywords. |
| 171 | _RENAME_KEYWORDS: frozenset[str] = frozenset({"renamed", "moved"}) |
| 172 | |
| 173 | # ── Helpers ──────────────────────────────────────────────────────────────────── |
| 174 | |
| 175 | def _classify_op(op: DomainOp) -> Literal["create", "impl", "sig", "rename", "delete", "other"]: |
| 176 | """Classify a symbol op into a change category. |
| 177 | |
| 178 | Returns one of: ``create``, ``impl``, ``sig``, ``rename``, ``delete``, ``other``. |
| 179 | """ |
| 180 | kind = op.get("op", "") |
| 181 | if kind == "insert": |
| 182 | return "create" |
| 183 | if kind == "delete": |
| 184 | return "delete" |
| 185 | if kind != "replace": |
| 186 | return "other" |
| 187 | |
| 188 | # For replace ops, check the summaries. |
| 189 | new_sum: str = str(op.get("new_summary") or op.get("content_summary") or "").lower() |
| 190 | old_sum: str = str(op.get("old_summary") or "").lower() |
| 191 | |
| 192 | if any(kw in new_sum for kw in _RENAME_KEYWORDS): |
| 193 | return "rename" |
| 194 | if any(kw in new_sum for kw in _SIG_KEYWORDS): |
| 195 | return "sig" |
| 196 | # Implementation changes show up in either summary field. |
| 197 | if any(kw in new_sum for kw in _IMPL_KEYWORDS) or any(kw in old_sum for kw in _IMPL_KEYWORDS): |
| 198 | return "impl" |
| 199 | # Any other replace without a clear signal is treated as an impl change |
| 200 | # (conservative: unknown changes are more likely to be body changes). |
| 201 | return "impl" |
| 202 | |
| 203 | def _days_ago(dt: datetime.datetime, now: datetime.datetime) -> int: |
| 204 | delta = now - dt.replace(tzinfo=None) if dt.tzinfo else now - dt |
| 205 | return max(0, delta.days) |
| 206 | |
| 207 | def _human_delta(days: int) -> str: |
| 208 | if days == 0: |
| 209 | return "today" |
| 210 | if days == 1: |
| 211 | return "1 day ago" |
| 212 | if days < 7: |
| 213 | return f"{days} days ago" |
| 214 | if days < 30: |
| 215 | weeks = days // 7 |
| 216 | return f"{weeks} wk ago" |
| 217 | if days < 365: |
| 218 | months = days // 30 |
| 219 | return f"{months} mo ago" |
| 220 | years = days // 365 |
| 221 | rem_months = (days % 365) // 30 |
| 222 | if rem_months: |
| 223 | return f"{years} yr {rem_months} mo ago" |
| 224 | return f"{years} yr ago" |
| 225 | |
| 226 | # ── Per-symbol accumulator ───────────────────────────────────────────────────── |
| 227 | |
| 228 | @dataclass |
| 229 | class _Acc: |
| 230 | """Running accumulator for one symbol's history.""" |
| 231 | |
| 232 | born_ts: datetime.datetime | None = None |
| 233 | born_commit: str = "" |
| 234 | last_change_ts: datetime.datetime | None = None |
| 235 | last_change_commit: str = "" |
| 236 | last_impl_ts: datetime.datetime | None = None |
| 237 | last_impl_commit: str = "" |
| 238 | impl_changes: int = 0 |
| 239 | sig_changes: int = 0 |
| 240 | renames: int = 0 |
| 241 | # Ordered event log for --explain (each item is (ts, commit_id, label)). |
| 242 | events: list[tuple[datetime.datetime, str, str]] = field(default_factory=list) |
| 243 | |
| 244 | def _update_acc(acc: _Acc, ts: datetime.datetime, commit_id: str, label: str) -> None: |
| 245 | """Update running min/max and append to event log.""" |
| 246 | # last change: keep maximum |
| 247 | if acc.last_change_ts is None or ts > acc.last_change_ts: |
| 248 | acc.last_change_ts = ts |
| 249 | acc.last_change_commit = commit_id |
| 250 | # born: keep minimum |
| 251 | if acc.born_ts is None or ts < acc.born_ts: |
| 252 | acc.born_ts = ts |
| 253 | acc.born_commit = commit_id |
| 254 | acc.events.append((ts, commit_id, label)) |
| 255 | |
| 256 | # ── TypedDict for output ─────────────────────────────────────────────────────── |
| 257 | |
| 258 | class _AgeRecord(TypedDict): |
| 259 | address: str |
| 260 | kind: str |
| 261 | file: str |
| 262 | born_commit: str |
| 263 | born_date: str |
| 264 | last_impl_commit: str |
| 265 | last_impl_date: str |
| 266 | last_change_commit: str |
| 267 | last_change_date: str |
| 268 | calendar_age_days: int |
| 269 | genetic_age_days: int |
| 270 | impl_changes: int |
| 271 | sig_changes: int |
| 272 | renames: int |
| 273 | est_survival_pct: int |
| 274 | |
| 275 | class _AgeFiltersDict(TypedDict, total=False): |
| 276 | kind: str | None |
| 277 | file: str | None |
| 278 | sort: str |
| 279 | top: int |
| 280 | since: str | None |
| 281 | max_commits: int |
| 282 | |
| 283 | class _AgeEventDict(TypedDict): |
| 284 | date: str |
| 285 | commit: str |
| 286 | label: str |
| 287 | |
| 288 | class _AgeListJson(EnvelopeJson): |
| 289 | ref: str |
| 290 | as_of: str |
| 291 | commits_analysed: int |
| 292 | truncated: bool |
| 293 | filters: _AgeFiltersDict |
| 294 | symbols: list[_AgeRecord] |
| 295 | |
| 296 | class _ExplainJson(EnvelopeJson): |
| 297 | address: str |
| 298 | kind: str |
| 299 | born_commit: str |
| 300 | born_date: str |
| 301 | last_impl_commit: str |
| 302 | last_impl_date: str |
| 303 | last_change_commit: str |
| 304 | last_change_date: str |
| 305 | calendar_age_days: int |
| 306 | genetic_age_days: int |
| 307 | impl_changes: int |
| 308 | sig_changes: int |
| 309 | renames: int |
| 310 | est_survival_pct: int |
| 311 | events: list[_AgeEventDict] |
| 312 | |
| 313 | # ── Core algorithm ───────────────────────────────────────────────────────────── |
| 314 | |
| 315 | def _collect_age_data( |
| 316 | root: pathlib.Path, |
| 317 | head_commit_id: str, |
| 318 | stop_at: str | None, |
| 319 | max_commits: int, |
| 320 | known_addresses: frozenset[str], |
| 321 | ) -> tuple[_AccMap, int, bool]: |
| 322 | """BFS walk — collect per-symbol history accumulators. |
| 323 | |
| 324 | Only tracks addresses in *known_addresses* (symbols present at HEAD). |
| 325 | This avoids building an unbounded accumulator for deleted symbols. |
| 326 | """ |
| 327 | commits, truncated = walk_commits_bfs( |
| 328 | root, head_commit_id, max_commits, stop_at_commit_id=stop_at |
| 329 | ) |
| 330 | |
| 331 | accumulators: _AccMap = {} |
| 332 | |
| 333 | for commit in commits: |
| 334 | if commit.structured_delta is None: |
| 335 | continue |
| 336 | ts = commit.committed_at |
| 337 | cid = commit.commit_id |
| 338 | ops: list[DomainOp] = commit.structured_delta["ops"] |
| 339 | |
| 340 | for op in flat_symbol_ops(ops): |
| 341 | addr: str = op["address"] |
| 342 | if "::import::" in addr: |
| 343 | continue |
| 344 | if addr not in known_addresses: |
| 345 | continue |
| 346 | |
| 347 | category = _classify_op(op) |
| 348 | if category == "other": |
| 349 | continue |
| 350 | |
| 351 | acc = accumulators.setdefault(addr, _Acc()) |
| 352 | |
| 353 | if category == "create": |
| 354 | _update_acc(acc, ts, cid, "created") |
| 355 | # born is set by _update_acc's min logic |
| 356 | |
| 357 | elif category == "impl": |
| 358 | _update_acc(acc, ts, cid, "implementation changed") |
| 359 | acc.impl_changes += 1 |
| 360 | if acc.last_impl_ts is None or ts > acc.last_impl_ts: |
| 361 | acc.last_impl_ts = ts |
| 362 | acc.last_impl_commit = cid |
| 363 | |
| 364 | elif category == "sig": |
| 365 | _update_acc(acc, ts, cid, "signature changed") |
| 366 | acc.sig_changes += 1 |
| 367 | if acc.last_impl_ts is None or ts > acc.last_impl_ts: |
| 368 | acc.last_impl_ts = ts |
| 369 | acc.last_impl_commit = cid |
| 370 | |
| 371 | elif category == "rename": |
| 372 | _update_acc(acc, ts, cid, "renamed / moved") |
| 373 | acc.renames += 1 |
| 374 | |
| 375 | elif category == "delete": |
| 376 | _update_acc(acc, ts, cid, "deleted") |
| 377 | |
| 378 | return accumulators, len(commits), truncated |
| 379 | |
| 380 | def _build_records( |
| 381 | accumulators: _AccMap, |
| 382 | symbol_kinds: _KindMap, # address → kind |
| 383 | now: datetime.datetime, |
| 384 | kind_filter: str | None, |
| 385 | file_filter: str | None, |
| 386 | sort_key: SortKey, |
| 387 | top: int, |
| 388 | ) -> list[_AgeRecord]: |
| 389 | """Convert accumulators into sorted _AgeRecord list.""" |
| 390 | records: list[_AgeRecord] = [] |
| 391 | |
| 392 | for address, acc in accumulators.items(): |
| 393 | if acc.born_ts is None: |
| 394 | continue # never seen a creation event — skip |
| 395 | |
| 396 | file_path = address.split("::")[0] |
| 397 | kind = symbol_kinds.get(address, "unknown") |
| 398 | |
| 399 | if kind_filter and kind.lower() != kind_filter: |
| 400 | continue |
| 401 | if file_filter and not ( |
| 402 | file_path == file_filter or file_path.endswith(f"/{file_filter}") |
| 403 | ): |
| 404 | continue |
| 405 | |
| 406 | born_ts = acc.born_ts |
| 407 | # Genetic reset: the later of born date and last impl change. |
| 408 | last_impl_ts = acc.last_impl_ts if acc.last_impl_ts is not None else born_ts |
| 409 | last_change_ts = acc.last_change_ts if acc.last_change_ts is not None else born_ts |
| 410 | |
| 411 | calendar_age = _days_ago(born_ts, now) |
| 412 | genetic_age = _days_ago(last_impl_ts, now) |
| 413 | est_survival = round(100 / (acc.impl_changes + 1)) |
| 414 | |
| 415 | records.append(_AgeRecord( |
| 416 | address=address, |
| 417 | kind=kind, |
| 418 | file=file_path, |
| 419 | born_commit=acc.born_commit, |
| 420 | born_date=born_ts.strftime("%Y-%m-%d"), |
| 421 | last_impl_commit=acc.last_impl_commit if acc.last_impl_commit else acc.born_commit, |
| 422 | last_impl_date=last_impl_ts.strftime("%Y-%m-%d"), |
| 423 | last_change_commit=acc.last_change_commit, |
| 424 | last_change_date=last_change_ts.strftime("%Y-%m-%d"), |
| 425 | calendar_age_days=calendar_age, |
| 426 | genetic_age_days=genetic_age, |
| 427 | impl_changes=acc.impl_changes, |
| 428 | sig_changes=acc.sig_changes, |
| 429 | renames=acc.renames, |
| 430 | est_survival_pct=est_survival, |
| 431 | )) |
| 432 | |
| 433 | def _key(r: _AgeRecord) -> tuple[int, ...]: |
| 434 | if sort_key == "rewrites": |
| 435 | return (-r["impl_changes"], -r["calendar_age_days"]) |
| 436 | if sort_key == "calendar": |
| 437 | return (-r["calendar_age_days"], -r["impl_changes"]) |
| 438 | if sort_key == "genetic": |
| 439 | return (-r["genetic_age_days"], -r["impl_changes"]) |
| 440 | # survival: lowest survival (most evolved) first |
| 441 | return (r["est_survival_pct"], -r["impl_changes"]) |
| 442 | |
| 443 | records.sort(key=_key) |
| 444 | return records[:top] |
| 445 | |
| 446 | # ── Formatters ───────────────────────────────────────────────────────────────── |
| 447 | |
| 448 | def _print_table( |
| 449 | records: list[_AgeRecord], |
| 450 | ref: str, |
| 451 | commits_analysed: int, |
| 452 | truncated: bool, |
| 453 | since: str | None, |
| 454 | sort_key: SortKey, |
| 455 | now: datetime.datetime, |
| 456 | ) -> None: |
| 457 | sort_labels: dict[SortKey, str] = { |
| 458 | "rewrites": "most rewrites first", |
| 459 | "calendar": "oldest symbols first", |
| 460 | "genetic": "oldest unmodified body first", |
| 461 | "survival": "least original code first", |
| 462 | } |
| 463 | scope = f"{since}..{ref}" if since else ref |
| 464 | trunc = " ⚠️ truncated" if truncated else "" |
| 465 | print( |
| 466 | f"\nSymbol evolutionary age — {scope}" |
| 467 | f" ({commits_analysed} commits · {now.strftime('%Y-%m-%d')}{trunc})" |
| 468 | ) |
| 469 | print(f"Sorted by: {sort_labels[sort_key]}\n") |
| 470 | |
| 471 | if not records: |
| 472 | print(" (no symbols with recorded history found)") |
| 473 | return |
| 474 | |
| 475 | max_addr = max(len(r["address"]) for r in records) |
| 476 | width = len(str(len(records))) |
| 477 | hdr = ( |
| 478 | f" {'#':>{width}} {'ADDRESS':<{max_addr}} " |
| 479 | f"{'BORN':>10} {'GENETIC':>10} {'REWRITES':>8} {'EST-SURV':>8}" |
| 480 | ) |
| 481 | print(hdr) |
| 482 | print(f" {'─' * (len(hdr) - 2)}") |
| 483 | |
| 484 | for i, r in enumerate(records, 1): |
| 485 | born_str = _human_delta(r["calendar_age_days"]) |
| 486 | gen_str = _human_delta(r["genetic_age_days"]) |
| 487 | surv_str = f"{r['est_survival_pct']:>3} %" |
| 488 | print( |
| 489 | f" {i:>{width}} {r['address']:<{max_addr}} " |
| 490 | f"{born_str:>10} {gen_str:>10} {r['impl_changes']:>8} {surv_str:>8}" |
| 491 | ) |
| 492 | |
| 493 | print( |
| 494 | "\n⚠️ High rewrites + low genetic age = symbol far from its original design.\n" |
| 495 | " Consider adding integration tests or refactoring toward a stable contract." |
| 496 | ) |
| 497 | |
| 498 | def _print_explain( |
| 499 | address: str, |
| 500 | acc: _Acc, |
| 501 | kind: str, |
| 502 | now: datetime.datetime, |
| 503 | ) -> None: |
| 504 | if acc.born_ts is None: |
| 505 | print(f"\n(no creation event found for '{sanitize_display(address)}')") |
| 506 | return |
| 507 | |
| 508 | born_ts = acc.born_ts |
| 509 | last_impl_ts = acc.last_impl_ts if acc.last_impl_ts is not None else born_ts |
| 510 | last_change_ts = acc.last_change_ts if acc.last_change_ts is not None else born_ts |
| 511 | |
| 512 | calendar_age = _days_ago(born_ts, now) |
| 513 | genetic_age = _days_ago(last_impl_ts, now) |
| 514 | last_change_age = _days_ago(last_change_ts, now) |
| 515 | est_survival = round(100 / (acc.impl_changes + 1)) |
| 516 | |
| 517 | print(f"\nEvolutionary age — {sanitize_display(address)}\n") |
| 518 | print(f" Kind: {kind}") |
| 519 | print( |
| 520 | f" Born: {born_ts.strftime('%Y-%m-%d')}" |
| 521 | f" ({acc.born_commit}) — {_human_delta(calendar_age)}" |
| 522 | ) |
| 523 | print( |
| 524 | f" Last impl change: {last_impl_ts.strftime('%Y-%m-%d')}" |
| 525 | f" ({acc.last_impl_commit if acc.last_impl_commit else acc.born_commit}) — " |
| 526 | f"{_human_delta(genetic_age)} (genetic age)" |
| 527 | ) |
| 528 | print( |
| 529 | f" Last any change: {last_change_ts.strftime('%Y-%m-%d')}" |
| 530 | f" ({acc.last_change_commit}) — {_human_delta(last_change_age)}" |
| 531 | ) |
| 532 | print() |
| 533 | print(f" Implementation changes: {acc.impl_changes}") |
| 534 | print(f" Signature changes: {acc.sig_changes}") |
| 535 | print(f" Renames: {acc.renames}") |
| 536 | print(f" Est. survival: {est_survival} % ({acc.impl_changes} generation(s) replaced)") |
| 537 | |
| 538 | # Timeline |
| 539 | if acc.events: |
| 540 | print("\n Change timeline (newest first):") |
| 541 | sorted_events = sorted(acc.events, key=lambda e: e[0], reverse=True) |
| 542 | for ev_ts, ev_cid, ev_label in sorted_events[:20]: |
| 543 | print(f" {ev_ts.strftime('%Y-%m-%d')} {short_id(ev_cid)} {ev_label}") |
| 544 | if len(sorted_events) > 20: |
| 545 | print(f" … {len(sorted_events) - 20} older events") |
| 546 | |
| 547 | # ── CLI ──────────────────────────────────────────────────────────────────────── |
| 548 | |
| 549 | def register( |
| 550 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 551 | ) -> None: |
| 552 | """Register the age subcommand.""" |
| 553 | parser = subparsers.add_parser( |
| 554 | "age", |
| 555 | help=( |
| 556 | "Evolutionary distance of every symbol: how much original " |
| 557 | "implementation remains, and when was it last rewritten." |
| 558 | ), |
| 559 | description=__doc__, |
| 560 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 561 | ) |
| 562 | parser.add_argument( |
| 563 | "--top", |
| 564 | type=int, default=_DEFAULT_TOP, metavar="N", |
| 565 | help=f"Number of symbols to show (default: {_DEFAULT_TOP}).", |
| 566 | ) |
| 567 | parser.add_argument( |
| 568 | "--sort", |
| 569 | choices=list(_SORT_CHOICES), default=_DEFAULT_SORT, metavar="KEY", |
| 570 | help=( |
| 571 | f"Sort order: rewrites (default), calendar, genetic, survival. " |
| 572 | f"'rewrites' = most-rewritten first. " |
| 573 | f"'calendar' = oldest symbols first. " |
| 574 | f"'genetic' = oldest unmodified body first. " |
| 575 | f"'survival' = least original code remaining first." |
| 576 | ), |
| 577 | ) |
| 578 | parser.add_argument( |
| 579 | "--kind", "-k", |
| 580 | default=None, metavar="KIND", dest="kind_filter", |
| 581 | help="Restrict to symbols of this kind (function, class, method, …).", |
| 582 | ) |
| 583 | parser.add_argument( |
| 584 | "--file", |
| 585 | default=None, metavar="FILE", dest="file_filter", |
| 586 | help="Restrict to symbols in this file (accepts a path suffix).", |
| 587 | ) |
| 588 | parser.add_argument( |
| 589 | "--since", "-s", |
| 590 | default=None, metavar="REF", |
| 591 | help="Limit analysis to commits reachable from HEAD but not from REF.", |
| 592 | ) |
| 593 | parser.add_argument( |
| 594 | "--max-commits", |
| 595 | type=int, default=_DEFAULT_MAX_COMMITS, metavar="N", |
| 596 | dest="max_commits", |
| 597 | help=f"Maximum commits to scan (default: {_DEFAULT_MAX_COMMITS}).", |
| 598 | ) |
| 599 | parser.add_argument( |
| 600 | "--explain", |
| 601 | default=None, metavar="ADDRESS", |
| 602 | help="Print a detailed per-event timeline for a single symbol.", |
| 603 | ) |
| 604 | parser.add_argument( |
| 605 | "--json", "-j", |
| 606 | action="store_true", dest="json_out", |
| 607 | help="Emit results as JSON (agent-friendly; -j is a shorthand alias).", |
| 608 | ) |
| 609 | parser.set_defaults(func=run) |
| 610 | |
| 611 | def run(args: argparse.Namespace) -> None: |
| 612 | """Show the evolutionary age of every symbol in the repository. |
| 613 | |
| 614 | Mines the commit history for each symbol's creation event, body rewrites, |
| 615 | signature changes, and renames. Computes two age metrics: ``calendar_age`` |
| 616 | (days since creation) and ``genetic_age`` (days since the last full body |
| 617 | rewrite). A symbol rewritten yesterday has genetic age of 1 even if it was |
| 618 | born two years ago. |
| 619 | |
| 620 | Agent quickstart |
| 621 | ---------------- |
| 622 | :: |
| 623 | |
| 624 | muse code age --json |
| 625 | muse code age --top 20 --sort calendar --json |
| 626 | muse code age --explain src/foo.py::MyClass --json |
| 627 | muse code age --kind function --file billing.py --json |
| 628 | |
| 629 | JSON fields |
| 630 | ----------- |
| 631 | ref Branch or ref analysed. |
| 632 | as_of Date of analysis (``YYYY-MM-DD``). |
| 633 | commits_analysed Number of commits scanned. |
| 634 | truncated ``true`` if ``--max-commits`` was reached before full history. |
| 635 | filters Echo of CLI filter arguments: kind, file, sort, top, since. |
| 636 | symbols List of symbol records — one per symbol (see below). |
| 637 | |
| 638 | Each symbol record: |
| 639 | |
| 640 | address Fully qualified address (``file.py::Symbol``). |
| 641 | kind Symbol kind: function, class, method, … |
| 642 | file Source file path. |
| 643 | born_commit Short commit ID of the creation event. |
| 644 | born_date Date of creation (``YYYY-MM-DD``). |
| 645 | last_impl_commit Short commit ID of the most recent body rewrite. |
| 646 | last_impl_date Date of that rewrite. |
| 647 | last_change_commit Short commit ID of the most recent change of any kind. |
| 648 | last_change_date Date of that change. |
| 649 | calendar_age_days Days since creation. |
| 650 | genetic_age_days Days since the last full body rewrite. |
| 651 | impl_changes Total body rewrites recorded. |
| 652 | sig_changes Total signature-only changes. |
| 653 | renames Total rename operations. |
| 654 | est_survival_pct Estimated % of the original implementation still present. |
| 655 | |
| 656 | With ``--explain``, the response is a single flat record plus an ``events`` |
| 657 | list. Each event: ``date`` (YYYY-MM-DD), ``commit`` (short ID), ``label``. |
| 658 | |
| 659 | Exit codes |
| 660 | ---------- |
| 661 | 0 Analysis complete. |
| 662 | 1 Invalid arguments or symbol not found (``--explain``). |
| 663 | 2 Not inside a Muse repository. |
| 664 | """ |
| 665 | elapsed = start_timer() |
| 666 | top: int = clamp_int(args.top, 1, 10_000, 'top') |
| 667 | sort_key: SortKey = args.sort |
| 668 | kind_filter: str | None = args.kind_filter |
| 669 | file_filter: str | None = args.file_filter |
| 670 | since: str | None = args.since |
| 671 | max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits') |
| 672 | explain_addr: str | None = args.explain |
| 673 | json_out: bool = args.json_out |
| 674 | |
| 675 | # ── Validation ──────────────────────────────────────────────────────────── |
| 676 | if top < 1: |
| 677 | print("❌ --top must be >= 1.", file=sys.stderr) |
| 678 | raise SystemExit(ExitCode.USER_ERROR) |
| 679 | if max_commits < 1: |
| 680 | print("❌ --max-commits must be >= 1.", file=sys.stderr) |
| 681 | raise SystemExit(ExitCode.USER_ERROR) |
| 682 | if kind_filter is not None: |
| 683 | kind_filter = kind_filter.strip().lower() |
| 684 | if explain_addr is not None and "::" not in explain_addr: |
| 685 | print( |
| 686 | "❌ --explain requires a qualified address (file.py::SymbolName).", |
| 687 | file=sys.stderr, |
| 688 | ) |
| 689 | raise SystemExit(ExitCode.USER_ERROR) |
| 690 | |
| 691 | # ── Repo setup ──────────────────────────────────────────────────────────── |
| 692 | root = require_repo() |
| 693 | |
| 694 | branch = read_current_branch(root) |
| 695 | |
| 696 | head = resolve_commit_ref(root, branch, None) |
| 697 | if head is None: |
| 698 | print("❌ HEAD commit not found.", file=sys.stderr) |
| 699 | raise SystemExit(ExitCode.USER_ERROR) |
| 700 | |
| 701 | stop_at: str | None = None |
| 702 | if since is not None: |
| 703 | since_commit = resolve_commit_ref(root, branch, since) |
| 704 | if since_commit is None: |
| 705 | print(f"❌ Commit '{since}' not found.", file=sys.stderr) |
| 706 | raise SystemExit(ExitCode.USER_ERROR) |
| 707 | stop_at = since_commit.commit_id |
| 708 | |
| 709 | # ── Load HEAD snapshot → known symbols ─────────────────────────────────── |
| 710 | manifest = get_commit_snapshot_manifest(root, head.commit_id) or {} |
| 711 | cache = load_symbol_cache(root) |
| 712 | all_trees = symbols_for_snapshot(root, manifest, cache=cache) |
| 713 | |
| 714 | symbol_kinds: _KindMap = {} |
| 715 | for file_path, tree in all_trees.items(): |
| 716 | for addr, sym in tree.items(): |
| 717 | if "::import::" not in addr: |
| 718 | symbol_kinds[addr] = sym["kind"] |
| 719 | |
| 720 | known_addresses = frozenset(symbol_kinds) |
| 721 | |
| 722 | if not known_addresses: |
| 723 | if json_out: |
| 724 | print(json.dumps(_AgeListJson( |
| 725 | **make_envelope(elapsed), |
| 726 | ref=branch, as_of="", commits_analysed=0, |
| 727 | truncated=False, filters={}, symbols=[], |
| 728 | ))) |
| 729 | else: |
| 730 | print("\n(no symbols found in the current snapshot)") |
| 731 | return |
| 732 | |
| 733 | # ── Mine commit history ─────────────────────────────────────────────────── |
| 734 | accumulators, commits_analysed, truncated = _collect_age_data( |
| 735 | root, head.commit_id, stop_at, max_commits, known_addresses |
| 736 | ) |
| 737 | |
| 738 | now = datetime.datetime.now() |
| 739 | |
| 740 | # ── --explain mode ──────────────────────────────────────────────────────── |
| 741 | if explain_addr is not None: |
| 742 | acc = accumulators.get(explain_addr) |
| 743 | if acc is None: |
| 744 | print( |
| 745 | f"❌ No history found for '{explain_addr}'.", |
| 746 | file=sys.stderr, |
| 747 | ) |
| 748 | print( |
| 749 | " The symbol may be new (added in the same commit as HEAD) " |
| 750 | "or may not exist.", |
| 751 | file=sys.stderr, |
| 752 | ) |
| 753 | raise SystemExit(ExitCode.USER_ERROR) |
| 754 | kind = symbol_kinds.get(explain_addr, "unknown") |
| 755 | if json_out: |
| 756 | born_ts = acc.born_ts |
| 757 | if born_ts is None: |
| 758 | print("{}", flush=True) |
| 759 | return |
| 760 | last_impl_ts = acc.last_impl_ts or born_ts |
| 761 | last_change_ts = acc.last_change_ts or born_ts |
| 762 | print(json.dumps(_ExplainJson( |
| 763 | **make_envelope(elapsed), |
| 764 | address=explain_addr, |
| 765 | kind=kind, |
| 766 | born_commit=acc.born_commit, |
| 767 | born_date=born_ts.strftime("%Y-%m-%d"), |
| 768 | last_impl_commit=(acc.last_impl_commit if acc.last_impl_commit else acc.born_commit), |
| 769 | last_impl_date=last_impl_ts.strftime("%Y-%m-%d"), |
| 770 | last_change_commit=acc.last_change_commit, |
| 771 | last_change_date=last_change_ts.strftime("%Y-%m-%d"), |
| 772 | calendar_age_days=_days_ago(born_ts, now), |
| 773 | genetic_age_days=_days_ago(last_impl_ts, now), |
| 774 | impl_changes=acc.impl_changes, |
| 775 | sig_changes=acc.sig_changes, |
| 776 | renames=acc.renames, |
| 777 | est_survival_pct=round(100 / (acc.impl_changes + 1)), |
| 778 | events=[ |
| 779 | {"date": e[0].strftime("%Y-%m-%d"), "commit": e[1], "label": e[2]} |
| 780 | for e in sorted(acc.events, key=lambda x: x[0], reverse=True) |
| 781 | ], |
| 782 | ))) |
| 783 | else: |
| 784 | _print_explain(explain_addr, acc, kind, now) |
| 785 | return |
| 786 | |
| 787 | # ── Build ranked table ──────────────────────────────────────────────────── |
| 788 | records = _build_records( |
| 789 | accumulators, symbol_kinds, now, |
| 790 | kind_filter=kind_filter, |
| 791 | file_filter=file_filter, |
| 792 | sort_key=sort_key, |
| 793 | top=top, |
| 794 | ) |
| 795 | |
| 796 | # ── Output ──────────────────────────────────────────────────────────────── |
| 797 | if json_out: |
| 798 | print(json.dumps(_AgeListJson( |
| 799 | **make_envelope(elapsed), |
| 800 | ref=branch, |
| 801 | as_of=now.strftime("%Y-%m-%d"), |
| 802 | commits_analysed=commits_analysed, |
| 803 | truncated=truncated, |
| 804 | filters={ |
| 805 | "kind": kind_filter, |
| 806 | "file": file_filter, |
| 807 | "sort": sort_key, |
| 808 | "top": top, |
| 809 | "since": since, |
| 810 | "max_commits": max_commits, |
| 811 | }, |
| 812 | symbols=[dict(r) for r in records], |
| 813 | ))) |
| 814 | return |
| 815 | |
| 816 | _print_table(records, branch, commits_analysed, truncated, since, sort_key, now) |
File History
1 commit
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b
docs(mwp-master): tick all ACs green; mark MWP-7 complete w…
Sonnet 4.6
20 days ago