gabriel / muse public
symlog.py python
1,038 lines 36.2 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days 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 logging
80 import os
81 import pathlib
82 import re
83 import tempfile
84 import urllib.parse
85 from dataclasses import dataclass, field as _field
86
87 from muse.core.paths import symlogs_dir as _symlogs_dir
88 from muse.core.types import DEFAULT_HASH_ALGO
89
90 logger = logging.getLogger(__name__)
91
92 # ---------------------------------------------------------------------------
93 # Constants
94 # ---------------------------------------------------------------------------
95
96 #: Null content ID — used as old_content_id for created symbols and
97 #: new_content_id for deleted / renamed-away symbols.
98 NULL_CONTENT_ID: str = f"{DEFAULT_HASH_ALGO}:" + "0" * 64
99
100 #: Maximum symlog file size that will be read. Files larger than this cap
101 #: are warned about rather than loaded entirely into memory.
102 _MAX_SYMLOG_BYTES: int = 10 * 1024 * 1024 # 10 MiB
103
104 #: Characters in the symbol name that do NOT need percent-encoding.
105 _SAFE_CHARS: str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
106
107 #: Regex that identifies a ``symbol-born-from:`` operation and captures the prior address.
108 _BORN_FROM_RE = re.compile(r"^symbol-born-from:\s*(.+)$")
109
110
111 # ---------------------------------------------------------------------------
112 # Helpers
113 # ---------------------------------------------------------------------------
114
115 def is_null_content_id(content_id: str) -> bool:
116 """Return ``True`` iff *content_id* is the canonical null content ID."""
117 return content_id == NULL_CONTENT_ID
118
119
120 def _encode_symbol_name(name: str) -> str:
121 """Percent-encode characters outside ``[a-zA-Z0-9._-]`` in *name*."""
122 return urllib.parse.quote(name, safe=_SAFE_CHARS)
123
124
125 def _decode_symbol_name(encoded: str) -> str:
126 """Reverse percent-encoding applied by ``_encode_symbol_name``."""
127 return urllib.parse.unquote(encoded)
128
129
130 def _reject_traversal(part: str, label: str) -> None:
131 """Raise ``ValueError`` when *part* contains a ``..`` path component."""
132 segments = pathlib.PurePosixPath(part).parts
133 if ".." in segments:
134 raise ValueError(
135 f"Path traversal detected in {label!r}: '..' components are not allowed."
136 )
137
138
139 def _sanitize_author(s: str) -> str:
140 """Strip characters that would corrupt the symlog line structure."""
141 return s.replace("\n", "").replace("\r", "").replace("\t", "")
142
143
144 def _sanitize_operation(s: str) -> str:
145 """Strip newlines to prevent line injection in the operation field."""
146 return s.replace("\n", "").replace("\r", "")
147
148
149 # ---------------------------------------------------------------------------
150 # Types
151 # ---------------------------------------------------------------------------
152
153 @dataclass(frozen=True)
154 class SymlogEntry:
155 """One line of a symbol journal.
156
157 The ``born_from`` field is non-None only when the operation string starts
158 with ``symbol-born-from:``; it is always derived from ``operation`` at
159 construction time and is never passed by callers. This ensures the two
160 fields are always consistent.
161 """
162
163 old_content_id: str # sha256:… or NULL_CONTENT_ID
164 new_content_id: str # sha256:… or NULL_CONTENT_ID
165 commit_id: str # sha256:… of the commit that caused this entry
166 author: str # sanitized author / agent_id
167 timestamp: datetime.datetime
168 operation: str # "symbol-created: …", "symbol-modified: …", etc.
169 born_from: str | None = _field(default=None, init=False)
170
171 def __post_init__(self) -> None:
172 m = _BORN_FROM_RE.match(self.operation)
173 object.__setattr__(self, "born_from", m.group(1).strip() if m else None)
174
175
176 def _make_entry(
177 old_content_id: str,
178 new_content_id: str,
179 commit_id: str,
180 author: str,
181 timestamp: datetime.datetime,
182 operation: str,
183 ) -> SymlogEntry:
184 """Construct a ``SymlogEntry`` from parsed line components."""
185 return SymlogEntry(
186 old_content_id=old_content_id,
187 new_content_id=new_content_id,
188 commit_id=commit_id,
189 author=author,
190 timestamp=timestamp,
191 operation=operation,
192 )
193
194
195 # ---------------------------------------------------------------------------
196 # Path helpers
197 # ---------------------------------------------------------------------------
198
199 def symlog_path(repo_root: pathlib.Path, symbol_addr: str) -> pathlib.Path:
200 """Return the journal file path for *symbol_addr*.
201
202 *symbol_addr* must contain exactly one ``::`` separator, e.g.
203 ``src/billing.py::compute_total``. The file path portion maps to a
204 directory under ``.muse/symlogs/``; the symbol name portion becomes the
205 leaf filename (percent-encoded for non-ASCII-safe characters).
206
207 Raises:
208 ValueError: ``::`` separator is missing, symbol name is empty,
209 or a ``..`` path component is detected.
210 """
211 if "::" not in symbol_addr:
212 raise ValueError(
213 f"Missing '::' separator in symbol address {symbol_addr!r}. "
214 "Expected form: 'path/to/file.py::symbol_name'."
215 )
216 file_path, _, symbol_name = symbol_addr.partition("::")
217 if not symbol_name:
218 raise ValueError(
219 f"Empty symbol name in symbol address {symbol_addr!r}."
220 )
221 # Reject path traversal in both parts.
222 _reject_traversal(file_path, "file_path")
223 _reject_traversal(symbol_name, "symbol_name")
224 encoded_name = _encode_symbol_name(symbol_name)
225 return _symlogs_dir(repo_root) / file_path / encoded_name
226
227
228 def _addr_from_path(symlogs_root: pathlib.Path, leaf: pathlib.Path) -> str:
229 """Reconstruct a symbol address from a leaf path under *symlogs_root*."""
230 rel = leaf.relative_to(symlogs_root)
231 parts = rel.parts
232 # All parts up to the last are the file path; the last is the encoded symbol name.
233 file_path = str(pathlib.PurePosixPath(*parts[:-1]))
234 symbol_name = _decode_symbol_name(parts[-1])
235 return f"{file_path}::{symbol_name}"
236
237
238 # ---------------------------------------------------------------------------
239 # Write
240 # ---------------------------------------------------------------------------
241
242 def append_symlog(
243 repo_root: pathlib.Path,
244 symbol_addr: str,
245 old_content_id: str,
246 new_content_id: str,
247 commit_id: str,
248 author: str,
249 operation: str,
250 ) -> None:
251 """Append one entry to the symbol journal for *symbol_addr*.
252
253 Parent directories are created automatically on first write. Author and
254 operation are sanitized to prevent line injection.
255
256 Args:
257 repo_root: Root of the Muse repository.
258 symbol_addr: ``"path/to/file.py::symbol_name"``.
259 old_content_id: Content ID before the change (NULL_CONTENT_ID for created).
260 new_content_id: Content ID after the change (NULL_CONTENT_ID for deleted).
261 commit_id: SHA-256 commit ID that caused this entry.
262 author: Agent ID or author string; sanitized before write.
263 operation: Lifecycle description; sanitized before write.
264 """
265 safe_author = _sanitize_author(author)
266 safe_operation = _sanitize_operation(operation)
267 now = datetime.datetime.now(tz=datetime.timezone.utc)
268 ts = int(now.timestamp())
269 tz_offset = "+0000"
270
271 line = (
272 f"{old_content_id} {new_content_id} {commit_id} "
273 f"{safe_author} {ts} {tz_offset}\t{safe_operation}\n"
274 )
275
276 p = symlog_path(repo_root, symbol_addr)
277 p.parent.mkdir(parents=True, exist_ok=True)
278 with p.open("a", encoding="utf-8") as fh:
279 fh.write(line)
280
281
282 # ---------------------------------------------------------------------------
283 # Parse
284 # ---------------------------------------------------------------------------
285
286 def _parse_line(line: str) -> SymlogEntry | None:
287 """Parse one symlog line; return None on malformed input.
288
289 Format::
290
291 <old_cid> <new_cid> <commit_id> <author…> <ts_unix> <tz_offset> TAB <operation>
292 """
293 line = line.rstrip("\n")
294 parts = line.split("\t", 1)
295 if len(parts) != 2:
296 return None
297 meta, operation = parts
298 tokens = meta.split()
299 # Minimum: old_cid new_cid commit_id author ts tz (6 tokens)
300 if len(tokens) < 6:
301 return None
302 old_content_id = tokens[0]
303 new_content_id = tokens[1]
304 commit_id = tokens[2]
305 ts_str = tokens[-2]
306 # author may contain spaces — everything between tokens[3] and the last two
307 author = " ".join(tokens[3:-2])
308 try:
309 ts = datetime.datetime.fromtimestamp(int(ts_str), tz=datetime.timezone.utc)
310 except (ValueError, OSError):
311 ts = datetime.datetime.now(tz=datetime.timezone.utc)
312 return _make_entry(
313 old_content_id=old_content_id,
314 new_content_id=new_content_id,
315 commit_id=commit_id,
316 author=author,
317 timestamp=ts,
318 operation=operation,
319 )
320
321
322 # ---------------------------------------------------------------------------
323 # Read
324 # ---------------------------------------------------------------------------
325
326 def read_symlog(
327 repo_root: pathlib.Path,
328 symbol_addr: str,
329 limit: int = 100,
330 *,
331 follow: bool = False,
332 ) -> list[SymlogEntry]:
333 """Return symlog entries for *symbol_addr*, newest-first, up to *limit*.
334
335 When *follow* is ``True`` and the oldest entry in this symbol's log has a
336 ``born_from`` pointer (i.e. the operation starts with ``symbol-born-from:``),
337 the function recursively reads the prior symbol's log and appends those
338 entries so the full rename chain is visible in one list.
339
340 Files larger than ``_MAX_SYMLOG_BYTES`` trigger a warning and are still
341 read (the OS read may truncate at a line boundary, but at least a partial
342 result is returned rather than an empty list for very large files).
343 """
344 p = symlog_path(repo_root, symbol_addr)
345 if not p.exists():
346 return []
347
348 try:
349 size = p.stat().st_size
350 if size > _MAX_SYMLOG_BYTES:
351 logger.warning(
352 "⚠️ Symlog %s is %.1f MiB — exceeds cap of %d MiB; "
353 "results may be incomplete. Consider pruning old entries.",
354 p,
355 size / (1024 * 1024),
356 _MAX_SYMLOG_BYTES // (1024 * 1024),
357 )
358 lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
359 except OSError as exc:
360 logger.warning("⚠️ Could not read symlog %s: %s", p, exc)
361 return []
362
363 entries: list[SymlogEntry] = []
364 for line in reversed(lines):
365 if not line.strip():
366 continue
367 entry = _parse_line(line)
368 if entry is not None:
369 entries.append(entry)
370 if len(entries) >= limit:
371 break
372
373 if follow and entries:
374 # Check the last (oldest) entry for a born-from pointer.
375 oldest = entries[-1]
376 if oldest.born_from:
377 prior_entries = read_symlog(
378 repo_root,
379 oldest.born_from,
380 limit=max(limit, 10_000),
381 follow=True,
382 )
383 entries.extend(prior_entries)
384
385 return entries
386
387
388 # ---------------------------------------------------------------------------
389 # Enumerate
390 # ---------------------------------------------------------------------------
391
392 def list_symlog_symbols(repo_root: pathlib.Path) -> list[str]:
393 """Return all symbol addresses that have a journal, sorted.
394
395 Symlinks are excluded — only regular files are yielded.
396 """
397 root = _symlogs_dir(repo_root)
398 if not root.exists():
399 return []
400 results: list[str] = []
401 for leaf in root.rglob("*"):
402 if leaf.is_file() and not leaf.is_symlink():
403 try:
404 results.append(_addr_from_path(root, leaf))
405 except ValueError:
406 continue
407 return sorted(results)
408
409
410 def list_symlog_symbols_for_file(
411 repo_root: pathlib.Path,
412 file_path: str,
413 ) -> list[str]:
414 """Return symbol addresses for all symbols in *file_path* that have a journal.
415
416 Uses a single ``os.listdir`` — O(1) regardless of total journal count.
417 Symlinks are excluded.
418
419 Args:
420 repo_root: Root of the Muse repository.
421 file_path: Repo-relative path to the source file, e.g. ``"src/billing.py"``.
422 """
423 file_dir = _symlogs_dir(repo_root) / file_path
424 if not file_dir.exists():
425 return []
426 results: list[str] = []
427 for p in file_dir.iterdir():
428 if p.is_file() and not p.is_symlink():
429 symbol_name = _decode_symbol_name(p.name)
430 results.append(f"{file_path}::{symbol_name}")
431 return sorted(results)
432
433
434 # ---------------------------------------------------------------------------
435 # Expire
436 # ---------------------------------------------------------------------------
437
438 def expire_symlog(
439 repo_root: pathlib.Path,
440 symbol_addr: str,
441 expire_days: int,
442 *,
443 dry_run: bool = False,
444 ) -> tuple[int, int]:
445 """Remove entries older than *expire_days* from one symbol journal.
446
447 The write is atomic: content is first written to a sibling ``.lock``
448 temp file then renamed into place with ``os.replace``. An empty journal
449 after expiry is deleted rather than left as a zero-byte stub.
450
451 Args:
452 repo_root: Root of the Muse repository.
453 symbol_addr: ``"path/to/file.py::symbol_name"``.
454 expire_days: Entries whose timestamp is older than this many days
455 are removed.
456 dry_run: When ``True``, compute counts but write nothing.
457
458 Returns:
459 ``(expired_count, kept_count)``.
460 """
461 p = symlog_path(repo_root, symbol_addr)
462 if not p.exists():
463 return 0, 0
464
465 try:
466 raw = p.read_text(encoding="utf-8", errors="replace")
467 except OSError as exc:
468 logger.warning("⚠️ symlog expire: could not read %s: %s", p, exc)
469 return 0, 0
470
471 cutoff_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) - expire_days * 86400
472
473 kept_lines: list[str] = []
474 expired = 0
475 kept = 0
476 for line in raw.splitlines(keepends=True):
477 stripped = line.strip()
478 if not stripped:
479 continue
480 entry = _parse_line(stripped)
481 if entry is None:
482 kept_lines.append(line)
483 kept += 1
484 continue
485 entry_ts = int(entry.timestamp.timestamp())
486 if entry_ts < cutoff_ts:
487 expired += 1
488 else:
489 kept_lines.append(line)
490 kept += 1
491
492 if dry_run:
493 return expired, kept
494
495 if not kept_lines:
496 try:
497 p.unlink()
498 except OSError as exc:
499 logger.warning("⚠️ symlog expire: could not remove %s: %s", p, exc)
500 return expired, kept
501
502 tmp_path = p.with_suffix(".lock")
503 try:
504 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
505 os.replace(tmp_path, p)
506 except OSError as exc:
507 logger.warning("⚠️ symlog expire: atomic write failed for %s: %s", p, exc)
508 try:
509 tmp_path.unlink(missing_ok=True)
510 except OSError:
511 pass
512 raise
513
514 return expired, kept
515
516
517 # ---------------------------------------------------------------------------
518 # Delete
519 # ---------------------------------------------------------------------------
520
521 def delete_symlog_entry(
522 repo_root: pathlib.Path,
523 symbol_addr: str,
524 index: int | None,
525 ) -> tuple[int, int]:
526 """Remove one entry (by index) or all entries from a symbol journal.
527
528 Args:
529 repo_root: Root of the Muse repository.
530 symbol_addr: ``"path/to/file.py::symbol_name"``.
531 index: 0-based index from newest (``@{0}`` = newest).
532 Pass ``None`` to delete **all** entries.
533
534 Returns:
535 ``(deleted_count, remaining_count)``.
536
537 Raises:
538 FileNotFoundError: Journal file does not exist (only when index is not None).
539 IndexError: *index* is out of range; args are ``(index, total)``.
540
541 The write is atomic when entries remain. An empty journal is deleted
542 rather than left as a zero-byte stub.
543 """
544 p = symlog_path(repo_root, symbol_addr)
545
546 if not p.exists():
547 if index is None:
548 return 0, 0 # --all on missing log is graceful
549 raise FileNotFoundError(p)
550
551 raw = p.read_text(encoding="utf-8", errors="replace")
552 all_lines = [ln for ln in raw.splitlines(keepends=True) if ln.strip()]
553 total = len(all_lines)
554
555 if index is None:
556 try:
557 p.unlink()
558 except OSError as exc:
559 logger.warning("⚠️ symlog delete --all: could not remove %s: %s", p, exc)
560 return total, 0
561
562 if index < 0 or index >= total:
563 raise IndexError(index, total)
564
565 # index is 0-based from newest; lines are stored oldest-first.
566 line_pos = total - 1 - index
567 kept_lines = all_lines[:line_pos] + all_lines[line_pos + 1:]
568
569 if not kept_lines:
570 try:
571 p.unlink()
572 except OSError as exc:
573 logger.warning("⚠️ symlog delete: could not remove %s: %s", p, exc)
574 return 1, 0
575
576 tmp_path = p.with_suffix(".lock")
577 try:
578 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
579 os.replace(tmp_path, p)
580 except OSError as exc:
581 logger.warning("⚠️ symlog delete: atomic write failed for %s: %s", p, exc)
582 try:
583 tmp_path.unlink(missing_ok=True)
584 except OSError:
585 pass
586 raise
587
588 return 1, len(kept_lines)
589
590
591 # ---------------------------------------------------------------------------
592 # Phase 2 — Symbol diff and commit integration
593 # ---------------------------------------------------------------------------
594
595 @dataclass
596 class SymbolDiff:
597 """Result of comparing two symbol maps (old vs. new content IDs).
598
599 ``renames`` holds ``(old_name, new_name)`` pairs where the same content ID
600 appears in both the deleted and created sets — same object, different name.
601 Renamed symbols are removed from ``created`` and ``deleted`` so callers
602 never double-count them.
603 """
604
605 created: set[str]
606 deleted: set[str]
607 modified: set[str]
608 renames: list[tuple[str, str]]
609
610
611 def compute_symbol_diff(
612 old_symbols: dict[str, str],
613 new_symbols: dict[str, str],
614 old_rename_keys: dict[str, str] | None = None,
615 new_rename_keys: dict[str, str] | None = None,
616 ) -> SymbolDiff:
617 """Diff two ``{symbol_name: content_id}`` maps.
618
619 Rename detection: a name that disappeared in *old* whose rename key matches
620 that of a new name that didn't exist before is classified as a rename rather
621 than a delete + create.
622
623 *old_rename_keys* / *new_rename_keys* are optional ``{name: key}`` maps
624 used exclusively for rename matching — typically ``body_hash`` values from
625 ``parse_symbols``, which are stable across pure renames. When not supplied,
626 the ``content_id`` from *old_symbols* / *new_symbols* is used as the key
627 instead (correct for unit tests with artificial IDs; rarely matches in
628 practice for real code because ``content_id`` encodes the function name).
629
630 A content-ID change with a name change is always treated as delete + create.
631 """
632 _old_rk = old_rename_keys if old_rename_keys is not None else old_symbols
633 _new_rk = new_rename_keys if new_rename_keys is not None else new_symbols
634
635 old_names = set(old_symbols)
636 new_names = set(new_symbols)
637
638 raw_deleted = old_names - new_names
639 raw_created = new_names - old_names
640 modified = {n for n in old_names & new_names if old_symbols[n] != new_symbols[n]}
641
642 # Build an inverted map of rename_key → name for the *created* side.
643 created_by_rk: dict[str, str] = {}
644 for name in raw_created:
645 rk = _new_rk.get(name, "")
646 if not rk:
647 continue
648 # Only map when a single created name has this key — ambiguous keys
649 # cannot be deterministically paired with a deleted name.
650 if rk not in created_by_rk:
651 created_by_rk[rk] = name
652 else:
653 created_by_rk[rk] = "" # sentinel: ambiguous
654
655 renames: list[tuple[str, str]] = []
656 confirmed_deleted = set(raw_deleted)
657 confirmed_created = set(raw_created)
658
659 for old_name in sorted(raw_deleted):
660 rk = _old_rk.get(old_name, "")
661 new_name = created_by_rk.get(rk) if rk else None
662 if new_name: # non-empty sentinel → unambiguous match
663 renames.append((old_name, new_name))
664 confirmed_deleted.discard(old_name)
665 confirmed_created.discard(new_name)
666
667 return SymbolDiff(
668 created=confirmed_created,
669 deleted=confirmed_deleted,
670 modified=modified,
671 renames=renames,
672 )
673
674
675 def extract_symbols(
676 repo_root: pathlib.Path,
677 file_object_id: str,
678 file_path: str = "",
679 ) -> dict[str, str]:
680 """Read a file object from the store and return ``{symbol_name: content_id}``.
681
682 Uses the code AST parser to extract top-level symbols. Returns an empty
683 dict for objects that are not present in the store, cannot be parsed, or
684 are non-code files. Never raises — parse failures are silently swallowed
685 because symlog writes are non-fatal.
686
687 Args:
688 repo_root: Root of the Muse repository.
689 file_object_id: Content-addressed ID of the file object (``sha256:…``).
690 file_path: Repo-relative file path passed to ``parse_symbols`` so
691 symbol keys are properly namespaced (``"src/f.py::name"``).
692 """
693 try:
694 from muse.core.object_store import read_object
695 source = read_object(repo_root, file_object_id)
696 if source is None:
697 return {}
698 from muse.plugins.code.ast_parser import parse_symbols
699 # parse_symbols returns {full_addr: {content_id: …, …}}
700 # Keys are "file.py::name" — we want just the name part.
701 raw = parse_symbols(source, file_path)
702 result: dict[str, str] = {}
703 for addr, sym in raw.items():
704 cid = sym.get("content_id", "")
705 if not cid:
706 continue
707 name = addr.split("::")[-1] if "::" in addr else addr
708 result[name] = cid
709 return result
710 except Exception:
711 return {}
712
713
714 def _extract_body_hashes(
715 repo_root: pathlib.Path,
716 file_object_id: str,
717 file_path: str = "",
718 ) -> dict[str, str]:
719 """Return ``{symbol_name: body_hash}`` for rename detection.
720
721 ``body_hash`` hashes only the function body, not the name — two functions
722 with the same body but different names share the same ``body_hash``, making
723 it the correct key for rename matching. Falls back to ``content_id`` if
724 ``body_hash`` is absent from the symbol record.
725 """
726 try:
727 from muse.core.object_store import read_object
728 source = read_object(repo_root, file_object_id)
729 if source is None:
730 return {}
731 from muse.plugins.code.ast_parser import parse_symbols
732 raw = parse_symbols(source, file_path)
733 result: dict[str, str] = {}
734 for addr, sym in raw.items():
735 key = sym.get("body_hash", "") or sym.get("content_id", "")
736 if not key:
737 continue
738 name = addr.split("::")[-1] if "::" in addr else addr
739 result[name] = key
740 return result
741 except Exception:
742 return {}
743
744
745 def _write_symlogs(
746 repo_root: pathlib.Path,
747 parent_commit_id: str | None,
748 new_snapshot_id: str,
749 new_commit_id: str,
750 author: str,
751 commit_message: str,
752 ) -> None:
753 """Write symlog entries for every symbol that changed in this commit.
754
755 Called by ``muse commit`` after the commit record is finalised. Wrapped
756 in a broad ``try/except`` at the call site — a failure here must never
757 abort a commit.
758
759 Algorithm:
760
761 1. Build ``old_manifest`` from the parent commit (empty for initial commit).
762 2. For each file whose object ID changed, extract old and new symbol maps.
763 3. Diff the two maps to classify changes as created/modified/deleted/renamed.
764 4. Append the appropriate symlog entry for each changed symbol.
765 """
766 from muse.core.object_store import read_object
767 from muse.core.commits import read_commit
768 from muse.core.snapshots import read_snapshot
769
770 # Load parent manifest.
771 old_manifest: dict[str, str] = {}
772 if parent_commit_id is not None:
773 parent_rec = read_commit(repo_root, parent_commit_id)
774 if parent_rec is not None:
775 snap_rec = read_snapshot(repo_root, parent_rec.snapshot_id)
776 if snap_rec is not None:
777 old_manifest = dict(snap_rec.manifest)
778
779 # Load new manifest.
780 new_snap_rec = read_snapshot(repo_root, new_snapshot_id)
781 if new_snap_rec is None:
782 return
783 new_manifest: dict[str, str] = dict(new_snap_rec.manifest)
784
785 first_line = (commit_message.splitlines()[0] if commit_message else "").strip()
786
787 # Find files whose object IDs differ.
788 all_paths = set(old_manifest) | set(new_manifest)
789 for file_path in sorted(all_paths):
790 old_obj = old_manifest.get(file_path, "")
791 new_obj = new_manifest.get(file_path, "")
792 if old_obj == new_obj:
793 continue # file unchanged at object level
794
795 old_syms = extract_symbols(repo_root, old_obj, file_path) if old_obj else {}
796 new_syms = extract_symbols(repo_root, new_obj, file_path) if new_obj else {}
797
798 if not old_syms and not new_syms:
799 continue # non-code file
800
801 old_bodies = _extract_body_hashes(repo_root, old_obj, file_path) if old_obj else {}
802 new_bodies = _extract_body_hashes(repo_root, new_obj, file_path) if new_obj else {}
803 diff = compute_symbol_diff(old_syms, new_syms, old_bodies, new_bodies)
804
805 # Created
806 for name in sorted(diff.created):
807 append_symlog(
808 repo_root,
809 f"{file_path}::{name}",
810 old_content_id=NULL_CONTENT_ID,
811 new_content_id=new_syms[name],
812 commit_id=new_commit_id,
813 author=author,
814 operation=f"symbol-created: {name}",
815 )
816
817 # Modified
818 for name in sorted(diff.modified):
819 append_symlog(
820 repo_root,
821 f"{file_path}::{name}",
822 old_content_id=old_syms[name],
823 new_content_id=new_syms[name],
824 commit_id=new_commit_id,
825 author=author,
826 operation=f"symbol-modified: {first_line}",
827 )
828
829 # Deleted
830 for name in sorted(diff.deleted):
831 append_symlog(
832 repo_root,
833 f"{file_path}::{name}",
834 old_content_id=old_syms[name],
835 new_content_id=NULL_CONTENT_ID,
836 commit_id=new_commit_id,
837 author=author,
838 operation=f"symbol-deleted: {name}",
839 )
840
841 # Renamed — two entries per rename
842 for old_name, new_name in diff.renames:
843 new_addr = f"{file_path}::{new_name}"
844 old_addr = f"{file_path}::{old_name}"
845 # Terminal entry on the old path
846 append_symlog(
847 repo_root,
848 old_addr,
849 old_content_id=old_syms[old_name],
850 new_content_id=NULL_CONTENT_ID,
851 commit_id=new_commit_id,
852 author=author,
853 operation=f"symbol-renamed-to: {new_addr}",
854 )
855 # Born-from entry on the new path
856 append_symlog(
857 repo_root,
858 new_addr,
859 old_content_id=NULL_CONTENT_ID,
860 new_content_id=new_syms[new_name],
861 commit_id=new_commit_id,
862 author=author,
863 operation=f"symbol-born-from: {old_addr}",
864 )
865
866
867 # ---------------------------------------------------------------------------
868 # Phase 5 — @{N} ref resolution
869 # ---------------------------------------------------------------------------
870
871 #: Regex matching ``<addr>@{N}`` — anchored at both ends.
872 _SYMLOG_REF_RE = re.compile(r"^(.+)@\{(\d+)\}$")
873
874
875 @dataclass(frozen=True)
876 class SymlogResolution:
877 """Result of resolving a ``<addr>@{N}`` symlog reference.
878
879 ``followed_from`` is non-None only when ``--follow`` crossed a rename
880 boundary — it holds the prior symbol address that contributed this entry.
881 ``content_id`` equals ``new_content_id`` of the resolved ``SymlogEntry``.
882 """
883
884 symbol: str # base symbol address (without @{N})
885 index: int # 0-based index (0 = newest)
886 content_id: str # new_content_id at this index
887 commit_id: str # commit that caused this entry
888 operation: str # lifecycle description
889 timestamp: datetime.datetime # UTC-aware entry timestamp
890 followed_from: str | None = None # prior addr when follow crossed a boundary
891
892
893 def resolve_symlog_addr(
894 spec: str,
895 repo_root: pathlib.Path,
896 *,
897 follow: bool = False,
898 ) -> "SymlogResolution | None":
899 """Resolve a ``<addr>@{N}`` symlog reference to a :class:`SymlogResolution`.
900
901 Args:
902 spec: Full spec string, e.g. ``"billing.py::compute_total@{2}"``.
903 repo_root: Root of the Muse repository.
904 follow: When ``True``, traverse rename chains so indices beyond the
905 primary symbol's log are resolved from prior symbol addresses.
906
907 Returns:
908 A :class:`SymlogResolution` on success, or ``None`` if *spec* does not
909 match the ``@{N}`` pattern (so callers can use ``if result is None:``
910 to fall through to normal processing).
911
912 Raises:
913 FileNotFoundError: No journal exists for the resolved symbol address.
914 IndexError: ``(index, total)`` when *index* is out of range.
915 ValueError: *addr* is not a valid symbol address (missing ``::``).
916 """
917 m = _SYMLOG_REF_RE.match(spec)
918 if m is None:
919 return None
920
921 addr = m.group(1)
922 index = int(m.group(2))
923
924 # Validate that the addr portion is a proper symbol address.
925 # This will raise ValueError if :: is missing (propagated to caller).
926 p = symlog_path(repo_root, addr)
927 if not p.exists():
928 raise FileNotFoundError(p)
929
930 if follow:
931 primary = read_symlog(repo_root, addr, limit=100_000, follow=False)
932 all_entries = read_symlog(repo_root, addr, limit=100_000, follow=True)
933 total = len(all_entries)
934 if index >= total:
935 raise IndexError(index, total)
936 entry = all_entries[index]
937 followed_from: str | None = None
938 if index >= len(primary) and primary:
939 followed_from = primary[-1].born_from
940 return SymlogResolution(
941 symbol=addr,
942 index=index,
943 content_id=entry.new_content_id,
944 commit_id=entry.commit_id,
945 operation=entry.operation,
946 timestamp=entry.timestamp,
947 followed_from=followed_from,
948 )
949
950 entries = read_symlog(repo_root, addr, limit=100_000, follow=False)
951 total = len(entries)
952 if index >= total:
953 raise IndexError(index, total)
954 entry = entries[index]
955 return SymlogResolution(
956 symbol=addr,
957 index=index,
958 content_id=entry.new_content_id,
959 commit_id=entry.commit_id,
960 operation=entry.operation,
961 timestamp=entry.timestamp,
962 followed_from=None,
963 )
964
965
966 def resolve_symbol_body(
967 repo_root: pathlib.Path,
968 symbol_addr: str,
969 commit_id: str,
970 ) -> "dict | None":
971 """Return symbol body info for *symbol_addr* at *commit_id*.
972
973 Reconstructs the symbol by reading: commit record → snapshot manifest →
974 file object → AST parse → slice by ``lineno``/``end_lineno``. Symbol
975 ``content_id`` values are AST-computed hashes not stored as raw objects,
976 so this walk is the only correct reconstruction path.
977
978 Returns a dict with keys ``source``, ``kind``, ``lineno``, ``end_lineno``,
979 ``qualified_name``, ``file_path`` on success, or ``None`` on any failure.
980 """
981 if "::" not in symbol_addr:
982 return None
983 file_path, _, symbol_name = symbol_addr.partition("::")
984 if not symbol_name:
985 return None
986
987 try:
988 from muse.core.commits import read_commit
989 from muse.core.object_store import read_object
990 from muse.core.snapshots import read_snapshot
991 from muse.plugins.code.ast_parser import adapter_for_path
992
993 commit_rec = read_commit(repo_root, commit_id)
994 if commit_rec is None:
995 return None
996 snap_rec = read_snapshot(repo_root, commit_rec.snapshot_id)
997 if snap_rec is None:
998 return None
999 file_obj_id = snap_rec.manifest.get(file_path)
1000 if file_obj_id is None:
1001 return None
1002 raw = read_object(repo_root, file_obj_id)
1003 if raw is None:
1004 return None
1005
1006 adapter = adapter_for_path(file_path)
1007 tree = adapter.parse_symbols(raw, file_path)
1008
1009 # Prefer exact qualified_name match, fall back to bare name.
1010 found = next(
1011 (r for r in tree.values() if r["qualified_name"] == symbol_name),
1012 None,
1013 )
1014 if found is None:
1015 found = next(
1016 (r for r in tree.values()
1017 if r["name"] == symbol_name and r["kind"] != "import"),
1018 None,
1019 )
1020 if found is None:
1021 return None
1022
1023 lineno = found["lineno"]
1024 end_lineno = found["end_lineno"]
1025 text = raw.decode("utf-8", errors="replace")
1026 lines = text.splitlines()
1027 # lineno is 1-based inclusive; end_lineno is 1-based inclusive.
1028 source = "\n".join(lines[lineno - 1:end_lineno])
1029 return {
1030 "source": source,
1031 "kind": found["kind"],
1032 "lineno": lineno,
1033 "end_lineno": end_lineno,
1034 "qualified_name": found["qualified_name"],
1035 "file_path": file_path,
1036 }
1037 except Exception:
1038 return None
File History 3 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago