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