gabriel / muse public
symlog.py python
1,929 lines 66.4 KB
Raw
sha256:87e65c3a0f38438d20b4534feb442bd274da6a5a7f0f5f95e9c4e4dfefa86c42 feat: muse#53 item 2 — muse code blame prefers symlog journal Sonnet 5 patch 9 hours ago
1 """``muse symlog`` — live per-symbol journal.
2
3 Every commit that changes a symbol writes an entry to its journal. The
4 journal lives in ``.muse/symlogs/`` and is readable in O(1) per symbol,
5 independent of total commit count.
6
7 Usage::
8
9 muse symlog "src/billing.py::compute_total" # human text, newest-first
10 muse symlog "src/billing.py::compute_total" --json # machine-readable
11 muse symlog "src/billing.py::compute_total" --limit 50
12 muse symlog "src/billing.py::compute_total" --follow # traverse rename chain
13 muse symlog "src/billing.py::compute_total" --diff # include body diff per entry
14 muse symlog "src/billing.py::compute_total" --operation symbol-modified
15 muse symlog "src/billing.py::compute_total" --author claude-code
16 muse symlog "src/billing.py::compute_total" --since 2026-06-01
17 muse symlog "src/billing.py::compute_total" --until 2026-07-01
18 muse symlog --file src/billing.py # all symbols in file
19 muse symlog --all # list all symbols with logs
20 muse symlog exists "src/billing.py::compute_total" # exit 0/1
21 muse symlog stat "src/billing.py::compute_total" # summary stats
22 muse symlog stat --file src/billing.py # stats aggregated over a file
23 muse symlog show "src/billing.py::compute_total@{2}" # header + full body at @{2}
24 muse symlog prune-orphans --dry-run # report orphaned journals
25 muse symlog prune-orphans # remove orphaned journals
26 muse symlog grep "deprecated" # search all symbol bodies
27 muse symlog grep "session.add" --file src/billing.py # scope search to a file
28 muse symlog hotspots --top 10 # most-churned symbols
29 muse symlog coupling --file src/billing.py # symbols that change together
30
31 Each text row shows::
32
33 @{N} <new_sha12> (<old_sha12>) <when> <author> <operation>
34
35 Null content IDs are rendered as ``"initial"`` (created) or ``"deleted"``
36 (terminal) so the column is always readable.
37
38 JSON schema (single-symbol query)::
39
40 {
41 "exit_code": 0,
42 "duration_ms": 1.2,
43 "symbol": "src/billing.py::compute_total",
44 "total": 12,
45 "limit": 20,
46 "followed": false,
47 "entries": [
48 {
49 "index": 0,
50 "old_content_id": "sha256:<64-hex>",
51 "new_content_id": "sha256:<64-hex>",
52 "commit_id": "sha256:<64-hex>",
53 "author": "claude-code",
54 "timestamp": "2026-06-28T14:22:11+00:00",
55 "operation": "symbol-modified: fix off-by-one in total",
56 "born_from": null,
57 "from_symbol": null,
58 "diff": null
59 }
60 ]
61 }
62
63 ``from_symbol`` is non-null on entries that came from a prior symbol
64 address via ``--follow``. ``diff`` is non-null only when ``--diff`` is
65 passed.
66
67 Exit codes::
68
69 0 Success (including empty log for ``exists``).
70 1 Invalid arguments / user error / not found.
71 2 Not inside a Muse repository.
72 """
73
74 from __future__ import annotations
75
76 import argparse
77 import datetime
78 import difflib
79 import json
80 import logging
81 import re
82 import sys
83 from typing import TypedDict
84
85 from muse.core.envelope import EnvelopeJson, make_envelope
86 from muse.core.errors import ExitCode
87 from muse.core.repo import require_repo
88 from muse.core.symlog import (
89 NULL_CONTENT_ID,
90 SymlogEntry,
91 SymlogResolution,
92 compute_symlog_coupling,
93 compute_symlog_hotspots,
94 delete_symlog_entry,
95 expire_symlog,
96 is_null_content_id,
97 list_symlog_symbols,
98 list_symlog_symbols_for_file,
99 prune_orphan_symlogs,
100 read_symlog,
101 read_symlog_with_follow,
102 resolve_symlog_addr,
103 resolve_symbol_body,
104 symlog_path,
105 )
106 from muse.core.timing import start_timer
107 from muse.core.types import long_id, short_id
108 from muse.core.validation import clamp_int, sanitize_display
109
110 logger = logging.getLogger(__name__)
111
112 # ---------------------------------------------------------------------------
113 # JSON TypedDicts
114 # ---------------------------------------------------------------------------
115
116
117 class _SymlogEntryJson(TypedDict):
118 index: int
119 old_content_id: str
120 new_content_id: str
121 commit_id: str
122 author: str
123 timestamp: str
124 operation: str
125 born_from: str | None
126 from_symbol: str | None
127 diff: str | None
128
129
130 class _SymlogResultJson(EnvelopeJson):
131 symbol: str
132 total: int
133 limit: int
134 followed: bool
135 entries: list[_SymlogEntryJson]
136
137
138 class _SymlogFileJson(EnvelopeJson):
139 file: str
140 symbols: list[_SymlogResultJson]
141 count: int
142
143
144 class _SymlogAllJson(EnvelopeJson):
145 symbols: list[str]
146 count: int
147
148
149 class _ExistsJson(EnvelopeJson):
150 exists: bool
151 count: int
152 symbol: str
153
154
155 class _SymlogStatJson(EnvelopeJson):
156 """JSON object returned by ``muse symlog stat`` (muse#53 item 10)."""
157
158 symbol: str | None
159 file: str | None
160 total_entries: int
161 operation_breakdown: dict[str, int]
162 most_active_author: str | None
163 first_commit_id: str | None
164 last_commit_id: str | None
165 first_timestamp: str | None
166 last_timestamp: str | None
167
168
169 class _PruneOrphansJson(EnvelopeJson):
170 """JSON object returned by ``muse symlog prune-orphans`` (muse#53 item 12)."""
171
172 pruned: int
173 dry_run: bool
174 symbols: list[str]
175
176
177 class _GrepMatchJson(TypedDict):
178 """One match in ``muse symlog grep`` output (muse#53 item 8)."""
179
180 symbol: str
181 index: int
182 commit_id: str
183 author: str
184 timestamp: str
185 operation: str
186
187
188 class _GrepJson(EnvelopeJson):
189 """JSON object returned by ``muse symlog grep`` (muse#53 item 8)."""
190
191 pattern: str
192 matches: list[_GrepMatchJson]
193 count: int
194
195
196 class _HotspotEntryJson(TypedDict):
197 symbol: str
198 modifications: int
199
200
201 class _HotspotsJson(EnvelopeJson):
202 """JSON object returned by ``muse symlog hotspots`` (muse#53 item 1)."""
203
204 file: str | None
205 top: int
206 hotspots: list[_HotspotEntryJson]
207 count: int
208
209
210 class _CouplingPairJson(TypedDict):
211 symbol_a: str
212 symbol_b: str
213 co_changes: int
214
215
216 class _SymlogCouplingJson(EnvelopeJson):
217 """JSON object returned by ``muse symlog coupling`` (muse#53 item 1)."""
218
219 file: str | None
220 top: int
221 min: int
222 pairs: list[_CouplingPairJson]
223 count: int
224
225
226 class _ExpireJson(EnvelopeJson):
227 expired: int
228 kept: int
229 dry_run: bool
230 symbols_processed: list[str]
231
232
233 class _DeleteJson(EnvelopeJson):
234 deleted: int
235 remaining: int
236 symbol: str
237
238
239 class _ResolveJson(EnvelopeJson):
240 symbol: str
241 index: int
242 content_id: str
243 commit_id: str
244 operation: str
245 followed_from: str | None
246
247
248 class _ShowJson(EnvelopeJson):
249 """JSON object returned by ``muse symlog show`` (muse#53 item 9)."""
250
251 symbol: str
252 index: int
253 content_id: str
254 commit_id: str
255 author: str
256 operation: str
257 followed_from: str | None
258 source: str | None
259
260
261 class _DiffJson(EnvelopeJson):
262 left: str
263 right: str
264 symbol: str
265 diff_lines: list[str]
266 added: int
267 removed: int
268 context: int
269
270
271 # ---------------------------------------------------------------------------
272 # Helpers
273 # ---------------------------------------------------------------------------
274
275 _NULL_NEW_LABEL = "deleted"
276 _NULL_OLD_LABEL = "initial"
277
278
279 def _fmt_cid(cid: str, is_old: bool) -> str:
280 """Short-form content ID, or a human sentinel for null IDs."""
281 if is_null_content_id(cid):
282 return _NULL_OLD_LABEL if is_old else _NULL_NEW_LABEL
283 return short_id(cid)
284
285
286 def _fmt_entry_text(idx: int, entry: SymlogEntry, from_symbol: str | None = None) -> str:
287 """Format one symlog entry for terminal output."""
288 new_short = sanitize_display(_fmt_cid(entry.new_content_id, is_old=False))
289 old_short = sanitize_display(_fmt_cid(entry.old_content_id, is_old=True))
290 when = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
291 safe_op = sanitize_display(entry.operation)
292 safe_author = sanitize_display(entry.author or "")
293 author_col = f" {safe_author}" if safe_author else ""
294 line = f"@{{{idx}}} {new_short} ({old_short}) {when}{author_col} {safe_op}"
295 if from_symbol:
296 line += f" [from {sanitize_display(from_symbol)}]"
297 return line
298
299
300 def _parse_date(value: str, flag: str) -> datetime.datetime:
301 """Parse ``YYYY-MM-DD`` into a UTC-aware datetime; exits USER_ERROR on bad format."""
302 try:
303 d = datetime.date.fromisoformat(value)
304 except ValueError:
305 print(
306 f"❌ Invalid date for {flag}: {sanitize_display(value)!r} — "
307 "expected YYYY-MM-DD.",
308 file=sys.stderr,
309 )
310 raise SystemExit(ExitCode.USER_ERROR)
311 return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.timezone.utc)
312
313
314 def _validate_symbol_addr(addr: str) -> None:
315 """Validate symbol address format; exits USER_ERROR if invalid."""
316 if not addr:
317 print(
318 "❌ Symbol address is empty — expected form: 'path/to/file.py::symbol_name'.",
319 file=sys.stderr,
320 )
321 raise SystemExit(ExitCode.USER_ERROR)
322 if "::" not in addr:
323 print(
324 f"❌ Missing '::' separator in symbol address {sanitize_display(addr)!r}. "
325 "Expected form: 'path/to/file.py::symbol_name'.",
326 file=sys.stderr,
327 )
328 raise SystemExit(ExitCode.USER_ERROR)
329 _file, _, sym_name = addr.partition("::")
330 if not sym_name:
331 print(
332 f"❌ Empty symbol name in {sanitize_display(addr)!r}. "
333 "Expected form: 'path/to/file.py::symbol_name'.",
334 file=sys.stderr,
335 )
336 raise SystemExit(ExitCode.USER_ERROR)
337 if ".." in addr.split("::")[0].split("/"):
338 print(
339 f"❌ Path traversal detected in {sanitize_display(addr)!r}: "
340 "'..' components are not allowed.",
341 file=sys.stderr,
342 )
343 raise SystemExit(ExitCode.USER_ERROR)
344
345
346 def _compute_diff(
347 old_cid: str,
348 new_cid: str,
349 repo_root,
350 symbol_addr: str | None = None,
351 entry: "SymlogEntry | None" = None,
352 ) -> str | None:
353 """Produce a unified diff of the symbol body between the old and new commit.
354
355 Symbol content IDs are hashes computed by the AST parser and are not stored
356 as raw objects in the Muse object store, so we reconstruct each body by
357 reading the file from the commit's snapshot and extracting the symbol.
358 Returns ``None`` when either side is null (created/deleted) or on any error.
359 """
360 if is_null_content_id(old_cid) or is_null_content_id(new_cid):
361 return None
362 if symbol_addr is None or entry is None:
363 return None
364
365 file_path, _, symbol_name = symbol_addr.partition("::")
366 if not symbol_name:
367 return None
368
369 try:
370 from muse.core.commits import read_commit
371 from muse.core.object_store import read_object
372 from muse.core.snapshots import read_snapshot
373 from muse.plugins.code.ast_parser import parse_symbols
374
375 # Read the new commit and its snapshot.
376 new_record = read_commit(repo_root, entry.commit_id)
377 if new_record is None or not new_record.parent_commit_id:
378 return None
379 new_snap = read_snapshot(repo_root, new_record.snapshot_id)
380 if new_snap is None:
381 return None
382 new_file_oid = new_snap.manifest.get(file_path)
383 if new_file_oid is None:
384 return None
385 new_file_bytes = read_object(repo_root, new_file_oid)
386 if new_file_bytes is None:
387 return None
388
389 # Read the parent commit and its snapshot (the "old" state).
390 old_record = read_commit(repo_root, new_record.parent_commit_id)
391 if old_record is None:
392 return None
393 old_snap = read_snapshot(repo_root, old_record.snapshot_id)
394 if old_snap is None:
395 return None
396 old_file_oid = old_snap.manifest.get(file_path)
397 if old_file_oid is None:
398 return None
399 old_file_bytes = read_object(repo_root, old_file_oid)
400 if old_file_bytes is None:
401 return None
402
403 # Extract the symbol body from each file version using line numbers.
404 full_addr = f"{file_path}::{symbol_name}"
405 new_syms = parse_symbols(new_file_bytes, file_path)
406 old_syms = parse_symbols(old_file_bytes, file_path)
407
408 def _body_text(syms, raw_bytes: bytes) -> str:
409 rec = syms.get(full_addr)
410 if rec is None:
411 return ""
412 lines = raw_bytes.decode("utf-8", errors="replace").splitlines()
413 start = rec["lineno"] - 1 # lineno is 1-based, inclusive
414 end = rec["end_lineno"] # end_lineno is 1-based, inclusive → exclusive slice
415 return "\n".join(lines[start:end])
416
417 new_body = _body_text(new_syms, new_file_bytes)
418 old_body = _body_text(old_syms, old_file_bytes)
419
420 if old_body == new_body:
421 return ""
422 old_lines = old_body.splitlines(keepends=True)
423 new_lines = new_body.splitlines(keepends=True)
424 diff_lines = list(difflib.unified_diff(
425 old_lines, new_lines,
426 fromfile=f"a/{symbol_name}",
427 tofile=f"b/{symbol_name}",
428 lineterm="",
429 ))
430 return "\n".join(diff_lines) if diff_lines else ""
431 except Exception:
432 return None
433
434
435 def _apply_filters(
436 entries: list[SymlogEntry],
437 operation_filter: str | None,
438 author_filter: str | None,
439 since_dt: datetime.datetime | None,
440 until_dt: datetime.datetime | None,
441 ) -> list[SymlogEntry]:
442 """Apply all active filters to the entry list."""
443 filtered = entries
444
445 if operation_filter is not None:
446 needle = operation_filter.lower()
447 filtered = [e for e in filtered if needle in e.operation.lower()]
448
449 if author_filter is not None:
450 needle_a = author_filter.lower()
451 filtered = [e for e in filtered if needle_a in (e.author or "").lower()]
452
453 if since_dt is not None:
454 filtered = [e for e in filtered if e.timestamp >= since_dt]
455
456 if until_dt is not None:
457 until_end = until_dt + datetime.timedelta(days=1)
458 filtered = [e for e in filtered if e.timestamp < until_end]
459
460 return filtered
461
462
463 def _entries_to_json(
464 entries: list[SymlogEntry],
465 from_symbol_map: dict[int, str],
466 include_diff: bool,
467 repo_root,
468 symbol_addr: str | None = None,
469 ) -> list[_SymlogEntryJson]:
470 """Convert SymlogEntry list to the JSON schema."""
471 out: list[_SymlogEntryJson] = []
472 for idx, entry in enumerate(entries):
473 diff_text: str | None = None
474 if include_diff:
475 diff_text = _compute_diff(
476 entry.old_content_id, entry.new_content_id, repo_root,
477 symbol_addr=symbol_addr, entry=entry,
478 )
479 out.append(_SymlogEntryJson(
480 index=idx,
481 old_content_id=long_id(entry.old_content_id),
482 new_content_id=long_id(entry.new_content_id),
483 commit_id=long_id(entry.commit_id),
484 author=entry.author,
485 timestamp=entry.timestamp.isoformat(),
486 operation=entry.operation,
487 born_from=entry.born_from,
488 from_symbol=from_symbol_map.get(idx),
489 diff=diff_text,
490 ))
491 return out
492
493
494 # ---------------------------------------------------------------------------
495 # Argument parser registration
496 # ---------------------------------------------------------------------------
497
498
499 def register(
500 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
501 ) -> None:
502 """Register the ``symlog`` subcommand tree."""
503 parser = subparsers.add_parser(
504 "symlog",
505 help="Show the live per-symbol journal written at commit time.",
506 description=__doc__,
507 formatter_class=argparse.RawDescriptionHelpFormatter,
508 )
509 parser.add_argument(
510 "positional", nargs="*", metavar="FILE::SYMBOL",
511 help=(
512 "Symbol address, e.g. 'src/billing.py::compute_total'. "
513 "Or 'exists FILE::SYMBOL' to run the existence check."
514 ),
515 )
516 parser.add_argument(
517 "--limit", type=int, default=20,
518 help="Maximum number of entries to show (after filters, default: 20).",
519 )
520 parser.add_argument(
521 "--follow", action="store_true", dest="follow",
522 help="Traverse rename chain — follow born-from pointers to prior symbol addresses.",
523 )
524 parser.add_argument(
525 "--diff", action="store_true", dest="include_diff",
526 help="Include unified body diff per entry (--json only for full content).",
527 )
528 parser.add_argument(
529 "--file", default=None, metavar="FILE_PATH", dest="file_path",
530 help="Show symlogs for all symbols in FILE_PATH.",
531 )
532 parser.add_argument(
533 "--all", action="store_true", dest="all_symbols",
534 help="List all symbols that have a symlog.",
535 )
536 parser.add_argument(
537 "--top", type=int, default=20, metavar="N",
538 help="Max ranked results to show. Used by hotspots/coupling only (default: 20).",
539 )
540 parser.add_argument(
541 "--min", type=int, default=2, metavar="N", dest="min_count",
542 help="Minimum shared-commit count for a coupling pair. Used by coupling only (default: 2).",
543 )
544 parser.add_argument(
545 "--operation", default=None, metavar="PATTERN", dest="operation_filter",
546 help="Filter to entries whose operation contains PATTERN (case-insensitive).",
547 )
548 parser.add_argument(
549 "--author", default=None, metavar="PATTERN", dest="author_filter",
550 help="Filter to entries whose author contains PATTERN (case-insensitive).",
551 )
552 parser.add_argument(
553 "--since", default=None, metavar="YYYY-MM-DD", dest="since",
554 help="Show only entries on or after this date.",
555 )
556 parser.add_argument(
557 "--until", default=None, metavar="YYYY-MM-DD", dest="until",
558 help="Show only entries on or before this date.",
559 )
560 parser.add_argument(
561 "--expire-days", type=int, default=None, metavar="N", dest="expire_days",
562 help=(
563 "Prune entries older than N days. Defaults to the symlog.expire-days "
564 "config key, then 90. Used by ``expire`` only."
565 ),
566 )
567 parser.add_argument(
568 "--dry-run", "-n", action="store_true", dest="dry_run",
569 help="Report counts without writing any files. Used by ``expire`` only.",
570 )
571 parser.add_argument(
572 "--json", "-j", action="store_true", dest="json_out",
573 help="Emit machine-readable JSON instead of human text.",
574 )
575
576 parser.set_defaults(func=run)
577
578
579 # ---------------------------------------------------------------------------
580 # Constants
581 # ---------------------------------------------------------------------------
582
583 _AT_INDEX_RE = re.compile(r"^@\{(\d+)\}$")
584 #: Mirrors muse.core.symlog._SYMLOG_REF_RE -- accepts a digit index OR a
585 #: relative-time spec inside the braces (muse#53 item 6); disambiguation
586 #: happens inside resolve_symlog_addr()/_resolve_symlog_index().
587 _SYMLOG_REF_RE = re.compile(r"^(.+)@\{([^}]+)\}$")
588 _DEFAULT_SYMLOG_EXPIRE_DAYS: int = 90
589
590 # ---------------------------------------------------------------------------
591 # exists subcommand
592 # ---------------------------------------------------------------------------
593
594
595 def run_exists(symbol: str, json_out: bool) -> None:
596 """Fast existence check — does this symbol have any symlog entries?
597
598 Exits 0 when entries are present, 1 when none.
599
600 JSON schema::
601
602 {
603 "exit_code": 0 or 1,
604 "duration_ms": 0.3,
605 "symbol": "src/billing.py::compute_total",
606 "exists": true,
607 "count": 7
608 }
609 """
610 elapsed = start_timer()
611
612 _validate_symbol_addr(symbol)
613
614 repo_root = require_repo()
615
616 entries = read_symlog(repo_root, symbol, limit=100_000, follow=False)
617 count = len(entries)
618 exists = count > 0
619
620 if json_out:
621 payload = _ExistsJson(
622 **make_envelope(elapsed, exit_code=0 if exists else 1),
623 exists=exists,
624 count=count,
625 symbol=symbol,
626 )
627 print(json.dumps(payload))
628 else:
629 safe_sym = sanitize_display(symbol)
630 if exists:
631 print(f"{safe_sym} has {count} symlog entr{'y' if count == 1 else 'ies'}.")
632 else:
633 print(f"No symlog entries for {safe_sym}.")
634
635 if not exists:
636 raise SystemExit(1)
637
638
639 # ---------------------------------------------------------------------------
640 # stat subcommand (muse#53 item 10)
641 # ---------------------------------------------------------------------------
642
643
644 def _categorize_symlog_operation(operation: str) -> str:
645 """Bucket a symlog operation string by its leading ``symbol-<kind>:`` prefix."""
646 return operation.split(":", 1)[0].strip().lower() if ":" in operation else operation.strip().lower()
647
648
649 def run_stat(args: argparse.Namespace) -> None:
650 """Summary statistics for a symbol's (or a file's) symlog (muse#53 item 10).
651
652 Either a single symbol address (``muse symlog stat "file.py::Fn"``) or
653 ``--file FILE_PATH`` (aggregating every symbol's journal in that file)
654 must be given.
655
656 JSON schema::
657
658 {
659 "exit_code": 0,
660 "duration_ms": 1.1,
661 "symbol": "src/billing.py::compute_total",
662 "file": null,
663 "total_entries": 12,
664 "operation_breakdown": {"symbol-created": 1, "symbol-modified": 10, "symbol-deleted": 1},
665 "most_active_author": "claude-code",
666 "first_commit_id": "sha256:<64-hex>",
667 "last_commit_id": "sha256:<64-hex>",
668 "first_timestamp": "2026-01-01T00:00:00+00:00",
669 "last_timestamp": "2026-03-16T12:00:00+00:00"
670 }
671
672 An empty symlog reports ``total_entries: 0``, an empty
673 ``operation_breakdown``, and every other field ``null``.
674
675 Exit codes::
676
677 0 Success (including an empty symlog).
678 1 Invalid arguments (missing symbol/--file, bad symbol address).
679 2 Not inside a Muse repository.
680 """
681 elapsed = start_timer()
682 positional: list[str] = getattr(args, "positional", [])
683 json_out: bool = getattr(args, "json_out", False)
684 file_path: str | None = getattr(args, "file_path", None)
685 symbol: str | None = positional[1] if len(positional) > 1 else None
686
687 if symbol is not None and file_path is not None:
688 print("❌ Specify either a symbol address or --file, not both.", file=sys.stderr)
689 raise SystemExit(ExitCode.USER_ERROR)
690
691 if symbol is None and file_path is None:
692 print(
693 "❌ 'stat' requires a symbol address or --file.\n"
694 " Example: muse symlog stat 'src/billing.py::compute_total'\n"
695 " Example: muse symlog stat --file src/billing.py",
696 file=sys.stderr,
697 )
698 raise SystemExit(ExitCode.USER_ERROR)
699
700 repo_root = require_repo()
701
702 entries: list[SymlogEntry] = []
703 if symbol is not None:
704 _validate_symbol_addr(symbol)
705 entries = read_symlog(repo_root, symbol, limit=100_000, follow=False)
706 else:
707 assert file_path is not None
708 for addr in list_symlog_symbols_for_file(repo_root, file_path):
709 entries.extend(read_symlog(repo_root, addr, limit=100_000, follow=False))
710 # Merge-sort by timestamp descending (newest-first), matching
711 # read_symlog's own per-symbol ordering contract.
712 entries.sort(key=lambda e: e.timestamp, reverse=True)
713
714 operation_breakdown: dict[str, int] = {}
715 author_counts: dict[str, int] = {}
716 for entry in entries:
717 category = _categorize_symlog_operation(entry.operation)
718 operation_breakdown[category] = operation_breakdown.get(category, 0) + 1
719 if entry.author:
720 author_counts[entry.author] = author_counts.get(entry.author, 0) + 1
721
722 most_active_author: str | None = None
723 if author_counts:
724 most_active_author = max(author_counts.items(), key=lambda kv: kv[1])[0]
725
726 # entries are newest-first (per-symbol read_symlog contract, and the
727 # file-mode merge above re-sorts to match).
728 first_commit_id = entries[-1].commit_id if entries else None
729 last_commit_id = entries[0].commit_id if entries else None
730 first_timestamp = entries[-1].timestamp.isoformat() if entries else None
731 last_timestamp = entries[0].timestamp.isoformat() if entries else None
732
733 if json_out:
734 payload = _SymlogStatJson(
735 **make_envelope(elapsed),
736 symbol=symbol,
737 file=file_path,
738 total_entries=len(entries),
739 operation_breakdown=operation_breakdown,
740 most_active_author=most_active_author,
741 first_commit_id=first_commit_id,
742 last_commit_id=last_commit_id,
743 first_timestamp=first_timestamp,
744 last_timestamp=last_timestamp,
745 )
746 print(json.dumps(payload))
747 return
748
749 label = sanitize_display(symbol) if symbol is not None else sanitize_display(file_path or "")
750 if not entries:
751 print(f"No symlog entries for {label}.")
752 return
753
754 print(f"Symlog stats for {label}\n")
755 print(f" Total entries: {len(entries)}")
756 print(f" Most active: {sanitize_display(most_active_author) if most_active_author else '(none)'}")
757 print(f" First entry: {first_timestamp}")
758 print(f" Last entry: {last_timestamp}")
759 print("\n Operation breakdown:")
760 for category, count in sorted(operation_breakdown.items(), key=lambda kv: -kv[1]):
761 print(f" {sanitize_display(category):<20} {count}")
762
763
764 # ---------------------------------------------------------------------------
765 # prune-orphans subcommand (muse#53 item 12)
766 # ---------------------------------------------------------------------------
767
768
769 def run_prune_orphans(args: argparse.Namespace) -> None:
770 """Find and remove symlog journals whose symbol no longer exists at HEAD.
771
772 A journal becomes orphaned when its file is deleted/renamed away, or the
773 symbol itself is removed from that file — the journal (append-only,
774 never auto-deleted) survives with no live symbol to show it for.
775 Analogous to ``muse gc`` for unreachable objects.
776
777 JSON schema::
778
779 {
780 "exit_code": 0,
781 "duration_ms": 3.2,
782 "pruned": 2,
783 "dry_run": false,
784 "symbols": ["src/old.py::removed_fn", "src/gone.py::deleted_class"]
785 }
786
787 Exit codes::
788
789 0 Success (including zero orphans).
790 2 Not inside a Muse repository.
791 """
792 elapsed = start_timer()
793 json_out: bool = getattr(args, "json_out", False)
794 dry_run: bool = getattr(args, "dry_run", False)
795
796 repo_root = require_repo()
797
798 orphans = prune_orphan_symlogs(repo_root, dry_run=dry_run)
799
800 if json_out:
801 payload = _PruneOrphansJson(
802 **make_envelope(elapsed),
803 pruned=len(orphans),
804 dry_run=dry_run,
805 symbols=orphans,
806 )
807 print(json.dumps(payload))
808 return
809
810 if not orphans:
811 print("No orphaned symlog journals found.")
812 return
813
814 verb = "Would prune" if dry_run else "Pruned"
815 print(f"{verb} {len(orphans)} orphaned symlog journal(s):")
816 for addr in orphans:
817 print(f" {sanitize_display(addr)}")
818
819
820 # ---------------------------------------------------------------------------
821 # grep subcommand (muse#53 item 8)
822 # ---------------------------------------------------------------------------
823
824
825 def run_grep(args: argparse.Namespace) -> None:
826 """Search symbol bodies across their full symlog history for PATTERN.
827
828 Answers: "when did this symbol last contain the string 'deprecated'?"
829 Reconstructs each entry's body via ``resolve_symbol_body`` and checks it
830 for a case-insensitive substring match against *PATTERN*. Scope defaults
831 to every symbol with a journal; narrow with ``--file``.
832
833 JSON schema::
834
835 {
836 "exit_code": 0,
837 "duration_ms": 12.4,
838 "pattern": "deprecated",
839 "count": 2,
840 "matches": [
841 {
842 "symbol": "src/billing.py::compute_total",
843 "index": 1,
844 "commit_id": "sha256:<64-hex>",
845 "author": "claude-code",
846 "timestamp": "2026-06-20T09:11:04+00:00",
847 "operation": "symbol-modified: mark deprecated"
848 }
849 ]
850 }
851
852 Exit codes::
853
854 0 Success (including zero matches).
855 1 Missing PATTERN argument.
856 2 Not inside a Muse repository.
857 """
858 elapsed = start_timer()
859
860 positional: list[str] = getattr(args, "positional", [])
861 file_path: str | None = getattr(args, "file_path", None)
862 json_out: bool = getattr(args, "json_out", False)
863
864 grep_positional = positional[1:]
865 if not grep_positional:
866 print(
867 "❌ 'grep' requires a search pattern.\n"
868 " Example: muse symlog grep 'deprecated'\n"
869 " Example: muse symlog grep 'deprecated' --file src/billing.py",
870 file=sys.stderr,
871 )
872 raise SystemExit(ExitCode.USER_ERROR)
873
874 pattern = grep_positional[0]
875 needle = pattern.lower()
876
877 repo_root = require_repo()
878
879 addrs = (
880 list_symlog_symbols_for_file(repo_root, file_path)
881 if file_path is not None
882 else list_symlog_symbols(repo_root)
883 )
884
885 matches: list[_GrepMatchJson] = []
886 for addr in addrs:
887 entries = read_symlog(repo_root, addr, limit=100_000, follow=False)
888 for idx, entry in enumerate(entries):
889 body = resolve_symbol_body(repo_root, addr, entry.commit_id)
890 if body is None:
891 continue
892 if needle in body["source"].lower():
893 matches.append(_GrepMatchJson(
894 symbol=addr,
895 index=idx,
896 commit_id=entry.commit_id,
897 author=entry.author,
898 timestamp=entry.timestamp.isoformat(),
899 operation=entry.operation,
900 ))
901
902 if json_out:
903 payload = _GrepJson(
904 **make_envelope(elapsed),
905 pattern=pattern,
906 matches=matches,
907 count=len(matches),
908 )
909 print(json.dumps(payload))
910 return
911
912 safe_pattern = sanitize_display(pattern)
913 if not matches:
914 print(f"No matches for {safe_pattern!r}.")
915 return
916
917 print(f"{len(matches)} match(es) for {safe_pattern!r}:\n")
918 for m in matches:
919 safe_sym = sanitize_display(m["symbol"])
920 safe_op = sanitize_display(m["operation"])
921 safe_author = sanitize_display(m["author"])
922 print(f" {safe_sym}@{{{m['index']}}} {m['timestamp']} {safe_author} {safe_op}")
923
924
925 # ---------------------------------------------------------------------------
926 # hotspots subcommand (muse#53 item 1)
927 # ---------------------------------------------------------------------------
928
929
930 def run_hotspots(args: argparse.Namespace) -> None:
931 """Rank symbols by churn — number of ``symbol-modified`` journal entries.
932
933 Reads the already-written symlog journal in O(1) per symbol — a
934 zero-scan alternative to ``muse code hotspots`` that is always
935 up to date, since it never walks commit history.
936
937 JSON schema::
938
939 {
940 "exit_code": 0,
941 "duration_ms": 4.1,
942 "file": null,
943 "top": 20,
944 "count": 2,
945 "hotspots": [
946 {"symbol": "src/billing.py::compute_total", "modifications": 7},
947 {"symbol": "src/auth.py::validate_token", "modifications": 3}
948 ]
949 }
950
951 Exit codes::
952
953 0 Success (including zero hotspots).
954 2 Not inside a Muse repository.
955 """
956 elapsed = start_timer()
957 file_path: str | None = getattr(args, "file_path", None)
958 top: int = clamp_int(getattr(args, "top", 20), 1, 100_000, "top")
959 json_out: bool = getattr(args, "json_out", False)
960
961 repo_root = require_repo()
962
963 ranked = compute_symlog_hotspots(repo_root, file_path=file_path, top=top)
964
965 if json_out:
966 payload = _HotspotsJson(
967 **make_envelope(elapsed),
968 file=file_path,
969 top=top,
970 hotspots=[_HotspotEntryJson(symbol=s, modifications=c) for s, c in ranked],
971 count=len(ranked),
972 )
973 print(json.dumps(payload))
974 return
975
976 if not ranked:
977 print("No symlog hotspots found.")
978 return
979
980 print(f"Symlog hotspots (top {len(ranked)}):\n")
981 for symbol, count in ranked:
982 print(f" {count:>4} {sanitize_display(symbol)}")
983
984
985 # ---------------------------------------------------------------------------
986 # coupling subcommand (muse#53 item 1)
987 # ---------------------------------------------------------------------------
988
989
990 def run_coupling(args: argparse.Namespace) -> None:
991 """Find symbol pairs that change together most often.
992
993 Two symbols are "coupled" when their journals share commit IDs.
994 Surfaced purely from symlog data, no AST traversal needed.
995
996 JSON schema::
997
998 {
999 "exit_code": 0,
1000 "duration_ms": 5.8,
1001 "file": null,
1002 "top": 20,
1003 "min": 2,
1004 "count": 1,
1005 "pairs": [
1006 {
1007 "symbol_a": "src/billing.py::compute_total",
1008 "symbol_b": "src/billing.py::apply_discount",
1009 "co_changes": 3
1010 }
1011 ]
1012 }
1013
1014 Exit codes::
1015
1016 0 Success (including zero coupled pairs).
1017 2 Not inside a Muse repository.
1018 """
1019 elapsed = start_timer()
1020 file_path: str | None = getattr(args, "file_path", None)
1021 top: int = clamp_int(getattr(args, "top", 20), 1, 100_000, "top")
1022 min_count: int = clamp_int(getattr(args, "min_count", 2), 1, 100_000, "min")
1023 json_out: bool = getattr(args, "json_out", False)
1024
1025 repo_root = require_repo()
1026
1027 pairs = compute_symlog_coupling(repo_root, file_path=file_path, min_count=min_count, top=top)
1028
1029 if json_out:
1030 payload = _SymlogCouplingJson(
1031 **make_envelope(elapsed),
1032 file=file_path,
1033 top=top,
1034 min=min_count,
1035 pairs=[
1036 _CouplingPairJson(symbol_a=a, symbol_b=b, co_changes=c)
1037 for a, b, c in pairs
1038 ],
1039 count=len(pairs),
1040 )
1041 print(json.dumps(payload))
1042 return
1043
1044 if not pairs:
1045 print("No coupled symbol pairs found.")
1046 return
1047
1048 print(f"Symlog coupling (top {len(pairs)}):\n")
1049 for a, b, count in pairs:
1050 print(f" {count:>4} {sanitize_display(a)} ↔ {sanitize_display(b)}")
1051
1052
1053 # ---------------------------------------------------------------------------
1054 # expire subcommand
1055 # ---------------------------------------------------------------------------
1056
1057
1058 def run_expire(args: argparse.Namespace) -> None:
1059 """Prune entries older than --expire-days from one or more symbol journals.
1060
1061 Dispatches to single-symbol, --file, or --all mode depending on flags.
1062
1063 JSON schema::
1064
1065 {
1066 "exit_code": 0,
1067 "duration_ms": 3.1,
1068 "expired": 34,
1069 "kept": 18,
1070 "dry_run": false,
1071 "symbols_processed": ["src/billing.py::compute_total"]
1072 }
1073 """
1074 elapsed = start_timer()
1075
1076 positional: list[str] = getattr(args, "positional", [])
1077 file_path: str | None = getattr(args, "file_path", None)
1078 all_symbols: bool = getattr(args, "all_symbols", False)
1079 dry_run: bool = getattr(args, "dry_run", False)
1080 json_out: bool = getattr(args, "json_out", False)
1081 expire_days_arg: int | None = getattr(args, "expire_days", None)
1082
1083 # Resolve symbol address (everything in positional after "expire")
1084 expire_positional = positional[1:]
1085 symbol: str | None = expire_positional[0] if expire_positional else None
1086
1087 # Resolve TTL: explicit flag → config → default
1088 if expire_days_arg is not None:
1089 expire_days = expire_days_arg
1090 else:
1091 repo_root_early = require_repo()
1092 from muse.cli.config import get_config_value as _gcv
1093 _raw = _gcv("symlog.expire-days", repo_root_early)
1094 try:
1095 expire_days = int(_raw) if _raw is not None else _DEFAULT_SYMLOG_EXPIRE_DAYS
1096 except (ValueError, TypeError):
1097 expire_days = _DEFAULT_SYMLOG_EXPIRE_DAYS
1098
1099 repo_root = require_repo()
1100
1101 total_expired = 0
1102 total_kept = 0
1103 symbols_processed: list[str] = []
1104
1105 if symbol and not file_path and not all_symbols:
1106 # Single-symbol mode
1107 _validate_symbol_addr(symbol)
1108 expired, kept = expire_symlog(repo_root, symbol, expire_days, dry_run=dry_run)
1109 total_expired += expired
1110 total_kept += kept
1111 if expired > 0 or kept > 0:
1112 symbols_processed.append(symbol)
1113
1114 elif file_path is not None:
1115 # --file mode
1116 addrs = list_symlog_symbols_for_file(repo_root, file_path)
1117 for addr in addrs:
1118 expired, kept = expire_symlog(repo_root, addr, expire_days, dry_run=dry_run)
1119 total_expired += expired
1120 total_kept += kept
1121 symbols_processed.append(addr)
1122
1123 elif all_symbols:
1124 # --all mode
1125 addrs = list_symlog_symbols(repo_root)
1126 for addr in addrs:
1127 expired, kept = expire_symlog(repo_root, addr, expire_days, dry_run=dry_run)
1128 total_expired += expired
1129 total_kept += kept
1130 symbols_processed.append(addr)
1131
1132 else:
1133 print(
1134 "❌ Specify a symbol address, --file FILE, or --all.\n"
1135 " Example: muse symlog expire 'src/billing.py::compute_total' --expire-days 90",
1136 file=sys.stderr,
1137 )
1138 raise SystemExit(ExitCode.USER_ERROR)
1139
1140 if json_out:
1141 payload = _ExpireJson(
1142 **make_envelope(elapsed),
1143 expired=total_expired,
1144 kept=total_kept,
1145 dry_run=dry_run,
1146 symbols_processed=symbols_processed,
1147 )
1148 print(json.dumps(payload))
1149 else:
1150 prefix = "[dry-run] " if dry_run else ""
1151 action = "Would expire" if dry_run else "Expired"
1152 print(
1153 f"{prefix}{action} {total_expired} entr{'y' if total_expired == 1 else 'ies'}, "
1154 f"kept {total_kept} across {len(symbols_processed)} symbol(s)."
1155 )
1156
1157
1158 # ---------------------------------------------------------------------------
1159 # delete subcommand
1160 # ---------------------------------------------------------------------------
1161
1162
1163 def run_delete(args: argparse.Namespace) -> None:
1164 """Remove one entry by index or all entries from a symbol journal.
1165
1166 JSON schema::
1167
1168 {
1169 "exit_code": 0,
1170 "duration_ms": 0.8,
1171 "deleted": 1,
1172 "remaining": 7,
1173 "symbol": "src/billing.py::compute_total"
1174 }
1175 """
1176 elapsed = start_timer()
1177
1178 positional: list[str] = getattr(args, "positional", [])
1179 all_symbols: bool = getattr(args, "all_symbols", False)
1180 json_out: bool = getattr(args, "json_out", False)
1181
1182 # positional: ["delete", SYMBOL, optional @{N}]
1183 delete_positional = positional[1:] # everything after "delete"
1184
1185 if not delete_positional:
1186 print(
1187 "❌ 'delete' requires a symbol address.\n"
1188 " Example: muse symlog delete 'src/billing.py::compute_total' @{0}\n"
1189 " Example: muse symlog delete 'src/billing.py::compute_total' --all",
1190 file=sys.stderr,
1191 )
1192 raise SystemExit(ExitCode.USER_ERROR)
1193
1194 symbol = delete_positional[0]
1195 _validate_symbol_addr(symbol)
1196
1197 # Parse optional @{N} token
1198 index: int | None = None
1199 if len(delete_positional) >= 2:
1200 token = delete_positional[1]
1201 m = _AT_INDEX_RE.match(token)
1202 if m is None:
1203 print(
1204 f"❌ Unrecognised index reference {sanitize_display(token)!r} — "
1205 "expected @{N} where N is a non-negative integer.",
1206 file=sys.stderr,
1207 )
1208 raise SystemExit(ExitCode.USER_ERROR)
1209 index = int(m.group(1))
1210
1211 if index is None and not all_symbols:
1212 print(
1213 "❌ Specify @{N} to delete one entry or --all to delete all entries.\n"
1214 " Example: muse symlog delete 'src/billing.py::compute_total' @{0}\n"
1215 " Example: muse symlog delete 'src/billing.py::compute_total' --all",
1216 file=sys.stderr,
1217 )
1218 raise SystemExit(ExitCode.USER_ERROR)
1219
1220 repo_root = require_repo()
1221
1222 try:
1223 deleted, remaining = delete_symlog_entry(
1224 repo_root, symbol, index if not all_symbols else None
1225 )
1226 except FileNotFoundError:
1227 print(
1228 f"❌ No symlog entries for {sanitize_display(symbol)!r}.",
1229 file=sys.stderr,
1230 )
1231 raise SystemExit(ExitCode.USER_ERROR)
1232 except IndexError as exc:
1233 bad_index, total = exc.args[0], exc.args[1]
1234 print(
1235 f"❌ @{{{bad_index}}} is out of range — "
1236 f"valid range: @{{0}} to @{{{total - 1}}} ({total} entr{'y' if total == 1 else 'ies'}).",
1237 file=sys.stderr,
1238 )
1239 raise SystemExit(ExitCode.USER_ERROR)
1240
1241 if json_out:
1242 payload = _DeleteJson(
1243 **make_envelope(elapsed),
1244 deleted=deleted,
1245 remaining=remaining,
1246 symbol=symbol,
1247 )
1248 print(json.dumps(payload))
1249 else:
1250 safe_sym = sanitize_display(symbol)
1251 print(f"Deleted {deleted} entr{'y' if deleted == 1 else 'ies'} from {safe_sym} ({remaining} remaining).")
1252
1253
1254 # ---------------------------------------------------------------------------
1255 # resolve subcommand
1256 # ---------------------------------------------------------------------------
1257
1258
1259 def run_resolve(args: argparse.Namespace) -> None:
1260 """Resolve a ``<addr>@{N}`` symlog reference to its content_id and commit_id.
1261
1262 JSON schema::
1263
1264 {
1265 "exit_code": 0,
1266 "duration_ms": 0.4,
1267 "symbol": "src/billing.py::compute_total",
1268 "index": 2,
1269 "content_id": "sha256:<64-hex>",
1270 "commit_id": "sha256:<64-hex>",
1271 "operation": "symbol-modified: add rounding",
1272 "timestamp": "2026-06-20T09:11:04+00:00",
1273 "followed_from": null
1274 }
1275 """
1276 elapsed = start_timer()
1277
1278 positional: list[str] = getattr(args, "positional", [])
1279 follow: bool = getattr(args, "follow", False)
1280 json_out: bool = getattr(args, "json_out", False)
1281
1282 # positional: ["resolve", SPEC]
1283 resolve_positional = positional[1:]
1284
1285 if not resolve_positional:
1286 print(
1287 "❌ 'resolve' requires a symbol ref: muse symlog resolve 'file.py::Sym@{N}' [--follow]",
1288 file=sys.stderr,
1289 )
1290 raise SystemExit(ExitCode.USER_ERROR)
1291
1292 spec = resolve_positional[0]
1293
1294 # Validate the spec matches addr@{N} form.
1295 m = _SYMLOG_REF_RE.match(spec)
1296 if m is None:
1297 print(
1298 f"❌ {sanitize_display(spec)!r} is not a valid symlog ref — "
1299 "expected form: 'file.py::Symbol@{N}'.",
1300 file=sys.stderr,
1301 )
1302 raise SystemExit(ExitCode.USER_ERROR)
1303
1304 repo_root = require_repo()
1305
1306 try:
1307 resolution = resolve_symlog_addr(spec, repo_root, follow=follow)
1308 except FileNotFoundError:
1309 addr = m.group(1)
1310 print(
1311 f"❌ No symlog entries for {sanitize_display(addr)!r}.",
1312 file=sys.stderr,
1313 )
1314 raise SystemExit(ExitCode.USER_ERROR)
1315 except IndexError as exc:
1316 bad_index, total = exc.args[0], exc.args[1]
1317 print(
1318 f"❌ @{{{bad_index}}} is out of range — "
1319 f"valid range: @{{0}} to @{{{max(0, total - 1)}}} "
1320 f"({total} entr{'y' if total == 1 else 'ies'}).",
1321 file=sys.stderr,
1322 )
1323 raise SystemExit(ExitCode.USER_ERROR)
1324 except ValueError as exc:
1325 print(f"❌ Invalid symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
1326 raise SystemExit(ExitCode.USER_ERROR)
1327
1328 # resolution cannot be None here since _SYMLOG_REF_RE matched.
1329 assert resolution is not None
1330
1331 if json_out:
1332 payload = _ResolveJson(
1333 **make_envelope(elapsed),
1334 symbol=resolution.symbol,
1335 index=resolution.index,
1336 content_id=resolution.content_id,
1337 commit_id=resolution.commit_id,
1338 operation=resolution.operation,
1339 followed_from=resolution.followed_from,
1340 )
1341 # Overwrite the envelope's wall-clock timestamp with the symlog
1342 # entry's own recorded timestamp so callers see when the change
1343 # happened, not when this command ran.
1344 payload["timestamp"] = resolution.timestamp.isoformat()
1345 print(json.dumps(payload))
1346 else:
1347 safe_sym = sanitize_display(resolution.symbol)
1348 safe_op = sanitize_display(resolution.operation)
1349 when = resolution.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
1350 print(f"{safe_sym}@{{{resolution.index}}}")
1351 print(f" content_id: {sanitize_display(resolution.content_id)}")
1352 print(f" commit_id: {sanitize_display(resolution.commit_id)}")
1353 print(f" operation: {safe_op}")
1354 print(f" timestamp: {when}")
1355 if resolution.followed_from:
1356 print(f" followed_from: {sanitize_display(resolution.followed_from)}")
1357
1358
1359 # ---------------------------------------------------------------------------
1360 # show subcommand (muse#53 item 9)
1361 # ---------------------------------------------------------------------------
1362
1363
1364 def run_show(args: argparse.Namespace) -> None:
1365 """Human-focused convenience view of a ``<addr>@{N}`` symlog reference.
1366
1367 Equivalent to ``muse code cat "addr@{N}"`` but with a header (operation,
1368 author, timestamp) printed before the full symbol body — useful for
1369 interactive exploration ("show me what this function looked like two
1370 changes ago") without needing ``--json`` and a second lookup.
1371
1372 JSON schema::
1373
1374 {
1375 "exit_code": 0,
1376 "duration_ms": 0.6,
1377 "symbol": "src/billing.py::compute_total",
1378 "index": 2,
1379 "content_id": "sha256:<64-hex>",
1380 "commit_id": "sha256:<64-hex>",
1381 "author": "claude-code",
1382 "operation": "symbol-modified: add rounding",
1383 "followed_from": null,
1384 "source": "def compute_total(items):\n return sum(items)"
1385 }
1386
1387 ``source`` is ``null`` when the body could not be reconstructed (e.g. the
1388 entry is a deletion, or the file/commit no longer resolves).
1389
1390 Exit codes::
1391
1392 0 Success.
1393 1 Invalid ref, no symlog, or index out of range.
1394 2 Not inside a Muse repository.
1395 """
1396 elapsed = start_timer()
1397
1398 positional: list[str] = getattr(args, "positional", [])
1399 follow: bool = getattr(args, "follow", False)
1400 json_out: bool = getattr(args, "json_out", False)
1401
1402 # positional: ["show", SPEC]
1403 show_positional = positional[1:]
1404
1405 if not show_positional:
1406 print(
1407 "❌ 'show' requires a symbol ref: muse symlog show 'file.py::Sym@{N}' [--follow]",
1408 file=sys.stderr,
1409 )
1410 raise SystemExit(ExitCode.USER_ERROR)
1411
1412 spec = show_positional[0]
1413
1414 m = _SYMLOG_REF_RE.match(spec)
1415 if m is None:
1416 print(
1417 f"❌ {sanitize_display(spec)!r} is not a valid symlog ref — "
1418 "expected form: 'file.py::Symbol@{N}'.",
1419 file=sys.stderr,
1420 )
1421 raise SystemExit(ExitCode.USER_ERROR)
1422
1423 repo_root = require_repo()
1424
1425 try:
1426 resolution = resolve_symlog_addr(spec, repo_root, follow=follow)
1427 except FileNotFoundError:
1428 addr = m.group(1)
1429 print(f"❌ No symlog entries for {sanitize_display(addr)!r}.", file=sys.stderr)
1430 raise SystemExit(ExitCode.USER_ERROR)
1431 except IndexError as exc:
1432 bad_index, total = exc.args[0], exc.args[1]
1433 print(
1434 f"❌ @{{{bad_index}}} is out of range — "
1435 f"valid range: @{{0}} to @{{{max(0, total - 1)}}} "
1436 f"({total} entr{'y' if total == 1 else 'ies'}).",
1437 file=sys.stderr,
1438 )
1439 raise SystemExit(ExitCode.USER_ERROR)
1440 except ValueError as exc:
1441 print(f"❌ Invalid symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
1442 raise SystemExit(ExitCode.USER_ERROR)
1443
1444 assert resolution is not None
1445
1446 body = resolve_symbol_body(repo_root, resolution.symbol, resolution.commit_id)
1447 source = body["source"] if body is not None else None
1448
1449 if json_out:
1450 payload = _ShowJson(
1451 **make_envelope(elapsed),
1452 symbol=resolution.symbol,
1453 index=resolution.index,
1454 content_id=resolution.content_id,
1455 commit_id=resolution.commit_id,
1456 author=resolution.author,
1457 operation=resolution.operation,
1458 followed_from=resolution.followed_from,
1459 source=source,
1460 )
1461 print(json.dumps(payload))
1462 return
1463
1464 safe_sym = sanitize_display(resolution.symbol)
1465 safe_op = sanitize_display(resolution.operation)
1466 safe_author = sanitize_display(resolution.author)
1467 when = resolution.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
1468 print(f"{safe_sym}@{{{resolution.index}}}")
1469 print(f" commit: {sanitize_display(resolution.commit_id)}")
1470 print(f" author: {safe_author}")
1471 print(f" when: {when}")
1472 print(f" operation: {safe_op}")
1473 if resolution.followed_from:
1474 print(f" followed_from: {sanitize_display(resolution.followed_from)}")
1475 print()
1476 if source is None:
1477 print(" (no body available — symbol was deleted, or source could not be reconstructed)")
1478 else:
1479 for line in source.splitlines():
1480 print(f" {line}")
1481
1482
1483 # ---------------------------------------------------------------------------
1484 # diff subcommand
1485 # ---------------------------------------------------------------------------
1486
1487
1488 def _get_symbol_body_from_workdir(
1489 repo_root: "pathlib.Path",
1490 symbol_addr: str,
1491 ) -> "str | None":
1492 """Return the current working-tree body for *symbol_addr*, or None on failure."""
1493 if "::" not in symbol_addr:
1494 return None
1495 file_path, _, symbol_name = symbol_addr.partition("::")
1496 if not symbol_name:
1497 return None
1498
1499 try:
1500 import pathlib as _pathlib
1501 from muse.plugins.code.ast_parser import adapter_for_path
1502
1503 disk_path = repo_root / file_path
1504 if not disk_path.is_file():
1505 return None
1506 raw = disk_path.read_bytes()
1507 adapter = adapter_for_path(file_path)
1508 tree = adapter.parse_symbols(raw, file_path)
1509
1510 found = next(
1511 (r for r in tree.values() if r["qualified_name"] == symbol_name),
1512 None,
1513 )
1514 if found is None:
1515 found = next(
1516 (r for r in tree.values()
1517 if r["name"] == symbol_name and r["kind"] != "import"),
1518 None,
1519 )
1520 if found is None:
1521 return None
1522
1523 lineno = found["lineno"]
1524 end_lineno = found["end_lineno"]
1525 text = raw.decode("utf-8", errors="replace")
1526 lines = text.splitlines()
1527 return "\n".join(lines[lineno - 1:end_lineno])
1528 except Exception:
1529 return None
1530
1531
1532 def run_diff(args: argparse.Namespace) -> None:
1533 """Show the unified diff between two symlog-indexed symbol versions.
1534
1535 JSON schema::
1536
1537 {
1538 "exit_code": 0,
1539 "duration_ms": 0.8,
1540 "left": "billing.py::compute_total@{1}",
1541 "right": "billing.py::compute_total@{0}",
1542 "symbol": "billing.py::compute_total",
1543 "diff_lines": ["-old line", "+new line", " context"],
1544 "added": 1,
1545 "removed": 1,
1546 "context": 5
1547 }
1548 """
1549 elapsed = start_timer()
1550
1551 positional: list[str] = getattr(args, "positional", [])
1552 follow: bool = getattr(args, "follow", False)
1553 json_out: bool = getattr(args, "json_out", False)
1554
1555 # positional: ["diff", LEFT_SPEC, RIGHT_SPEC]
1556 diff_positional = positional[1:]
1557
1558 if len(diff_positional) < 2:
1559 print(
1560 "❌ 'diff' requires two specs:\n"
1561 " muse symlog diff 'file.py::Sym@{1}' 'file.py::Sym@{0}'\n"
1562 " muse symlog diff 'file.py::Sym@{2}' HEAD",
1563 file=sys.stderr,
1564 )
1565 raise SystemExit(ExitCode.USER_ERROR)
1566
1567 left_spec = diff_positional[0]
1568 right_spec = diff_positional[1]
1569
1570 # Parse left spec — must be addr@{N}.
1571 left_m = _SYMLOG_REF_RE.match(left_spec)
1572 if left_m is None:
1573 print(
1574 f"❌ Left spec {sanitize_display(left_spec)!r} must be 'addr@{{N}}'.",
1575 file=sys.stderr,
1576 )
1577 raise SystemExit(ExitCode.USER_ERROR)
1578
1579 left_addr = left_m.group(1)
1580 right_from_head = right_spec.upper() == "HEAD"
1581
1582 # Parse right spec — addr@{M} or HEAD.
1583 if not right_from_head:
1584 right_m = _SYMLOG_REF_RE.match(right_spec)
1585 if right_m is None:
1586 print(
1587 f"❌ Right spec {sanitize_display(right_spec)!r} must be 'addr@{{N}}' or HEAD.",
1588 file=sys.stderr,
1589 )
1590 raise SystemExit(ExitCode.USER_ERROR)
1591 right_addr = right_m.group(1)
1592 else:
1593 right_addr = left_addr
1594
1595 repo_root = require_repo()
1596
1597 # Resolve left body from symlog.
1598 try:
1599 left_res = resolve_symlog_addr(left_spec, repo_root, follow=follow)
1600 except FileNotFoundError:
1601 print(
1602 f"❌ No symlog entries for {sanitize_display(left_addr)!r}.",
1603 file=sys.stderr,
1604 )
1605 raise SystemExit(ExitCode.USER_ERROR)
1606 except IndexError as exc:
1607 bad_index, total = exc.args[0], exc.args[1]
1608 print(
1609 f"❌ Left @{{{bad_index}}} is out of range — "
1610 f"valid range: @{{0}} to @{{{max(0, total - 1)}}}.",
1611 file=sys.stderr,
1612 )
1613 raise SystemExit(ExitCode.USER_ERROR)
1614 except ValueError as exc:
1615 print(f"❌ Invalid left symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
1616 raise SystemExit(ExitCode.USER_ERROR)
1617
1618 assert left_res is not None
1619
1620 left_body_info = resolve_symbol_body(repo_root, left_addr, left_res.commit_id)
1621 if left_body_info is None:
1622 print(
1623 f"❌ Could not reconstruct body for {sanitize_display(left_addr)!r} "
1624 f"at commit {sanitize_display(left_res.commit_id[:8])}.",
1625 file=sys.stderr,
1626 )
1627 raise SystemExit(ExitCode.USER_ERROR)
1628 left_body = left_body_info["source"]
1629
1630 # Resolve right body.
1631 if right_from_head:
1632 right_body = _get_symbol_body_from_workdir(repo_root, right_addr)
1633 if right_body is None:
1634 print(
1635 f"❌ Could not read working-tree body for {sanitize_display(right_addr)!r}.",
1636 file=sys.stderr,
1637 )
1638 raise SystemExit(ExitCode.USER_ERROR)
1639 else:
1640 try:
1641 right_res = resolve_symlog_addr(right_spec, repo_root, follow=follow)
1642 except FileNotFoundError:
1643 print(
1644 f"❌ No symlog entries for {sanitize_display(right_addr)!r}.",
1645 file=sys.stderr,
1646 )
1647 raise SystemExit(ExitCode.USER_ERROR)
1648 except IndexError as exc:
1649 bad_index, total = exc.args[0], exc.args[1]
1650 print(
1651 f"❌ Right @{{{bad_index}}} is out of range — "
1652 f"valid range: @{{0}} to @{{{max(0, total - 1)}}}.",
1653 file=sys.stderr,
1654 )
1655 raise SystemExit(ExitCode.USER_ERROR)
1656 except ValueError as exc:
1657 print(f"❌ Invalid right symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
1658 raise SystemExit(ExitCode.USER_ERROR)
1659
1660 assert right_res is not None
1661 right_body_info = resolve_symbol_body(repo_root, right_addr, right_res.commit_id)
1662 if right_body_info is None:
1663 print(
1664 f"❌ Could not reconstruct body for {sanitize_display(right_addr)!r}.",
1665 file=sys.stderr,
1666 )
1667 raise SystemExit(ExitCode.USER_ERROR)
1668 right_body = right_body_info["source"]
1669
1670 # Compute unified diff.
1671 symbol_name = left_addr.split("::")[-1] if "::" in left_addr else left_addr
1672 left_lines = left_body.splitlines(keepends=True)
1673 right_lines = right_body.splitlines(keepends=True)
1674 raw_diff = list(difflib.unified_diff(
1675 left_lines, right_lines,
1676 fromfile=f"a/{symbol_name}",
1677 tofile=f"b/{symbol_name}",
1678 lineterm="",
1679 ))
1680
1681 # Count added/removed/context lines (skip --- and +++ header lines).
1682 added = sum(1 for ln in raw_diff if ln.startswith("+") and not ln.startswith("+++"))
1683 removed = sum(1 for ln in raw_diff if ln.startswith("-") and not ln.startswith("---"))
1684 context = sum(1 for ln in raw_diff if ln.startswith(" "))
1685
1686 if json_out:
1687 payload = _DiffJson(
1688 **make_envelope(elapsed),
1689 left=left_spec,
1690 right=right_spec,
1691 symbol=left_addr,
1692 diff_lines=raw_diff,
1693 added=added,
1694 removed=removed,
1695 context=context,
1696 )
1697 print(json.dumps(payload))
1698 else:
1699 if not raw_diff:
1700 print(f"No differences between {sanitize_display(left_spec)} and {sanitize_display(right_spec)}.")
1701 else:
1702 for line in raw_diff:
1703 print(line)
1704
1705
1706 # ---------------------------------------------------------------------------
1707 # Command entry point
1708 # ---------------------------------------------------------------------------
1709
1710
1711 def run(args: argparse.Namespace) -> None:
1712 """Show the live per-symbol journal.
1713
1714 Dispatches to ``exists``, ``--file``, ``--all``, or single-symbol mode
1715 depending on the flags provided.
1716 """
1717 elapsed = start_timer()
1718
1719 positional: list[str] = getattr(args, "positional", [])
1720 json_out_top: bool = getattr(args, "json_out", False)
1721
1722 # ── resolve subcommand dispatch ───────────────────────────────────────────
1723 if positional and positional[0] == "resolve":
1724 run_resolve(args)
1725 return
1726
1727 # ── show subcommand dispatch (muse#53 item 9) ─────────────────────────────
1728 if positional and positional[0] == "show":
1729 run_show(args)
1730 return
1731
1732 # ── diff subcommand dispatch ──────────────────────────────────────────────
1733 if positional and positional[0] == "diff":
1734 run_diff(args)
1735 return
1736
1737 # ── expire subcommand dispatch ────────────────────────────────────────────
1738 if positional and positional[0] == "expire":
1739 run_expire(args)
1740 return
1741
1742 # ── delete subcommand dispatch ────────────────────────────────────────────
1743 if positional and positional[0] == "delete":
1744 run_delete(args)
1745 return
1746
1747 # ── exists subcommand dispatch ────────────────────────────────────────────
1748 if positional and positional[0] == "exists":
1749 if len(positional) < 2:
1750 print(
1751 "❌ 'exists' requires a symbol address.\n"
1752 " Example: muse symlog exists 'src/billing.py::compute_total'",
1753 file=sys.stderr,
1754 )
1755 raise SystemExit(ExitCode.USER_ERROR)
1756 run_exists(positional[1], json_out_top)
1757 return
1758
1759 # ── stat subcommand dispatch (muse#53 item 10) ────────────────────────────
1760 if positional and positional[0] == "stat":
1761 run_stat(args)
1762 return
1763
1764 # ── prune-orphans subcommand dispatch (muse#53 item 12) ───────────────────
1765 if positional and positional[0] == "prune-orphans":
1766 run_prune_orphans(args)
1767 return
1768
1769 # ── grep subcommand dispatch (muse#53 item 8) ─────────────────────────────
1770 if positional and positional[0] == "grep":
1771 run_grep(args)
1772 return
1773
1774 # ── hotspots subcommand dispatch (muse#53 item 1) ─────────────────────────
1775 if positional and positional[0] == "hotspots":
1776 run_hotspots(args)
1777 return
1778
1779 # ── coupling subcommand dispatch (muse#53 item 1) ─────────────────────────
1780 if positional and positional[0] == "coupling":
1781 run_coupling(args)
1782 return
1783
1784 file_path: str | None = getattr(args, "file_path", None)
1785 all_symbols: bool = getattr(args, "all_symbols", False)
1786 symbol: str | None = positional[0] if positional else None
1787 limit: int = clamp_int(getattr(args, "limit", 20), 1, 100_000, "limit")
1788 follow: bool = getattr(args, "follow", False)
1789 include_diff: bool = getattr(args, "include_diff", False)
1790 json_out: bool = getattr(args, "json_out", False)
1791 operation_filter: str | None = getattr(args, "operation_filter", None)
1792 author_filter: str | None = getattr(args, "author_filter", None)
1793 since_str: str | None = getattr(args, "since", None)
1794 until_str: str | None = getattr(args, "until", None)
1795
1796 # Parse dates early so bad values fail before repo access.
1797 since_dt: datetime.datetime | None = (
1798 _parse_date(since_str, "--since") if since_str else None
1799 )
1800 until_dt: datetime.datetime | None = (
1801 _parse_date(until_str, "--until") if until_str else None
1802 )
1803 if since_dt and until_dt and since_dt > until_dt:
1804 print("❌ --since must not be after --until.", file=sys.stderr)
1805 raise SystemExit(ExitCode.USER_ERROR)
1806
1807 repo_root = require_repo()
1808
1809 # ── --all mode ─────────────────────────────────────────────────────────────
1810
1811 if all_symbols:
1812 addrs = list_symlog_symbols(repo_root)
1813 if json_out:
1814 payload = _SymlogAllJson(
1815 **make_envelope(elapsed),
1816 symbols=addrs,
1817 count=len(addrs),
1818 )
1819 print(json.dumps(payload))
1820 else:
1821 if not addrs:
1822 print("No symlog entries found in this repository.")
1823 return
1824 print(f"Symbols with symlog entries ({len(addrs)}):")
1825 for addr in addrs:
1826 print(f" {sanitize_display(addr)}")
1827 return
1828
1829 # ── --file mode ────────────────────────────────────────────────────────────
1830
1831 if file_path is not None:
1832 addrs = list_symlog_symbols_for_file(repo_root, file_path)
1833 results: list[_SymlogResultJson] = []
1834 for addr in addrs:
1835 entries_raw, fsmap = read_symlog_with_follow(repo_root, addr, follow=follow, limit=limit * 10)
1836 filtered = _apply_filters(entries_raw, operation_filter, author_filter, since_dt, until_dt)
1837 displayed = filtered[:limit]
1838 json_entries = _entries_to_json(displayed, fsmap, include_diff, repo_root, symbol_addr=addr)
1839 results.append(_SymlogResultJson(
1840 **make_envelope(elapsed),
1841 symbol=addr,
1842 total=len(filtered),
1843 limit=limit,
1844 followed=follow,
1845 entries=json_entries,
1846 ))
1847 if json_out:
1848 payload = _SymlogFileJson(
1849 **make_envelope(elapsed),
1850 file=file_path,
1851 symbols=results,
1852 count=len(results),
1853 )
1854 print(json.dumps(payload))
1855 else:
1856 if not results:
1857 print(f"No symlog entries found for file: {sanitize_display(file_path)}")
1858 return
1859 for res in results:
1860 safe_sym = sanitize_display(res["symbol"])
1861 print(f"\n── {safe_sym} ({res['total']} entries) ──")
1862 for entry_json in res["entries"]:
1863 e = SymlogEntry(
1864 old_content_id=entry_json["old_content_id"],
1865 new_content_id=entry_json["new_content_id"],
1866 commit_id=entry_json["commit_id"],
1867 author=entry_json["author"],
1868 timestamp=datetime.datetime.fromisoformat(entry_json["timestamp"]),
1869 operation=entry_json["operation"],
1870 )
1871 print(_fmt_entry_text(entry_json["index"], e, entry_json.get("from_symbol")))
1872 return
1873
1874 # ── Single-symbol mode ────────────────────────────────────────────────────
1875
1876 if symbol is None:
1877 print(
1878 "❌ Specify a symbol address, --file FILE, or --all.\n"
1879 " Example: muse symlog 'src/billing.py::compute_total'",
1880 file=sys.stderr,
1881 )
1882 raise SystemExit(ExitCode.USER_ERROR)
1883
1884 _validate_symbol_addr(symbol)
1885
1886 # Read a generous cap so post-filter limit can be satisfied.
1887 raw_limit = min(limit * 50, 100_000)
1888 entries_raw, fsmap = read_symlog_with_follow(repo_root, symbol, follow=follow, limit=raw_limit)
1889
1890 filtered = _apply_filters(entries_raw, operation_filter, author_filter, since_dt, until_dt)
1891 displayed = filtered[:limit]
1892
1893 if json_out:
1894 json_entries = _entries_to_json(displayed, fsmap, include_diff, repo_root, symbol_addr=symbol)
1895 payload_result = _SymlogResultJson(
1896 **make_envelope(elapsed),
1897 symbol=symbol,
1898 total=len(filtered),
1899 limit=limit,
1900 followed=follow,
1901 entries=json_entries,
1902 )
1903 print(json.dumps(payload_result))
1904 return
1905
1906 safe_sym = sanitize_display(symbol)
1907 if not displayed:
1908 if filtered:
1909 print(f"No symlog entries for {safe_sym} match the active filters.")
1910 else:
1911 print(f"No symlog entries for {safe_sym}.")
1912 return
1913
1914 print(f"Symlog for {safe_sym} (newest first)\n")
1915 for idx, entry in enumerate(displayed):
1916 from_sym = fsmap.get(idx)
1917 print(_fmt_entry_text(idx, entry, from_sym))
1918 if include_diff:
1919 diff_text = _compute_diff(
1920 entry.old_content_id, entry.new_content_id, repo_root,
1921 symbol_addr=symbol, entry=entry,
1922 )
1923 if diff_text is not None:
1924 for line in diff_text.splitlines():
1925 print(f" {line}")
1926
1927 if len(filtered) > limit:
1928 remaining = len(filtered) - limit
1929 print(f"\n … {remaining} older entr{'y' if remaining == 1 else 'ies'} — increase --limit to see more.")
File History 1 commit
sha256:87e65c3a0f38438d20b4534feb442bd274da6a5a7f0f5f95e9c4e4dfefa86c42 feat: muse#53 item 2 — muse code blame prefers symlog journal Sonnet 5 patch 9 hours ago