gabriel / muse public
symlog.py python
1,281 lines 45.5 KB
Raw
sha256:986277e88b9de700cfb77ed3385e7b344df08fbb801c9615906da7df9c45c91d feat: muse#53 item 12 — muse symlog prune-orphans Sonnet 5 patch 1 day ago
1 """Symlog — live per-symbol journal written at commit time.
2
3 Every commit that changes a symbol's content ID appends an entry to that
4 symbol's journal. Journals are stored in a directory tree that mirrors the
5 source tree::
6
7 .muse/symlogs/
8 src/
9 billing.py/
10 compute_total ← one file per symbol
11 validate_invoice
12 tests/
13 test_billing.py/
14 test_compute_total
15
16 This layout gives O(1) per-symbol reads (no commit-history scan), O(1) per-file
17 symbol enumeration (one ``os.listdir``), and independent per-symbol GC.
18
19 Entry format
20 ------------
21 Each journal file is an append-only plain-text sequence of lines::
22
23 <old_content_id> <new_content_id> <commit_id> <author> <ts_unix> <tz_offset> TAB <operation>
24
25 Fields:
26
27 old_content_id sha256:<64-hex> — symbol content ID before the change, or
28 NULL_CONTENT_ID for created symbols.
29 new_content_id sha256:<64-hex> — symbol content ID after the change, or
30 NULL_CONTENT_ID for deleted/renamed-away symbols.
31 commit_id sha256:<64-hex> — commit that caused this entry.
32 author sanitized author or agent_id string.
33 ts_unix Unix seconds as a decimal integer (UTC).
34 tz_offset UTC offset in +HHMM / -HHMM form (always +0000 today).
35 operation Free-form description of the lifecycle event.
36
37 Lifecycle sentinels
38 -------------------
39 symbol-created: <name>
40 old_content_id == NULL_CONTENT_ID.
41
42 symbol-modified: <commit message first line>
43 Both content IDs are populated.
44
45 symbol-deleted: <name>
46 new_content_id == NULL_CONTENT_ID.
47
48 symbol-renamed-to: <new_addr>
49 Terminal entry written to the OLD symbol path. new_content_id == NULL_CONTENT_ID.
50
51 symbol-born-from: <old_addr>
52 Born-from entry written to the NEW symbol path. old_content_id == NULL_CONTENT_ID.
53 The ``born_from`` field of the parsed entry is set to <old_addr>.
54
55 Path encoding
56 -------------
57 The symbol name (part after ``::``) is percent-encoded for any character
58 outside ``[a-zA-Z0-9._-]``. The file path portion is used as-is (it must
59 already be a valid relative path). ``..`` components in either portion raise
60 ``ValueError`` before any filesystem access.
61
62 Security model
63 --------------
64 ``author`` and ``operation`` are sanitized before being written to disk:
65 newlines are stripped to prevent line injection; tabs are stripped from
66 ``author`` to prevent corruption of the tab-delimited parse boundary.
67
68 ``list_symlog_symbols`` and ``list_symlog_symbols_for_file`` skip symlinks —
69 a crafted symlink inside ``.muse/symlogs/`` cannot be followed to enumerate
70 arbitrary filesystem paths.
71
72 ``read_symlog`` enforces a ``_MAX_SYMLOG_BYTES`` file-size cap before reading
73 to prevent OOM from excessively large or maliciously crafted journal files.
74 """
75
76 from __future__ import annotations
77
78 import datetime
79 import hashlib
80 import json
81 import logging
82 import os
83 import pathlib
84 import re
85 import tempfile
86 import urllib.parse
87 from dataclasses import dataclass, field as _field
88
89 from muse.core.paths import symlogs_dir as _symlogs_dir
90 from muse.core.types import DEFAULT_HASH_ALGO
91
92 logger = logging.getLogger(__name__)
93
94 # ---------------------------------------------------------------------------
95 # Constants
96 # ---------------------------------------------------------------------------
97
98 #: Null content ID — used as old_content_id for created symbols and
99 #: new_content_id for deleted / renamed-away symbols.
100 NULL_CONTENT_ID: str = f"{DEFAULT_HASH_ALGO}:" + "0" * 64
101
102 #: Maximum symlog file size that will be read. Files larger than this cap
103 #: are warned about rather than loaded entirely into memory.
104 _MAX_SYMLOG_BYTES: int = 10 * 1024 * 1024 # 10 MiB
105
106 #: Characters in the symbol name that do NOT need percent-encoding.
107 _SAFE_CHARS: str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
108
109 #: Leaf filenames at or under this many UTF-8 bytes are always safe on every
110 #: filesystem this tool supports (POSIX's 255-byte NAME_MAX, with headroom
111 #: for the ".lock" suffix atomic writes append). Percent-encoded names longer
112 #: than this fall back to a hashed leaf name instead of raising OSError.
113 _MAX_LEAF_BYTES: int = 200
114
115 #: Marker prefix for hashed fallback leaf names. Percent-encoding (see
116 #: ``_encode_symbol_name``) only ever emits characters from ``_SAFE_CHARS``
117 #: or a literal ``%``, so ``!`` can never appear in a normally-encoded leaf —
118 #: making it an unambiguous signal that the leaf name must be resolved via
119 #: the sidecar index rather than percent-decoded directly.
120 _HASHED_LEAF_PREFIX: str = "!"
121
122 #: Sidecar index file, one per symlog directory, mapping hashed leaf names
123 #: back to their original (pre-hash) symbol name. Only written/consulted for
124 #: leaves that were too long to encode directly; normal leaves never touch it.
125 _LEAF_INDEX_FILENAME: str = ".leaf_index.json"
126
127 #: Regex that identifies a ``symbol-born-from:`` operation and captures the prior address.
128 _BORN_FROM_RE = re.compile(r"^symbol-born-from:\s*(.+)$")
129
130
131 # ---------------------------------------------------------------------------
132 # Helpers
133 # ---------------------------------------------------------------------------
134
135 def is_null_content_id(content_id: str) -> bool:
136 """Return ``True`` iff *content_id* is the canonical null content ID."""
137 return content_id == NULL_CONTENT_ID
138
139
140 def _encode_symbol_name(name: str) -> str:
141 """Percent-encode characters outside ``[a-zA-Z0-9._-]`` in *name*."""
142 return urllib.parse.quote(name, safe=_SAFE_CHARS)
143
144
145 def _decode_symbol_name(encoded: str) -> str:
146 """Reverse percent-encoding applied by ``_encode_symbol_name``."""
147 return urllib.parse.unquote(encoded)
148
149
150 def _hashed_leaf_name(symbol_name: str) -> str:
151 """Return a short, collision-resistant leaf name for a too-long symbol name."""
152 digest = hashlib.sha256(symbol_name.encode("utf-8")).hexdigest()
153 return f"{_HASHED_LEAF_PREFIX}{digest[:48]}"
154
155
156 def _leaf_index_path(leaf: pathlib.Path) -> pathlib.Path:
157 """Return the sidecar index path for the symlog directory containing *leaf*."""
158 return leaf.parent / _LEAF_INDEX_FILENAME
159
160
161 def _record_hashed_leaf(leaf: pathlib.Path, symbol_name: str) -> None:
162 """Record *symbol_name* under *leaf*'s hashed name in the sidecar index.
163
164 Idempotent and atomic (write-to-temp + ``os.replace``) — safe to call on
165 every append, not just the first.
166 """
167 index_path = _leaf_index_path(leaf)
168 try:
169 data = (
170 json.loads(index_path.read_text(encoding="utf-8"))
171 if index_path.exists()
172 else {}
173 )
174 except (OSError, ValueError):
175 data = {}
176 if data.get(leaf.name) == symbol_name:
177 return
178 data[leaf.name] = symbol_name
179 tmp_path = index_path.with_name(index_path.name + ".tmp")
180 tmp_path.write_text(json.dumps(data, sort_keys=True), encoding="utf-8")
181 os.replace(tmp_path, index_path)
182
183
184 def _resolve_hashed_leaf(leaf: pathlib.Path) -> str | None:
185 """Look up the original symbol name for a hashed *leaf* via the sidecar index."""
186 index_path = _leaf_index_path(leaf)
187 if not index_path.exists():
188 return None
189 try:
190 data = json.loads(index_path.read_text(encoding="utf-8"))
191 except (OSError, ValueError):
192 return None
193 value = data.get(leaf.name)
194 return value if isinstance(value, str) else None
195
196
197 def _reject_traversal(part: str, label: str) -> None:
198 """Raise ``ValueError`` when *part* contains a ``..`` path component."""
199 segments = pathlib.PurePosixPath(part).parts
200 if ".." in segments:
201 raise ValueError(
202 f"Path traversal detected in {label!r}: '..' components are not allowed."
203 )
204
205
206 def _sanitize_author(s: str) -> str:
207 """Strip characters that would corrupt the symlog line structure."""
208 return s.replace("\n", "").replace("\r", "").replace("\t", "")
209
210
211 def _sanitize_operation(s: str) -> str:
212 """Strip newlines to prevent line injection in the operation field."""
213 return s.replace("\n", "").replace("\r", "")
214
215
216 # ---------------------------------------------------------------------------
217 # Types
218 # ---------------------------------------------------------------------------
219
220 @dataclass(frozen=True)
221 class SymlogEntry:
222 """One line of a symbol journal.
223
224 The ``born_from`` field is non-None only when the operation string starts
225 with ``symbol-born-from:``; it is always derived from ``operation`` at
226 construction time and is never passed by callers. This ensures the two
227 fields are always consistent.
228 """
229
230 old_content_id: str # sha256:… or NULL_CONTENT_ID
231 new_content_id: str # sha256:… or NULL_CONTENT_ID
232 commit_id: str # sha256:… of the commit that caused this entry
233 author: str # sanitized author / agent_id
234 timestamp: datetime.datetime
235 operation: str # "symbol-created: …", "symbol-modified: …", etc.
236 born_from: str | None = _field(default=None, init=False)
237
238 def __post_init__(self) -> None:
239 m = _BORN_FROM_RE.match(self.operation)
240 object.__setattr__(self, "born_from", m.group(1).strip() if m else None)
241
242
243 def _make_entry(
244 old_content_id: str,
245 new_content_id: str,
246 commit_id: str,
247 author: str,
248 timestamp: datetime.datetime,
249 operation: str,
250 ) -> SymlogEntry:
251 """Construct a ``SymlogEntry`` from parsed line components."""
252 return SymlogEntry(
253 old_content_id=old_content_id,
254 new_content_id=new_content_id,
255 commit_id=commit_id,
256 author=author,
257 timestamp=timestamp,
258 operation=operation,
259 )
260
261
262 # ---------------------------------------------------------------------------
263 # Path helpers
264 # ---------------------------------------------------------------------------
265
266 def symlog_path(repo_root: pathlib.Path, symbol_addr: str) -> pathlib.Path:
267 """Return the journal file path for *symbol_addr*.
268
269 *symbol_addr* must contain exactly one ``::`` separator, e.g.
270 ``src/billing.py::compute_total``. The file path portion maps to a
271 directory under ``.muse/symlogs/``; the symbol name portion becomes the
272 leaf filename (percent-encoded for non-ASCII-safe characters).
273
274 Raises:
275 ValueError: ``::`` separator is missing, symbol name is empty,
276 or a ``..`` path component is detected.
277 """
278 if "::" not in symbol_addr:
279 raise ValueError(
280 f"Missing '::' separator in symbol address {symbol_addr!r}. "
281 "Expected form: 'path/to/file.py::symbol_name'."
282 )
283 file_path, _, symbol_name = symbol_addr.partition("::")
284 if not symbol_name:
285 raise ValueError(
286 f"Empty symbol name in symbol address {symbol_addr!r}."
287 )
288 # Reject path traversal in both parts.
289 _reject_traversal(file_path, "file_path")
290 _reject_traversal(symbol_name, "symbol_name")
291 encoded_name = _encode_symbol_name(symbol_name)
292 if len(encoded_name.encode("utf-8")) > _MAX_LEAF_BYTES:
293 encoded_name = _hashed_leaf_name(symbol_name)
294 return _symlogs_dir(repo_root) / file_path / encoded_name
295
296
297 def _addr_from_path(symlogs_root: pathlib.Path, leaf: pathlib.Path) -> str:
298 """Reconstruct a symbol address from a leaf path under *symlogs_root*.
299
300 Raises:
301 ValueError: *leaf* is a hashed fallback name with no sidecar index
302 entry (index missing or corrupt).
303 """
304 rel = leaf.relative_to(symlogs_root)
305 parts = rel.parts
306 # All parts up to the last are the file path; the last is the encoded symbol name.
307 file_path = str(pathlib.PurePosixPath(*parts[:-1]))
308 leaf_name = parts[-1]
309 if leaf_name.startswith(_HASHED_LEAF_PREFIX):
310 symbol_name = _resolve_hashed_leaf(leaf)
311 if symbol_name is None:
312 raise ValueError(
313 f"No sidecar index entry for hashed symlog leaf {leaf!r}."
314 )
315 else:
316 symbol_name = _decode_symbol_name(leaf_name)
317 return f"{file_path}::{symbol_name}"
318
319
320 # ---------------------------------------------------------------------------
321 # Write
322 # ---------------------------------------------------------------------------
323
324 def append_symlog(
325 repo_root: pathlib.Path,
326 symbol_addr: str,
327 old_content_id: str,
328 new_content_id: str,
329 commit_id: str,
330 author: str,
331 operation: str,
332 ) -> None:
333 """Append one entry to the symbol journal for *symbol_addr*.
334
335 Parent directories are created automatically on first write. Author and
336 operation are sanitized to prevent line injection.
337
338 Args:
339 repo_root: Root of the Muse repository.
340 symbol_addr: ``"path/to/file.py::symbol_name"``.
341 old_content_id: Content ID before the change (NULL_CONTENT_ID for created).
342 new_content_id: Content ID after the change (NULL_CONTENT_ID for deleted).
343 commit_id: SHA-256 commit ID that caused this entry.
344 author: Agent ID or author string; sanitized before write.
345 operation: Lifecycle description; sanitized before write.
346 """
347 safe_author = _sanitize_author(author)
348 safe_operation = _sanitize_operation(operation)
349 now = datetime.datetime.now(tz=datetime.timezone.utc)
350 ts = int(now.timestamp())
351 tz_offset = "+0000"
352
353 line = (
354 f"{old_content_id} {new_content_id} {commit_id} "
355 f"{safe_author} {ts} {tz_offset}\t{safe_operation}\n"
356 )
357
358 p = symlog_path(repo_root, symbol_addr)
359 p.parent.mkdir(parents=True, exist_ok=True)
360 if p.name.startswith(_HASHED_LEAF_PREFIX):
361 _, _, symbol_name = symbol_addr.partition("::")
362 _record_hashed_leaf(p, symbol_name)
363 with p.open("a", encoding="utf-8") as fh:
364 fh.write(line)
365
366
367 # ---------------------------------------------------------------------------
368 # Parse
369 # ---------------------------------------------------------------------------
370
371 def _parse_line(line: str) -> SymlogEntry | None:
372 """Parse one symlog line; return None on malformed input.
373
374 Format::
375
376 <old_cid> <new_cid> <commit_id> <author…> <ts_unix> <tz_offset> TAB <operation>
377 """
378 line = line.rstrip("\n")
379 parts = line.split("\t", 1)
380 if len(parts) != 2:
381 return None
382 meta, operation = parts
383 tokens = meta.split()
384 # Minimum: old_cid new_cid commit_id author ts tz (6 tokens)
385 if len(tokens) < 6:
386 return None
387 old_content_id = tokens[0]
388 new_content_id = tokens[1]
389 commit_id = tokens[2]
390 ts_str = tokens[-2]
391 # author may contain spaces — everything between tokens[3] and the last two
392 author = " ".join(tokens[3:-2])
393 try:
394 ts = datetime.datetime.fromtimestamp(int(ts_str), tz=datetime.timezone.utc)
395 except (ValueError, OSError):
396 ts = datetime.datetime.now(tz=datetime.timezone.utc)
397 return _make_entry(
398 old_content_id=old_content_id,
399 new_content_id=new_content_id,
400 commit_id=commit_id,
401 author=author,
402 timestamp=ts,
403 operation=operation,
404 )
405
406
407 # ---------------------------------------------------------------------------
408 # Read
409 # ---------------------------------------------------------------------------
410
411 def read_symlog(
412 repo_root: pathlib.Path,
413 symbol_addr: str,
414 limit: int = 100,
415 *,
416 follow: bool = False,
417 ) -> list[SymlogEntry]:
418 """Return symlog entries for *symbol_addr*, newest-first, up to *limit*.
419
420 When *follow* is ``True`` and the oldest entry in this symbol's log has a
421 ``born_from`` pointer (i.e. the operation starts with ``symbol-born-from:``),
422 the function recursively reads the prior symbol's log and appends those
423 entries so the full rename chain is visible in one list.
424
425 Files larger than ``_MAX_SYMLOG_BYTES`` trigger a warning and are still
426 read (the OS read may truncate at a line boundary, but at least a partial
427 result is returned rather than an empty list for very large files).
428 """
429 p = symlog_path(repo_root, symbol_addr)
430 if not p.exists():
431 return []
432
433 try:
434 size = p.stat().st_size
435 if size > _MAX_SYMLOG_BYTES:
436 logger.warning(
437 "⚠️ Symlog %s is %.1f MiB — exceeds cap of %d MiB; "
438 "results may be incomplete. Consider pruning old entries.",
439 p,
440 size / (1024 * 1024),
441 _MAX_SYMLOG_BYTES // (1024 * 1024),
442 )
443 lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
444 except OSError as exc:
445 logger.warning("⚠️ Could not read symlog %s: %s", p, exc)
446 return []
447
448 entries: list[SymlogEntry] = []
449 for line in reversed(lines):
450 if not line.strip():
451 continue
452 entry = _parse_line(line)
453 if entry is not None:
454 entries.append(entry)
455 if len(entries) >= limit:
456 break
457
458 if follow and entries:
459 # Check the last (oldest) entry for a born-from pointer.
460 oldest = entries[-1]
461 if oldest.born_from:
462 prior_entries = read_symlog(
463 repo_root,
464 oldest.born_from,
465 limit=max(limit, 10_000),
466 follow=True,
467 )
468 entries.extend(prior_entries)
469
470 return entries
471
472
473 # ---------------------------------------------------------------------------
474 # Enumerate
475 # ---------------------------------------------------------------------------
476
477 def list_symlog_symbols(repo_root: pathlib.Path) -> list[str]:
478 """Return all symbol addresses that have a journal, sorted.
479
480 Symlinks are excluded — only regular files are yielded.
481 """
482 root = _symlogs_dir(repo_root)
483 if not root.exists():
484 return []
485 results: list[str] = []
486 for leaf in root.rglob("*"):
487 if leaf.name == _LEAF_INDEX_FILENAME:
488 continue
489 if leaf.is_file() and not leaf.is_symlink():
490 try:
491 results.append(_addr_from_path(root, leaf))
492 except ValueError:
493 continue
494 return sorted(results)
495
496
497 def list_symlog_symbols_for_file(
498 repo_root: pathlib.Path,
499 file_path: str,
500 ) -> list[str]:
501 """Return symbol addresses for all symbols in *file_path* that have a journal.
502
503 Uses a single ``os.listdir`` — O(1) regardless of total journal count.
504 Symlinks are excluded.
505
506 Args:
507 repo_root: Root of the Muse repository.
508 file_path: Repo-relative path to the source file, e.g. ``"src/billing.py"``.
509 """
510 symlogs_root = _symlogs_dir(repo_root)
511 file_dir = symlogs_root / file_path
512 if not file_dir.exists():
513 return []
514 results: list[str] = []
515 for p in file_dir.iterdir():
516 if p.name == _LEAF_INDEX_FILENAME:
517 continue
518 if p.is_file() and not p.is_symlink():
519 try:
520 results.append(_addr_from_path(symlogs_root, p))
521 except ValueError:
522 continue
523 return sorted(results)
524
525
526 # ---------------------------------------------------------------------------
527 # Expire
528 # ---------------------------------------------------------------------------
529
530 def expire_symlog(
531 repo_root: pathlib.Path,
532 symbol_addr: str,
533 expire_days: int,
534 *,
535 dry_run: bool = False,
536 ) -> tuple[int, int]:
537 """Remove entries older than *expire_days* from one symbol journal.
538
539 The write is atomic: content is first written to a sibling ``.lock``
540 temp file then renamed into place with ``os.replace``. An empty journal
541 after expiry is deleted rather than left as a zero-byte stub.
542
543 Args:
544 repo_root: Root of the Muse repository.
545 symbol_addr: ``"path/to/file.py::symbol_name"``.
546 expire_days: Entries whose timestamp is older than this many days
547 are removed.
548 dry_run: When ``True``, compute counts but write nothing.
549
550 Returns:
551 ``(expired_count, kept_count)``.
552 """
553 p = symlog_path(repo_root, symbol_addr)
554 if not p.exists():
555 return 0, 0
556
557 try:
558 raw = p.read_text(encoding="utf-8", errors="replace")
559 except OSError as exc:
560 logger.warning("⚠️ symlog expire: could not read %s: %s", p, exc)
561 return 0, 0
562
563 cutoff_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) - expire_days * 86400
564
565 kept_lines: list[str] = []
566 expired = 0
567 kept = 0
568 for line in raw.splitlines(keepends=True):
569 stripped = line.strip()
570 if not stripped:
571 continue
572 entry = _parse_line(stripped)
573 if entry is None:
574 kept_lines.append(line)
575 kept += 1
576 continue
577 entry_ts = int(entry.timestamp.timestamp())
578 if entry_ts < cutoff_ts:
579 expired += 1
580 else:
581 kept_lines.append(line)
582 kept += 1
583
584 if dry_run:
585 return expired, kept
586
587 if not kept_lines:
588 try:
589 p.unlink()
590 except OSError as exc:
591 logger.warning("⚠️ symlog expire: could not remove %s: %s", p, exc)
592 return expired, kept
593
594 tmp_path = p.with_suffix(".lock")
595 try:
596 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
597 os.replace(tmp_path, p)
598 except OSError as exc:
599 logger.warning("⚠️ symlog expire: atomic write failed for %s: %s", p, exc)
600 try:
601 tmp_path.unlink(missing_ok=True)
602 except OSError:
603 pass
604 raise
605
606 return expired, kept
607
608
609 # ---------------------------------------------------------------------------
610 # Delete
611 # ---------------------------------------------------------------------------
612
613 def delete_symlog_entry(
614 repo_root: pathlib.Path,
615 symbol_addr: str,
616 index: int | None,
617 ) -> tuple[int, int]:
618 """Remove one entry (by index) or all entries from a symbol journal.
619
620 Args:
621 repo_root: Root of the Muse repository.
622 symbol_addr: ``"path/to/file.py::symbol_name"``.
623 index: 0-based index from newest (``@{0}`` = newest).
624 Pass ``None`` to delete **all** entries.
625
626 Returns:
627 ``(deleted_count, remaining_count)``.
628
629 Raises:
630 FileNotFoundError: Journal file does not exist (only when index is not None).
631 IndexError: *index* is out of range; args are ``(index, total)``.
632
633 The write is atomic when entries remain. An empty journal is deleted
634 rather than left as a zero-byte stub.
635 """
636 p = symlog_path(repo_root, symbol_addr)
637
638 if not p.exists():
639 if index is None:
640 return 0, 0 # --all on missing log is graceful
641 raise FileNotFoundError(p)
642
643 raw = p.read_text(encoding="utf-8", errors="replace")
644 all_lines = [ln for ln in raw.splitlines(keepends=True) if ln.strip()]
645 total = len(all_lines)
646
647 if index is None:
648 try:
649 p.unlink()
650 except OSError as exc:
651 logger.warning("⚠️ symlog delete --all: could not remove %s: %s", p, exc)
652 return total, 0
653
654 if index < 0 or index >= total:
655 raise IndexError(index, total)
656
657 # index is 0-based from newest; lines are stored oldest-first.
658 line_pos = total - 1 - index
659 kept_lines = all_lines[:line_pos] + all_lines[line_pos + 1:]
660
661 if not kept_lines:
662 try:
663 p.unlink()
664 except OSError as exc:
665 logger.warning("⚠️ symlog delete: could not remove %s: %s", p, exc)
666 return 1, 0
667
668 tmp_path = p.with_suffix(".lock")
669 try:
670 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
671 os.replace(tmp_path, p)
672 except OSError as exc:
673 logger.warning("⚠️ symlog delete: atomic write failed for %s: %s", p, exc)
674 try:
675 tmp_path.unlink(missing_ok=True)
676 except OSError:
677 pass
678 raise
679
680 return 1, len(kept_lines)
681
682
683 # ---------------------------------------------------------------------------
684 # Orphan pruning (muse#53 item 12)
685 # ---------------------------------------------------------------------------
686
687 def _symbol_exists_in_head(repo_root: pathlib.Path, symbol_addr: str) -> bool:
688 """Return ``True`` if *symbol_addr*'s file and symbol both exist at HEAD.
689
690 Never raises — any failure along the way (no HEAD commit, file removed,
691 parse failure) is treated as "doesn't exist", matching
692 :func:`extract_symbols`'s own fail-silent contract.
693 """
694 if "::" not in symbol_addr:
695 return False
696 file_path, _, symbol_name = symbol_addr.partition("::")
697 if not symbol_name:
698 return False
699 try:
700 from muse.core.refs import get_head_commit_id, read_current_branch
701 from muse.core.commits import read_commit
702 from muse.core.snapshots import read_snapshot
703
704 branch = read_current_branch(repo_root)
705 commit_id = get_head_commit_id(repo_root, branch)
706 if commit_id is None:
707 return False
708 commit_rec = read_commit(repo_root, commit_id)
709 if commit_rec is None:
710 return False
711 snap_rec = read_snapshot(repo_root, commit_rec.snapshot_id)
712 if snap_rec is None:
713 return False
714 file_obj_id = snap_rec.manifest.get(file_path)
715 if file_obj_id is None:
716 return False
717 symbols = extract_symbols(repo_root, file_obj_id, file_path)
718 return symbol_name in symbols
719 except Exception:
720 return False
721
722
723 def find_orphan_symlogs(repo_root: pathlib.Path) -> list[str]:
724 """Return symbol addresses with a journal whose symbol no longer exists at HEAD.
725
726 A symbol is orphaned when either its file was deleted/renamed away, or
727 the symbol itself was removed from that file — in both cases the journal
728 under ``.muse/symlogs/`` survives (journals are append-only and never
729 auto-deleted on symbol removal) with no live symbol to show it for.
730 """
731 return [
732 addr for addr in list_symlog_symbols(repo_root)
733 if not _symbol_exists_in_head(repo_root, addr)
734 ]
735
736
737 def prune_orphan_symlogs(
738 repo_root: pathlib.Path,
739 *,
740 dry_run: bool = False,
741 ) -> list[str]:
742 """Delete (or report, with *dry_run*) every orphaned symlog journal.
743
744 Reuses :func:`delete_symlog_entry` with ``index=None`` for the actual
745 removal — the same "delete the whole journal" path ``muse symlog delete
746 --all`` already uses and already has tested atomic-delete behavior for.
747
748 Returns:
749 The list of symbol addresses pruned (or that would be pruned, under
750 ``dry_run``).
751 """
752 orphans = find_orphan_symlogs(repo_root)
753 if dry_run:
754 return orphans
755 for addr in orphans:
756 try:
757 delete_symlog_entry(repo_root, addr, None)
758 except FileNotFoundError:
759 continue
760 return orphans
761
762
763 # ---------------------------------------------------------------------------
764 # Phase 2 — Symbol diff and commit integration
765 # ---------------------------------------------------------------------------
766
767 @dataclass
768 class SymbolDiff:
769 """Result of comparing two symbol maps (old vs. new content IDs).
770
771 ``renames`` holds ``(old_name, new_name)`` pairs where the same content ID
772 appears in both the deleted and created sets — same object, different name.
773 Renamed symbols are removed from ``created`` and ``deleted`` so callers
774 never double-count them.
775 """
776
777 created: set[str]
778 deleted: set[str]
779 modified: set[str]
780 renames: list[tuple[str, str]]
781
782
783 def compute_symbol_diff(
784 old_symbols: dict[str, str],
785 new_symbols: dict[str, str],
786 old_rename_keys: dict[str, str] | None = None,
787 new_rename_keys: dict[str, str] | None = None,
788 ) -> SymbolDiff:
789 """Diff two ``{symbol_name: content_id}`` maps.
790
791 Rename detection: a name that disappeared in *old* whose rename key matches
792 that of a new name that didn't exist before is classified as a rename rather
793 than a delete + create.
794
795 *old_rename_keys* / *new_rename_keys* are optional ``{name: key}`` maps
796 used exclusively for rename matching — typically ``body_hash`` values from
797 ``parse_symbols``, which are stable across pure renames. When not supplied,
798 the ``content_id`` from *old_symbols* / *new_symbols* is used as the key
799 instead (correct for unit tests with artificial IDs; rarely matches in
800 practice for real code because ``content_id`` encodes the function name).
801
802 A content-ID change with a name change is always treated as delete + create.
803 """
804 _old_rk = old_rename_keys if old_rename_keys is not None else old_symbols
805 _new_rk = new_rename_keys if new_rename_keys is not None else new_symbols
806
807 old_names = set(old_symbols)
808 new_names = set(new_symbols)
809
810 raw_deleted = old_names - new_names
811 raw_created = new_names - old_names
812 modified = {n for n in old_names & new_names if old_symbols[n] != new_symbols[n]}
813
814 # Build an inverted map of rename_key → name for the *created* side.
815 created_by_rk: dict[str, str] = {}
816 for name in raw_created:
817 rk = _new_rk.get(name, "")
818 if not rk:
819 continue
820 # Only map when a single created name has this key — ambiguous keys
821 # cannot be deterministically paired with a deleted name.
822 if rk not in created_by_rk:
823 created_by_rk[rk] = name
824 else:
825 created_by_rk[rk] = "" # sentinel: ambiguous
826
827 renames: list[tuple[str, str]] = []
828 confirmed_deleted = set(raw_deleted)
829 confirmed_created = set(raw_created)
830
831 for old_name in sorted(raw_deleted):
832 rk = _old_rk.get(old_name, "")
833 new_name = created_by_rk.get(rk) if rk else None
834 if new_name: # non-empty sentinel → unambiguous match
835 renames.append((old_name, new_name))
836 confirmed_deleted.discard(old_name)
837 confirmed_created.discard(new_name)
838
839 return SymbolDiff(
840 created=confirmed_created,
841 deleted=confirmed_deleted,
842 modified=modified,
843 renames=renames,
844 )
845
846
847 def extract_symbols(
848 repo_root: pathlib.Path,
849 file_object_id: str,
850 file_path: str = "",
851 ) -> dict[str, str]:
852 """Read a file object from the store and return ``{symbol_name: content_id}``.
853
854 Uses the code AST parser to extract top-level symbols. Returns an empty
855 dict for objects that are not present in the store, cannot be parsed, or
856 are non-code files. Never raises — parse failures are silently swallowed
857 because symlog writes are non-fatal.
858
859 Args:
860 repo_root: Root of the Muse repository.
861 file_object_id: Content-addressed ID of the file object (``sha256:…``).
862 file_path: Repo-relative file path passed to ``parse_symbols`` so
863 symbol keys are properly namespaced (``"src/f.py::name"``).
864 """
865 try:
866 from muse.core.object_store import read_object
867 source = read_object(repo_root, file_object_id)
868 if source is None:
869 return {}
870 from muse.plugins.code.ast_parser import parse_symbols
871 # parse_symbols returns {full_addr: {content_id: …, …}}
872 # Keys are "file.py::name" — we want just the name part.
873 raw = parse_symbols(source, file_path)
874 result: dict[str, str] = {}
875 for addr, sym in raw.items():
876 cid = sym.get("content_id", "")
877 if not cid:
878 continue
879 name = addr.split("::")[-1] if "::" in addr else addr
880 result[name] = cid
881 return result
882 except Exception:
883 return {}
884
885
886 def _extract_body_hashes(
887 repo_root: pathlib.Path,
888 file_object_id: str,
889 file_path: str = "",
890 ) -> dict[str, str]:
891 """Return ``{symbol_name: body_hash}`` for rename detection.
892
893 ``body_hash`` hashes only the function body, not the name — two functions
894 with the same body but different names share the same ``body_hash``, making
895 it the correct key for rename matching. Falls back to ``content_id`` if
896 ``body_hash`` is absent from the symbol record.
897 """
898 try:
899 from muse.core.object_store import read_object
900 source = read_object(repo_root, file_object_id)
901 if source is None:
902 return {}
903 from muse.plugins.code.ast_parser import parse_symbols
904 raw = parse_symbols(source, file_path)
905 result: dict[str, str] = {}
906 for addr, sym in raw.items():
907 key = sym.get("body_hash", "") or sym.get("content_id", "")
908 if not key:
909 continue
910 name = addr.split("::")[-1] if "::" in addr else addr
911 result[name] = key
912 return result
913 except Exception:
914 return {}
915
916
917 def _write_symlogs(
918 repo_root: pathlib.Path,
919 parent_commit_id: str | None,
920 new_snapshot_id: str,
921 new_commit_id: str,
922 author: str,
923 commit_message: str,
924 ) -> None:
925 """Write symlog entries for every symbol that changed in this commit.
926
927 Called by ``muse commit`` after the commit record is finalised. Wrapped
928 in a broad ``try/except`` at the call site — a failure here must never
929 abort a commit.
930
931 Algorithm:
932
933 1. Build ``old_manifest`` from the parent commit (empty for initial commit).
934 2. For each file whose object ID changed, extract old and new symbol maps.
935 3. Diff the two maps to classify changes as created/modified/deleted/renamed.
936 4. Append the appropriate symlog entry for each changed symbol.
937 """
938 from muse.core.object_store import read_object
939 from muse.core.commits import read_commit
940 from muse.core.snapshots import read_snapshot
941
942 # Load parent manifest.
943 old_manifest: dict[str, str] = {}
944 if parent_commit_id is not None:
945 parent_rec = read_commit(repo_root, parent_commit_id)
946 if parent_rec is not None:
947 snap_rec = read_snapshot(repo_root, parent_rec.snapshot_id)
948 if snap_rec is not None:
949 old_manifest = dict(snap_rec.manifest)
950
951 # Load new manifest.
952 new_snap_rec = read_snapshot(repo_root, new_snapshot_id)
953 if new_snap_rec is None:
954 return
955 new_manifest: dict[str, str] = dict(new_snap_rec.manifest)
956
957 first_line = (commit_message.splitlines()[0] if commit_message else "").strip()
958
959 # Find files whose object IDs differ.
960 all_paths = set(old_manifest) | set(new_manifest)
961 for file_path in sorted(all_paths):
962 old_obj = old_manifest.get(file_path, "")
963 new_obj = new_manifest.get(file_path, "")
964 if old_obj == new_obj:
965 continue # file unchanged at object level
966
967 old_syms = extract_symbols(repo_root, old_obj, file_path) if old_obj else {}
968 new_syms = extract_symbols(repo_root, new_obj, file_path) if new_obj else {}
969
970 if not old_syms and not new_syms:
971 continue # non-code file
972
973 old_bodies = _extract_body_hashes(repo_root, old_obj, file_path) if old_obj else {}
974 new_bodies = _extract_body_hashes(repo_root, new_obj, file_path) if new_obj else {}
975 diff = compute_symbol_diff(old_syms, new_syms, old_bodies, new_bodies)
976
977 # Created
978 for name in sorted(diff.created):
979 append_symlog(
980 repo_root,
981 f"{file_path}::{name}",
982 old_content_id=NULL_CONTENT_ID,
983 new_content_id=new_syms[name],
984 commit_id=new_commit_id,
985 author=author,
986 operation=f"symbol-created: {name}",
987 )
988
989 # Modified
990 for name in sorted(diff.modified):
991 append_symlog(
992 repo_root,
993 f"{file_path}::{name}",
994 old_content_id=old_syms[name],
995 new_content_id=new_syms[name],
996 commit_id=new_commit_id,
997 author=author,
998 operation=f"symbol-modified: {first_line}",
999 )
1000
1001 # Deleted
1002 for name in sorted(diff.deleted):
1003 append_symlog(
1004 repo_root,
1005 f"{file_path}::{name}",
1006 old_content_id=old_syms[name],
1007 new_content_id=NULL_CONTENT_ID,
1008 commit_id=new_commit_id,
1009 author=author,
1010 operation=f"symbol-deleted: {name}",
1011 )
1012
1013 # Renamed — two entries per rename
1014 for old_name, new_name in diff.renames:
1015 new_addr = f"{file_path}::{new_name}"
1016 old_addr = f"{file_path}::{old_name}"
1017 # Terminal entry on the old path
1018 append_symlog(
1019 repo_root,
1020 old_addr,
1021 old_content_id=old_syms[old_name],
1022 new_content_id=NULL_CONTENT_ID,
1023 commit_id=new_commit_id,
1024 author=author,
1025 operation=f"symbol-renamed-to: {new_addr}",
1026 )
1027 # Born-from entry on the new path
1028 append_symlog(
1029 repo_root,
1030 new_addr,
1031 old_content_id=NULL_CONTENT_ID,
1032 new_content_id=new_syms[new_name],
1033 commit_id=new_commit_id,
1034 author=author,
1035 operation=f"symbol-born-from: {old_addr}",
1036 )
1037
1038
1039 # ---------------------------------------------------------------------------
1040 # Phase 5 — @{N} ref resolution
1041 # ---------------------------------------------------------------------------
1042
1043 #: Regex matching ``<addr>@{N}`` or ``<addr>@{<timespec>}`` — anchored at both
1044 #: ends. Group 2 is disambiguated (digit index vs relative-time spec) inside
1045 #: ``resolve_symlog_addr`` / ``_resolve_symlog_index`` (muse#53 item 6).
1046 _SYMLOG_REF_RE = re.compile(r"^(.+)@\{([^}]+)\}$")
1047
1048 #: Matches "N.unit.ago" relative-time specs, e.g. "1.hour.ago", "2.days.ago".
1049 _SYMLOG_TIME_AGO_RE = re.compile(
1050 r"^(?P<num>\d+)\.(?P<unit>second|minute|hour|day|week)s?\.ago$"
1051 )
1052
1053 _SYMLOG_TIME_UNIT_SECONDS = {
1054 "second": 1, "minute": 60, "hour": 3600, "day": 86_400, "week": 604_800,
1055 }
1056
1057
1058 def _parse_symlog_time_cutoff(inner: str) -> datetime.datetime | None:
1059 """Parse a relative-time ``@{...}`` spec into an absolute UTC cutoff.
1060
1061 Supported forms (muse#53 item 6, mirrors the reflog equivalent):
1062
1063 - ``N.unit.ago`` — unit is second/minute/hour/day/week, singular or
1064 plural (e.g. ``"1.hour.ago"``, ``"2.days.ago"``).
1065 - ``yesterday`` — start of today (UTC midnight).
1066
1067 Returns ``None`` for anything that isn't a recognized time spec.
1068 """
1069 now = datetime.datetime.now(datetime.timezone.utc)
1070 if inner == "yesterday":
1071 return now.replace(hour=0, minute=0, second=0, microsecond=0)
1072 m = _SYMLOG_TIME_AGO_RE.match(inner)
1073 if m is None:
1074 return None
1075 num = int(m.group("num"))
1076 unit_seconds = _SYMLOG_TIME_UNIT_SECONDS[m.group("unit")]
1077 return now - datetime.timedelta(seconds=num * unit_seconds)
1078
1079
1080 def _resolve_symlog_index(inner: str, entries: "list[SymlogEntry]") -> int:
1081 """Resolve the inner content of ``@{...}`` to a 0-based index into *entries*.
1082
1083 *entries* must already be newest-first (the ``read_symlog`` contract).
1084 Digit-only *inner* resolves by position; anything else is tried as a
1085 relative-time spec, resolving to the first (newest) entry at or before
1086 the computed cutoff.
1087
1088 Raises:
1089 IndexError: ``(index, total)`` for an out-of-range digit index, or
1090 ``(-1, total)`` when a valid time spec has no qualifying entry.
1091 ValueError: *inner* is neither a digit index nor a recognized
1092 relative-time spec.
1093 """
1094 total = len(entries)
1095 if inner.isdigit():
1096 index = int(inner)
1097 if index >= total:
1098 raise IndexError(index, total)
1099 return index
1100
1101 cutoff = _parse_symlog_time_cutoff(inner)
1102 if cutoff is None:
1103 raise ValueError(
1104 f"{inner!r} inside @{{...}} is neither an index nor a recognized "
1105 "relative-time spec (e.g. '1.hour.ago', 'yesterday')"
1106 )
1107 for idx, entry in enumerate(entries):
1108 if entry.timestamp <= cutoff:
1109 return idx
1110 raise IndexError(-1, total)
1111
1112
1113 @dataclass(frozen=True)
1114 class SymlogResolution:
1115 """Result of resolving a ``<addr>@{N}`` symlog reference.
1116
1117 ``followed_from`` is non-None only when ``--follow`` crossed a rename
1118 boundary — it holds the prior symbol address that contributed this entry.
1119 ``content_id`` equals ``new_content_id`` of the resolved ``SymlogEntry``.
1120 """
1121
1122 symbol: str # base symbol address (without @{N})
1123 index: int # 0-based index (0 = newest)
1124 content_id: str # new_content_id at this index
1125 commit_id: str # commit that caused this entry
1126 operation: str # lifecycle description
1127 timestamp: datetime.datetime # UTC-aware entry timestamp
1128 followed_from: str | None = None # prior addr when follow crossed a boundary
1129 author: str = "" # author/agent_id recorded on the entry (muse#53 item 9)
1130
1131
1132 def resolve_symlog_addr(
1133 spec: str,
1134 repo_root: pathlib.Path,
1135 *,
1136 follow: bool = False,
1137 ) -> "SymlogResolution | None":
1138 """Resolve a ``<addr>@{N}`` symlog reference to a :class:`SymlogResolution`.
1139
1140 Args:
1141 spec: Full spec string, e.g. ``"billing.py::compute_total@{2}"``,
1142 or a relative-time spec (muse#53 item 6), e.g.
1143 ``"billing.py::compute_total@{1.hour.ago}"``.
1144 repo_root: Root of the Muse repository.
1145 follow: When ``True``, traverse rename chains so indices beyond the
1146 primary symbol's log are resolved from prior symbol addresses.
1147
1148 Returns:
1149 A :class:`SymlogResolution` on success, or ``None`` if *spec* does not
1150 match the ``@{...}`` pattern (so callers can use ``if result is None:``
1151 to fall through to normal processing).
1152
1153 Raises:
1154 FileNotFoundError: No journal exists for the resolved symbol address.
1155 IndexError: ``(index, total)`` for an out-of-range digit index,
1156 or ``(-1, total)`` when a time spec has no
1157 qualifying entry.
1158 ValueError: *addr* is not a valid symbol address (missing
1159 ``::``), or the ``@{...}`` content is neither a
1160 digit index nor a recognized relative-time spec.
1161 """
1162 m = _SYMLOG_REF_RE.match(spec)
1163 if m is None:
1164 return None
1165
1166 addr = m.group(1)
1167 inner = m.group(2)
1168
1169 # Validate that the addr portion is a proper symbol address.
1170 # This will raise ValueError if :: is missing (propagated to caller).
1171 p = symlog_path(repo_root, addr)
1172 if not p.exists():
1173 raise FileNotFoundError(p)
1174
1175 if follow:
1176 primary = read_symlog(repo_root, addr, limit=100_000, follow=False)
1177 all_entries = read_symlog(repo_root, addr, limit=100_000, follow=True)
1178 index = _resolve_symlog_index(inner, all_entries)
1179 entry = all_entries[index]
1180 followed_from: str | None = None
1181 if index >= len(primary) and primary:
1182 followed_from = primary[-1].born_from
1183 return SymlogResolution(
1184 symbol=addr,
1185 index=index,
1186 content_id=entry.new_content_id,
1187 commit_id=entry.commit_id,
1188 operation=entry.operation,
1189 timestamp=entry.timestamp,
1190 followed_from=followed_from,
1191 author=entry.author,
1192 )
1193
1194 entries = read_symlog(repo_root, addr, limit=100_000, follow=False)
1195 index = _resolve_symlog_index(inner, entries)
1196 entry = entries[index]
1197 return SymlogResolution(
1198 symbol=addr,
1199 index=index,
1200 content_id=entry.new_content_id,
1201 commit_id=entry.commit_id,
1202 operation=entry.operation,
1203 timestamp=entry.timestamp,
1204 followed_from=None,
1205 author=entry.author,
1206 )
1207
1208
1209 def resolve_symbol_body(
1210 repo_root: pathlib.Path,
1211 symbol_addr: str,
1212 commit_id: str,
1213 ) -> "dict | None":
1214 """Return symbol body info for *symbol_addr* at *commit_id*.
1215
1216 Reconstructs the symbol by reading: commit record → snapshot manifest →
1217 file object → AST parse → slice by ``lineno``/``end_lineno``. Symbol
1218 ``content_id`` values are AST-computed hashes not stored as raw objects,
1219 so this walk is the only correct reconstruction path.
1220
1221 Returns a dict with keys ``source``, ``kind``, ``lineno``, ``end_lineno``,
1222 ``qualified_name``, ``file_path`` on success, or ``None`` on any failure.
1223 """
1224 if "::" not in symbol_addr:
1225 return None
1226 file_path, _, symbol_name = symbol_addr.partition("::")
1227 if not symbol_name:
1228 return None
1229
1230 try:
1231 from muse.core.commits import read_commit
1232 from muse.core.object_store import read_object
1233 from muse.core.snapshots import read_snapshot
1234 from muse.plugins.code.ast_parser import adapter_for_path
1235
1236 commit_rec = read_commit(repo_root, commit_id)
1237 if commit_rec is None:
1238 return None
1239 snap_rec = read_snapshot(repo_root, commit_rec.snapshot_id)
1240 if snap_rec is None:
1241 return None
1242 file_obj_id = snap_rec.manifest.get(file_path)
1243 if file_obj_id is None:
1244 return None
1245 raw = read_object(repo_root, file_obj_id)
1246 if raw is None:
1247 return None
1248
1249 adapter = adapter_for_path(file_path)
1250 tree = adapter.parse_symbols(raw, file_path)
1251
1252 # Prefer exact qualified_name match, fall back to bare name.
1253 found = next(
1254 (r for r in tree.values() if r["qualified_name"] == symbol_name),
1255 None,
1256 )
1257 if found is None:
1258 found = next(
1259 (r for r in tree.values()
1260 if r["name"] == symbol_name and r["kind"] != "import"),
1261 None,
1262 )
1263 if found is None:
1264 return None
1265
1266 lineno = found["lineno"]
1267 end_lineno = found["end_lineno"]
1268 text = raw.decode("utf-8", errors="replace")
1269 lines = text.splitlines()
1270 # lineno is 1-based inclusive; end_lineno is 1-based inclusive.
1271 source = "\n".join(lines[lineno - 1:end_lineno])
1272 return {
1273 "source": source,
1274 "kind": found["kind"],
1275 "lineno": lineno,
1276 "end_lineno": end_lineno,
1277 "qualified_name": found["qualified_name"],
1278 "file_path": file_path,
1279 }
1280 except Exception:
1281 return None
File History 1 commit
sha256:986277e88b9de700cfb77ed3385e7b344df08fbb801c9615906da7df9c45c91d feat: muse#53 item 12 — muse symlog prune-orphans Sonnet 5 patch 1 day ago