"""Tests for symlog commit integration — Phase 2. Covers SL_13 through SL_22. """ from __future__ import annotations import json import os import pathlib import textwrap import pytest from tests.cli_test_helper import CliRunner from muse.core.ids import hash_blob from muse.core.symlog import ( NULL_CONTENT_ID, SymbolDiff, compute_symbol_diff, extract_symbols, is_null_content_id, list_symlog_symbols_for_file, read_symlog, ) runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]): saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _init_repo(repo: pathlib.Path) -> None: repo.mkdir(parents=True, exist_ok=True) _invoke(repo, ["init"]) def _write_file(repo: pathlib.Path, rel: str, content: str) -> None: p = repo / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(textwrap.dedent(content)) def _commit(repo: pathlib.Path, msg: str) -> str: """Stage all and commit; return the commit_id from JSON output.""" _invoke(repo, ["code", "add", "."]) result = _invoke(repo, [ "commit", "-m", msg, "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--author", "claude-code", "--json", ]) data = json.loads(result.stdout) return data["commit_id"] def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: _init_repo(tmp_path) return tmp_path # =========================================================================== # SL_13 — extract_symbols # =========================================================================== class TestExtractSymbols: def test_returns_symbol_name_to_content_id_map(self, tmp_path: pathlib.Path) -> None: """extract_symbols reads a file object and returns {name: content_id}.""" from muse.core.object_store import write_object repo = _make_repo(tmp_path) src = b"def compute_total(items):\n return sum(items)\n" digest = hash_blob(src) write_object(repo, digest, src) result = extract_symbols(repo, digest, "src/billing.py") assert isinstance(result, dict) assert "compute_total" in result assert result["compute_total"].startswith("sha256:") def test_returns_empty_for_non_code_file(self, tmp_path: pathlib.Path) -> None: """extract_symbols returns {} for binary or non-parseable objects.""" from muse.core.object_store import write_object repo = _make_repo(tmp_path) src = b"\x00\x01\x02\x03 binary data \xff" digest = hash_blob(src) write_object(repo, digest, src) result = extract_symbols(repo, digest, "src/binary.bin") assert result == {} def test_returns_empty_for_unknown_object_id(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = extract_symbols(repo, "sha256:" + "a" * 64) assert result == {} def test_multiple_symbols(self, tmp_path: pathlib.Path) -> None: from muse.core.object_store import write_object repo = _make_repo(tmp_path) src = b""" def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None class Invoice: pass """ digest = hash_blob(src) write_object(repo, digest, src) result = extract_symbols(repo, digest, "src/billing.py") assert "compute_total" in result assert "validate_invoice" in result for cid in result.values(): assert cid.startswith("sha256:") # =========================================================================== # SL_14 — compute_symbol_diff # =========================================================================== class TestComputeSymbolDiff: _CID_A = "sha256:" + "a" * 64 _CID_B = "sha256:" + "b" * 64 _CID_C = "sha256:" + "c" * 64 def test_created_symbols(self) -> None: old: dict[str, str] = {} new = {"compute_total": self._CID_A, "validate": self._CID_B} diff = compute_symbol_diff(old, new) assert "compute_total" in diff.created assert "validate" in diff.created assert not diff.deleted assert not diff.modified def test_deleted_symbols(self) -> None: old = {"compute_total": self._CID_A} new: dict[str, str] = {} diff = compute_symbol_diff(old, new) assert "compute_total" in diff.deleted assert not diff.created assert not diff.modified def test_modified_symbols(self) -> None: old = {"compute_total": self._CID_A} new = {"compute_total": self._CID_B} diff = compute_symbol_diff(old, new) assert "compute_total" in diff.modified assert not diff.created assert not diff.deleted def test_unchanged_symbols_not_in_diff(self) -> None: old = {"compute_total": self._CID_A} new = {"compute_total": self._CID_A} diff = compute_symbol_diff(old, new) assert not diff.created assert not diff.deleted assert not diff.modified assert not diff.renames def test_rename_detection(self) -> None: """A deleted name whose content_id matches a created name's content_id → rename.""" old = {"compute_total": self._CID_A} new = {"compute_invoice_total": self._CID_A} diff = compute_symbol_diff(old, new) assert len(diff.renames) == 1 old_name, new_name = diff.renames[0] assert old_name == "compute_total" assert new_name == "compute_invoice_total" assert "compute_total" not in diff.deleted assert "compute_invoice_total" not in diff.created def test_rename_with_content_change_is_not_a_rename(self) -> None: old = {"compute_total": self._CID_A} new = {"compute_invoice_total": self._CID_B} diff = compute_symbol_diff(old, new) assert not diff.renames assert "compute_total" in diff.deleted assert "compute_invoice_total" in diff.created def test_mixed_operations(self) -> None: old = { "compute_total": self._CID_A, "validate_invoice": self._CID_B, "old_helper": self._CID_C, } new = { "compute_total": self._CID_B, "format_output": self._CID_A, "new_helper": self._CID_C, } diff = compute_symbol_diff(old, new) assert "compute_total" in diff.modified assert "validate_invoice" in diff.deleted assert "format_output" in diff.created assert ("old_helper", "new_helper") in diff.renames def test_returns_symbol_diff_dataclass(self) -> None: diff = compute_symbol_diff({}, {}) assert isinstance(diff, SymbolDiff) assert hasattr(diff, "created") assert hasattr(diff, "deleted") assert hasattr(diff, "modified") assert hasattr(diff, "renames") # =========================================================================== # SL_15 — _write_symlogs is non-fatal (tested via commit) # =========================================================================== class TestWriteSymlogsNonFatal: def test_commit_succeeds_even_with_no_code_files(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "README.txt", "hello\n") commit_id = _commit(repo, "add readme") assert commit_id.startswith("sha256:") def test_commit_returns_commit_id(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") commit_id = _commit(repo, "add billing") assert commit_id.startswith("sha256:") # =========================================================================== # SL_16 — Created symbols get symbol-created entries # =========================================================================== class TestSymlogCreated: def test_new_symbol_gets_created_entry(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add compute_total") entries = read_symlog(repo, "src/billing.py::compute_total") assert len(entries) >= 1 newest = entries[0] assert newest.operation.startswith("symbol-created:") assert is_null_content_id(newest.old_content_id) assert not is_null_content_id(newest.new_content_id) def test_created_entry_has_correct_author(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add compute_total") entries = read_symlog(repo, "src/billing.py::compute_total") assert entries[0].author == "claude-code" def test_all_new_symbols_get_created_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None """)) _commit(repo, "add billing module") syms = list_symlog_symbols_for_file(repo, "src/billing.py") names = {s.split("::")[-1] for s in syms} assert "compute_total" in names assert "validate_invoice" in names # =========================================================================== # SL_17 — Modified symbols get symbol-modified entries # =========================================================================== class TestSymlogModified: def test_modified_symbol_gets_modified_entry(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add compute_total") _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items) + 0.0\n") _commit(repo, "fix: improve compute_total") entries = read_symlog(repo, "src/billing.py::compute_total") assert entries[0].operation.startswith("symbol-modified:") assert not is_null_content_id(entries[0].old_content_id) assert not is_null_content_id(entries[0].new_content_id) assert entries[0].old_content_id != entries[0].new_content_id def test_modified_entry_contains_commit_message(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add compute_total") _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items) + 0.0\n") _commit(repo, "fix: off-by-one in total") entries = read_symlog(repo, "src/billing.py::compute_total") assert "fix: off-by-one in total" in entries[0].operation def test_modified_entry_has_correct_commit_id(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items) + 0.0\n") commit_id = _commit(repo, "fix: improve") entries = read_symlog(repo, "src/billing.py::compute_total") assert entries[0].commit_id == commit_id # =========================================================================== # SL_18 — Deleted symbols get symbol-deleted entries # =========================================================================== class TestSymlogDeleted: def test_deleted_symbol_gets_deleted_entry(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def old_helper(x): return x """)) _commit(repo, "add billing") _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "remove old_helper") entries = read_symlog(repo, "src/billing.py::old_helper") assert len(entries) >= 2 assert entries[0].operation.startswith("symbol-deleted:") assert is_null_content_id(entries[0].new_content_id) assert not is_null_content_id(entries[0].old_content_id) # =========================================================================== # SL_19 — Renamed symbols get two entries # =========================================================================== class TestSymlogRenamed: def test_rename_writes_two_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add compute_total") # Rename: same body, different name _write_file(repo, "src/billing.py", "def compute_invoice_total(items):\n return sum(items)\n") _commit(repo, "rename: compute_total to compute_invoice_total") old_entries = read_symlog(repo, "src/billing.py::compute_total") new_entries = read_symlog(repo, "src/billing.py::compute_invoice_total") # Old path: terminal renamed-to entry assert any(e.operation.startswith("symbol-renamed-to:") for e in old_entries) renamed_to = next(e for e in old_entries if e.operation.startswith("symbol-renamed-to:")) assert "compute_invoice_total" in renamed_to.operation assert is_null_content_id(renamed_to.new_content_id) # New path: born-from entry assert any(e.operation.startswith("symbol-born-from:") for e in new_entries) born_from = next(e for e in new_entries if e.operation.startswith("symbol-born-from:")) assert "compute_total" in born_from.operation assert born_from.born_from is not None assert "compute_total" in born_from.born_from assert is_null_content_id(born_from.old_content_id) # =========================================================================== # SL_20 — Initial commit: all symbols get symbol-created entries # =========================================================================== class TestSymlogInitialCommit: def test_initial_commit_all_symbols_created(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None """)) _commit(repo, "initial: add billing module") for sym in ("compute_total", "validate_invoice"): entries = read_symlog(repo, f"src/billing.py::{sym}") assert len(entries) == 1 assert entries[0].operation.startswith("symbol-created:") assert is_null_content_id(entries[0].old_content_id) # =========================================================================== # SL_21 — Integration: two-commit sequence # =========================================================================== class TestIntegrationTwoCommitSequence: def test_create_modify_delete_sequence(self, tmp_path: pathlib.Path) -> None: """SL_21: two-commit sequence with 3 functions; verify exact entries.""" repo = _make_repo(tmp_path) # Commit 1: create 3 functions _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None def format_output(result): return str(result) """)) commit1 = _commit(repo, "feat: add billing module") for sym in ("compute_total", "validate_invoice", "format_output"): entries = read_symlog(repo, f"src/billing.py::{sym}") assert len(entries) == 1, f"expected 1 entry for {sym}, got {len(entries)}" assert entries[0].operation.startswith("symbol-created:") assert entries[0].commit_id == commit1 # Commit 2: modify compute_total, delete format_output, add new_helper _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return round(sum(items), 2) def validate_invoice(inv): return inv is not None def new_helper(x): return x * 2 """)) commit2 = _commit(repo, "fix: round total; add new_helper; remove format_output") # compute_total: modified ct_entries = read_symlog(repo, "src/billing.py::compute_total") assert len(ct_entries) == 2 assert ct_entries[0].operation.startswith("symbol-modified:") assert ct_entries[0].commit_id == commit2 assert ct_entries[1].operation.startswith("symbol-created:") assert ct_entries[1].commit_id == commit1 # validate_invoice: unchanged — no new entry vi_entries = read_symlog(repo, "src/billing.py::validate_invoice") assert len(vi_entries) == 1 assert vi_entries[0].operation.startswith("symbol-created:") # format_output: deleted fo_entries = read_symlog(repo, "src/billing.py::format_output") assert fo_entries[0].operation.startswith("symbol-deleted:") assert fo_entries[0].commit_id == commit2 # new_helper: created nh_entries = read_symlog(repo, "src/billing.py::new_helper") assert len(nh_entries) == 1 assert nh_entries[0].operation.startswith("symbol-created:") assert nh_entries[0].commit_id == commit2 # =========================================================================== # SL_22 — Integration: rename scenario with --follow # =========================================================================== class TestIntegrationRenameFollow: def test_rename_follow_traverses_chain(self, tmp_path: pathlib.Path) -> None: """SL_22: foo's log ends with renamed-to; bar starts with born-from; read_symlog('bar', follow=True) returns full history.""" repo = _make_repo(tmp_path) # Commit 1: create foo _write_file(repo, "src/billing.py", "def foo(x):\n return x\n") commit1 = _commit(repo, "add foo") foo_c1 = read_symlog(repo, "src/billing.py::foo") assert foo_c1[0].operation.startswith("symbol-created:") # Commit 2: rename foo → bar (same body, different name) _write_file(repo, "src/billing.py", "def bar(x):\n return x\n") commit2 = _commit(repo, "rename: foo to bar") # foo's log ends with symbol-renamed-to foo_entries = read_symlog(repo, "src/billing.py::foo") assert any(e.operation.startswith("symbol-renamed-to:") for e in foo_entries) # bar's log has born-from entry bar_entries = read_symlog(repo, "src/billing.py::bar") assert any(e.operation.startswith("symbol-born-from:") for e in bar_entries) # --follow: bar's full history includes foo's created entry bar_followed = read_symlog(repo, "src/billing.py::bar", follow=True) ops = [e.operation for e in bar_followed] assert any(op.startswith("symbol-born-from:") for op in ops) assert any(op.startswith("symbol-created:") for op in ops) assert len(bar_followed) >= 3