narrative.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 narrative β€” plain-English story of a symbol's life.
2
3 Every other analysis command answers a *dimensional* question:
4
5 ``age`` β†’ how old?
6 ``hotspots`` β†’ how churned?
7 ``blast-risk`` β†’ how risky?
8 ``semantic-test-coverage`` β†’ how tested?
9
10 ``narrative`` answers a *biographical* question:
11
12 **"What happened to this symbol, and why?"**
13
14 It reads every structured delta that ever touched the symbol, walks back
15 through the commit graph, and composes a timeline or flowing prose story of
16 the symbol's complete life β€” from the commit that created it to the last
17 commit that changed it.
18
19 This is only possible because Muse records *why* each change happened at the
20 semantic level (body rewrite, signature change, rename) in addition to *what*
21 changed. A line-diff VCS can tell you bytes changed; Muse tells you the
22 story.
23
24 Output modes
25 ------------
26 ``--format timeline`` (default)
27 Chronological event list with dates, commit fragments, and SemVer bumps.
28
29 ``--format prose``
30 Flowing paragraph β€” suitable for proposal summaries, code reviews,
31 or documentation.
32
33 ``--show-source``
34 Append the symbol's source at birth (first version) and at HEAD (current
35 version), if the source can be read from the object store.
36
37 Usage::
38
39 muse code narrative "billing.py::Invoice.compute_total"
40 muse code narrative "billing.py::Invoice.compute_total" --format prose
41 muse code narrative "billing.py::Invoice.compute_total" --show-source
42 muse code narrative "billing.py::Invoice.compute_total" --since v1.0
43 muse code narrative "billing.py::Invoice.compute_total" --max-commits 500
44 muse code narrative "billing.py::Invoice.compute_total" --json
45
46 Timeline output::
47
48 Invoice.compute_total (method) Β· billing.py
49
50 🌱 Born Jan 12, 2026 · commit a3f2c9e1 · MINOR
51 "feat: initial invoice model"
52 Created as a method on Invoice, taking 2 parameters.
53
54 ✏️ Signature Feb 3, 2026 · commit b7e3d012 · MINOR
55 "feat: add optional currency parameter"
56
57 πŸ”§ Body Feb 14, 2026 Β· commit c1a9f055 Β· PATCH
58 "perf: vectorise invoice computation"
59
60 🏷️ Renamed Mar 1, 2026 · commit d4b2e733 · PATCH
61 "refactor: clean up public API names"
62 compute_invoice_total β†’ compute_total
63
64 πŸ”§ Body Mar 8, 2026 Β· commit e8c6a191 Β· MINOR
65 "feat: add currency conversion logic"
66 Largest change (+47 lines)
67
68 ✏️ Signature Mar 20, 2026 · commit f2d9b447 · MAJOR ⚠️ breaking
69 "breaking: currency parameter now required"
70
71 ─────────────────────────────────────────────────────────────────
72 Life summary
73 Born: Jan 12, 2026 (97 days ago)
74 Last change: Mar 20, 2026 (commit f2d9b447)
75 Events: 3 body rewrites Β· 2 signature changes Β· 1 rename
76 Survival: ~25% of original implementation remains
77 Status: Alive
78
79 Prose output (``--format prose``)::
80
81 Invoice.compute_total was born on January 12th, 2026 as a method on the
82 Invoice class (commit a3f2c9e1). Three weeks later its signature was
83 extended with an optional currency parameter β€” a MINOR bump. On February
84 14th the body was rewritten for performance, replacing a manual loop with
85 a sum() comprehension. On March 1st the function was renamed from
86 compute_invoice_total to compute_total during an API cleanup. The most
87 significant event came on March 8th: a full body rewrite to add currency
88 conversion logic, the largest change in the symbol's history. Finally,
89 on March 20th the currency parameter was made required β€” a MAJOR breaking
90 change.
91
92 At 97 days old, the method has survived 3 body rewrites and retains an
93 estimated 25% of its original implementation.
94
95 JSON output (``--json``)::
96
97 {
98 "address": "billing.py::Invoice.compute_total",
99 "name": "compute_total",
100 "kind": "method",
101 "status": "alive",
102 "born_date": "2026-01-12",
103 "born_commit": "a3f2c9e1",
104 "last_change_date": "2026-03-20",
105 "last_change_commit": "f2d9b447",
106 "calendar_age_days": 97,
107 "genetic_age_days": 12,
108 "impl_changes": 3,
109 "sig_changes": 2,
110 "renames": 1,
111 "est_survival_pct": 25,
112 "commits_analysed": 304,
113 "truncated": false,
114 "events": [
115 {
116 "date": "2026-01-12",
117 "commit_id": "a3f2c9e1",
118 "commit_msg": "feat: initial invoice model",
119 "event_type": "create",
120 "sem_ver_bump": "minor",
121 "detail": "Created as a method taking 2 parameters."
122 }
123 ]
124 }
125
126 Security note
127 -------------
128 Symbol addresses are validated to contain ``::`` and both path components are
129 stripped of leading/trailing whitespace before any file-system access.
130 Commit messages are sanitised (control characters removed) before display so
131 a malicious commit message cannot inject terminal escape sequences.
132 """
133
134 from __future__ import annotations
135
136 import argparse
137 import ast
138 import datetime
139 import json
140 import logging
141 import pathlib
142 import re
143 import sys
144 from dataclasses import dataclass, field
145 from typing import Literal, TypedDict
146
147 from muse.core.errors import ExitCode
148 from muse.core.repo import read_repo_id, require_repo
149 from muse.core.store import (
150 get_commit_snapshot_manifest,
151 read_current_branch,
152 resolve_commit_ref,
153 )
154 from muse.domain import DomainOp
155 from muse.plugins.code._callgraph import find_func_node
156 from muse.plugins.code._query import flat_symbol_ops, symbols_for_snapshot, walk_commits_bfs
157 from muse.core.validation import clamp_int, MAX_AST_BYTES, sanitize_display
158
159
160 type _StrMap = dict[str, str]
161 logger = logging.getLogger(__name__)
162
163 # ── Constants ──────────────────────────────────────────────────────────────────
164
165 _DEFAULT_MAX_COMMITS = 10_000
166
167 # Op summary keywords β€” same as used by ``age`` for consistency.
168 _IMPL_KEYWORDS: frozenset[str] = frozenset(
169 {"implementation", "modified", "body", "reformatted"}
170 )
171 _SIG_KEYWORDS: frozenset[str] = frozenset({"signature"})
172 _RENAME_KEYWORDS: frozenset[str] = frozenset({"renamed", "moved"})
173
174 # Control-character sanitisation pattern β€” strip anything that could be a
175 # terminal escape sequence (ESC and all C0/C1 control chars except newline).
176 _CTRL_RE = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f\x1b]")
177
178 EventType = Literal["create", "impl", "sig", "rename", "delete", "other"]
179 OutputFormat = Literal["timeline", "prose"]
180
181
182 _EVENT_ICON: _StrMap = {
183 "create": "🌱",
184 "impl": "πŸ”§",
185 "sig": "✏️ ",
186 "rename": "🏷️ ",
187 "delete": "πŸ’€",
188 "other": " ",
189 }
190
191 _EVENT_LABEL: _StrMap = {
192 "create": "Born ",
193 "impl": "Body ",
194 "sig": "Signature",
195 "rename": "Renamed ",
196 "delete": "Deleted ",
197 "other": "Changed ",
198 }
199
200
201 # ── TypedDicts ─────────────────────────────────────────────────────────────────
202
203
204 class _EventRecord(TypedDict):
205 """A single event in a symbol's life."""
206
207 date: str
208 commit_id: str
209 commit_msg: str
210 event_type: str
211 sem_ver_bump: str
212 detail: str
213
214
215 class _NarrativeJson(TypedDict):
216 """Top-level JSON output structure."""
217
218 address: str
219 name: str
220 kind: str
221 file: str
222 status: str
223 born_date: str
224 born_commit: str
225 last_change_date: str
226 last_change_commit: str
227 last_impl_date: str
228 last_impl_commit: str
229 calendar_age_days: int
230 genetic_age_days: int
231 impl_changes: int
232 sig_changes: int
233 renames: int
234 est_survival_pct: int
235 commits_analysed: int
236 truncated: bool
237 events: list[_EventRecord]
238
239
240 # ── Internal accumulators ──────────────────────────────────────────────────────
241
242
243 @dataclass
244 class _RawEvent:
245 """An event captured during the BFS walk, before prose generation."""
246
247 ts: datetime.datetime
248 commit_id: str
249 commit_msg: str
250 sem_ver_bump: str
251 event_type: EventType
252 # Extra context extracted from the op summaries.
253 op_old_summary: str = ""
254 op_new_summary: str = ""
255
256
257 @dataclass
258 class _SymbolHistory:
259 """Accumulated history for one symbol."""
260
261 born_ts: datetime.datetime | None = None
262 born_commit: str = ""
263 last_change_ts: datetime.datetime | None = None
264 last_change_commit: str = ""
265 last_impl_ts: datetime.datetime | None = None
266 last_impl_commit: str = ""
267 impl_changes: int = 0
268 sig_changes: int = 0
269 renames: int = 0
270 status: str = "alive" # "alive" | "deleted"
271 events: list[_RawEvent] = field(default_factory=list)
272
273
274 # ── Helpers ────────────────────────────────────────────────────────────────────
275
276
277
278 def _sanitise_msg(msg: str, max_len: int = 72) -> str:
279 """Strip control characters and truncate commit message for display."""
280 clean = _CTRL_RE.sub("", msg).strip()
281 if len(clean) > max_len:
282 clean = clean[:max_len - 1] + "…"
283 return clean
284
285
286 def _classify_op(
287 op: DomainOp,
288 ) -> EventType:
289 """Classify a symbol op into a change category.
290
291 Returns one of: ``create``, ``impl``, ``sig``, ``rename``, ``delete``,
292 ``other``.
293 """
294 kind = op.get("op", "")
295 if kind == "insert":
296 return "create"
297 if kind == "delete":
298 return "delete"
299 if kind != "replace":
300 return "other"
301
302 new_sum = str(op.get("new_summary") or op.get("content_summary") or "").lower()
303 old_sum = str(op.get("old_summary") or "").lower()
304
305 if any(kw in new_sum for kw in _RENAME_KEYWORDS):
306 return "rename"
307 if any(kw in new_sum for kw in _SIG_KEYWORDS):
308 return "sig"
309 if any(kw in new_sum for kw in _IMPL_KEYWORDS) or any(
310 kw in old_sum for kw in _IMPL_KEYWORDS
311 ):
312 return "impl"
313 # Unknown replace β†’ conservative impl change.
314 return "impl"
315
316
317 def _format_date(dt: datetime.datetime) -> str:
318 """Format a datetime as ``Mon DD, YYYY`` (e.g. ``Jan 12, 2026``)."""
319 return dt.strftime("%b %d, %Y").replace(" ", " ")
320
321
322 def _format_date_long(dt: datetime.datetime) -> str:
323 """Format as ``January 12th, 2026``."""
324 day = dt.day
325 suffix = (
326 "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
327 )
328 return dt.strftime(f"%B {day}{suffix}, %Y")
329
330
331 def _days_ago(dt: datetime.datetime | None) -> str:
332 """Return a human-readable relative string for *dt*."""
333 if dt is None:
334 return "unknown"
335 now = datetime.datetime.now(tz=datetime.timezone.utc)
336 aware = dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc)
337 delta = now - aware
338 days = max(0, delta.days)
339 if days == 0:
340 return "today"
341 if days == 1:
342 return "1 day ago"
343 if days < 7:
344 return f"{days} days ago"
345 if days < 30:
346 return f"{days // 7} wk ago"
347 if days < 365:
348 return f"{days // 30} mo ago"
349 yr = days // 365
350 mo = (days % 365) // 30
351 return f"{yr} yr {mo} mo ago" if mo else f"{yr} yr ago"
352
353
354 def _relative_to(earlier: datetime.datetime, later: datetime.datetime) -> str:
355 """Return human-readable delta between two datetimes (e.g. '3 weeks later')."""
356 earlier_n = earlier.replace(tzinfo=None) if earlier.tzinfo else earlier
357 later_n = later.replace(tzinfo=None) if later.tzinfo else later
358 days = max(0, (later_n - earlier_n).days)
359 if days == 0:
360 return "the same day"
361 if days == 1:
362 return "1 day later"
363 if days < 7:
364 return f"{days} days later"
365 if days < 30:
366 weeks = days // 7
367 return f"{weeks} week{'s' if weeks > 1 else ''} later"
368 if days < 365:
369 months = days // 30
370 return f"{months} month{'s' if months > 1 else ''} later"
371 years = days // 365
372 return f"{years} year{'s' if years > 1 else ''} later"
373
374
375 def _extract_rename(new_summary: str, old_summary: str) -> tuple[str, str]:
376 """Try to extract old_name β†’ new_name from rename op summaries.
377
378 Returns (old_name, new_name) or ("", "") if not extractable.
379 """
380 # Try pattern: "renamed X to Y" or "moved X to Y"
381 m = re.search(r"(?:renamed?|moved?)\s+(\S+)\s+to\s+(\S+)", new_summary, re.IGNORECASE)
382 if m:
383 return m.group(1), m.group(2)
384 # Try pattern from old_summary "X" β†’ new_summary "Y" (simple name extraction)
385 old_name = old_summary.split("::")[1] if "::" in old_summary else ""
386 new_name = new_summary.split("::")[1] if "::" in new_summary else ""
387 return old_name, new_name
388
389
390 def _extract_source(
391 root: pathlib.Path,
392 snapshot_commit_id: str,
393 address: str,
394 max_lines: int = 10,
395 ) -> str | None:
396 """Extract the source of a symbol at a given commit.
397
398 Reads the file blob from the object store for *snapshot_commit_id*, parses
399 the AST, and extracts the function/class body lines.
400
401 Args:
402 root: Repository root.
403 snapshot_commit_id: Commit ID to read the snapshot from.
404 address: Full symbol address (``file::QualifiedName``).
405 max_lines: Maximum source lines to return.
406
407 Returns:
408 Indented source string, or ``None`` if not extractable.
409 """
410 from muse.core.object_store import read_object
411 from muse.core.store import get_commit_snapshot_manifest
412
413 if "::" not in address:
414 return None
415 file_path, sym_name = address.split("::", 1)
416
417 manifest = get_commit_snapshot_manifest(root, snapshot_commit_id)
418 if not manifest:
419 return None
420 obj_id = manifest.get(file_path)
421 if obj_id is None:
422 return None
423
424 raw = read_object(root, obj_id)
425 if raw is None:
426 return None
427 try:
428 if len(raw) > MAX_AST_BYTES:
429 return None
430 tree = ast.parse(raw)
431 except SyntaxError:
432 return None
433
434 func_node = find_func_node(tree.body, sym_name.split("."))
435 if func_node is None:
436 # Try class-level
437 return None
438
439 # Extract source lines
440 try:
441 source_lines = raw.decode("utf-8", errors="replace").splitlines()
442 except Exception:
443 return None
444
445 start = func_node.lineno - 1
446 end = func_node.end_lineno or (start + max_lines)
447 snippet = source_lines[start : min(end, start + max_lines)]
448 if end - start > max_lines:
449 snippet.append(" …")
450 return "\n".join(" " + line for line in snippet)
451
452
453 # ── Core BFS walk ──────────────────────────────────────────────────────────────
454
455
456 def _collect_history(
457 root: pathlib.Path,
458 head_commit_id: str,
459 address: str,
460 max_commits: int,
461 stop_at: str | None,
462 ) -> tuple[_SymbolHistory, int, bool]:
463 """Walk the commit DAG and build a ``_SymbolHistory`` for *address*.
464
465 Args:
466 root: Repository root.
467 head_commit_id: SHA-256 of HEAD commit.
468 address: Symbol address to trace.
469 max_commits: BFS depth cap.
470 stop_at: Optional exclusive lower-bound commit ID.
471
472 Returns:
473 ``(history, commits_analysed, truncated)``
474 """
475 commits, truncated = walk_commits_bfs(
476 root, head_commit_id, max_commits, stop_at_commit_id=stop_at
477 )
478
479 history = _SymbolHistory()
480
481 for commit in commits:
482 if commit.structured_delta is None:
483 continue
484 ops: list[DomainOp] = commit.structured_delta["ops"]
485 ts = commit.committed_at
486 cid = commit.commit_id
487 msg = _sanitise_msg(commit.message)
488 bump = (commit.sem_ver_bump if commit.sem_ver_bump else "none").lower()
489
490 for op in flat_symbol_ops(ops):
491 if op["address"] != address:
492 continue
493 if "::import::" in op["address"]:
494 continue
495
496 event_type = _classify_op(op)
497 old_sum = str(op.get("old_summary") or "").strip()
498 new_sum = str(op.get("new_summary") or op.get("content_summary") or "").strip()
499
500 raw_event = _RawEvent(
501 ts=ts,
502 commit_id=cid[:8],
503 commit_msg=msg,
504 sem_ver_bump=bump,
505 event_type=event_type,
506 op_old_summary=old_sum,
507 op_new_summary=new_sum,
508 )
509 history.events.append(raw_event)
510
511 # Update lifecycle metadata.
512 ts_naive = ts.replace(tzinfo=None) if ts.tzinfo else ts
513
514 if history.born_ts is None or ts_naive < (
515 history.born_ts.replace(tzinfo=None) if history.born_ts.tzinfo else history.born_ts
516 ):
517 history.born_ts = ts
518 history.born_commit = cid[:8]
519
520 if history.last_change_ts is None or ts_naive > (
521 history.last_change_ts.replace(tzinfo=None)
522 if history.last_change_ts.tzinfo
523 else history.last_change_ts
524 ):
525 history.last_change_ts = ts
526 history.last_change_commit = cid[:8]
527
528 if event_type in ("impl", "sig"):
529 if history.last_impl_ts is None or ts_naive > (
530 history.last_impl_ts.replace(tzinfo=None)
531 if history.last_impl_ts.tzinfo
532 else history.last_impl_ts
533 ):
534 history.last_impl_ts = ts
535 history.last_impl_commit = cid[:8]
536
537 if event_type == "impl":
538 history.impl_changes += 1
539 elif event_type == "sig":
540 history.sig_changes += 1
541 elif event_type == "rename":
542 history.renames += 1
543 elif event_type == "delete":
544 history.status = "deleted"
545
546 # Sort events oldest-first for display.
547 history.events.sort(key=lambda e: e.ts)
548
549 return history, len(commits), truncated
550
551
552 # ── Prose generation ───────────────────────────────────────────────────────────
553
554
555 _BIRTH_OPENERS: tuple[str, ...] = (
556 "was born",
557 "was introduced",
558 "first appeared",
559 "was created",
560 )
561
562 _IMPL_VERBS: tuple[str, ...] = (
563 "its body was rewritten",
564 "the implementation was overhauled",
565 "it underwent a body rewrite",
566 "the logic was reworked",
567 )
568
569 _SIG_VERBS: tuple[str, ...] = (
570 "its signature changed",
571 "the interface was updated",
572 "the signature was modified",
573 )
574
575
576 def _prose_sentence(
577 event: _RawEvent,
578 index: int,
579 born_ts: datetime.datetime | None,
580 prev_ts: datetime.datetime | None,
581 name: str,
582 kind: str,
583 ) -> str:
584 """Generate one prose sentence for an event."""
585 date_str = _format_date_long(event.ts)
586 bump_str = (
587 f" β€” a {event.sem_ver_bump.upper()} bump"
588 if event.sem_ver_bump not in ("none", "")
589 else ""
590 )
591
592 if event.event_type == "create":
593 opener = _BIRTH_OPENERS[index % len(_BIRTH_OPENERS)]
594 rel = ""
595 return (
596 f"**{name}** {opener} on {date_str} as a {kind}"
597 f" (commit {event.commit_id}){bump_str}."
598 )
599
600 # Relative time since birth or previous event.
601 relative: str
602 if prev_ts is not None:
603 relative = _relative_to(prev_ts, event.ts)
604 elif born_ts is not None:
605 relative = _relative_to(born_ts, event.ts)
606 else:
607 relative = f"On {date_str},"
608
609 if event.event_type == "impl":
610 verb = _IMPL_VERBS[index % len(_IMPL_VERBS)]
611 return f"{relative.capitalize()}, {verb}{bump_str}."
612
613 if event.event_type == "sig":
614 verb = _SIG_VERBS[index % len(_SIG_VERBS)]
615 return f"{relative.capitalize()}, {verb}{bump_str}."
616
617 if event.event_type == "rename":
618 old, new = _extract_rename(event.op_new_summary, event.op_old_summary)
619 rename_detail = f" from {old} to {new}" if old and new else ""
620 return f"{relative.capitalize()}, it was renamed{rename_detail}{bump_str}."
621
622 if event.event_type == "delete":
623 return f"{relative.capitalize()}, the symbol was deleted{bump_str}."
624
625 return f"{relative.capitalize()}, it changed{bump_str}."
626
627
628 def _generate_prose(
629 history: _SymbolHistory,
630 address: str,
631 name: str,
632 kind: str,
633 ) -> str:
634 """Generate a flowing paragraph narrative for *history*."""
635 if not history.events:
636 return f"No history found for {address}."
637
638 sentences: list[str] = []
639 prev_ts: datetime.datetime | None = None
640
641 for i, event in enumerate(history.events):
642 sentence = _prose_sentence(event, i, history.born_ts, prev_ts, name, kind)
643 sentences.append(sentence)
644 prev_ts = event.ts
645
646 # Summary sentence.
647 parts: list[str] = []
648 if history.born_ts:
649 now = datetime.datetime.now(tz=datetime.timezone.utc)
650 aware = (
651 history.born_ts
652 if history.born_ts.tzinfo
653 else history.born_ts.replace(tzinfo=datetime.timezone.utc)
654 )
655 age_days = (now - aware).days
656 parts.append(f"At {age_days} days old")
657
658 if history.impl_changes > 0:
659 rw = history.impl_changes
660 parts_body = f"survived {rw} body rewrite{'s' if rw != 1 else ''}"
661 survival = round(100 / (history.impl_changes + 1))
662 parts_body += f" and retains an estimated {survival}% of its original implementation"
663 if parts:
664 sentences.append(f" {parts[0]}, the {kind} has {parts_body}.")
665 else:
666 sentences.append(f" The {kind} has {parts_body}.")
667 elif parts:
668 if history.status == "deleted":
669 sentences.append(f" {parts[0]}, the {kind} is now deleted.")
670 else:
671 sentences.append(f" {parts[0]}, the {kind} remains unchanged.")
672
673 return " " + "\n ".join(sentences)
674
675
676 # ── Event detail builder ───────────────────────────────────────────────────────
677
678
679 def _event_detail(event: _RawEvent) -> str:
680 """Generate a short detail line for a timeline event."""
681 if event.event_type == "rename":
682 old, new = _extract_rename(event.op_new_summary, event.op_old_summary)
683 if old and new:
684 return f"{old} β†’ {new}"
685 if event.event_type == "create":
686 summary = event.op_new_summary or event.op_old_summary
687 if summary:
688 return summary[:80]
689 return ""
690
691
692 # ── Formatters ─────────────────────────────────────────────────────────────────
693
694
695 def _print_timeline(
696 history: _SymbolHistory,
697 address: str,
698 name: str,
699 kind: str,
700 commits_analysed: int,
701 truncated: bool,
702 show_source: bool,
703 source_birth: str | None,
704 source_head: str | None,
705 ) -> None:
706 """Print the human-readable timeline view."""
707 file_part = address.split("::")[0] if "::" in address else address
708 print(f"\n {sanitize_display(name)} ({sanitize_display(kind)}) Β· {sanitize_display(file_part)}\n")
709
710 if not history.events:
711 print(" No history found in the analysed commit range.")
712 return
713
714 # Detect largest single change (impl events by line count proxy β€” use index).
715 impl_events = [e for e in history.events if e.event_type == "impl"]
716 largest_idx: int | None = None
717 if len(impl_events) >= 2:
718 # We don't have line counts, so mark the last body rewrite as notable
719 # when there are multiple β€” common pattern.
720 largest_idx = history.events.index(impl_events[-1])
721
722 for i, event in enumerate(history.events):
723 icon = _EVENT_ICON.get(event.event_type, " ")
724 label = _EVENT_LABEL.get(event.event_type, "Changed ")
725 date_str = _format_date(event.ts)
726 bump = event.sem_ver_bump
727 bump_str = f" Β· {bump.upper()}" if bump and bump != "none" else ""
728 breaking = " ⚠️ breaking" if bump == "major" else ""
729
730 print(f" {icon} {label} {date_str} Β· commit {event.commit_id}{bump_str}{breaking}")
731 print(f" \"{event.commit_msg}\"")
732
733 detail = _event_detail(event)
734 if detail:
735 print(f" {detail}")
736
737 if largest_idx is not None and i == largest_idx:
738 print(" Largest change in symbol's history")
739
740 print()
741
742 # Summary bar.
743 sep = "─" * 65
744 print(f" {sep}")
745 print(" Life summary")
746
747 if history.born_ts:
748 print(
749 f" Born: {_format_date(history.born_ts)}"
750 f" ({_days_ago(history.born_ts)}) Β· commit {history.born_commit}"
751 )
752 if history.last_change_ts:
753 print(
754 f" Last change: {_format_date(history.last_change_ts)}"
755 f" Β· commit {history.last_change_commit}"
756 )
757
758 counts: list[str] = []
759 if history.impl_changes:
760 counts.append(f"{history.impl_changes} body rewrite{'s' if history.impl_changes != 1 else ''}")
761 if history.sig_changes:
762 counts.append(f"{history.sig_changes} signature change{'s' if history.sig_changes != 1 else ''}")
763 if history.renames:
764 counts.append(f"{history.renames} rename{'s' if history.renames != 1 else ''}")
765 if counts:
766 print(f" Events: {' Β· '.join(counts)}")
767
768 survival = round(100 / (history.impl_changes + 1))
769 print(f" Survival: ~{survival}% of original implementation remains")
770 print(f" Status: {history.status.capitalize()}")
771
772 if truncated:
773 print(f"\n ⚠️ History truncated at {commits_analysed} commits.")
774 print()
775
776 # Optional source code snippets.
777 if show_source:
778 if source_birth:
779 print(f" First version (commit {history.born_commit})")
780 print(source_birth)
781 print()
782 if source_head:
783 print(" Current version (HEAD)")
784 print(source_head)
785 print()
786
787
788 def _print_prose(
789 history: _SymbolHistory,
790 address: str,
791 name: str,
792 kind: str,
793 truncated: bool,
794 ) -> None:
795 """Print the prose narrative view."""
796 print(f"\n {sanitize_display(name)} ({sanitize_display(kind)})\n")
797 prose = _generate_prose(history, address, name, kind)
798 # Wrap at ~80 chars for readability.
799 print(prose)
800 if truncated:
801 print(f"\n ⚠️ History was truncated; narrative may be incomplete.")
802 print()
803
804
805 def _build_json_events(history: _SymbolHistory) -> list[_EventRecord]:
806 """Convert internal events to JSON-ready records."""
807 records: list[_EventRecord] = []
808 for event in history.events:
809 detail = _event_detail(event)
810 if not detail and event.op_new_summary:
811 detail = event.op_new_summary[:80]
812 records.append(
813 _EventRecord(
814 date=event.ts.strftime("%Y-%m-%d"),
815 commit_id=event.commit_id,
816 commit_msg=event.commit_msg,
817 event_type=event.event_type,
818 sem_ver_bump=event.sem_ver_bump,
819 detail=detail,
820 )
821 )
822 return records
823
824
825 # ── Entry point ────────────────────────────────────────────────────────────────
826
827
828 def run(args: argparse.Namespace) -> None:
829 """Entry point for ``muse code narrative``."""
830 root = require_repo()
831
832 # ── Argument validation ────────────────────────────────────────────────────
833 address: str = args.address.strip()
834 if "::" not in address:
835 print(
836 "❌ ADDRESS must be in ``file::Symbol`` format"
837 " (e.g. billing.py::Invoice.compute_total).",
838 file=sys.stderr,
839 )
840 raise SystemExit(ExitCode.USER_ERROR)
841
842 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
843 if max_commits < 1:
844 print("❌ --max-commits must be >= 1.", file=sys.stderr)
845 raise SystemExit(ExitCode.USER_ERROR)
846
847 output_format: OutputFormat = args.format
848 show_source: bool = args.show_source
849
850 # ── Resolve HEAD ───────────────────────────────────────────────────────────
851 repo_id = read_repo_id(root)
852 branch = read_current_branch(root)
853
854 head = resolve_commit_ref(root, repo_id, branch, None)
855 if head is None:
856 print("❌ HEAD commit not found β€” is this an empty repository?", file=sys.stderr)
857 raise SystemExit(ExitCode.USER_ERROR)
858
859 # ── Resolve --since ────────────────────────────────────────────────────────
860 stop_at: str | None = None
861 if args.since:
862 since_commit = resolve_commit_ref(root, repo_id, branch, args.since)
863 if since_commit is None:
864 print(f"❌ Could not resolve --since ref: {args.since!r}", file=sys.stderr)
865 raise SystemExit(ExitCode.USER_ERROR)
866 stop_at = since_commit.commit_id
867
868 # ── Verify symbol exists in HEAD snapshot ─────────────────────────────────
869 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
870 all_trees = symbols_for_snapshot(root, manifest)
871 # Build flat address β†’ kind map.
872 addr_to_kind: _StrMap = {}
873 for sym_tree in all_trees.values():
874 for addr, rec in sym_tree.items():
875 addr_to_kind[addr] = rec["kind"]
876
877 file_part = address.split("::")[0]
878 sym_part = address.split("::", 1)[1]
879 name = sym_part.split(".")[-1] # bare symbol name (last component)
880
881 # Be tolerant: the symbol may have been deleted (status=deleted) β€” still
882 # show its history. But try to get the kind from HEAD first.
883 kind = addr_to_kind.get(address, "symbol")
884
885 # ── Collect commit history ─────────────────────────────────────────────────
886 history, commits_analysed, truncated = _collect_history(
887 root, head.commit_id, address, max_commits, stop_at
888 )
889
890 if not history.events:
891 print(
892 f"❌ No history found for {address!r} in the last"
893 f" {commits_analysed} commit{'s' if commits_analysed != 1 else ''}.",
894 file=sys.stderr,
895 )
896 print(
897 " Check that the address is correct (file::ClassName.method_name)",
898 file=sys.stderr,
899 )
900 raise SystemExit(ExitCode.USER_ERROR)
901
902 # ── Optional source extraction ────────────────────────────────────────────
903 source_birth: str | None = None
904 source_head: str | None = None
905 if show_source and history.born_commit:
906 from muse.core.store import resolve_commit_ref as _rcr
907
908 birth_commit = _rcr(root, repo_id, branch, history.born_commit)
909 if birth_commit:
910 source_birth = _extract_source(root, birth_commit.commit_id, address)
911 source_head = _extract_source(root, head.commit_id, address)
912
913 # ── Output ────────────────────────────────────────────────────────────────
914 if args.json:
915 now = datetime.datetime.now(tz=datetime.timezone.utc)
916
917 def _days(dt: datetime.datetime | None) -> int:
918 if dt is None:
919 return 0
920 aware = dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc)
921 return max(0, (now - aware).days)
922
923 def _date_str(dt: datetime.datetime | None) -> str:
924 return dt.strftime("%Y-%m-%d") if dt else ""
925
926 calendar_age = _days(history.born_ts)
927 genetic_age = _days(history.last_impl_ts)
928 survival = round(100 / (history.impl_changes + 1))
929
930 out: _NarrativeJson = _NarrativeJson(
931 address=address,
932 name=name,
933 kind=kind,
934 file=file_part,
935 status=history.status,
936 born_date=_date_str(history.born_ts),
937 born_commit=history.born_commit,
938 last_change_date=_date_str(history.last_change_ts),
939 last_change_commit=history.last_change_commit,
940 last_impl_date=_date_str(history.last_impl_ts),
941 last_impl_commit=history.last_impl_commit,
942 calendar_age_days=calendar_age,
943 genetic_age_days=genetic_age,
944 impl_changes=history.impl_changes,
945 sig_changes=history.sig_changes,
946 renames=history.renames,
947 est_survival_pct=survival,
948 commits_analysed=commits_analysed,
949 truncated=truncated,
950 events=_build_json_events(history),
951 )
952 print(json.dumps(out, indent=2))
953 return
954
955 if output_format == "prose":
956 _print_prose(history, address, name, kind, truncated)
957 else:
958 _print_timeline(
959 history,
960 address,
961 name,
962 kind,
963 commits_analysed,
964 truncated,
965 show_source,
966 source_birth,
967 source_head,
968 )
969
970
971 # ── CLI registration ───────────────────────────────────────────────────────────
972
973
974 def register(
975 sub: argparse._SubParsersAction[argparse.ArgumentParser],
976 ) -> None:
977 """Register ``narrative`` under the ``code`` subcommand group.
978
979 Args:
980 sub: The subparser action from the ``code`` command group.
981 """
982 p = sub.add_parser(
983 "narrative",
984 help=(
985 "Plain-English story of a symbol's life, built from structured"
986 " commit deltas."
987 ),
988 description=__doc__,
989 formatter_class=argparse.RawDescriptionHelpFormatter,
990 )
991 p.add_argument(
992 "address",
993 metavar="ADDRESS",
994 help=(
995 "Full symbol address to narrate"
996 " (e.g. billing.py::Invoice.compute_total)."
997 ),
998 )
999 p.add_argument(
1000 "--format",
1001 metavar="FMT",
1002 choices=["timeline", "prose"],
1003 default="timeline",
1004 help=(
1005 "Output format: ``timeline`` (default) shows a chronological"
1006 " event list; ``prose`` generates a flowing paragraph story."
1007 ),
1008 )
1009 p.add_argument(
1010 "--show-source",
1011 action="store_true",
1012 help=(
1013 "Append the symbol's source code at birth and at HEAD"
1014 " (Python only; requires the blob to be in the object store)."
1015 ),
1016 )
1017 p.add_argument(
1018 "--since",
1019 metavar="REF",
1020 help=(
1021 "Limit history to commits after REF"
1022 " (branch name, commit SHA, or tag)."
1023 ),
1024 )
1025 p.add_argument(
1026 "--max-commits",
1027 type=int,
1028 default=_DEFAULT_MAX_COMMITS,
1029 metavar="N",
1030 help=f"Maximum commits to walk (default: {_DEFAULT_MAX_COMMITS}).",
1031 )
1032 p.add_argument(
1033 "--json",
1034 action="store_true",
1035 help="Emit JSON instead of human-readable text.",
1036 )
1037 p.set_defaults(func=run)