gabriel / muse public
symlog.py python
1,297 lines 45.3 KB
Raw
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 2 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
22 Each text row shows::
23
24 @{N} <new_sha12> (<old_sha12>) <when> <author> <operation>
25
26 Null content IDs are rendered as ``"initial"`` (created) or ``"deleted"``
27 (terminal) so the column is always readable.
28
29 JSON schema (single-symbol query)::
30
31 {
32 "exit_code": 0,
33 "duration_ms": 1.2,
34 "symbol": "src/billing.py::compute_total",
35 "total": 12,
36 "limit": 20,
37 "followed": false,
38 "entries": [
39 {
40 "index": 0,
41 "old_content_id": "sha256:<64-hex>",
42 "new_content_id": "sha256:<64-hex>",
43 "commit_id": "sha256:<64-hex>",
44 "author": "claude-code",
45 "timestamp": "2026-06-28T14:22:11+00:00",
46 "operation": "symbol-modified: fix off-by-one in total",
47 "born_from": null,
48 "from_symbol": null,
49 "diff": null
50 }
51 ]
52 }
53
54 ``from_symbol`` is non-null on entries that came from a prior symbol
55 address via ``--follow``. ``diff`` is non-null only when ``--diff`` is
56 passed.
57
58 Exit codes::
59
60 0 Success (including empty log for ``exists``).
61 1 Invalid arguments / user error / not found.
62 2 Not inside a Muse repository.
63 """
64
65 from __future__ import annotations
66
67 import argparse
68 import datetime
69 import difflib
70 import json
71 import logging
72 import re
73 import sys
74 from typing import TypedDict
75
76 from muse.core.envelope import EnvelopeJson, make_envelope
77 from muse.core.errors import ExitCode
78 from muse.core.repo import require_repo
79 from muse.core.symlog import (
80 NULL_CONTENT_ID,
81 SymlogEntry,
82 SymlogResolution,
83 delete_symlog_entry,
84 expire_symlog,
85 is_null_content_id,
86 list_symlog_symbols,
87 list_symlog_symbols_for_file,
88 read_symlog,
89 resolve_symlog_addr,
90 resolve_symbol_body,
91 symlog_path,
92 )
93 from muse.core.timing import start_timer
94 from muse.core.types import long_id, short_id
95 from muse.core.validation import clamp_int, sanitize_display
96
97 logger = logging.getLogger(__name__)
98
99 # ---------------------------------------------------------------------------
100 # JSON TypedDicts
101 # ---------------------------------------------------------------------------
102
103
104 class _SymlogEntryJson(TypedDict):
105 index: int
106 old_content_id: str
107 new_content_id: str
108 commit_id: str
109 author: str
110 timestamp: str
111 operation: str
112 born_from: str | None
113 from_symbol: str | None
114 diff: str | None
115
116
117 class _SymlogResultJson(EnvelopeJson):
118 symbol: str
119 total: int
120 limit: int
121 followed: bool
122 entries: list[_SymlogEntryJson]
123
124
125 class _SymlogFileJson(EnvelopeJson):
126 file: str
127 symbols: list[_SymlogResultJson]
128 count: int
129
130
131 class _SymlogAllJson(EnvelopeJson):
132 symbols: list[str]
133 count: int
134
135
136 class _ExistsJson(EnvelopeJson):
137 exists: bool
138 count: int
139 symbol: str
140
141
142 class _ExpireJson(EnvelopeJson):
143 expired: int
144 kept: int
145 dry_run: bool
146 symbols_processed: list[str]
147
148
149 class _DeleteJson(EnvelopeJson):
150 deleted: int
151 remaining: int
152 symbol: str
153
154
155 class _ResolveJson(EnvelopeJson):
156 symbol: str
157 index: int
158 content_id: str
159 commit_id: str
160 operation: str
161 followed_from: str | None
162
163
164 class _DiffJson(EnvelopeJson):
165 left: str
166 right: str
167 symbol: str
168 diff_lines: list[str]
169 added: int
170 removed: int
171 context: int
172
173
174 # ---------------------------------------------------------------------------
175 # Helpers
176 # ---------------------------------------------------------------------------
177
178 _NULL_NEW_LABEL = "deleted"
179 _NULL_OLD_LABEL = "initial"
180
181
182 def _fmt_cid(cid: str, is_old: bool) -> str:
183 """Short-form content ID, or a human sentinel for null IDs."""
184 if is_null_content_id(cid):
185 return _NULL_OLD_LABEL if is_old else _NULL_NEW_LABEL
186 return short_id(cid)
187
188
189 def _fmt_entry_text(idx: int, entry: SymlogEntry, from_symbol: str | None = None) -> str:
190 """Format one symlog entry for terminal output."""
191 new_short = sanitize_display(_fmt_cid(entry.new_content_id, is_old=False))
192 old_short = sanitize_display(_fmt_cid(entry.old_content_id, is_old=True))
193 when = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
194 safe_op = sanitize_display(entry.operation)
195 safe_author = sanitize_display(entry.author or "")
196 author_col = f" {safe_author}" if safe_author else ""
197 line = f"@{{{idx}}} {new_short} ({old_short}) {when}{author_col} {safe_op}"
198 if from_symbol:
199 line += f" [from {sanitize_display(from_symbol)}]"
200 return line
201
202
203 def _parse_date(value: str, flag: str) -> datetime.datetime:
204 """Parse ``YYYY-MM-DD`` into a UTC-aware datetime; exits USER_ERROR on bad format."""
205 try:
206 d = datetime.date.fromisoformat(value)
207 except ValueError:
208 print(
209 f"❌ Invalid date for {flag}: {sanitize_display(value)!r} — "
210 "expected YYYY-MM-DD.",
211 file=sys.stderr,
212 )
213 raise SystemExit(ExitCode.USER_ERROR)
214 return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.timezone.utc)
215
216
217 def _validate_symbol_addr(addr: str) -> None:
218 """Validate symbol address format; exits USER_ERROR if invalid."""
219 if not addr:
220 print(
221 "❌ Symbol address is empty — expected form: 'path/to/file.py::symbol_name'.",
222 file=sys.stderr,
223 )
224 raise SystemExit(ExitCode.USER_ERROR)
225 if "::" not in addr:
226 print(
227 f"❌ Missing '::' separator in symbol address {sanitize_display(addr)!r}. "
228 "Expected form: 'path/to/file.py::symbol_name'.",
229 file=sys.stderr,
230 )
231 raise SystemExit(ExitCode.USER_ERROR)
232 _file, _, sym_name = addr.partition("::")
233 if not sym_name:
234 print(
235 f"❌ Empty symbol name in {sanitize_display(addr)!r}. "
236 "Expected form: 'path/to/file.py::symbol_name'.",
237 file=sys.stderr,
238 )
239 raise SystemExit(ExitCode.USER_ERROR)
240 if ".." in addr.split("::")[0].split("/"):
241 print(
242 f"❌ Path traversal detected in {sanitize_display(addr)!r}: "
243 "'..' components are not allowed.",
244 file=sys.stderr,
245 )
246 raise SystemExit(ExitCode.USER_ERROR)
247
248
249 def _compute_diff(
250 old_cid: str,
251 new_cid: str,
252 repo_root,
253 symbol_addr: str | None = None,
254 entry: "SymlogEntry | None" = None,
255 ) -> str | None:
256 """Produce a unified diff of the symbol body between the old and new commit.
257
258 Symbol content IDs are hashes computed by the AST parser and are not stored
259 as raw objects in the Muse object store, so we reconstruct each body by
260 reading the file from the commit's snapshot and extracting the symbol.
261 Returns ``None`` when either side is null (created/deleted) or on any error.
262 """
263 if is_null_content_id(old_cid) or is_null_content_id(new_cid):
264 return None
265 if symbol_addr is None or entry is None:
266 return None
267
268 file_path, _, symbol_name = symbol_addr.partition("::")
269 if not symbol_name:
270 return None
271
272 try:
273 from muse.core.commits import read_commit
274 from muse.core.object_store import read_object
275 from muse.core.snapshots import read_snapshot
276 from muse.plugins.code.ast_parser import parse_symbols
277
278 # Read the new commit and its snapshot.
279 new_record = read_commit(repo_root, entry.commit_id)
280 if new_record is None or not new_record.parent_commit_id:
281 return None
282 new_snap = read_snapshot(repo_root, new_record.snapshot_id)
283 if new_snap is None:
284 return None
285 new_file_oid = new_snap.manifest.get(file_path)
286 if new_file_oid is None:
287 return None
288 new_file_bytes = read_object(repo_root, new_file_oid)
289 if new_file_bytes is None:
290 return None
291
292 # Read the parent commit and its snapshot (the "old" state).
293 old_record = read_commit(repo_root, new_record.parent_commit_id)
294 if old_record is None:
295 return None
296 old_snap = read_snapshot(repo_root, old_record.snapshot_id)
297 if old_snap is None:
298 return None
299 old_file_oid = old_snap.manifest.get(file_path)
300 if old_file_oid is None:
301 return None
302 old_file_bytes = read_object(repo_root, old_file_oid)
303 if old_file_bytes is None:
304 return None
305
306 # Extract the symbol body from each file version using line numbers.
307 full_addr = f"{file_path}::{symbol_name}"
308 new_syms = parse_symbols(new_file_bytes, file_path)
309 old_syms = parse_symbols(old_file_bytes, file_path)
310
311 def _body_text(syms, raw_bytes: bytes) -> str:
312 rec = syms.get(full_addr)
313 if rec is None:
314 return ""
315 lines = raw_bytes.decode("utf-8", errors="replace").splitlines()
316 start = rec["lineno"] - 1 # lineno is 1-based, inclusive
317 end = rec["end_lineno"] # end_lineno is 1-based, inclusive → exclusive slice
318 return "\n".join(lines[start:end])
319
320 new_body = _body_text(new_syms, new_file_bytes)
321 old_body = _body_text(old_syms, old_file_bytes)
322
323 if old_body == new_body:
324 return ""
325 old_lines = old_body.splitlines(keepends=True)
326 new_lines = new_body.splitlines(keepends=True)
327 diff_lines = list(difflib.unified_diff(
328 old_lines, new_lines,
329 fromfile=f"a/{symbol_name}",
330 tofile=f"b/{symbol_name}",
331 lineterm="",
332 ))
333 return "\n".join(diff_lines) if diff_lines else ""
334 except Exception:
335 return None
336
337
338 def _apply_filters(
339 entries: list[SymlogEntry],
340 operation_filter: str | None,
341 author_filter: str | None,
342 since_dt: datetime.datetime | None,
343 until_dt: datetime.datetime | None,
344 ) -> list[SymlogEntry]:
345 """Apply all active filters to the entry list."""
346 filtered = entries
347
348 if operation_filter is not None:
349 needle = operation_filter.lower()
350 filtered = [e for e in filtered if needle in e.operation.lower()]
351
352 if author_filter is not None:
353 needle_a = author_filter.lower()
354 filtered = [e for e in filtered if needle_a in (e.author or "").lower()]
355
356 if since_dt is not None:
357 filtered = [e for e in filtered if e.timestamp >= since_dt]
358
359 if until_dt is not None:
360 until_end = until_dt + datetime.timedelta(days=1)
361 filtered = [e for e in filtered if e.timestamp < until_end]
362
363 return filtered
364
365
366 def _entries_to_json(
367 entries: list[SymlogEntry],
368 from_symbol_map: dict[int, str],
369 include_diff: bool,
370 repo_root,
371 symbol_addr: str | None = None,
372 ) -> list[_SymlogEntryJson]:
373 """Convert SymlogEntry list to the JSON schema."""
374 out: list[_SymlogEntryJson] = []
375 for idx, entry in enumerate(entries):
376 diff_text: str | None = None
377 if include_diff:
378 diff_text = _compute_diff(
379 entry.old_content_id, entry.new_content_id, repo_root,
380 symbol_addr=symbol_addr, entry=entry,
381 )
382 out.append(_SymlogEntryJson(
383 index=idx,
384 old_content_id=long_id(entry.old_content_id),
385 new_content_id=long_id(entry.new_content_id),
386 commit_id=long_id(entry.commit_id),
387 author=entry.author,
388 timestamp=entry.timestamp.isoformat(),
389 operation=entry.operation,
390 born_from=entry.born_from,
391 from_symbol=from_symbol_map.get(idx),
392 diff=diff_text,
393 ))
394 return out
395
396
397 def _read_with_follow(
398 repo_root,
399 symbol_addr: str,
400 follow: bool,
401 limit: int,
402 ) -> tuple[list[SymlogEntry], dict[int, str]]:
403 """Read symlog entries with optional follow, returning entries + from_symbol map.
404
405 When ``follow=True``, entries that came from a prior symbol address
406 (via a rename chain) are annotated in the ``from_symbol`` map
407 (index → prior_addr).
408 """
409 if not follow:
410 entries = read_symlog(repo_root, symbol_addr, limit=limit, follow=False)
411 return entries, {}
412
413 # Read with follow from the core — it appends prior entries.
414 # We need to identify which entries came from the prior symbol.
415 # Strategy: read without follow first, then read the prior symbol separately
416 # and mark those entries with their origin address.
417 own_entries = read_symlog(repo_root, symbol_addr, limit=limit * 10, follow=False)
418 from_symbol_map: dict[int, str] = {}
419
420 if own_entries:
421 oldest = own_entries[-1]
422 if oldest.born_from:
423 prior_addr = oldest.born_from
424 # Recursively read with follow to get the full chain.
425 prior_entries, prior_map = _read_with_follow(
426 repo_root, prior_addr, follow=True, limit=limit * 10
427 )
428 # Merge: own entries first (newer), then prior entries (older).
429 combined = own_entries + prior_entries
430 # Build from_symbol map: prior entries get from_symbol = prior_addr
431 for i in range(len(own_entries), len(combined)):
432 # The prior entry itself may come from an even earlier symbol;
433 # use the prior_map to get its annotation, falling back to prior_addr.
434 prior_idx = i - len(own_entries)
435 from_symbol_map[i] = prior_map.get(prior_idx, prior_addr)
436 return combined, from_symbol_map
437
438 return own_entries, {}
439
440
441 # ---------------------------------------------------------------------------
442 # Argument parser registration
443 # ---------------------------------------------------------------------------
444
445
446 def register(
447 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
448 ) -> None:
449 """Register the ``symlog`` subcommand tree."""
450 parser = subparsers.add_parser(
451 "symlog",
452 help="Show the live per-symbol journal written at commit time.",
453 description=__doc__,
454 formatter_class=argparse.RawDescriptionHelpFormatter,
455 )
456 parser.add_argument(
457 "positional", nargs="*", metavar="FILE::SYMBOL",
458 help=(
459 "Symbol address, e.g. 'src/billing.py::compute_total'. "
460 "Or 'exists FILE::SYMBOL' to run the existence check."
461 ),
462 )
463 parser.add_argument(
464 "--limit", type=int, default=20,
465 help="Maximum number of entries to show (after filters, default: 20).",
466 )
467 parser.add_argument(
468 "--follow", action="store_true", dest="follow",
469 help="Traverse rename chain — follow born-from pointers to prior symbol addresses.",
470 )
471 parser.add_argument(
472 "--diff", action="store_true", dest="include_diff",
473 help="Include unified body diff per entry (--json only for full content).",
474 )
475 parser.add_argument(
476 "--file", default=None, metavar="FILE_PATH", dest="file_path",
477 help="Show symlogs for all symbols in FILE_PATH.",
478 )
479 parser.add_argument(
480 "--all", action="store_true", dest="all_symbols",
481 help="List all symbols that have a symlog.",
482 )
483 parser.add_argument(
484 "--operation", default=None, metavar="PATTERN", dest="operation_filter",
485 help="Filter to entries whose operation contains PATTERN (case-insensitive).",
486 )
487 parser.add_argument(
488 "--author", default=None, metavar="PATTERN", dest="author_filter",
489 help="Filter to entries whose author contains PATTERN (case-insensitive).",
490 )
491 parser.add_argument(
492 "--since", default=None, metavar="YYYY-MM-DD", dest="since",
493 help="Show only entries on or after this date.",
494 )
495 parser.add_argument(
496 "--until", default=None, metavar="YYYY-MM-DD", dest="until",
497 help="Show only entries on or before this date.",
498 )
499 parser.add_argument(
500 "--expire-days", type=int, default=None, metavar="N", dest="expire_days",
501 help=(
502 "Prune entries older than N days. Defaults to the symlog.expire-days "
503 "config key, then 90. Used by ``expire`` only."
504 ),
505 )
506 parser.add_argument(
507 "--dry-run", "-n", action="store_true", dest="dry_run",
508 help="Report counts without writing any files. Used by ``expire`` only.",
509 )
510 parser.add_argument(
511 "--json", "-j", action="store_true", dest="json_out",
512 help="Emit machine-readable JSON instead of human text.",
513 )
514
515 parser.set_defaults(func=run)
516
517
518 # ---------------------------------------------------------------------------
519 # Constants
520 # ---------------------------------------------------------------------------
521
522 _AT_INDEX_RE = re.compile(r"^@\{(\d+)\}$")
523 _SYMLOG_REF_RE = re.compile(r"^(.+)@\{(\d+)\}$")
524 _DEFAULT_SYMLOG_EXPIRE_DAYS: int = 90
525
526 # ---------------------------------------------------------------------------
527 # exists subcommand
528 # ---------------------------------------------------------------------------
529
530
531 def run_exists(symbol: str, json_out: bool) -> None:
532 """Fast existence check — does this symbol have any symlog entries?
533
534 Exits 0 when entries are present, 1 when none.
535
536 JSON schema::
537
538 {
539 "exit_code": 0 or 1,
540 "duration_ms": 0.3,
541 "symbol": "src/billing.py::compute_total",
542 "exists": true,
543 "count": 7
544 }
545 """
546 elapsed = start_timer()
547
548 _validate_symbol_addr(symbol)
549
550 repo_root = require_repo()
551
552 entries = read_symlog(repo_root, symbol, limit=100_000, follow=False)
553 count = len(entries)
554 exists = count > 0
555
556 if json_out:
557 payload = _ExistsJson(
558 **make_envelope(elapsed, exit_code=0 if exists else 1),
559 exists=exists,
560 count=count,
561 symbol=symbol,
562 )
563 print(json.dumps(payload))
564 else:
565 safe_sym = sanitize_display(symbol)
566 if exists:
567 print(f"{safe_sym} has {count} symlog entr{'y' if count == 1 else 'ies'}.")
568 else:
569 print(f"No symlog entries for {safe_sym}.")
570
571 if not exists:
572 raise SystemExit(1)
573
574
575 # ---------------------------------------------------------------------------
576 # expire subcommand
577 # ---------------------------------------------------------------------------
578
579
580 def run_expire(args: argparse.Namespace) -> None:
581 """Prune entries older than --expire-days from one or more symbol journals.
582
583 Dispatches to single-symbol, --file, or --all mode depending on flags.
584
585 JSON schema::
586
587 {
588 "exit_code": 0,
589 "duration_ms": 3.1,
590 "expired": 34,
591 "kept": 18,
592 "dry_run": false,
593 "symbols_processed": ["src/billing.py::compute_total"]
594 }
595 """
596 elapsed = start_timer()
597
598 positional: list[str] = getattr(args, "positional", [])
599 file_path: str | None = getattr(args, "file_path", None)
600 all_symbols: bool = getattr(args, "all_symbols", False)
601 dry_run: bool = getattr(args, "dry_run", False)
602 json_out: bool = getattr(args, "json_out", False)
603 expire_days_arg: int | None = getattr(args, "expire_days", None)
604
605 # Resolve symbol address (everything in positional after "expire")
606 expire_positional = positional[1:]
607 symbol: str | None = expire_positional[0] if expire_positional else None
608
609 # Resolve TTL: explicit flag → config → default
610 if expire_days_arg is not None:
611 expire_days = expire_days_arg
612 else:
613 repo_root_early = require_repo()
614 from muse.cli.config import get_config_value as _gcv
615 _raw = _gcv("symlog.expire-days", repo_root_early)
616 try:
617 expire_days = int(_raw) if _raw is not None else _DEFAULT_SYMLOG_EXPIRE_DAYS
618 except (ValueError, TypeError):
619 expire_days = _DEFAULT_SYMLOG_EXPIRE_DAYS
620
621 repo_root = require_repo()
622
623 total_expired = 0
624 total_kept = 0
625 symbols_processed: list[str] = []
626
627 if symbol and not file_path and not all_symbols:
628 # Single-symbol mode
629 _validate_symbol_addr(symbol)
630 expired, kept = expire_symlog(repo_root, symbol, expire_days, dry_run=dry_run)
631 total_expired += expired
632 total_kept += kept
633 if expired > 0 or kept > 0:
634 symbols_processed.append(symbol)
635
636 elif file_path is not None:
637 # --file mode
638 addrs = list_symlog_symbols_for_file(repo_root, file_path)
639 for addr in addrs:
640 expired, kept = expire_symlog(repo_root, addr, expire_days, dry_run=dry_run)
641 total_expired += expired
642 total_kept += kept
643 symbols_processed.append(addr)
644
645 elif all_symbols:
646 # --all mode
647 addrs = list_symlog_symbols(repo_root)
648 for addr in addrs:
649 expired, kept = expire_symlog(repo_root, addr, expire_days, dry_run=dry_run)
650 total_expired += expired
651 total_kept += kept
652 symbols_processed.append(addr)
653
654 else:
655 print(
656 "❌ Specify a symbol address, --file FILE, or --all.\n"
657 " Example: muse symlog expire 'src/billing.py::compute_total' --expire-days 90",
658 file=sys.stderr,
659 )
660 raise SystemExit(ExitCode.USER_ERROR)
661
662 if json_out:
663 payload = _ExpireJson(
664 **make_envelope(elapsed),
665 expired=total_expired,
666 kept=total_kept,
667 dry_run=dry_run,
668 symbols_processed=symbols_processed,
669 )
670 print(json.dumps(payload))
671 else:
672 prefix = "[dry-run] " if dry_run else ""
673 action = "Would expire" if dry_run else "Expired"
674 print(
675 f"{prefix}{action} {total_expired} entr{'y' if total_expired == 1 else 'ies'}, "
676 f"kept {total_kept} across {len(symbols_processed)} symbol(s)."
677 )
678
679
680 # ---------------------------------------------------------------------------
681 # delete subcommand
682 # ---------------------------------------------------------------------------
683
684
685 def run_delete(args: argparse.Namespace) -> None:
686 """Remove one entry by index or all entries from a symbol journal.
687
688 JSON schema::
689
690 {
691 "exit_code": 0,
692 "duration_ms": 0.8,
693 "deleted": 1,
694 "remaining": 7,
695 "symbol": "src/billing.py::compute_total"
696 }
697 """
698 elapsed = start_timer()
699
700 positional: list[str] = getattr(args, "positional", [])
701 all_symbols: bool = getattr(args, "all_symbols", False)
702 json_out: bool = getattr(args, "json_out", False)
703
704 # positional: ["delete", SYMBOL, optional @{N}]
705 delete_positional = positional[1:] # everything after "delete"
706
707 if not delete_positional:
708 print(
709 "❌ 'delete' requires a symbol address.\n"
710 " Example: muse symlog delete 'src/billing.py::compute_total' @{0}\n"
711 " Example: muse symlog delete 'src/billing.py::compute_total' --all",
712 file=sys.stderr,
713 )
714 raise SystemExit(ExitCode.USER_ERROR)
715
716 symbol = delete_positional[0]
717 _validate_symbol_addr(symbol)
718
719 # Parse optional @{N} token
720 index: int | None = None
721 if len(delete_positional) >= 2:
722 token = delete_positional[1]
723 m = _AT_INDEX_RE.match(token)
724 if m is None:
725 print(
726 f"❌ Unrecognised index reference {sanitize_display(token)!r} — "
727 "expected @{N} where N is a non-negative integer.",
728 file=sys.stderr,
729 )
730 raise SystemExit(ExitCode.USER_ERROR)
731 index = int(m.group(1))
732
733 if index is None and not all_symbols:
734 print(
735 "❌ Specify @{N} to delete one entry or --all to delete all entries.\n"
736 " Example: muse symlog delete 'src/billing.py::compute_total' @{0}\n"
737 " Example: muse symlog delete 'src/billing.py::compute_total' --all",
738 file=sys.stderr,
739 )
740 raise SystemExit(ExitCode.USER_ERROR)
741
742 repo_root = require_repo()
743
744 try:
745 deleted, remaining = delete_symlog_entry(
746 repo_root, symbol, index if not all_symbols else None
747 )
748 except FileNotFoundError:
749 print(
750 f"❌ No symlog entries for {sanitize_display(symbol)!r}.",
751 file=sys.stderr,
752 )
753 raise SystemExit(ExitCode.USER_ERROR)
754 except IndexError as exc:
755 bad_index, total = exc.args[0], exc.args[1]
756 print(
757 f"❌ @{{{bad_index}}} is out of range — "
758 f"valid range: @{{0}} to @{{{total - 1}}} ({total} entr{'y' if total == 1 else 'ies'}).",
759 file=sys.stderr,
760 )
761 raise SystemExit(ExitCode.USER_ERROR)
762
763 if json_out:
764 payload = _DeleteJson(
765 **make_envelope(elapsed),
766 deleted=deleted,
767 remaining=remaining,
768 symbol=symbol,
769 )
770 print(json.dumps(payload))
771 else:
772 safe_sym = sanitize_display(symbol)
773 print(f"Deleted {deleted} entr{'y' if deleted == 1 else 'ies'} from {safe_sym} ({remaining} remaining).")
774
775
776 # ---------------------------------------------------------------------------
777 # resolve subcommand
778 # ---------------------------------------------------------------------------
779
780
781 def run_resolve(args: argparse.Namespace) -> None:
782 """Resolve a ``<addr>@{N}`` symlog reference to its content_id and commit_id.
783
784 JSON schema::
785
786 {
787 "exit_code": 0,
788 "duration_ms": 0.4,
789 "symbol": "src/billing.py::compute_total",
790 "index": 2,
791 "content_id": "sha256:<64-hex>",
792 "commit_id": "sha256:<64-hex>",
793 "operation": "symbol-modified: add rounding",
794 "timestamp": "2026-06-20T09:11:04+00:00",
795 "followed_from": null
796 }
797 """
798 elapsed = start_timer()
799
800 positional: list[str] = getattr(args, "positional", [])
801 follow: bool = getattr(args, "follow", False)
802 json_out: bool = getattr(args, "json_out", False)
803
804 # positional: ["resolve", SPEC]
805 resolve_positional = positional[1:]
806
807 if not resolve_positional:
808 print(
809 "❌ 'resolve' requires a symbol ref: muse symlog resolve 'file.py::Sym@{N}' [--follow]",
810 file=sys.stderr,
811 )
812 raise SystemExit(ExitCode.USER_ERROR)
813
814 spec = resolve_positional[0]
815
816 # Validate the spec matches addr@{N} form.
817 m = _SYMLOG_REF_RE.match(spec)
818 if m is None:
819 print(
820 f"❌ {sanitize_display(spec)!r} is not a valid symlog ref — "
821 "expected form: 'file.py::Symbol@{N}'.",
822 file=sys.stderr,
823 )
824 raise SystemExit(ExitCode.USER_ERROR)
825
826 repo_root = require_repo()
827
828 try:
829 resolution = resolve_symlog_addr(spec, repo_root, follow=follow)
830 except FileNotFoundError:
831 addr = m.group(1)
832 print(
833 f"❌ No symlog entries for {sanitize_display(addr)!r}.",
834 file=sys.stderr,
835 )
836 raise SystemExit(ExitCode.USER_ERROR)
837 except IndexError as exc:
838 bad_index, total = exc.args[0], exc.args[1]
839 print(
840 f"❌ @{{{bad_index}}} is out of range — "
841 f"valid range: @{{0}} to @{{{max(0, total - 1)}}} "
842 f"({total} entr{'y' if total == 1 else 'ies'}).",
843 file=sys.stderr,
844 )
845 raise SystemExit(ExitCode.USER_ERROR)
846 except ValueError as exc:
847 print(f"❌ Invalid symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
848 raise SystemExit(ExitCode.USER_ERROR)
849
850 # resolution cannot be None here since _SYMLOG_REF_RE matched.
851 assert resolution is not None
852
853 if json_out:
854 payload = _ResolveJson(
855 **make_envelope(elapsed),
856 symbol=resolution.symbol,
857 index=resolution.index,
858 content_id=resolution.content_id,
859 commit_id=resolution.commit_id,
860 operation=resolution.operation,
861 followed_from=resolution.followed_from,
862 )
863 # Overwrite the envelope's wall-clock timestamp with the symlog
864 # entry's own recorded timestamp so callers see when the change
865 # happened, not when this command ran.
866 payload["timestamp"] = resolution.timestamp.isoformat()
867 print(json.dumps(payload))
868 else:
869 safe_sym = sanitize_display(resolution.symbol)
870 safe_op = sanitize_display(resolution.operation)
871 when = resolution.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
872 print(f"{safe_sym}@{{{resolution.index}}}")
873 print(f" content_id: {sanitize_display(resolution.content_id)}")
874 print(f" commit_id: {sanitize_display(resolution.commit_id)}")
875 print(f" operation: {safe_op}")
876 print(f" timestamp: {when}")
877 if resolution.followed_from:
878 print(f" followed_from: {sanitize_display(resolution.followed_from)}")
879
880
881 # ---------------------------------------------------------------------------
882 # diff subcommand
883 # ---------------------------------------------------------------------------
884
885
886 def _get_symbol_body_from_workdir(
887 repo_root: "pathlib.Path",
888 symbol_addr: str,
889 ) -> "str | None":
890 """Return the current working-tree body for *symbol_addr*, or None on failure."""
891 if "::" not in symbol_addr:
892 return None
893 file_path, _, symbol_name = symbol_addr.partition("::")
894 if not symbol_name:
895 return None
896
897 try:
898 import pathlib as _pathlib
899 from muse.plugins.code.ast_parser import adapter_for_path
900
901 disk_path = repo_root / file_path
902 if not disk_path.is_file():
903 return None
904 raw = disk_path.read_bytes()
905 adapter = adapter_for_path(file_path)
906 tree = adapter.parse_symbols(raw, file_path)
907
908 found = next(
909 (r for r in tree.values() if r["qualified_name"] == symbol_name),
910 None,
911 )
912 if found is None:
913 found = next(
914 (r for r in tree.values()
915 if r["name"] == symbol_name and r["kind"] != "import"),
916 None,
917 )
918 if found is None:
919 return None
920
921 lineno = found["lineno"]
922 end_lineno = found["end_lineno"]
923 text = raw.decode("utf-8", errors="replace")
924 lines = text.splitlines()
925 return "\n".join(lines[lineno - 1:end_lineno])
926 except Exception:
927 return None
928
929
930 def run_diff(args: argparse.Namespace) -> None:
931 """Show the unified diff between two symlog-indexed symbol versions.
932
933 JSON schema::
934
935 {
936 "exit_code": 0,
937 "duration_ms": 0.8,
938 "left": "billing.py::compute_total@{1}",
939 "right": "billing.py::compute_total@{0}",
940 "symbol": "billing.py::compute_total",
941 "diff_lines": ["-old line", "+new line", " context"],
942 "added": 1,
943 "removed": 1,
944 "context": 5
945 }
946 """
947 elapsed = start_timer()
948
949 positional: list[str] = getattr(args, "positional", [])
950 follow: bool = getattr(args, "follow", False)
951 json_out: bool = getattr(args, "json_out", False)
952
953 # positional: ["diff", LEFT_SPEC, RIGHT_SPEC]
954 diff_positional = positional[1:]
955
956 if len(diff_positional) < 2:
957 print(
958 "❌ 'diff' requires two specs:\n"
959 " muse symlog diff 'file.py::Sym@{1}' 'file.py::Sym@{0}'\n"
960 " muse symlog diff 'file.py::Sym@{2}' HEAD",
961 file=sys.stderr,
962 )
963 raise SystemExit(ExitCode.USER_ERROR)
964
965 left_spec = diff_positional[0]
966 right_spec = diff_positional[1]
967
968 # Parse left spec — must be addr@{N}.
969 left_m = _SYMLOG_REF_RE.match(left_spec)
970 if left_m is None:
971 print(
972 f"❌ Left spec {sanitize_display(left_spec)!r} must be 'addr@{{N}}'.",
973 file=sys.stderr,
974 )
975 raise SystemExit(ExitCode.USER_ERROR)
976
977 left_addr = left_m.group(1)
978 right_from_head = right_spec.upper() == "HEAD"
979
980 # Parse right spec — addr@{M} or HEAD.
981 if not right_from_head:
982 right_m = _SYMLOG_REF_RE.match(right_spec)
983 if right_m is None:
984 print(
985 f"❌ Right spec {sanitize_display(right_spec)!r} must be 'addr@{{N}}' or HEAD.",
986 file=sys.stderr,
987 )
988 raise SystemExit(ExitCode.USER_ERROR)
989 right_addr = right_m.group(1)
990 else:
991 right_addr = left_addr
992
993 repo_root = require_repo()
994
995 # Resolve left body from symlog.
996 try:
997 left_res = resolve_symlog_addr(left_spec, repo_root, follow=follow)
998 except FileNotFoundError:
999 print(
1000 f"❌ No symlog entries for {sanitize_display(left_addr)!r}.",
1001 file=sys.stderr,
1002 )
1003 raise SystemExit(ExitCode.USER_ERROR)
1004 except IndexError as exc:
1005 bad_index, total = exc.args[0], exc.args[1]
1006 print(
1007 f"❌ Left @{{{bad_index}}} is out of range — "
1008 f"valid range: @{{0}} to @{{{max(0, total - 1)}}}.",
1009 file=sys.stderr,
1010 )
1011 raise SystemExit(ExitCode.USER_ERROR)
1012 except ValueError as exc:
1013 print(f"❌ Invalid left symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
1014 raise SystemExit(ExitCode.USER_ERROR)
1015
1016 assert left_res is not None
1017
1018 left_body_info = resolve_symbol_body(repo_root, left_addr, left_res.commit_id)
1019 if left_body_info is None:
1020 print(
1021 f"❌ Could not reconstruct body for {sanitize_display(left_addr)!r} "
1022 f"at commit {sanitize_display(left_res.commit_id[:8])}.",
1023 file=sys.stderr,
1024 )
1025 raise SystemExit(ExitCode.USER_ERROR)
1026 left_body = left_body_info["source"]
1027
1028 # Resolve right body.
1029 if right_from_head:
1030 right_body = _get_symbol_body_from_workdir(repo_root, right_addr)
1031 if right_body is None:
1032 print(
1033 f"❌ Could not read working-tree body for {sanitize_display(right_addr)!r}.",
1034 file=sys.stderr,
1035 )
1036 raise SystemExit(ExitCode.USER_ERROR)
1037 else:
1038 try:
1039 right_res = resolve_symlog_addr(right_spec, repo_root, follow=follow)
1040 except FileNotFoundError:
1041 print(
1042 f"❌ No symlog entries for {sanitize_display(right_addr)!r}.",
1043 file=sys.stderr,
1044 )
1045 raise SystemExit(ExitCode.USER_ERROR)
1046 except IndexError as exc:
1047 bad_index, total = exc.args[0], exc.args[1]
1048 print(
1049 f"❌ Right @{{{bad_index}}} is out of range — "
1050 f"valid range: @{{0}} to @{{{max(0, total - 1)}}}.",
1051 file=sys.stderr,
1052 )
1053 raise SystemExit(ExitCode.USER_ERROR)
1054 except ValueError as exc:
1055 print(f"❌ Invalid right symbol address: {sanitize_display(str(exc))}", file=sys.stderr)
1056 raise SystemExit(ExitCode.USER_ERROR)
1057
1058 assert right_res is not None
1059 right_body_info = resolve_symbol_body(repo_root, right_addr, right_res.commit_id)
1060 if right_body_info is None:
1061 print(
1062 f"❌ Could not reconstruct body for {sanitize_display(right_addr)!r}.",
1063 file=sys.stderr,
1064 )
1065 raise SystemExit(ExitCode.USER_ERROR)
1066 right_body = right_body_info["source"]
1067
1068 # Compute unified diff.
1069 symbol_name = left_addr.split("::")[-1] if "::" in left_addr else left_addr
1070 left_lines = left_body.splitlines(keepends=True)
1071 right_lines = right_body.splitlines(keepends=True)
1072 raw_diff = list(difflib.unified_diff(
1073 left_lines, right_lines,
1074 fromfile=f"a/{symbol_name}",
1075 tofile=f"b/{symbol_name}",
1076 lineterm="",
1077 ))
1078
1079 # Count added/removed/context lines (skip --- and +++ header lines).
1080 added = sum(1 for ln in raw_diff if ln.startswith("+") and not ln.startswith("+++"))
1081 removed = sum(1 for ln in raw_diff if ln.startswith("-") and not ln.startswith("---"))
1082 context = sum(1 for ln in raw_diff if ln.startswith(" "))
1083
1084 if json_out:
1085 payload = _DiffJson(
1086 **make_envelope(elapsed),
1087 left=left_spec,
1088 right=right_spec,
1089 symbol=left_addr,
1090 diff_lines=raw_diff,
1091 added=added,
1092 removed=removed,
1093 context=context,
1094 )
1095 print(json.dumps(payload))
1096 else:
1097 if not raw_diff:
1098 print(f"No differences between {sanitize_display(left_spec)} and {sanitize_display(right_spec)}.")
1099 else:
1100 for line in raw_diff:
1101 print(line)
1102
1103
1104 # ---------------------------------------------------------------------------
1105 # Command entry point
1106 # ---------------------------------------------------------------------------
1107
1108
1109 def run(args: argparse.Namespace) -> None:
1110 """Show the live per-symbol journal.
1111
1112 Dispatches to ``exists``, ``--file``, ``--all``, or single-symbol mode
1113 depending on the flags provided.
1114 """
1115 elapsed = start_timer()
1116
1117 positional: list[str] = getattr(args, "positional", [])
1118 json_out_top: bool = getattr(args, "json_out", False)
1119
1120 # ── resolve subcommand dispatch ───────────────────────────────────────────
1121 if positional and positional[0] == "resolve":
1122 run_resolve(args)
1123 return
1124
1125 # ── diff subcommand dispatch ──────────────────────────────────────────────
1126 if positional and positional[0] == "diff":
1127 run_diff(args)
1128 return
1129
1130 # ── expire subcommand dispatch ────────────────────────────────────────────
1131 if positional and positional[0] == "expire":
1132 run_expire(args)
1133 return
1134
1135 # ── delete subcommand dispatch ────────────────────────────────────────────
1136 if positional and positional[0] == "delete":
1137 run_delete(args)
1138 return
1139
1140 # ── exists subcommand dispatch ────────────────────────────────────────────
1141 if positional and positional[0] == "exists":
1142 if len(positional) < 2:
1143 print(
1144 "❌ 'exists' requires a symbol address.\n"
1145 " Example: muse symlog exists 'src/billing.py::compute_total'",
1146 file=sys.stderr,
1147 )
1148 raise SystemExit(ExitCode.USER_ERROR)
1149 run_exists(positional[1], json_out_top)
1150 return
1151
1152 file_path: str | None = getattr(args, "file_path", None)
1153 all_symbols: bool = getattr(args, "all_symbols", False)
1154 symbol: str | None = positional[0] if positional else None
1155 limit: int = clamp_int(getattr(args, "limit", 20), 1, 100_000, "limit")
1156 follow: bool = getattr(args, "follow", False)
1157 include_diff: bool = getattr(args, "include_diff", False)
1158 json_out: bool = getattr(args, "json_out", False)
1159 operation_filter: str | None = getattr(args, "operation_filter", None)
1160 author_filter: str | None = getattr(args, "author_filter", None)
1161 since_str: str | None = getattr(args, "since", None)
1162 until_str: str | None = getattr(args, "until", None)
1163
1164 # Parse dates early so bad values fail before repo access.
1165 since_dt: datetime.datetime | None = (
1166 _parse_date(since_str, "--since") if since_str else None
1167 )
1168 until_dt: datetime.datetime | None = (
1169 _parse_date(until_str, "--until") if until_str else None
1170 )
1171 if since_dt and until_dt and since_dt > until_dt:
1172 print("❌ --since must not be after --until.", file=sys.stderr)
1173 raise SystemExit(ExitCode.USER_ERROR)
1174
1175 repo_root = require_repo()
1176
1177 # ── --all mode ─────────────────────────────────────────────────────────────
1178
1179 if all_symbols:
1180 addrs = list_symlog_symbols(repo_root)
1181 if json_out:
1182 payload = _SymlogAllJson(
1183 **make_envelope(elapsed),
1184 symbols=addrs,
1185 count=len(addrs),
1186 )
1187 print(json.dumps(payload))
1188 else:
1189 if not addrs:
1190 print("No symlog entries found in this repository.")
1191 return
1192 print(f"Symbols with symlog entries ({len(addrs)}):")
1193 for addr in addrs:
1194 print(f" {sanitize_display(addr)}")
1195 return
1196
1197 # ── --file mode ────────────────────────────────────────────────────────────
1198
1199 if file_path is not None:
1200 addrs = list_symlog_symbols_for_file(repo_root, file_path)
1201 results: list[_SymlogResultJson] = []
1202 for addr in addrs:
1203 entries_raw, fsmap = _read_with_follow(repo_root, addr, follow=follow, limit=limit * 10)
1204 filtered = _apply_filters(entries_raw, operation_filter, author_filter, since_dt, until_dt)
1205 displayed = filtered[:limit]
1206 json_entries = _entries_to_json(displayed, fsmap, include_diff, repo_root, symbol_addr=addr)
1207 results.append(_SymlogResultJson(
1208 **make_envelope(elapsed),
1209 symbol=addr,
1210 total=len(filtered),
1211 limit=limit,
1212 followed=follow,
1213 entries=json_entries,
1214 ))
1215 if json_out:
1216 payload = _SymlogFileJson(
1217 **make_envelope(elapsed),
1218 file=file_path,
1219 symbols=results,
1220 count=len(results),
1221 )
1222 print(json.dumps(payload))
1223 else:
1224 if not results:
1225 print(f"No symlog entries found for file: {sanitize_display(file_path)}")
1226 return
1227 for res in results:
1228 safe_sym = sanitize_display(res["symbol"])
1229 print(f"\n── {safe_sym} ({res['total']} entries) ──")
1230 for entry_json in res["entries"]:
1231 e = SymlogEntry(
1232 old_content_id=entry_json["old_content_id"],
1233 new_content_id=entry_json["new_content_id"],
1234 commit_id=entry_json["commit_id"],
1235 author=entry_json["author"],
1236 timestamp=datetime.datetime.fromisoformat(entry_json["timestamp"]),
1237 operation=entry_json["operation"],
1238 )
1239 print(_fmt_entry_text(entry_json["index"], e, entry_json.get("from_symbol")))
1240 return
1241
1242 # ── Single-symbol mode ────────────────────────────────────────────────────
1243
1244 if symbol is None:
1245 print(
1246 "❌ Specify a symbol address, --file FILE, or --all.\n"
1247 " Example: muse symlog 'src/billing.py::compute_total'",
1248 file=sys.stderr,
1249 )
1250 raise SystemExit(ExitCode.USER_ERROR)
1251
1252 _validate_symbol_addr(symbol)
1253
1254 # Read a generous cap so post-filter limit can be satisfied.
1255 raw_limit = min(limit * 50, 100_000)
1256 entries_raw, fsmap = _read_with_follow(repo_root, symbol, follow=follow, limit=raw_limit)
1257
1258 filtered = _apply_filters(entries_raw, operation_filter, author_filter, since_dt, until_dt)
1259 displayed = filtered[:limit]
1260
1261 if json_out:
1262 json_entries = _entries_to_json(displayed, fsmap, include_diff, repo_root, symbol_addr=symbol)
1263 payload_result = _SymlogResultJson(
1264 **make_envelope(elapsed),
1265 symbol=symbol,
1266 total=len(filtered),
1267 limit=limit,
1268 followed=follow,
1269 entries=json_entries,
1270 )
1271 print(json.dumps(payload_result))
1272 return
1273
1274 safe_sym = sanitize_display(symbol)
1275 if not displayed:
1276 if filtered:
1277 print(f"No symlog entries for {safe_sym} match the active filters.")
1278 else:
1279 print(f"No symlog entries for {safe_sym}.")
1280 return
1281
1282 print(f"Symlog for {safe_sym} (newest first)\n")
1283 for idx, entry in enumerate(displayed):
1284 from_sym = fsmap.get(idx)
1285 print(_fmt_entry_text(idx, entry, from_sym))
1286 if include_diff:
1287 diff_text = _compute_diff(
1288 entry.old_content_id, entry.new_content_id, repo_root,
1289 symbol_addr=symbol, entry=entry,
1290 )
1291 if diff_text is not None:
1292 for line in diff_text.splitlines():
1293 print(f" {line}")
1294
1295 if len(filtered) > limit:
1296 remaining = len(filtered) - limit
1297 print(f"\n … {remaining} older entr{'y' if remaining == 1 else 'ies'} — increase --limit to see more.")
File History 1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 2 hours ago