age.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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)