gabriel / muse public
narrative.py python
1,107 lines 38.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
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._types import short_id
148 from muse.core.errors import ExitCode
149 from muse.core.repo import read_repo_id, require_repo
150 from muse.core.store import (
151 get_commit_snapshot_manifest,
152 read_current_branch,
153 resolve_commit_ref,
154 )
155 from muse.core.envelope import EnvelopeJson, make_envelope
156 from muse.core.timing import start_timer
157 from muse.domain import DomainOp
158 from muse.plugins.code._callgraph import find_func_node
159 from muse.plugins.code._query import flat_symbol_ops, symbols_for_snapshot, walk_commits_bfs
160 from muse.core.validation import clamp_int, MAX_AST_BYTES, sanitize_display
161
162
163 type _StrMap = dict[str, str]
164 logger = logging.getLogger(__name__)
165
166 # ── Constants ──────────────────────────────────────────────────────────────────
167
168 _DEFAULT_MAX_COMMITS = 10_000
169
170 # Op summary keywords — same as used by ``age`` for consistency.
171 _IMPL_KEYWORDS: frozenset[str] = frozenset(
172 {"implementation", "modified", "body", "reformatted"}
173 )
174 _SIG_KEYWORDS: frozenset[str] = frozenset({"signature"})
175 _RENAME_KEYWORDS: frozenset[str] = frozenset({"renamed", "moved"})
176
177 # Control-character sanitisation pattern — strip anything that could be a
178 # terminal escape sequence (ESC and all C0/C1 control chars except newline).
179 _CTRL_RE = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f\x1b]")
180
181 EventType = Literal["create", "impl", "sig", "rename", "delete", "other"]
182 OutputFormat = Literal["timeline", "prose"]
183
184
185 _EVENT_ICON: _StrMap = {
186 "create": "🌱",
187 "impl": "🔧",
188 "sig": "✏️ ",
189 "rename": "🏷️ ",
190 "delete": "💀",
191 "other": " ",
192 }
193
194 _EVENT_LABEL: _StrMap = {
195 "create": "Born ",
196 "impl": "Body ",
197 "sig": "Signature",
198 "rename": "Renamed ",
199 "delete": "Deleted ",
200 "other": "Changed ",
201 }
202
203
204 # ── TypedDicts ─────────────────────────────────────────────────────────────────
205
206
207 class _EventRecord(TypedDict):
208 """A single event in a symbol's life."""
209
210 date: str
211 commit_id: str
212 commit_msg: str
213 event_type: str
214 sem_ver_bump: str
215 detail: str
216
217
218 class _NarrativeJson(EnvelopeJson):
219 """Top-level JSON output structure.
220
221 Machine-readable envelope emitted by ``muse code narrative --json``.
222
223 Fields
224 ------
225 address: Full ``file::Symbol`` address narrated.
226 name: Bare symbol name (last ``::`` component).
227 kind: Symbol kind as reported by the code plugin (e.g. ``"function"``).
228 file: File portion of *address*.
229 status: ``"alive"`` or ``"deleted"``.
230 born_date: ISO-8601 date of first appearance (``"YYYY-MM-DD"``).
231 born_commit: Short commit ID of the birth commit.
232 last_change_date: ISO-8601 date of the most recent change.
233 last_change_commit: Short commit ID of the most recent change.
234 last_impl_date: ISO-8601 date of the most recent impl/sig change (empty if none).
235 last_impl_commit: Short commit ID of the most recent impl/sig change (empty if none).
236 calendar_age_days: Days since birth (wall-clock).
237 genetic_age_days: Days since last impl/sig change (proxy for "genetic age").
238 impl_changes: Count of body-rewrite events.
239 sig_changes: Count of signature-change events.
240 renames: Count of rename events.
241 est_survival_pct: Estimated percentage of original implementation remaining.
242 commits_analysed: Number of commits walked during the BFS.
243 truncated: ``true`` if the walk was capped by ``--max-commits``.
244 events: Chronological list of ``_EventRecord`` dicts.
245 """
246
247 address: str
248 name: str
249 kind: str
250 file: str
251 status: str
252 born_date: str
253 born_commit: str
254 last_change_date: str
255 last_change_commit: str
256 last_impl_date: str
257 last_impl_commit: str
258 calendar_age_days: int
259 genetic_age_days: int
260 impl_changes: int
261 sig_changes: int
262 renames: int
263 est_survival_pct: int
264 commits_analysed: int
265 truncated: bool
266 events: list[_EventRecord]
267
268
269 # ── Internal accumulators ──────────────────────────────────────────────────────
270
271
272 @dataclass
273 class _RawEvent:
274 """An event captured during the BFS walk, before prose generation."""
275
276 ts: datetime.datetime
277 commit_id: str
278 commit_msg: str
279 sem_ver_bump: str
280 event_type: EventType
281 # Extra context extracted from the op summaries.
282 op_old_summary: str = ""
283 op_new_summary: str = ""
284
285
286 @dataclass
287 class _SymbolHistory:
288 """Accumulated history for one symbol."""
289
290 born_ts: datetime.datetime | None = None
291 born_commit: str = ""
292 last_change_ts: datetime.datetime | None = None
293 last_change_commit: str = ""
294 last_impl_ts: datetime.datetime | None = None
295 last_impl_commit: str = ""
296 impl_changes: int = 0
297 sig_changes: int = 0
298 renames: int = 0
299 status: str = "alive" # "alive" | "deleted"
300 events: list[_RawEvent] = field(default_factory=list)
301
302
303 # ── Helpers ────────────────────────────────────────────────────────────────────
304
305
306
307 def _sanitise_msg(msg: str, max_len: int = 72) -> str:
308 """Strip control characters and truncate commit message for display."""
309 clean = _CTRL_RE.sub("", msg).strip()
310 if len(clean) > max_len:
311 clean = f"{clean[:max_len - 1]}…"
312 return clean
313
314
315 def _classify_op(
316 op: DomainOp,
317 ) -> EventType:
318 """Classify a symbol op into a change category.
319
320 Returns one of: ``create``, ``impl``, ``sig``, ``rename``, ``delete``,
321 ``other``.
322 """
323 kind = op.get("op", "")
324 if kind == "insert":
325 return "create"
326 if kind == "delete":
327 return "delete"
328 if kind != "replace":
329 return "other"
330
331 new_sum = str(op.get("new_summary") or op.get("content_summary") or "").lower()
332 old_sum = str(op.get("old_summary") or "").lower()
333
334 if any(kw in new_sum for kw in _RENAME_KEYWORDS):
335 return "rename"
336 if any(kw in new_sum for kw in _SIG_KEYWORDS):
337 return "sig"
338 if any(kw in new_sum for kw in _IMPL_KEYWORDS) or any(
339 kw in old_sum for kw in _IMPL_KEYWORDS
340 ):
341 return "impl"
342 # Unknown replace → conservative impl change.
343 return "impl"
344
345
346 def _format_date(dt: datetime.datetime) -> str:
347 """Format a datetime as ``Mon DD, YYYY`` (e.g. ``Jan 12, 2026``)."""
348 return dt.strftime("%b %d, %Y").replace(" ", " ")
349
350
351 def _format_date_long(dt: datetime.datetime) -> str:
352 """Format as ``January 12th, 2026``."""
353 day = dt.day
354 suffix = (
355 "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
356 )
357 return dt.strftime(f"%B {day}{suffix}, %Y")
358
359
360 def _days_ago(dt: datetime.datetime | None) -> str:
361 """Return a human-readable relative string for *dt*."""
362 if dt is None:
363 return "unknown"
364 now = datetime.datetime.now(tz=datetime.timezone.utc)
365 aware = dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc)
366 delta = now - aware
367 days = max(0, delta.days)
368 if days == 0:
369 return "today"
370 if days == 1:
371 return "1 day ago"
372 if days < 7:
373 return f"{days} days ago"
374 if days < 30:
375 return f"{days // 7} wk ago"
376 if days < 365:
377 return f"{days // 30} mo ago"
378 yr = days // 365
379 mo = (days % 365) // 30
380 return f"{yr} yr {mo} mo ago" if mo else f"{yr} yr ago"
381
382
383 def _relative_to(earlier: datetime.datetime, later: datetime.datetime) -> str:
384 """Return human-readable delta between two datetimes (e.g. '3 weeks later')."""
385 earlier_n = earlier.replace(tzinfo=None) if earlier.tzinfo else earlier
386 later_n = later.replace(tzinfo=None) if later.tzinfo else later
387 days = max(0, (later_n - earlier_n).days)
388 if days == 0:
389 return "the same day"
390 if days == 1:
391 return "1 day later"
392 if days < 7:
393 return f"{days} days later"
394 if days < 30:
395 weeks = days // 7
396 return f"{weeks} week{'s' if weeks > 1 else ''} later"
397 if days < 365:
398 months = days // 30
399 return f"{months} month{'s' if months > 1 else ''} later"
400 years = days // 365
401 return f"{years} year{'s' if years > 1 else ''} later"
402
403
404 def _extract_rename(new_summary: str, old_summary: str) -> tuple[str, str]:
405 """Try to extract old_name → new_name from rename op summaries.
406
407 Returns (old_name, new_name) or ("", "") if not extractable.
408 """
409 # Try pattern: "renamed X to Y" or "moved X to Y"
410 m = re.search(r"(?:renamed?|moved?)\s+(\S+)\s+to\s+(\S+)", new_summary, re.IGNORECASE)
411 if m:
412 return m.group(1), m.group(2)
413 # Try pattern from old_summary "X" → new_summary "Y" (simple name extraction)
414 old_name = old_summary.split("::")[1] if "::" in old_summary else ""
415 new_name = new_summary.split("::")[1] if "::" in new_summary else ""
416 return old_name, new_name
417
418
419 def _extract_source(
420 root: pathlib.Path,
421 snapshot_commit_id: str,
422 address: str,
423 max_lines: int = 10,
424 ) -> str | None:
425 """Extract the source of a symbol at a given commit.
426
427 Reads the file blob from the object store for *snapshot_commit_id*, parses
428 the AST, and extracts the function/class body lines.
429
430 Args:
431 root: Repository root.
432 snapshot_commit_id: Commit ID to read the snapshot from.
433 address: Full symbol address (``file::QualifiedName``).
434 max_lines: Maximum source lines to return.
435
436 Returns:
437 Indented source string, or ``None`` if not extractable.
438 """
439 from muse.core.object_store import read_object
440 from muse.core.store import get_commit_snapshot_manifest
441
442 if "::" not in address:
443 return None
444 file_path, sym_name = address.split("::", 1)
445
446 manifest = get_commit_snapshot_manifest(root, snapshot_commit_id)
447 if not manifest:
448 return None
449 obj_id = manifest.get(file_path)
450 if obj_id is None:
451 return None
452
453 raw = read_object(root, obj_id)
454 if raw is None:
455 return None
456 try:
457 if len(raw) > MAX_AST_BYTES:
458 return None
459 tree = ast.parse(raw)
460 except SyntaxError:
461 return None
462
463 func_node = find_func_node(tree.body, sym_name.split("."))
464 if func_node is None:
465 # Try class-level
466 return None
467
468 # Extract source lines
469 try:
470 source_lines = raw.decode("utf-8", errors="replace").splitlines()
471 except Exception:
472 return None
473
474 start = func_node.lineno - 1
475 end = func_node.end_lineno or (start + max_lines)
476 snippet = source_lines[start : min(end, start + max_lines)]
477 if end - start > max_lines:
478 snippet.append(" …")
479 return "\n".join(f" {line}" for line in snippet)
480
481
482 # ── Core BFS walk ──────────────────────────────────────────────────────────────
483
484
485 def _collect_history(
486 root: pathlib.Path,
487 head_commit_id: str,
488 address: str,
489 max_commits: int,
490 stop_at: str | None,
491 ) -> tuple[_SymbolHistory, int, bool]:
492 """Walk the commit DAG and build a ``_SymbolHistory`` for *address*.
493
494 Args:
495 root: Repository root.
496 head_commit_id: SHA-256 of HEAD commit.
497 address: Symbol address to trace.
498 max_commits: BFS depth cap.
499 stop_at: Optional exclusive lower-bound commit ID.
500
501 Returns:
502 ``(history, commits_analysed, truncated)``
503 """
504 commits, truncated = walk_commits_bfs(
505 root, head_commit_id, max_commits, stop_at_commit_id=stop_at
506 )
507
508 history = _SymbolHistory()
509
510 for commit in commits:
511 if commit.structured_delta is None:
512 continue
513 ops: list[DomainOp] = commit.structured_delta["ops"]
514 ts = commit.committed_at
515 cid = commit.commit_id
516 msg = _sanitise_msg(commit.message)
517 bump = (commit.sem_ver_bump if commit.sem_ver_bump else "none").lower()
518
519 for op in flat_symbol_ops(ops):
520 if op["address"] != address:
521 continue
522 if "::import::" in op["address"]:
523 continue
524
525 event_type = _classify_op(op)
526 old_sum = str(op.get("old_summary") or "").strip()
527 new_sum = str(op.get("new_summary") or op.get("content_summary") or "").strip()
528
529 raw_event = _RawEvent(
530 ts=ts,
531 commit_id=short_id(cid),
532 commit_msg=msg,
533 sem_ver_bump=bump,
534 event_type=event_type,
535 op_old_summary=old_sum,
536 op_new_summary=new_sum,
537 )
538 history.events.append(raw_event)
539
540 # Update lifecycle metadata.
541 ts_naive = ts.replace(tzinfo=None) if ts.tzinfo else ts
542
543 if history.born_ts is None or ts_naive < (
544 history.born_ts.replace(tzinfo=None) if history.born_ts.tzinfo else history.born_ts
545 ):
546 history.born_ts = ts
547 history.born_commit = short_id(cid)
548
549 if history.last_change_ts is None or ts_naive > (
550 history.last_change_ts.replace(tzinfo=None)
551 if history.last_change_ts.tzinfo
552 else history.last_change_ts
553 ):
554 history.last_change_ts = ts
555 history.last_change_commit = short_id(cid)
556
557 if event_type in ("impl", "sig"):
558 if history.last_impl_ts is None or ts_naive > (
559 history.last_impl_ts.replace(tzinfo=None)
560 if history.last_impl_ts.tzinfo
561 else history.last_impl_ts
562 ):
563 history.last_impl_ts = ts
564 history.last_impl_commit = short_id(cid)
565
566 if event_type == "impl":
567 history.impl_changes += 1
568 elif event_type == "sig":
569 history.sig_changes += 1
570 elif event_type == "rename":
571 history.renames += 1
572 elif event_type == "delete":
573 history.status = "deleted"
574
575 # Sort events oldest-first for display.
576 history.events.sort(key=lambda e: e.ts)
577
578 return history, len(commits), truncated
579
580
581 # ── Prose generation ───────────────────────────────────────────────────────────
582
583
584 _BIRTH_OPENERS: tuple[str, ...] = (
585 "was born",
586 "was introduced",
587 "first appeared",
588 "was created",
589 )
590
591 _IMPL_VERBS: tuple[str, ...] = (
592 "its body was rewritten",
593 "the implementation was overhauled",
594 "it underwent a body rewrite",
595 "the logic was reworked",
596 )
597
598 _SIG_VERBS: tuple[str, ...] = (
599 "its signature changed",
600 "the interface was updated",
601 "the signature was modified",
602 )
603
604
605 def _prose_sentence(
606 event: _RawEvent,
607 index: int,
608 born_ts: datetime.datetime | None,
609 prev_ts: datetime.datetime | None,
610 name: str,
611 kind: str,
612 ) -> str:
613 """Generate one prose sentence for an event."""
614 date_str = _format_date_long(event.ts)
615 bump_str = (
616 f" — a {event.sem_ver_bump.upper()} bump"
617 if event.sem_ver_bump not in ("none", "")
618 else ""
619 )
620
621 if event.event_type == "create":
622 opener = _BIRTH_OPENERS[index % len(_BIRTH_OPENERS)]
623 rel = ""
624 return (
625 f"**{name}** {opener} on {date_str} as a {kind}"
626 f" (commit {event.commit_id}){bump_str}."
627 )
628
629 # Relative time since birth or previous event.
630 relative: str
631 if prev_ts is not None:
632 relative = _relative_to(prev_ts, event.ts)
633 elif born_ts is not None:
634 relative = _relative_to(born_ts, event.ts)
635 else:
636 relative = f"On {date_str},"
637
638 if event.event_type == "impl":
639 verb = _IMPL_VERBS[index % len(_IMPL_VERBS)]
640 return f"{relative.capitalize()}, {verb}{bump_str}."
641
642 if event.event_type == "sig":
643 verb = _SIG_VERBS[index % len(_SIG_VERBS)]
644 return f"{relative.capitalize()}, {verb}{bump_str}."
645
646 if event.event_type == "rename":
647 old, new = _extract_rename(event.op_new_summary, event.op_old_summary)
648 rename_detail = f" from {old} to {new}" if old and new else ""
649 return f"{relative.capitalize()}, it was renamed{rename_detail}{bump_str}."
650
651 if event.event_type == "delete":
652 return f"{relative.capitalize()}, the symbol was deleted{bump_str}."
653
654 return f"{relative.capitalize()}, it changed{bump_str}."
655
656
657 def _generate_prose(
658 history: _SymbolHistory,
659 address: str,
660 name: str,
661 kind: str,
662 ) -> str:
663 """Generate a flowing paragraph narrative for *history*."""
664 if not history.events:
665 return f"No history found for {address}."
666
667 sentences: list[str] = []
668 prev_ts: datetime.datetime | None = None
669
670 for i, event in enumerate(history.events):
671 sentence = _prose_sentence(event, i, history.born_ts, prev_ts, name, kind)
672 sentences.append(sentence)
673 prev_ts = event.ts
674
675 # Summary sentence.
676 parts: list[str] = []
677 if history.born_ts:
678 now = datetime.datetime.now(tz=datetime.timezone.utc)
679 aware = (
680 history.born_ts
681 if history.born_ts.tzinfo
682 else history.born_ts.replace(tzinfo=datetime.timezone.utc)
683 )
684 age_days = (now - aware).days
685 parts.append(f"At {age_days} days old")
686
687 if history.impl_changes > 0:
688 rw = history.impl_changes
689 parts_body = f"survived {rw} body rewrite{'s' if rw != 1 else ''}"
690 survival = round(100 / (history.impl_changes + 1))
691 parts_body += f" and retains an estimated {survival}% of its original implementation"
692 if parts:
693 sentences.append(f" {parts[0]}, the {kind} has {parts_body}.")
694 else:
695 sentences.append(f" The {kind} has {parts_body}.")
696 elif parts:
697 if history.status == "deleted":
698 sentences.append(f" {parts[0]}, the {kind} is now deleted.")
699 else:
700 sentences.append(f" {parts[0]}, the {kind} remains unchanged.")
701
702 return " " + "\n ".join(sentences)
703
704
705 # ── Event detail builder ───────────────────────────────────────────────────────
706
707
708 def _event_detail(event: _RawEvent) -> str:
709 """Generate a short detail line for a timeline event."""
710 if event.event_type == "rename":
711 old, new = _extract_rename(event.op_new_summary, event.op_old_summary)
712 if old and new:
713 return f"{old} → {new}"
714 if event.event_type == "create":
715 summary = event.op_new_summary or event.op_old_summary
716 if summary:
717 return summary[:80]
718 return ""
719
720
721 # ── Formatters ─────────────────────────────────────────────────────────────────
722
723
724 def _print_timeline(
725 history: _SymbolHistory,
726 address: str,
727 name: str,
728 kind: str,
729 commits_analysed: int,
730 truncated: bool,
731 show_source: bool,
732 source_birth: str | None,
733 source_head: str | None,
734 ) -> None:
735 """Print the human-readable timeline view."""
736 file_part = address.split("::")[0] if "::" in address else address
737 print(f"\n {sanitize_display(name)} ({sanitize_display(kind)}) · {sanitize_display(file_part)}\n")
738
739 if not history.events:
740 print(" No history found in the analysed commit range.")
741 return
742
743 # Detect largest single change (impl events by line count proxy — use index).
744 impl_events = [e for e in history.events if e.event_type == "impl"]
745 largest_idx: int | None = None
746 if len(impl_events) >= 2:
747 # We don't have line counts, so mark the last body rewrite as notable
748 # when there are multiple — common pattern.
749 largest_idx = history.events.index(impl_events[-1])
750
751 for i, event in enumerate(history.events):
752 icon = _EVENT_ICON.get(event.event_type, " ")
753 label = _EVENT_LABEL.get(event.event_type, "Changed ")
754 date_str = _format_date(event.ts)
755 bump = event.sem_ver_bump
756 bump_str = f" · {bump.upper()}" if bump and bump != "none" else ""
757 breaking = " ⚠️ breaking" if bump == "major" else ""
758
759 print(f" {icon} {label} {date_str} · commit {event.commit_id}{bump_str}{breaking}")
760 print(f" \"{event.commit_msg}\"")
761
762 detail = _event_detail(event)
763 if detail:
764 print(f" {detail}")
765
766 if largest_idx is not None and i == largest_idx:
767 print(" Largest change in symbol's history")
768
769 print()
770
771 # Summary bar.
772 sep = "─" * 65
773 print(f" {sep}")
774 print(" Life summary")
775
776 if history.born_ts:
777 print(
778 f" Born: {_format_date(history.born_ts)}"
779 f" ({_days_ago(history.born_ts)}) · commit {history.born_commit}"
780 )
781 if history.last_change_ts:
782 print(
783 f" Last change: {_format_date(history.last_change_ts)}"
784 f" · commit {history.last_change_commit}"
785 )
786
787 counts: list[str] = []
788 if history.impl_changes:
789 counts.append(f"{history.impl_changes} body rewrite{'s' if history.impl_changes != 1 else ''}")
790 if history.sig_changes:
791 counts.append(f"{history.sig_changes} signature change{'s' if history.sig_changes != 1 else ''}")
792 if history.renames:
793 counts.append(f"{history.renames} rename{'s' if history.renames != 1 else ''}")
794 if counts:
795 print(f" Events: {' · '.join(counts)}")
796
797 survival = round(100 / (history.impl_changes + 1))
798 print(f" Survival: ~{survival}% of original implementation remains")
799 print(f" Status: {history.status.capitalize()}")
800
801 if truncated:
802 print(f"\n ⚠️ History truncated at {commits_analysed} commits.")
803 print()
804
805 # Optional source code snippets.
806 if show_source:
807 if source_birth:
808 print(f" First version (commit {history.born_commit})")
809 print(source_birth)
810 print()
811 if source_head:
812 print(" Current version (HEAD)")
813 print(source_head)
814 print()
815
816
817 def _print_prose(
818 history: _SymbolHistory,
819 address: str,
820 name: str,
821 kind: str,
822 truncated: bool,
823 ) -> None:
824 """Print the prose narrative view."""
825 print(f"\n {sanitize_display(name)} ({sanitize_display(kind)})\n")
826 prose = _generate_prose(history, address, name, kind)
827 # Wrap at ~80 chars for readability.
828 print(prose)
829 if truncated:
830 print(f"\n ⚠️ History was truncated; narrative may be incomplete.")
831 print()
832
833
834 def _build_json_events(history: _SymbolHistory) -> list[_EventRecord]:
835 """Convert internal events to JSON-ready records."""
836 records: list[_EventRecord] = []
837 for event in history.events:
838 detail = _event_detail(event)
839 if not detail and event.op_new_summary:
840 detail = event.op_new_summary[:80]
841 records.append(
842 _EventRecord(
843 date=event.ts.strftime("%Y-%m-%d"),
844 commit_id=event.commit_id,
845 commit_msg=event.commit_msg,
846 event_type=event.event_type,
847 sem_ver_bump=event.sem_ver_bump,
848 detail=detail,
849 )
850 )
851 return records
852
853
854 # ── Entry point ────────────────────────────────────────────────────────────────
855
856
857 def run(args: argparse.Namespace) -> None:
858 """Render the life story of a symbol across commit history.
859
860 Walks commit history for a ``file::Symbol`` address, builds a chronological
861 event log of implementation changes, signature changes, and renames, and
862 computes evolutionary metrics (calendar age, genetic age, survival estimate).
863
864 Agent quickstart
865 ----------------
866 ::
867
868 muse code narrative "billing.py::Invoice" --json
869 muse code narrative "billing.py::compute_total" -n 50 --json
870 muse code narrative "billing.py::Invoice" --format prose --json
871
872 JSON fields
873 -----------
874 address Symbol address (``file::Symbol``).
875 name Symbol short name.
876 kind Symbol kind (``function``, ``class``, …).
877 file File part of the address.
878 status ``"active"`` or ``"deleted"``.
879 born_date ISO date the symbol was first introduced.
880 born_commit Commit ID where the symbol was born.
881 last_change_date ISO date of the last implementation change.
882 calendar_age_days Days since the symbol was born.
883 genetic_age_days Days since the last implementation change.
884 impl_changes Number of implementation-level changes.
885 sig_changes Number of signature changes.
886 renames Number of renames.
887 est_survival_pct Estimated survival probability (0–100).
888 commits_analysed Number of commits walked.
889 truncated ``true`` if the walk hit the ``-n`` cap.
890 events Chronological event list.
891
892 Exit codes
893 ----------
894 0 Success.
895 1 Invalid address format or no history found.
896 2 Not inside a Muse repository.
897 """
898 elapsed = start_timer()
899 root = require_repo()
900
901 # ── Argument validation ────────────────────────────────────────────────────
902 address: str = args.address.strip()
903 if "::" not in address:
904 print(
905 "❌ ADDRESS must be in ``file::Symbol`` format"
906 " (e.g. billing.py::Invoice.compute_total).",
907 file=sys.stderr,
908 )
909 raise SystemExit(ExitCode.USER_ERROR)
910
911 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
912 if max_commits < 1:
913 print("❌ --max-commits must be >= 1.", file=sys.stderr)
914 raise SystemExit(ExitCode.USER_ERROR)
915
916 output_format: OutputFormat = args.format
917 show_source: bool = args.show_source
918
919 # ── Resolve HEAD ───────────────────────────────────────────────────────────
920 repo_id = read_repo_id(root)
921 branch = read_current_branch(root)
922
923 head = resolve_commit_ref(root, repo_id, branch, None)
924 if head is None:
925 print("❌ HEAD commit not found — is this an empty repository?", file=sys.stderr)
926 raise SystemExit(ExitCode.USER_ERROR)
927
928 # ── Resolve --since ────────────────────────────────────────────────────────
929 stop_at: str | None = None
930 if args.since:
931 since_commit = resolve_commit_ref(root, repo_id, branch, args.since)
932 if since_commit is None:
933 print(f"❌ Could not resolve --since ref: {args.since!r}", file=sys.stderr)
934 raise SystemExit(ExitCode.USER_ERROR)
935 stop_at = since_commit.commit_id
936
937 # ── Verify symbol exists in HEAD snapshot ─────────────────────────────────
938 manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
939 all_trees = symbols_for_snapshot(root, manifest)
940 # Build flat address → kind map.
941 addr_to_kind: _StrMap = {}
942 for sym_tree in all_trees.values():
943 for addr, rec in sym_tree.items():
944 addr_to_kind[addr] = rec["kind"]
945
946 file_part = address.split("::")[0]
947 sym_part = address.split("::", 1)[1]
948 name = sym_part.split(".")[-1] # bare symbol name (last component)
949
950 # Be tolerant: the symbol may have been deleted (status=deleted) — still
951 # show its history. But try to get the kind from HEAD first.
952 kind = addr_to_kind.get(address, "symbol")
953
954 # ── Collect commit history ─────────────────────────────────────────────────
955 history, commits_analysed, truncated = _collect_history(
956 root, head.commit_id, address, max_commits, stop_at
957 )
958
959 if not history.events:
960 print(
961 f"❌ No history found for {address!r} in the last"
962 f" {commits_analysed} commit{'s' if commits_analysed != 1 else ''}.",
963 file=sys.stderr,
964 )
965 print(
966 " Check that the address is correct (file::ClassName.method_name)",
967 file=sys.stderr,
968 )
969 raise SystemExit(ExitCode.USER_ERROR)
970
971 # ── Optional source extraction ────────────────────────────────────────────
972 source_birth: str | None = None
973 source_head: str | None = None
974 if show_source and history.born_commit:
975 from muse.core.store import resolve_commit_ref as _rcr
976
977 birth_commit = _rcr(root, repo_id, branch, history.born_commit)
978 if birth_commit:
979 source_birth = _extract_source(root, birth_commit.commit_id, address)
980 source_head = _extract_source(root, head.commit_id, address)
981
982 # ── Output ────────────────────────────────────────────────────────────────
983 if args.json_out:
984 now = datetime.datetime.now(tz=datetime.timezone.utc)
985
986 def _days(dt: datetime.datetime | None) -> int:
987 if dt is None:
988 return 0
989 aware = dt if dt.tzinfo else dt.replace(tzinfo=datetime.timezone.utc)
990 return max(0, (now - aware).days)
991
992 def _date_str(dt: datetime.datetime | None) -> str:
993 return dt.strftime("%Y-%m-%d") if dt else ""
994
995 calendar_age = _days(history.born_ts)
996 genetic_age = _days(history.last_impl_ts)
997 survival = round(100 / (history.impl_changes + 1))
998
999 print(json.dumps(_NarrativeJson(
1000 **make_envelope(elapsed),
1001 address=address,
1002 name=name,
1003 kind=kind,
1004 file=file_part,
1005 status=history.status,
1006 born_date=_date_str(history.born_ts),
1007 born_commit=history.born_commit,
1008 last_change_date=_date_str(history.last_change_ts),
1009 last_change_commit=history.last_change_commit,
1010 last_impl_date=_date_str(history.last_impl_ts),
1011 last_impl_commit=history.last_impl_commit,
1012 calendar_age_days=calendar_age,
1013 genetic_age_days=genetic_age,
1014 impl_changes=history.impl_changes,
1015 sig_changes=history.sig_changes,
1016 renames=history.renames,
1017 est_survival_pct=survival,
1018 commits_analysed=commits_analysed,
1019 truncated=truncated,
1020 events=_build_json_events(history),
1021 )))
1022 return
1023
1024 if output_format == "prose":
1025 _print_prose(history, address, name, kind, truncated)
1026 else:
1027 _print_timeline(
1028 history,
1029 address,
1030 name,
1031 kind,
1032 commits_analysed,
1033 truncated,
1034 show_source,
1035 source_birth,
1036 source_head,
1037 )
1038
1039
1040 # ── CLI registration ───────────────────────────────────────────────────────────
1041
1042
1043 def register(
1044 sub: argparse._SubParsersAction[argparse.ArgumentParser],
1045 ) -> None:
1046 """Register ``narrative`` under the ``code`` subcommand group.
1047
1048 Args:
1049 sub: The subparser action from the ``code`` command group.
1050 """
1051 p = sub.add_parser(
1052 "narrative",
1053 help=(
1054 "Plain-English story of a symbol's life, built from structured"
1055 " commit deltas."
1056 ),
1057 description=__doc__,
1058 formatter_class=argparse.RawDescriptionHelpFormatter,
1059 )
1060 p.add_argument(
1061 "address",
1062 metavar="ADDRESS",
1063 help=(
1064 "Full symbol address to narrate"
1065 " (e.g. billing.py::Invoice.compute_total)."
1066 ),
1067 )
1068 p.add_argument(
1069 "--format",
1070 metavar="FMT",
1071 choices=["timeline", "prose"],
1072 default="timeline",
1073 help=(
1074 "Output format: ``timeline`` (default) shows a chronological"
1075 " event list; ``prose`` generates a flowing paragraph story."
1076 ),
1077 )
1078 p.add_argument(
1079 "--show-source",
1080 action="store_true",
1081 help=(
1082 "Append the symbol's source code at birth and at HEAD"
1083 " (Python only; requires the blob to be in the object store)."
1084 ),
1085 )
1086 p.add_argument(
1087 "--since",
1088 metavar="REF",
1089 help=(
1090 "Limit history to commits after REF"
1091 " (branch name, commit SHA, or tag)."
1092 ),
1093 )
1094 p.add_argument(
1095 "--max-commits",
1096 type=int,
1097 default=_DEFAULT_MAX_COMMITS,
1098 metavar="N",
1099 help=f"Maximum commits to walk (default: {_DEFAULT_MAX_COMMITS}).",
1100 )
1101 p.add_argument(
1102 "--json", "-j",
1103 action="store_true",
1104 dest="json_out",
1105 help="Emit JSON instead of human-readable text.",
1106 )
1107 p.set_defaults(func=run)
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago