age.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 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 | from __future__ import annotations |
| 125 | |
| 126 | import argparse |
| 127 | import datetime |
| 128 | import json |
| 129 | import logging |
| 130 | import pathlib |
| 131 | import sys |
| 132 | from dataclasses import dataclass, field |
| 133 | from typing import Literal, TypedDict |
| 134 | |
| 135 | from muse.core.errors import ExitCode |
| 136 | from muse.core.repo import read_repo_id, require_repo |
| 137 | from muse.core.store import ( |
| 138 | get_commit_snapshot_manifest, |
| 139 | read_current_branch, |
| 140 | resolve_commit_ref, |
| 141 | ) |
| 142 | from muse.core.symbol_cache import load_symbol_cache |
| 143 | from muse.domain import DomainOp |
| 144 | from muse.plugins.code._query import ( |
| 145 | flat_symbol_ops, |
| 146 | symbols_for_snapshot, |
| 147 | walk_commits_bfs, |
| 148 | ) |
| 149 | from muse.core.validation import clamp_int, sanitize_display |
| 150 | |
| 151 | logger = logging.getLogger(__name__) |
| 152 | |
| 153 | type _AccMap = dict[str, _Acc] |
| 154 | type _KindMap = dict[str, str] |
| 155 | |
| 156 | # ββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 157 | |
| 158 | _DEFAULT_TOP = 20 |
| 159 | _DEFAULT_MAX_COMMITS = 10_000 |
| 160 | |
| 161 | SortKey = Literal["rewrites", "calendar", "genetic", "survival"] |
| 162 | _SORT_CHOICES: tuple[SortKey, ...] = ("rewrites", "calendar", "genetic", "survival") |
| 163 | _DEFAULT_SORT: SortKey = "rewrites" |
| 164 | |
| 165 | # Keywords in op summaries that indicate a body/implementation change. |
| 166 | _IMPL_KEYWORDS: frozenset[str] = frozenset( |
| 167 | {"implementation", "modified", "body", "reformatted"} |
| 168 | ) |
| 169 | # Signature-only change keywords (no body change). |
| 170 | _SIG_KEYWORDS: frozenset[str] = frozenset({"signature"}) |
| 171 | # Rename keywords. |
| 172 | _RENAME_KEYWORDS: frozenset[str] = frozenset({"renamed", "moved"}) |
| 173 | |
| 174 | |
| 175 | # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 176 | |
| 177 | |
| 178 | |
| 179 | def _classify_op(op: DomainOp) -> Literal["create", "impl", "sig", "rename", "delete", "other"]: |
| 180 | """Classify a symbol op into a change category. |
| 181 | |
| 182 | Returns one of: ``create``, ``impl``, ``sig``, ``rename``, ``delete``, ``other``. |
| 183 | """ |
| 184 | kind = op.get("op", "") |
| 185 | if kind == "insert": |
| 186 | return "create" |
| 187 | if kind == "delete": |
| 188 | return "delete" |
| 189 | if kind != "replace": |
| 190 | return "other" |
| 191 | |
| 192 | # For replace ops, check the summaries. |
| 193 | new_sum: str = str(op.get("new_summary") or op.get("content_summary") or "").lower() |
| 194 | old_sum: str = str(op.get("old_summary") or "").lower() |
| 195 | |
| 196 | if any(kw in new_sum for kw in _RENAME_KEYWORDS): |
| 197 | return "rename" |
| 198 | if any(kw in new_sum for kw in _SIG_KEYWORDS): |
| 199 | return "sig" |
| 200 | # Implementation changes show up in either summary field. |
| 201 | if any(kw in new_sum for kw in _IMPL_KEYWORDS) or any(kw in old_sum for kw in _IMPL_KEYWORDS): |
| 202 | return "impl" |
| 203 | # Any other replace without a clear signal is treated as an impl change |
| 204 | # (conservative: unknown changes are more likely to be body changes). |
| 205 | return "impl" |
| 206 | |
| 207 | |
| 208 | def _days_ago(dt: datetime.datetime, now: datetime.datetime) -> int: |
| 209 | delta = now - dt.replace(tzinfo=None) if dt.tzinfo else now - dt |
| 210 | return max(0, delta.days) |
| 211 | |
| 212 | |
| 213 | def _human_delta(days: int) -> str: |
| 214 | if days == 0: |
| 215 | return "today" |
| 216 | if days == 1: |
| 217 | return "1 day ago" |
| 218 | if days < 7: |
| 219 | return f"{days} days ago" |
| 220 | if days < 30: |
| 221 | weeks = days // 7 |
| 222 | return f"{weeks} wk ago" |
| 223 | if days < 365: |
| 224 | months = days // 30 |
| 225 | return f"{months} mo ago" |
| 226 | years = days // 365 |
| 227 | rem_months = (days % 365) // 30 |
| 228 | if rem_months: |
| 229 | return f"{years} yr {rem_months} mo ago" |
| 230 | return f"{years} yr ago" |
| 231 | |
| 232 | |
| 233 | # ββ Per-symbol accumulator βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 234 | |
| 235 | |
| 236 | @dataclass |
| 237 | class _Acc: |
| 238 | """Running accumulator for one symbol's history.""" |
| 239 | |
| 240 | born_ts: datetime.datetime | None = None |
| 241 | born_commit: str = "" |
| 242 | last_change_ts: datetime.datetime | None = None |
| 243 | last_change_commit: str = "" |
| 244 | last_impl_ts: datetime.datetime | None = None |
| 245 | last_impl_commit: str = "" |
| 246 | impl_changes: int = 0 |
| 247 | sig_changes: int = 0 |
| 248 | renames: int = 0 |
| 249 | # Ordered event log for --explain (each item is (ts, commit_id, label)). |
| 250 | events: list[tuple[datetime.datetime, str, str]] = field(default_factory=list) |
| 251 | |
| 252 | |
| 253 | def _update_acc(acc: _Acc, ts: datetime.datetime, commit_id: str, label: str) -> None: |
| 254 | """Update running min/max and append to event log.""" |
| 255 | # last change: keep maximum |
| 256 | if acc.last_change_ts is None or ts > acc.last_change_ts: |
| 257 | acc.last_change_ts = ts |
| 258 | acc.last_change_commit = commit_id |
| 259 | # born: keep minimum |
| 260 | if acc.born_ts is None or ts < acc.born_ts: |
| 261 | acc.born_ts = ts |
| 262 | acc.born_commit = commit_id |
| 263 | acc.events.append((ts, commit_id, label)) |
| 264 | |
| 265 | |
| 266 | # ββ TypedDict for output βββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 267 | |
| 268 | |
| 269 | class _AgeRecord(TypedDict): |
| 270 | address: str |
| 271 | kind: str |
| 272 | file: str |
| 273 | born_commit: str |
| 274 | born_date: str |
| 275 | last_impl_commit: str |
| 276 | last_impl_date: str |
| 277 | last_change_commit: str |
| 278 | last_change_date: str |
| 279 | calendar_age_days: int |
| 280 | genetic_age_days: int |
| 281 | impl_changes: int |
| 282 | sig_changes: int |
| 283 | renames: int |
| 284 | est_survival_pct: int |
| 285 | |
| 286 | |
| 287 | # ββ Core algorithm βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 288 | |
| 289 | |
| 290 | def _collect_age_data( |
| 291 | root: pathlib.Path, |
| 292 | head_commit_id: str, |
| 293 | stop_at: str | None, |
| 294 | max_commits: int, |
| 295 | known_addresses: frozenset[str], |
| 296 | ) -> tuple[_AccMap, int, bool]: |
| 297 | """BFS walk β collect per-symbol history accumulators. |
| 298 | |
| 299 | Only tracks addresses in *known_addresses* (symbols present at HEAD). |
| 300 | This avoids building an unbounded accumulator for deleted symbols. |
| 301 | """ |
| 302 | commits, truncated = walk_commits_bfs( |
| 303 | root, head_commit_id, max_commits, stop_at_commit_id=stop_at |
| 304 | ) |
| 305 | |
| 306 | accumulators: _AccMap = {} |
| 307 | |
| 308 | for commit in commits: |
| 309 | if commit.structured_delta is None: |
| 310 | continue |
| 311 | ts = commit.committed_at |
| 312 | cid = commit.commit_id |
| 313 | ops: list[DomainOp] = commit.structured_delta["ops"] |
| 314 | |
| 315 | for op in flat_symbol_ops(ops): |
| 316 | addr: str = op["address"] |
| 317 | if "::import::" in addr: |
| 318 | continue |
| 319 | if addr not in known_addresses: |
| 320 | continue |
| 321 | |
| 322 | category = _classify_op(op) |
| 323 | if category == "other": |
| 324 | continue |
| 325 | |
| 326 | acc = accumulators.setdefault(addr, _Acc()) |
| 327 | |
| 328 | if category == "create": |
| 329 | _update_acc(acc, ts, cid, "created") |
| 330 | # born is set by _update_acc's min logic |
| 331 | |
| 332 | elif category == "impl": |
| 333 | _update_acc(acc, ts, cid, "implementation changed") |
| 334 | acc.impl_changes += 1 |
| 335 | if acc.last_impl_ts is None or ts > acc.last_impl_ts: |
| 336 | acc.last_impl_ts = ts |
| 337 | acc.last_impl_commit = cid |
| 338 | |
| 339 | elif category == "sig": |
| 340 | _update_acc(acc, ts, cid, "signature changed") |
| 341 | acc.sig_changes += 1 |
| 342 | if acc.last_impl_ts is None or ts > acc.last_impl_ts: |
| 343 | acc.last_impl_ts = ts |
| 344 | acc.last_impl_commit = cid |
| 345 | |
| 346 | elif category == "rename": |
| 347 | _update_acc(acc, ts, cid, "renamed / moved") |
| 348 | acc.renames += 1 |
| 349 | |
| 350 | elif category == "delete": |
| 351 | _update_acc(acc, ts, cid, "deleted") |
| 352 | |
| 353 | return accumulators, len(commits), truncated |
| 354 | |
| 355 | |
| 356 | def _build_records( |
| 357 | accumulators: _AccMap, |
| 358 | symbol_kinds: _KindMap, # address β kind |
| 359 | now: datetime.datetime, |
| 360 | kind_filter: str | None, |
| 361 | file_filter: str | None, |
| 362 | sort_key: SortKey, |
| 363 | top: int, |
| 364 | ) -> list[_AgeRecord]: |
| 365 | """Convert accumulators into sorted _AgeRecord list.""" |
| 366 | records: list[_AgeRecord] = [] |
| 367 | |
| 368 | for address, acc in accumulators.items(): |
| 369 | if acc.born_ts is None: |
| 370 | continue # never seen a creation event β skip |
| 371 | |
| 372 | file_path = address.split("::")[0] |
| 373 | kind = symbol_kinds.get(address, "unknown") |
| 374 | |
| 375 | if kind_filter and kind.lower() != kind_filter: |
| 376 | continue |
| 377 | if file_filter and not ( |
| 378 | file_path == file_filter or file_path.endswith("/" + file_filter) |
| 379 | ): |
| 380 | continue |
| 381 | |
| 382 | born_ts = acc.born_ts |
| 383 | # Genetic reset: the later of born date and last impl change. |
| 384 | last_impl_ts = acc.last_impl_ts if acc.last_impl_ts is not None else born_ts |
| 385 | last_change_ts = acc.last_change_ts if acc.last_change_ts is not None else born_ts |
| 386 | |
| 387 | calendar_age = _days_ago(born_ts, now) |
| 388 | genetic_age = _days_ago(last_impl_ts, now) |
| 389 | est_survival = round(100 / (acc.impl_changes + 1)) |
| 390 | |
| 391 | records.append(_AgeRecord( |
| 392 | address=address, |
| 393 | kind=kind, |
| 394 | file=file_path, |
| 395 | born_commit=acc.born_commit[:12], |
| 396 | born_date=born_ts.strftime("%Y-%m-%d"), |
| 397 | last_impl_commit=acc.last_impl_commit[:12] if acc.last_impl_commit else acc.born_commit[:12], |
| 398 | last_impl_date=last_impl_ts.strftime("%Y-%m-%d"), |
| 399 | last_change_commit=acc.last_change_commit[:12], |
| 400 | last_change_date=last_change_ts.strftime("%Y-%m-%d"), |
| 401 | calendar_age_days=calendar_age, |
| 402 | genetic_age_days=genetic_age, |
| 403 | impl_changes=acc.impl_changes, |
| 404 | sig_changes=acc.sig_changes, |
| 405 | renames=acc.renames, |
| 406 | est_survival_pct=est_survival, |
| 407 | )) |
| 408 | |
| 409 | def _key(r: _AgeRecord) -> tuple[int, ...]: |
| 410 | if sort_key == "rewrites": |
| 411 | return (-r["impl_changes"], -r["calendar_age_days"]) |
| 412 | if sort_key == "calendar": |
| 413 | return (-r["calendar_age_days"], -r["impl_changes"]) |
| 414 | if sort_key == "genetic": |
| 415 | return (-r["genetic_age_days"], -r["impl_changes"]) |
| 416 | # survival: lowest survival (most evolved) first |
| 417 | return (r["est_survival_pct"], -r["impl_changes"]) |
| 418 | |
| 419 | records.sort(key=_key) |
| 420 | return records[:top] |
| 421 | |
| 422 | |
| 423 | # ββ Formatters βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 424 | |
| 425 | |
| 426 | def _print_table( |
| 427 | records: list[_AgeRecord], |
| 428 | ref: str, |
| 429 | commits_analysed: int, |
| 430 | truncated: bool, |
| 431 | since: str | None, |
| 432 | sort_key: SortKey, |
| 433 | now: datetime.datetime, |
| 434 | ) -> None: |
| 435 | sort_labels: dict[SortKey, str] = { |
| 436 | "rewrites": "most rewrites first", |
| 437 | "calendar": "oldest symbols first", |
| 438 | "genetic": "oldest unmodified body first", |
| 439 | "survival": "least original code first", |
| 440 | } |
| 441 | scope = f"{since}..{ref}" if since else ref |
| 442 | trunc = " β οΈ truncated" if truncated else "" |
| 443 | print( |
| 444 | f"\nSymbol evolutionary age β {scope}" |
| 445 | f" ({commits_analysed} commits Β· {now.strftime('%Y-%m-%d')}{trunc})" |
| 446 | ) |
| 447 | print(f"Sorted by: {sort_labels[sort_key]}\n") |
| 448 | |
| 449 | if not records: |
| 450 | print(" (no symbols with recorded history found)") |
| 451 | return |
| 452 | |
| 453 | max_addr = max(len(r["address"]) for r in records) |
| 454 | width = len(str(len(records))) |
| 455 | hdr = ( |
| 456 | f" {'#':>{width}} {'ADDRESS':<{max_addr}} " |
| 457 | f"{'BORN':>10} {'GENETIC':>10} {'REWRITES':>8} {'EST-SURV':>8}" |
| 458 | ) |
| 459 | print(hdr) |
| 460 | print(" " + "β" * (len(hdr) - 2)) |
| 461 | |
| 462 | for i, r in enumerate(records, 1): |
| 463 | born_str = _human_delta(r["calendar_age_days"]) |
| 464 | gen_str = _human_delta(r["genetic_age_days"]) |
| 465 | surv_str = f"{r['est_survival_pct']:>3} %" |
| 466 | print( |
| 467 | f" {i:>{width}} {r['address']:<{max_addr}} " |
| 468 | f"{born_str:>10} {gen_str:>10} {r['impl_changes']:>8} {surv_str:>8}" |
| 469 | ) |
| 470 | |
| 471 | print( |
| 472 | "\nβ οΈ High rewrites + low genetic age = symbol far from its original design.\n" |
| 473 | " Consider adding integration tests or refactoring toward a stable contract." |
| 474 | ) |
| 475 | |
| 476 | |
| 477 | def _print_explain( |
| 478 | address: str, |
| 479 | acc: _Acc, |
| 480 | kind: str, |
| 481 | now: datetime.datetime, |
| 482 | ) -> None: |
| 483 | if acc.born_ts is None: |
| 484 | print(f"\n(no creation event found for '{sanitize_display(address)}')") |
| 485 | return |
| 486 | |
| 487 | born_ts = acc.born_ts |
| 488 | last_impl_ts = acc.last_impl_ts if acc.last_impl_ts is not None else born_ts |
| 489 | last_change_ts = acc.last_change_ts if acc.last_change_ts is not None else born_ts |
| 490 | |
| 491 | calendar_age = _days_ago(born_ts, now) |
| 492 | genetic_age = _days_ago(last_impl_ts, now) |
| 493 | last_change_age = _days_ago(last_change_ts, now) |
| 494 | est_survival = round(100 / (acc.impl_changes + 1)) |
| 495 | |
| 496 | print(f"\nEvolutionary age β {sanitize_display(address)}\n") |
| 497 | print(f" Kind: {kind}") |
| 498 | print( |
| 499 | f" Born: {born_ts.strftime('%Y-%m-%d')}" |
| 500 | f" ({acc.born_commit[:12]}) β {_human_delta(calendar_age)}" |
| 501 | ) |
| 502 | print( |
| 503 | f" Last impl change: {last_impl_ts.strftime('%Y-%m-%d')}" |
| 504 | f" ({acc.last_impl_commit[:12] if acc.last_impl_commit else acc.born_commit[:12]}) β " |
| 505 | f"{_human_delta(genetic_age)} (genetic age)" |
| 506 | ) |
| 507 | print( |
| 508 | f" Last any change: {last_change_ts.strftime('%Y-%m-%d')}" |
| 509 | f" ({acc.last_change_commit[:12]}) β {_human_delta(last_change_age)}" |
| 510 | ) |
| 511 | print() |
| 512 | print(f" Implementation changes: {acc.impl_changes}") |
| 513 | print(f" Signature changes: {acc.sig_changes}") |
| 514 | print(f" Renames: {acc.renames}") |
| 515 | print(f" Est. survival: {est_survival} % ({acc.impl_changes} generation(s) replaced)") |
| 516 | |
| 517 | # Timeline |
| 518 | if acc.events: |
| 519 | print("\n Change timeline (newest first):") |
| 520 | sorted_events = sorted(acc.events, key=lambda e: e[0], reverse=True) |
| 521 | for ev_ts, ev_cid, ev_label in sorted_events[:20]: |
| 522 | print(f" {ev_ts.strftime('%Y-%m-%d')} {ev_cid[:12]} {ev_label}") |
| 523 | if len(sorted_events) > 20: |
| 524 | print(f" β¦ {len(sorted_events) - 20} older events") |
| 525 | |
| 526 | |
| 527 | # ββ CLI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 528 | |
| 529 | |
| 530 | def register( |
| 531 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 532 | ) -> None: |
| 533 | """Register the age subcommand.""" |
| 534 | parser = subparsers.add_parser( |
| 535 | "age", |
| 536 | help=( |
| 537 | "Evolutionary distance of every symbol: how much original " |
| 538 | "implementation remains, and when was it last rewritten." |
| 539 | ), |
| 540 | description=__doc__, |
| 541 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 542 | ) |
| 543 | parser.add_argument( |
| 544 | "--top", "-n", |
| 545 | type=int, default=_DEFAULT_TOP, metavar="N", |
| 546 | help=f"Number of symbols to show (default: {_DEFAULT_TOP}).", |
| 547 | ) |
| 548 | parser.add_argument( |
| 549 | "--sort", |
| 550 | choices=list(_SORT_CHOICES), default=_DEFAULT_SORT, metavar="KEY", |
| 551 | help=( |
| 552 | f"Sort order: rewrites (default), calendar, genetic, survival. " |
| 553 | f"'rewrites' = most-rewritten first. " |
| 554 | f"'calendar' = oldest symbols first. " |
| 555 | f"'genetic' = oldest unmodified body first. " |
| 556 | f"'survival' = least original code remaining first." |
| 557 | ), |
| 558 | ) |
| 559 | parser.add_argument( |
| 560 | "--kind", "-k", |
| 561 | default=None, metavar="KIND", dest="kind_filter", |
| 562 | help="Restrict to symbols of this kind (function, class, method, β¦).", |
| 563 | ) |
| 564 | parser.add_argument( |
| 565 | "--file", "-f", |
| 566 | default=None, metavar="FILE", dest="file_filter", |
| 567 | help="Restrict to symbols in this file (accepts a path suffix).", |
| 568 | ) |
| 569 | parser.add_argument( |
| 570 | "--since", "-s", |
| 571 | default=None, metavar="REF", |
| 572 | help="Limit analysis to commits reachable from HEAD but not from REF.", |
| 573 | ) |
| 574 | parser.add_argument( |
| 575 | "--max-commits", |
| 576 | type=int, default=_DEFAULT_MAX_COMMITS, metavar="N", |
| 577 | dest="max_commits", |
| 578 | help=f"Maximum commits to scan (default: {_DEFAULT_MAX_COMMITS}).", |
| 579 | ) |
| 580 | parser.add_argument( |
| 581 | "--explain", |
| 582 | default=None, metavar="ADDRESS", |
| 583 | help="Print a detailed per-event timeline for a single symbol.", |
| 584 | ) |
| 585 | parser.add_argument( |
| 586 | "--json", |
| 587 | action="store_true", dest="as_json", |
| 588 | help="Emit results as JSON.", |
| 589 | ) |
| 590 | parser.set_defaults(func=run) |
| 591 | |
| 592 | |
| 593 | def run(args: argparse.Namespace) -> None: |
| 594 | """Show the evolutionary age of every symbol in the repository. |
| 595 | |
| 596 | Mines the commit history for each symbol's creation event, every body |
| 597 | rewrite, signature change, and rename. Computes two age metrics: |
| 598 | |
| 599 | * ``calendar_age`` β days since creation. |
| 600 | * ``genetic_age`` β days since the last full implementation rewrite. |
| 601 | A symbol rewritten yesterday has a genetic age of 1 day even if it |
| 602 | was born 2 years ago. |
| 603 | """ |
| 604 | top: int = clamp_int(args.top, 1, 10_000, 'top') |
| 605 | sort_key: SortKey = args.sort |
| 606 | kind_filter: str | None = args.kind_filter |
| 607 | file_filter: str | None = args.file_filter |
| 608 | since: str | None = args.since |
| 609 | max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits') |
| 610 | explain_addr: str | None = args.explain |
| 611 | as_json: bool = args.as_json |
| 612 | |
| 613 | # ββ Validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 614 | if top < 1: |
| 615 | print("β --top must be >= 1.", file=sys.stderr) |
| 616 | raise SystemExit(ExitCode.USER_ERROR) |
| 617 | if max_commits < 1: |
| 618 | print("β --max-commits must be >= 1.", file=sys.stderr) |
| 619 | raise SystemExit(ExitCode.USER_ERROR) |
| 620 | if kind_filter is not None: |
| 621 | kind_filter = kind_filter.strip().lower() |
| 622 | if explain_addr is not None and "::" not in explain_addr: |
| 623 | print( |
| 624 | "β --explain requires a qualified address (file.py::SymbolName).", |
| 625 | file=sys.stderr, |
| 626 | ) |
| 627 | raise SystemExit(ExitCode.USER_ERROR) |
| 628 | |
| 629 | # ββ Repo setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 630 | root = require_repo() |
| 631 | repo_id = read_repo_id(root) |
| 632 | branch = read_current_branch(root) |
| 633 | |
| 634 | head = resolve_commit_ref(root, repo_id, branch, None) |
| 635 | if head is None: |
| 636 | print("β HEAD commit not found.", file=sys.stderr) |
| 637 | raise SystemExit(ExitCode.USER_ERROR) |
| 638 | |
| 639 | stop_at: str | None = None |
| 640 | if since is not None: |
| 641 | since_commit = resolve_commit_ref(root, repo_id, branch, since) |
| 642 | if since_commit is None: |
| 643 | print(f"β Commit '{since}' not found.", file=sys.stderr) |
| 644 | raise SystemExit(ExitCode.USER_ERROR) |
| 645 | stop_at = since_commit.commit_id |
| 646 | |
| 647 | # ββ Load HEAD snapshot β known symbols βββββββββββββββββββββββββββββββββββ |
| 648 | manifest = get_commit_snapshot_manifest(root, head.commit_id) or {} |
| 649 | cache = load_symbol_cache(root) |
| 650 | all_trees = symbols_for_snapshot(root, manifest, cache=cache) |
| 651 | |
| 652 | symbol_kinds: _KindMap = {} |
| 653 | for file_path, tree in all_trees.items(): |
| 654 | for addr, sym in tree.items(): |
| 655 | if "::import::" not in addr: |
| 656 | symbol_kinds[addr] = sym["kind"] |
| 657 | |
| 658 | known_addresses = frozenset(symbol_kinds) |
| 659 | |
| 660 | if not known_addresses: |
| 661 | if as_json: |
| 662 | print(json.dumps({ |
| 663 | "ref": branch, "commits_analysed": 0, |
| 664 | "truncated": False, "symbols": [], |
| 665 | }, indent=2)) |
| 666 | else: |
| 667 | print("\n(no symbols found in the current snapshot)") |
| 668 | return |
| 669 | |
| 670 | # ββ Mine commit history βββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 671 | accumulators, commits_analysed, truncated = _collect_age_data( |
| 672 | root, head.commit_id, stop_at, max_commits, known_addresses |
| 673 | ) |
| 674 | |
| 675 | now = datetime.datetime.now() |
| 676 | |
| 677 | # ββ --explain mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 678 | if explain_addr is not None: |
| 679 | acc = accumulators.get(explain_addr) |
| 680 | if acc is None: |
| 681 | print( |
| 682 | f"β No history found for '{explain_addr}'.", |
| 683 | file=sys.stderr, |
| 684 | ) |
| 685 | print( |
| 686 | " The symbol may be new (added in the same commit as HEAD) " |
| 687 | "or may not exist.", |
| 688 | file=sys.stderr, |
| 689 | ) |
| 690 | raise SystemExit(ExitCode.USER_ERROR) |
| 691 | kind = symbol_kinds.get(explain_addr, "unknown") |
| 692 | if as_json: |
| 693 | born_ts = acc.born_ts |
| 694 | if born_ts is None: |
| 695 | print("{}", flush=True) |
| 696 | return |
| 697 | last_impl_ts = acc.last_impl_ts or born_ts |
| 698 | last_change_ts = acc.last_change_ts or born_ts |
| 699 | print(json.dumps({ |
| 700 | "address": explain_addr, |
| 701 | "kind": kind, |
| 702 | "born_commit": acc.born_commit[:12], |
| 703 | "born_date": born_ts.strftime("%Y-%m-%d"), |
| 704 | "last_impl_commit": (acc.last_impl_commit[:12] if acc.last_impl_commit else acc.born_commit[:12]), |
| 705 | "last_impl_date": last_impl_ts.strftime("%Y-%m-%d"), |
| 706 | "last_change_commit": acc.last_change_commit[:12], |
| 707 | "last_change_date": last_change_ts.strftime("%Y-%m-%d"), |
| 708 | "calendar_age_days": _days_ago(born_ts, now), |
| 709 | "genetic_age_days": _days_ago(last_impl_ts, now), |
| 710 | "impl_changes": acc.impl_changes, |
| 711 | "sig_changes": acc.sig_changes, |
| 712 | "renames": acc.renames, |
| 713 | "est_survival_pct": round(100 / (acc.impl_changes + 1)), |
| 714 | "events": [ |
| 715 | {"date": e[0].strftime("%Y-%m-%d"), "commit": e[1][:12], "label": e[2]} |
| 716 | for e in sorted(acc.events, key=lambda x: x[0], reverse=True) |
| 717 | ], |
| 718 | }, indent=2)) |
| 719 | else: |
| 720 | _print_explain(explain_addr, acc, kind, now) |
| 721 | return |
| 722 | |
| 723 | # ββ Build ranked table ββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 724 | records = _build_records( |
| 725 | accumulators, symbol_kinds, now, |
| 726 | kind_filter=kind_filter, |
| 727 | file_filter=file_filter, |
| 728 | sort_key=sort_key, |
| 729 | top=top, |
| 730 | ) |
| 731 | |
| 732 | # ββ Output ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 733 | if as_json: |
| 734 | print(json.dumps({ |
| 735 | "ref": branch, |
| 736 | "as_of": now.strftime("%Y-%m-%d"), |
| 737 | "commits_analysed": commits_analysed, |
| 738 | "truncated": truncated, |
| 739 | "filters": { |
| 740 | "kind": kind_filter, |
| 741 | "file": file_filter, |
| 742 | "sort": sort_key, |
| 743 | "top": top, |
| 744 | "since": since, |
| 745 | "max_commits": max_commits, |
| 746 | }, |
| 747 | "symbols": [dict(r) for r in records], |
| 748 | }, indent=2)) |
| 749 | return |
| 750 | |
| 751 | _print_table(records, branch, commits_analysed, truncated, since, sort_key, now) |