gabriel / muse public
age.py python
816 lines 30.5 KB
Raw
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