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