"""Optional local index layer for Muse repositories. Indexes live under ``.muse/indices/`` and are derived, versioned, and fully rebuildable from the commit history. **No index is required for repository correctness** — all commands work without them; indexes only accelerate repeated queries. Available indexes ----------------- ``symbol_history`` Maps symbol addresses to their event timeline across all commits. Enables O(1) ``muse symbol-log``, ``muse lineage``, and ``muse query-history`` instead of O(commits × files) scans. ``hash_occurrence`` Maps ``body_hash`` values to the list of symbol addresses that share them. Enables O(1) ``muse clones`` and ``muse find-symbol hash=``. Storage format -------------- Index files are stored as **msgpack** (``*.msgpack``) under ``.muse/indices/``. msgpack is used instead of JSON because it is ~2× faster to serialize and deserialize, and the files are internal — they are never directly read by a human or an agent. All user-facing output (``--json`` flag) is still JSON built from the deserialized in-memory structures. Writes are **atomic**: data is flushed to a ``.tmp`` sibling and then renamed over the target file so a crash mid-write never corrupts an index. Rebuild ------- Indexes are rebuilt by ``muse code index rebuild``. They can also be built incrementally: the ``update_*_index`` functions accept an existing index dict and patch it rather than rebuilding from scratch. """ from __future__ import annotations import datetime import logging import pathlib from typing import TypedDict import msgpack from muse._version import __version__ as _SCHEMA_VERSION from muse.core._types import Metadata, MsgpackDict from muse.core.store import MsgpackValue logger = logging.getLogger(__name__) type _EntryList = list[Metadata] type _EntryMap = dict[str, _EntryList] type _StringListMap = dict[str, list[str]] # --------------------------------------------------------------------------- # Typed index entry shapes # --------------------------------------------------------------------------- class SymbolHistoryEntry: """One event in a symbol's history timeline.""" __slots__ = ( "commit_id", "committed_at", "op", "content_id", "body_hash", "signature_id", ) def __init__( self, commit_id: str, committed_at: str, op: str, content_id: str, body_hash: str, signature_id: str, ) -> None: self.commit_id = commit_id self.committed_at = committed_at self.op = op self.content_id = content_id self.body_hash = body_hash self.signature_id = signature_id def to_dict(self) -> Metadata: """Serialise to a plain string-keyed, string-valued mapping.""" return { "commit_id": self.commit_id, "committed_at": self.committed_at, "op": self.op, "content_id": self.content_id, "body_hash": self.body_hash, "signature_id": self.signature_id, } @classmethod def from_dict(cls, d: Metadata) -> "SymbolHistoryEntry": """Construct from a typed string-keyed mapping (e.g. ``to_dict`` output).""" return cls( commit_id=d["commit_id"], committed_at=d["committed_at"], op=d["op"], content_id=d["content_id"], body_hash=d["body_hash"], signature_id=d["signature_id"], ) @classmethod def from_msgpack(cls, d: MsgpackDict) -> "SymbolHistoryEntry": """Deserialise from a raw msgpack mapping (all fields coerced to ``str``).""" def _s(key: str) -> str: v = d.get(key, "") return v if isinstance(v, str) else "" return cls( commit_id=_s("commit_id"), committed_at=_s("committed_at"), op=_s("op"), content_id=_s("content_id"), body_hash=_s("body_hash"), signature_id=_s("signature_id"), ) # --------------------------------------------------------------------------- # On-disk document shapes (one TypedDict per index type) # --------------------------------------------------------------------------- class _SymbolHistoryDoc(TypedDict): """Msgpack document layout for the ``symbol_history`` index file.""" schema_version: str index: str updated_at: str entries: _EntryMap class _HashOccurrenceDoc(TypedDict): """Msgpack document layout for the ``hash_occurrence`` index file.""" schema_version: str index: str updated_at: str entries: _StringListMap # --------------------------------------------------------------------------- # Index I/O helpers # --------------------------------------------------------------------------- def _indices_dir(root: pathlib.Path) -> pathlib.Path: return root / ".muse" / "indices" def _index_path(root: pathlib.Path, name: str) -> pathlib.Path: """Return the msgpack file path for a named index.""" return _indices_dir(root) / f"{name}.msgpack" def _ensure_dir(root: pathlib.Path) -> None: _indices_dir(root).mkdir(parents=True, exist_ok=True) def _now_iso() -> str: return datetime.datetime.now(datetime.timezone.utc).isoformat() def _write_symbol_history_doc(path: pathlib.Path, doc: _SymbolHistoryDoc) -> None: """Atomically serialise a :class:`_SymbolHistoryDoc` to *path*.""" _write_bytes_atomic(path, msgpack.packb(doc, use_bin_type=True)) def _write_hash_occurrence_doc(path: pathlib.Path, doc: _HashOccurrenceDoc) -> None: """Atomically serialise a :class:`_HashOccurrenceDoc` to *path*.""" _write_bytes_atomic(path, msgpack.packb(doc, use_bin_type=True)) # --------------------------------------------------------------------------- # Read safety constants (index-specific) # --------------------------------------------------------------------------- # Index files grow with repository size and can reach hundreds of MiB for # large repos. The cap is more generous than the commit/snapshot store while # still bounding worst-case memory allocation before read_bytes() is called. _MAX_INDEX_BYTES: int = 512 * 1024 * 1024 # 512 MiB _INDEX_MAX_STR_LEN: int = 1_048_576 # 1 MiB per string value _INDEX_MAX_BIN_LEN: int = 1_048_576 # allow binary body hashes stored as bytes _INDEX_MAX_ARRAY_LEN: int = 10_000_000 # large repos have millions of symbol events _INDEX_MAX_MAP_LEN: int = 10_000_000 # same — hash_occurrence can be very wide def _write_bytes_atomic(path: pathlib.Path, packed: bytes) -> None: """Replace *path* with *packed* using a ``.tmp`` rename to guarantee atomicity.""" tmp = path.with_suffix(".tmp") try: tmp.write_bytes(packed) tmp.replace(path) except OSError: tmp.unlink(missing_ok=True) raise def _read_msgpack(path: pathlib.Path) -> MsgpackValue: """Read and unpack an index msgpack file, enforcing size and per-value limits. Index files can grow into the hundreds of MiB for large repositories, so the cap is more generous than the commit/snapshot store. The stat check still fires *before* ``read_bytes()`` so a gigantic or adversarially crafted index file never triggers an OOM during the read itself. Raises :exc:`OSError` if the file exceeds ``_MAX_INDEX_BYTES``. """ size = path.stat().st_size if size > _MAX_INDEX_BYTES: raise OSError( f"Index file {path.name!r} is {size:,} bytes, exceeding the " f"{_MAX_INDEX_BYTES // (1024 * 1024)} MiB read limit." ) result: MsgpackValue = msgpack.unpackb( path.read_bytes(), raw=False, max_str_len=_INDEX_MAX_STR_LEN, max_bin_len=_INDEX_MAX_BIN_LEN, max_array_len=_INDEX_MAX_ARRAY_LEN, max_map_len=_INDEX_MAX_MAP_LEN, ) return result # --------------------------------------------------------------------------- # Symbol history index # --------------------------------------------------------------------------- SymbolHistoryIndex = dict[str, list[SymbolHistoryEntry]] def load_symbol_history(root: pathlib.Path) -> SymbolHistoryIndex: """Load the symbol history index, returning an empty dict if absent.""" path = _index_path(root, "symbol_history") if not path.exists(): return {} try: raw = _read_msgpack(path) if not isinstance(raw, dict): raise ValueError("top-level document is not a dict") entries_raw = raw.get("entries") if not isinstance(entries_raw, dict): return {} result: SymbolHistoryIndex = {} for address, entry_list in entries_raw.items(): if not isinstance(entry_list, list): continue entries: list[SymbolHistoryEntry] = [] for item in entry_list: if isinstance(item, dict): entries.append(SymbolHistoryEntry.from_msgpack(item)) result[address] = entries return result except Exception as exc: logger.warning("⚠️ Corrupt symbol_history index: %s — returning empty", exc) return {} def save_symbol_history(root: pathlib.Path, index: SymbolHistoryIndex) -> None: """Persist the symbol history index as msgpack (atomic write).""" _ensure_dir(root) path = _index_path(root, "symbol_history") doc = _SymbolHistoryDoc( schema_version=_SCHEMA_VERSION, index="symbol_history", updated_at=_now_iso(), entries={ addr: [e.to_dict() for e in evts] for addr, evts in sorted(index.items()) }, ) _write_symbol_history_doc(path, doc) logger.debug("✅ Saved symbol_history index (%d addresses)", len(index)) # --------------------------------------------------------------------------- # Hash occurrence index # --------------------------------------------------------------------------- HashOccurrenceIndex = dict[str, list[str]] def load_hash_occurrence(root: pathlib.Path) -> HashOccurrenceIndex: """Load the hash occurrence index, returning an empty dict if absent.""" path = _index_path(root, "hash_occurrence") if not path.exists(): return {} try: raw = _read_msgpack(path) if not isinstance(raw, dict): raise ValueError("top-level document is not a dict") entries_raw = raw.get("entries") if not isinstance(entries_raw, dict): return {} result: HashOccurrenceIndex = {} for body_hash, addresses in entries_raw.items(): if isinstance(addresses, list): result[body_hash] = [a for a in addresses if isinstance(a, str)] return result except Exception as exc: logger.warning("⚠️ Corrupt hash_occurrence index: %s — returning empty", exc) return {} def save_hash_occurrence(root: pathlib.Path, index: HashOccurrenceIndex) -> None: """Persist the hash occurrence index as msgpack (atomic write).""" _ensure_dir(root) path = _index_path(root, "hash_occurrence") doc = _HashOccurrenceDoc( schema_version=_SCHEMA_VERSION, index="hash_occurrence", updated_at=_now_iso(), entries={h: sorted(addrs) for h, addrs in sorted(index.items())}, ) _write_hash_occurrence_doc(path, doc) logger.debug("✅ Saved hash_occurrence index (%d hashes)", len(index)) # --------------------------------------------------------------------------- # Index metadata # --------------------------------------------------------------------------- #: All index names known to this module. KNOWN_INDEX_NAMES: tuple[str, ...] = ("symbol_history", "hash_occurrence") class IndexInfoEntry(TypedDict): """Status information for one local index — returned by :func:`index_info`.""" name: str status: str # "present" | "absent" | "corrupt" entries: int updated_at: str | None def index_info(root: pathlib.Path) -> list[IndexInfoEntry]: """Return status information about all known indexes. ``entries`` is always an ``int``. Callers must not convert it. """ result: list[IndexInfoEntry] = [] for name in KNOWN_INDEX_NAMES: path = _index_path(root, name) if path.exists(): try: raw = _read_msgpack(path) if not isinstance(raw, dict): raise ValueError("not a dict") upd = raw.get("updated_at") updated_at: str | None = upd if isinstance(upd, str) else None entries_data = raw.get("entries") entries = len(entries_data) if isinstance(entries_data, dict) else 0 result.append(IndexInfoEntry( name=name, status="present", updated_at=updated_at, entries=entries, )) except Exception: result.append(IndexInfoEntry( name=name, status="corrupt", updated_at=None, entries=0, )) else: result.append(IndexInfoEntry( name=name, status="absent", updated_at=None, entries=0, )) return result def purge_index(root: pathlib.Path, name: str) -> bool: """Delete the on-disk file for the named index. Returns ``True`` if the file existed and was deleted, ``False`` if it was already absent. Raises ``ValueError`` for unknown index names. """ if name not in KNOWN_INDEX_NAMES: raise ValueError(f"Unknown index name '{name}'. Valid names: {', '.join(KNOWN_INDEX_NAMES)}") path = _index_path(root, name) try: path.unlink() logger.debug("🗑️ Purged index: %s", name) return True except FileNotFoundError: return False