"""Tests for muse/core/symlog.py — Phase 1 core storage layer. Covers SL_01 through SL_12. """ from __future__ import annotations import datetime import pathlib import time import pytest # --------------------------------------------------------------------------- # Helpers that will be imported once the module exists # --------------------------------------------------------------------------- from muse.core.symlog import ( NULL_CONTENT_ID, SymlogEntry, append_symlog, delete_symlog_entry, expire_symlog, is_null_content_id, list_symlog_symbols, list_symlog_symbols_for_file, read_symlog, symlog_path, ) # Test fixtures — distinct content IDs (sha256: + 64-hex) _CID_A = "sha256:" + "a" * 64 _CID_B = "sha256:" + "b" * 64 _CID_C = "sha256:" + "c" * 64 _CID_D = "sha256:" + "d" * 64 _COMMIT_X = "sha256:" + "1" * 64 _COMMIT_Y = "sha256:" + "2" * 64 _COMMIT_Z = "sha256:" + "3" * 64 # =========================================================================== # SL_11 — NULL_CONTENT_ID and is_null_content_id # =========================================================================== class TestNullContentId: def test_null_content_id_format(self) -> None: """NULL_CONTENT_ID is sha256: followed by 64 zero hex chars.""" assert NULL_CONTENT_ID == "sha256:" + "0" * 64 def test_is_null_content_id_true(self) -> None: assert is_null_content_id(NULL_CONTENT_ID) is True def test_is_null_content_id_false_for_real_id(self) -> None: assert is_null_content_id(_CID_A) is False def test_is_null_content_id_false_for_bare_zeros(self) -> None: """Bare 64-char zeros without prefix are NOT the null ID.""" assert is_null_content_id("0" * 64) is False # =========================================================================== # SL_01 — SymlogEntry dataclass # =========================================================================== class TestSymlogEntry: def _make_entry( self, old_content_id: str = _CID_A, new_content_id: str = _CID_B, commit_id: str = _COMMIT_X, author: str = "claude-code", operation: str = "symbol-modified: add rounding", ts: datetime.datetime | None = None, ) -> SymlogEntry: return SymlogEntry( old_content_id=old_content_id, new_content_id=new_content_id, commit_id=commit_id, author=author, timestamp=ts or datetime.datetime.now(tz=datetime.timezone.utc), operation=operation, ) def test_has_seven_fields(self) -> None: """SymlogEntry exposes the seven required fields.""" e = self._make_entry() assert hasattr(e, "old_content_id") assert hasattr(e, "new_content_id") assert hasattr(e, "commit_id") assert hasattr(e, "author") assert hasattr(e, "timestamp") assert hasattr(e, "operation") assert hasattr(e, "born_from") def test_frozen(self) -> None: e = self._make_entry() with pytest.raises((AttributeError, TypeError)): e.author = "other" # type: ignore[misc] def test_born_from_none_for_regular_operation(self) -> None: e = self._make_entry(operation="symbol-modified: fix rounding") assert e.born_from is None def test_born_from_parsed_for_born_from_operation(self) -> None: """born_from is parsed from the operation string.""" e = self._make_entry(operation="symbol-born-from: src/billing.py::compute_total") assert e.born_from == "src/billing.py::compute_total" def test_born_from_none_for_symbol_created(self) -> None: e = self._make_entry( old_content_id=NULL_CONTENT_ID, operation="symbol-created: compute_total", ) assert e.born_from is None def test_born_from_none_for_symbol_renamed_to(self) -> None: """symbol-renamed-to is a terminal entry on the OLD path — born_from stays None.""" e = self._make_entry( new_content_id=NULL_CONTENT_ID, operation="symbol-renamed-to: src/billing.py::compute_invoice_total", ) assert e.born_from is None def test_born_from_extracts_full_address(self) -> None: addr = "src/deep/module.py::SomeClass.method_name" e = self._make_entry(operation=f"symbol-born-from: {addr}") assert e.born_from == addr # =========================================================================== # SL_04 — Path encoding # =========================================================================== class TestPathEncoding: def test_simple_symbol_maps_correctly(self, tmp_path: pathlib.Path) -> None: """src/billing.py::compute_total → .muse/symlogs/src/billing.py/compute_total""" p = symlog_path(tmp_path, "src/billing.py::compute_total") assert p == tmp_path / ".muse" / "symlogs" / "src" / "billing.py" / "compute_total" def test_nested_path_maps_correctly(self, tmp_path: pathlib.Path) -> None: p = symlog_path(tmp_path, "tests/test_billing.py::test_compute_total") assert p == tmp_path / ".muse" / "symlogs" / "tests" / "test_billing.py" / "test_compute_total" def test_special_chars_in_symbol_name_are_percent_encoded(self, tmp_path: pathlib.Path) -> None: """Characters outside [a-zA-Z0-9._-] in the symbol name are percent-encoded.""" p = symlog_path(tmp_path, "src/billing.py::compute total") leaf = p.name assert " " not in leaf assert "%" in leaf # percent-encoded def test_colon_in_symbol_name_is_percent_encoded(self, tmp_path: pathlib.Path) -> None: """The :: separator splits file path from symbol name; colons IN the symbol name are encoded.""" p = symlog_path(tmp_path, "src/billing.py::SomeClass:method") leaf = p.name assert ":" not in leaf def test_dotdot_in_file_path_raises(self, tmp_path: pathlib.Path) -> None: with pytest.raises(ValueError, match=r"\.\.|traversal"): symlog_path(tmp_path, "../escape.py::bad") def test_dotdot_in_symbol_name_raises(self, tmp_path: pathlib.Path) -> None: with pytest.raises(ValueError, match=r"\.\.|traversal"): symlog_path(tmp_path, "src/billing.py::../../../etc/passwd") def test_missing_double_colon_raises(self, tmp_path: pathlib.Path) -> None: with pytest.raises(ValueError, match=r"::|separator"): symlog_path(tmp_path, "src/billing.py") def test_empty_symbol_name_raises(self, tmp_path: pathlib.Path) -> None: with pytest.raises(ValueError, match=r"empty|symbol"): symlog_path(tmp_path, "src/billing.py::") def test_plain_symbol_name_unchanged(self, tmp_path: pathlib.Path) -> None: """Names with only [a-zA-Z0-9._-] chars pass through unchanged.""" p = symlog_path(tmp_path, "src/m.py::compute_total-v2.helper") assert p.name == "compute_total-v2.helper" # =========================================================================== # SL_02 + SL_10 — append_symlog and sanitization # =========================================================================== class TestAppendSymlog: def test_creates_parent_dirs_and_file(self, tmp_path: pathlib.Path) -> None: append_symlog( tmp_path, "src/billing.py::compute_total", old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A, commit_id=_COMMIT_X, author="claude-code", operation="symbol-created: compute_total", ) p = symlog_path(tmp_path, "src/billing.py::compute_total") assert p.exists() assert p.is_file() def test_one_line_written_per_call(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" for i in range(3): append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation=f"symbol-modified: change {i}", ) p = symlog_path(tmp_path, addr) lines = [l for l in p.read_text().splitlines() if l.strip()] assert len(lines) == 3 def test_line_format(self, tmp_path: pathlib.Path) -> None: """Each line is tab-delimited between metadata and operation.""" addr = "src/billing.py::compute_total" append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation="symbol-modified: add rounding", ) p = symlog_path(tmp_path, addr) line = p.read_text().strip() assert "\t" in line meta, op = line.split("\t", 1) tokens = meta.split() # old_content_id new_content_id commit_id author ts tz assert tokens[0] == _CID_A assert tokens[1] == _CID_B assert tokens[2] == _COMMIT_X assert op == "symbol-modified: add rounding" def test_author_newline_stripped(self, tmp_path: pathlib.Path) -> None: """SL_10: newlines in author are stripped before write.""" addr = "src/billing.py::compute_total" append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="bad\nauthor", operation="symbol-modified: x", ) entries = read_symlog(tmp_path, addr) assert "\n" not in entries[0].author def test_author_tab_stripped(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="bad\tauthor", operation="symbol-modified: x", ) entries = read_symlog(tmp_path, addr) assert "\t" not in entries[0].author def test_operation_newline_stripped(self, tmp_path: pathlib.Path) -> None: """SL_10: newlines in operation are stripped to prevent line injection.""" addr = "src/billing.py::compute_total" append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation="symbol-modified: injected\nfake entry", ) entries = read_symlog(tmp_path, addr) assert "\n" not in entries[0].operation def test_different_symbols_same_file_get_separate_files(self, tmp_path: pathlib.Path) -> None: for name in ("compute_total", "validate_invoice"): append_symlog( tmp_path, f"src/billing.py::{name}", old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A, commit_id=_COMMIT_X, author="claude-code", operation=f"symbol-created: {name}", ) p1 = symlog_path(tmp_path, "src/billing.py::compute_total") p2 = symlog_path(tmp_path, "src/billing.py::validate_invoice") assert p1.exists() assert p2.exists() assert p1 != p2 # =========================================================================== # SL_03 + SL_09 — read_symlog (newest-first, limit, follow, size cap) # =========================================================================== class TestReadSymlog: def _write_n_entries( self, tmp_path: pathlib.Path, addr: str, n: int, *, old_cid: str = _CID_A, new_cid: str = _CID_B, ) -> None: for i in range(n): append_symlog( tmp_path, addr, old_content_id=old_cid, new_content_id=new_cid, commit_id=_COMMIT_X, author="claude-code", operation=f"symbol-modified: change {i}", ) def test_empty_log_returns_empty_list(self, tmp_path: pathlib.Path) -> None: entries = read_symlog(tmp_path, "src/billing.py::compute_total") assert entries == [] def test_entries_returned_newest_first(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" append_symlog(tmp_path, addr, _CID_A, _CID_B, _COMMIT_X, "claude-code", "symbol-modified: first") time.sleep(0.01) append_symlog(tmp_path, addr, _CID_B, _CID_C, _COMMIT_Y, "claude-code", "symbol-modified: second") entries = read_symlog(tmp_path, addr) assert entries[0].operation == "symbol-modified: second" assert entries[1].operation == "symbol-modified: first" def test_limit_respected(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_n_entries(tmp_path, addr, 10) entries = read_symlog(tmp_path, addr, limit=3) assert len(entries) == 3 def test_all_fields_round_trip(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation="symbol-modified: round-trip test", ) entries = read_symlog(tmp_path, addr) assert len(entries) == 1 e = entries[0] assert e.old_content_id == _CID_A assert e.new_content_id == _CID_B assert e.commit_id == _COMMIT_X assert e.author == "claude-code" assert e.operation == "symbol-modified: round-trip test" assert isinstance(e.timestamp, datetime.datetime) assert e.timestamp.tzinfo is not None def test_born_from_parsed_on_read(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_invoice_total" old_addr = "src/billing.py::compute_total" append_symlog( tmp_path, addr, old_content_id=NULL_CONTENT_ID, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation=f"symbol-born-from: {old_addr}", ) entries = read_symlog(tmp_path, addr) assert entries[0].born_from == old_addr def test_follow_traverses_rename_chain(self, tmp_path: pathlib.Path) -> None: """SL_03: follow=True reads prior symbol's log when oldest entry is born-from.""" old_addr = "src/billing.py::compute_total" new_addr = "src/billing.py::compute_invoice_total" # Write two entries in old symbol's log append_symlog(tmp_path, old_addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total") append_symlog(tmp_path, old_addr, _CID_A, _CID_B, _COMMIT_Y, "claude-code", "symbol-modified: improve") # Terminal rename entry on old path append_symlog(tmp_path, old_addr, _CID_B, NULL_CONTENT_ID, _COMMIT_Z, "claude-code", f"symbol-renamed-to: {new_addr}") # Born-from entry on new path append_symlog(tmp_path, new_addr, NULL_CONTENT_ID, _CID_C, _COMMIT_Z, "claude-code", f"symbol-born-from: {old_addr}") entries = read_symlog(tmp_path, new_addr, follow=True) # Should have new entry + old entries (created, modified, renamed-to) assert len(entries) >= 4 # The new_addr entry should be first (newest) assert entries[0].operation.startswith("symbol-born-from:") def test_file_size_cap_warns_and_returns(self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture) -> None: """SL_09: files over _MAX_SYMLOG_BYTES trigger a warning.""" import muse.core.symlog as symlog_mod addr = "src/billing.py::compute_total" p = symlog_path(tmp_path, addr) p.parent.mkdir(parents=True, exist_ok=True) original_cap = symlog_mod._MAX_SYMLOG_BYTES try: symlog_mod._MAX_SYMLOG_BYTES = 10 # tiny cap for test p.write_text("x" * 20) import logging with caplog.at_level(logging.WARNING, logger="muse.core.symlog"): result = read_symlog(tmp_path, addr) assert any("MiB" in r.message or "cap" in r.message.lower() or "exceeds" in r.message.lower() for r in caplog.records) finally: symlog_mod._MAX_SYMLOG_BYTES = original_cap def test_follow_false_does_not_traverse(self, tmp_path: pathlib.Path) -> None: """follow=False (default) reads only the requested symbol's log.""" old_addr = "src/billing.py::compute_total" new_addr = "src/billing.py::compute_invoice_total" append_symlog(tmp_path, old_addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total") append_symlog(tmp_path, new_addr, NULL_CONTENT_ID, _CID_B, _COMMIT_Y, "claude-code", f"symbol-born-from: {old_addr}") entries = read_symlog(tmp_path, new_addr, follow=False) assert len(entries) == 1 # only the new_addr entry, no old_addr entries # =========================================================================== # SL_05 + SL_06 — list_symlog_symbols and list_symlog_symbols_for_file # =========================================================================== class TestListSymlogSymbols: def _write(self, tmp_path: pathlib.Path, addr: str) -> None: append_symlog( tmp_path, addr, old_content_id=NULL_CONTENT_ID, new_content_id=_CID_A, commit_id=_COMMIT_X, author="claude-code", operation="symbol-created: x", ) def test_empty_repo_returns_empty(self, tmp_path: pathlib.Path) -> None: assert list_symlog_symbols(tmp_path) == [] def test_returns_all_symbol_addresses_sorted(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path, "src/billing.py::compute_total") self._write(tmp_path, "src/billing.py::validate_invoice") self._write(tmp_path, "src/auth.py::login") result = list_symlog_symbols(tmp_path) assert result == sorted(result) assert "src/billing.py::compute_total" in result assert "src/billing.py::validate_invoice" in result assert "src/auth.py::login" in result def test_skips_symlinks(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path, "src/billing.py::compute_total") real_p = symlog_path(tmp_path, "src/billing.py::compute_total") link_p = real_p.parent / "symlink_fn" link_p.symlink_to(real_p) result = list_symlog_symbols(tmp_path) # Only the real file — symlink excluded decoded = [r.split("::")[-1] for r in result] assert "symlink_fn" not in decoded def test_list_for_file_returns_only_that_files_symbols(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path, "src/billing.py::compute_total") self._write(tmp_path, "src/billing.py::validate_invoice") self._write(tmp_path, "src/auth.py::login") result = list_symlog_symbols_for_file(tmp_path, "src/billing.py") assert set(result) == { "src/billing.py::compute_total", "src/billing.py::validate_invoice", } assert "src/auth.py::login" not in result def test_list_for_file_empty_dir_returns_empty(self, tmp_path: pathlib.Path) -> None: result = list_symlog_symbols_for_file(tmp_path, "src/billing.py") assert result == [] def test_list_for_file_skips_symlinks(self, tmp_path: pathlib.Path) -> None: self._write(tmp_path, "src/billing.py::compute_total") real_p = symlog_path(tmp_path, "src/billing.py::compute_total") link_p = real_p.parent / "symlink_fn" link_p.symlink_to(real_p) result = list_symlog_symbols_for_file(tmp_path, "src/billing.py") decoded = [r.split("::")[-1] for r in result] assert "symlink_fn" not in decoded # =========================================================================== # SL_07 — expire_symlog # =========================================================================== class TestExpireSymlog: def _write_old(self, tmp_path: pathlib.Path, addr: str, age_days: float = 100.0) -> None: """Write an entry with a timestamp in the past.""" p = symlog_path(tmp_path, addr) p.parent.mkdir(parents=True, exist_ok=True) old_ts = int(time.time()) - int(age_days * 86400) line = f"{_CID_A} {_CID_B} {_COMMIT_X} claude-code {old_ts} +0000\tsymbol-modified: old\n" with p.open("a") as fh: fh.write(line) def _write_recent(self, tmp_path: pathlib.Path, addr: str) -> None: append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_C, commit_id=_COMMIT_Y, author="claude-code", operation="symbol-modified: recent", ) def test_no_log_file_returns_zero_zero(self, tmp_path: pathlib.Path) -> None: expired, kept = expire_symlog(tmp_path, "src/billing.py::compute_total", expire_days=90) assert (expired, kept) == (0, 0) def test_old_entries_expired(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_old(tmp_path, addr, age_days=100) self._write_recent(tmp_path, addr) expired, kept = expire_symlog(tmp_path, addr, expire_days=90) assert expired == 1 assert kept == 1 def test_all_expired_file_deleted(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_old(tmp_path, addr, age_days=200) expire_symlog(tmp_path, addr, expire_days=90) assert not symlog_path(tmp_path, addr).exists() def test_dry_run_writes_nothing(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_old(tmp_path, addr, age_days=200) expired, kept = expire_symlog(tmp_path, addr, expire_days=90, dry_run=True) assert expired == 1 assert symlog_path(tmp_path, addr).exists() # still there def test_atomic_write(self, tmp_path: pathlib.Path) -> None: """After expire the .lock temp file must not exist.""" addr = "src/billing.py::compute_total" self._write_old(tmp_path, addr, age_days=200) self._write_recent(tmp_path, addr) expire_symlog(tmp_path, addr, expire_days=90) lock = symlog_path(tmp_path, addr).with_suffix(".lock") assert not lock.exists() def test_recent_entries_kept(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_recent(tmp_path, addr) expired, kept = expire_symlog(tmp_path, addr, expire_days=90) assert expired == 0 assert kept == 1 assert symlog_path(tmp_path, addr).exists() # =========================================================================== # SL_08 — delete_symlog_entry # =========================================================================== class TestDeleteSymlogEntry: def _write_entries(self, tmp_path: pathlib.Path, addr: str, n: int) -> None: for i in range(n): append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation=f"symbol-modified: entry {i}", ) def test_delete_all_removes_file(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_entries(tmp_path, addr, 3) deleted, remaining = delete_symlog_entry(tmp_path, addr, index=None) assert deleted == 3 assert remaining == 0 assert not symlog_path(tmp_path, addr).exists() def test_delete_index_zero_removes_newest(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_entries(tmp_path, addr, 3) deleted, remaining = delete_symlog_entry(tmp_path, addr, index=0) assert deleted == 1 assert remaining == 2 entries = read_symlog(tmp_path, addr) assert len(entries) == 2 # @{0} was entry 2 (newest), so now entries 0 and 1 remain assert all("entry 2" not in e.operation for e in entries) def test_delete_middle_entry(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_entries(tmp_path, addr, 3) delete_symlog_entry(tmp_path, addr, index=1) entries = read_symlog(tmp_path, addr) assert len(entries) == 2 ops = [e.operation for e in entries] # Middle entry (index 1, which was "entry 1" in the file) is gone assert not any("entry 1" in op for op in ops) def test_out_of_bounds_raises_index_error(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_entries(tmp_path, addr, 3) with pytest.raises(IndexError): delete_symlog_entry(tmp_path, addr, index=99) def test_missing_log_all_is_graceful(self, tmp_path: pathlib.Path) -> None: deleted, remaining = delete_symlog_entry(tmp_path, "src/billing.py::compute_total", index=None) assert deleted == 0 assert remaining == 0 def test_missing_log_by_index_raises(self, tmp_path: pathlib.Path) -> None: with pytest.raises(FileNotFoundError): delete_symlog_entry(tmp_path, "src/billing.py::compute_total", index=0) def test_atomic_write_no_lock_file_left(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_entries(tmp_path, addr, 3) delete_symlog_entry(tmp_path, addr, index=0) lock = symlog_path(tmp_path, addr).with_suffix(".lock") assert not lock.exists() def test_delete_last_entry_removes_file(self, tmp_path: pathlib.Path) -> None: addr = "src/billing.py::compute_total" self._write_entries(tmp_path, addr, 1) deleted, remaining = delete_symlog_entry(tmp_path, addr, index=0) assert deleted == 1 assert remaining == 0 assert not symlog_path(tmp_path, addr).exists() # =========================================================================== # SL_12 — Integration test # =========================================================================== class TestIntegration: def test_five_entries_round_trip_newest_first(self, tmp_path: pathlib.Path) -> None: """SL_12: Write 5 entries, read back newest-first, verify all fields round-trip.""" addr = "src/billing.py::compute_total" ops = [f"symbol-modified: change {i}" for i in range(5)] for op in ops: append_symlog( tmp_path, addr, old_content_id=_CID_A, new_content_id=_CID_B, commit_id=_COMMIT_X, author="claude-code", operation=op, ) entries = read_symlog(tmp_path, addr, limit=100) assert len(entries) == 5 # Newest first — last written operation is at index 0 assert entries[0].operation == "symbol-modified: change 4" assert entries[4].operation == "symbol-modified: change 0" for e in entries: assert e.old_content_id == _CID_A assert e.new_content_id == _CID_B assert e.commit_id == _COMMIT_X assert e.author == "claude-code" assert e.born_from is None def test_two_symbols_same_file_independent(self, tmp_path: pathlib.Path) -> None: """SL_12: Two symbols in same file; list_symlog_symbols_for_file returns both.""" addr1 = "src/billing.py::compute_total" addr2 = "src/billing.py::validate_invoice" append_symlog(tmp_path, addr1, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total") append_symlog(tmp_path, addr2, NULL_CONTENT_ID, _CID_B, _COMMIT_Y, "claude-code", "symbol-created: validate_invoice") syms = list_symlog_symbols_for_file(tmp_path, "src/billing.py") assert addr1 in syms assert addr2 in syms assert len(syms) == 2 e1 = read_symlog(tmp_path, addr1) e2 = read_symlog(tmp_path, addr2) assert len(e1) == 1 assert len(e2) == 1 assert e1[0].new_content_id == _CID_A assert e2[0].new_content_id == _CID_B def test_created_deleted_sentinel_fields(self, tmp_path: pathlib.Path) -> None: """Sentinel entries use NULL_CONTENT_ID correctly and is_null_content_id detects them.""" addr = "src/billing.py::compute_total" # Created append_symlog(tmp_path, addr, NULL_CONTENT_ID, _CID_A, _COMMIT_X, "claude-code", "symbol-created: compute_total") # Modified append_symlog(tmp_path, addr, _CID_A, _CID_B, _COMMIT_Y, "claude-code", "symbol-modified: improve") # Deleted append_symlog(tmp_path, addr, _CID_B, NULL_CONTENT_ID, _COMMIT_Z, "claude-code", "symbol-deleted: compute_total") entries = read_symlog(tmp_path, addr, limit=100) assert len(entries) == 3 deleted_entry = entries[0] # newest first created_entry = entries[2] assert is_null_content_id(deleted_entry.new_content_id) assert is_null_content_id(created_entry.old_content_id) assert not is_null_content_id(entries[1].old_content_id) assert not is_null_content_id(entries[1].new_content_id)