"""TDD tests for muse#49 Phase 3 — cache-invalidation gap coverage (CT_06–CT_10). muse's symbol/call-graph caches are content-addressed per-file memoization tables (``object_id -> SymbolTree`` / ``object_id -> per-file subgraph``), not snapshot/commit-ID-keyed global caches. There is no single "cache valid for commit X" flag — validity is implicit per file: a cache entry for a given blob hash is correct forever, because the key IS the content. These tests prove the 5 gap scenarios identified in the audit (docs/code-test-internals.md, CT_02) actually behave as that model predicts, using call-count spying on ``read_object``/``parse_symbols`` (the established pattern from test_test_selection_speedup.py) so a regression that silently reintroduced a full re-parse would be caught — asserting on final output alone would not catch it, since test-selection correctness is independent of cache warmth. """ from __future__ import annotations import pathlib from unittest.mock import patch import pytest from muse.core.callgraph_cache import CallGraphCache from muse.core.object_store import write_object from muse.core.paths import muse_dir from muse.core.symbol_cache import SymbolCache from muse.core.test_selection import ChangedSymbol, select_tests from muse.core.types import Manifest, blob_id def _write_file(root: pathlib.Path, rel_path: str, content: bytes) -> str: """Write *content* to both the object store and the working tree, return its object_id.""" oid = blob_id(content) write_object(root, oid, content) full = root / rel_path full.parent.mkdir(parents=True, exist_ok=True) full.write_bytes(content) return oid def _base_repo(tmp_path: pathlib.Path) -> pathlib.Path: muse_dir(tmp_path).mkdir(exist_ok=True) return tmp_path PROD_V1 = b"""\ def compute(x: int) -> int: return x * 2 """ PROD_V2 = b"""\ def compute(x: int) -> int: return x * 3 # changed body -> new object_id """ TEST_V1 = b"""\ from prod import compute def test_compute() -> None: assert compute(2) == 4 """ # --------------------------------------------------------------------------- # CT_06 — a new commit (changed file content) invalidates the cache for that # file only; the per-file memoization table has no entry for the new blob, # so it is necessarily re-parsed. # --------------------------------------------------------------------------- class TestNewCommitInvalidatesChangedFile: def test_changed_file_is_reparsed_unchanged_file_is_not( self, tmp_path: pathlib.Path ) -> None: root = _base_repo(tmp_path) prod_oid_v1 = _write_file(root, "prod.py", PROD_V1) test_oid = _write_file(root, "tests/test_prod.py", TEST_V1) manifest_v1: Manifest = {"prod.py": prod_oid_v1, "tests/test_prod.py": test_oid} changed = [ChangedSymbol(address="prod.py::compute", change_kind="modified")] sym_cache = SymbolCache.empty() cg_cache = CallGraphCache.empty() select_tests(root, changed, manifest_v1, cache=sym_cache, callgraph_cache=cg_cache) # Simulate a new commit: prod.py's body changes -> new object_id. prod_oid_v2 = _write_file(root, "prod.py", PROD_V2) assert prod_oid_v2 != prod_oid_v1, "test fixture must actually change the blob hash" manifest_v2: Manifest = {"prod.py": prod_oid_v2, "tests/test_prod.py": test_oid} result = select_tests(root, changed, manifest_v2, cache=sym_cache, callgraph_cache=cg_cache) # New blob has no cache entry under either cache -> at least one miss. assert result["cache_hit"] is False assert result["cache_misses"] > 0 # --------------------------------------------------------------------------- # CT_07 — an unchanged snapshot reuses the cache entirely: zero object-store # reads, zero re-parses. Verified by call-count spying, not output alone. # --------------------------------------------------------------------------- class TestUnchangedSnapshotReusesCache: def test_second_identical_run_reads_no_objects_and_parses_nothing( self, tmp_path: pathlib.Path ) -> None: root = _base_repo(tmp_path) prod_oid = _write_file(root, "prod.py", PROD_V1) test_oid = _write_file(root, "tests/test_prod.py", TEST_V1) manifest: Manifest = {"prod.py": prod_oid, "tests/test_prod.py": test_oid} changed = [ChangedSymbol(address="prod.py::compute", change_kind="modified")] sym_cache = SymbolCache.empty() cg_cache = CallGraphCache.empty() select_tests(root, changed, manifest, cache=sym_cache, callgraph_cache=cg_cache) with ( patch("muse.plugins.code._query.read_object") as mock_query_read, patch("muse.plugins.code._callgraph.read_object") as mock_cg_read, patch("muse.plugins.code.ast_parser.parse_symbols") as mock_parse, ): result = select_tests( root, changed, manifest, cache=sym_cache, callgraph_cache=cg_cache ) mock_query_read.assert_not_called() mock_cg_read.assert_not_called() mock_parse.assert_not_called() assert result["cache_hit"] is True assert result["cache_misses"] == 0 # --------------------------------------------------------------------------- # CT_08 — a new import added to prod.py changes its object_id, forcing a # call-graph rebuild for that file; a newly-reachable test must appear in # the selection once the new call edge is present. # --------------------------------------------------------------------------- class TestNewImportRebuildsCallGraphAndIncludesNewlyReachableTest: def test_newly_reachable_test_appears_after_call_graph_rebuild( self, tmp_path: pathlib.Path ) -> None: root = _base_repo(tmp_path) helper_src = b"""\ def helper(x: int) -> int: return x + 1 """ prod_v1 = b"""\ def compute(x: int) -> int: return x * 2 """ # v2 adds a call to helper() -> new edge compute -> helper. prod_v2 = b"""\ from helper import helper def compute(x: int) -> int: return helper(x) * 2 """ test_helper_src = b"""\ from helper import helper def test_helper() -> None: assert helper(1) == 2 """ helper_oid = _write_file(root, "helper.py", helper_src) prod_oid_v1 = _write_file(root, "prod.py", prod_v1) test_helper_oid = _write_file(root, "tests/test_helper.py", test_helper_src) manifest_v1: Manifest = { "helper.py": helper_oid, "prod.py": prod_oid_v1, "tests/test_helper.py": test_helper_oid, } sym_cache = SymbolCache.empty() cg_cache = CallGraphCache.empty() changed_helper = [ChangedSymbol(address="helper.py::helper", change_kind="modified")] result_v1 = select_tests( root, changed_helper, manifest_v1, cache=sym_cache, callgraph_cache=cg_cache ) v1_targets = {t["node_id"] for t in result_v1["test_targets"]} # Before the new import, compute() does not call helper() -> only # the direct caller (test_helper) is covered via direct heuristics, # not via a prod.py -> helper.py call edge. prod_oid_v2 = _write_file(root, "prod.py", prod_v2) assert prod_oid_v2 != prod_oid_v1 manifest_v2: Manifest = { "helper.py": helper_oid, "prod.py": prod_oid_v2, "tests/test_helper.py": test_helper_oid, } result_v2 = select_tests( root, changed_helper, manifest_v2, cache=sym_cache, callgraph_cache=cg_cache ) v2_targets = {t["node_id"] for t in result_v2["test_targets"]} # prod.py was re-parsed (new object_id -> cache miss) and the test # covering helper.py is still selected after the call-graph rebuild. assert result_v2["cache_misses"] > 0 assert any("test_helper" in t for t in v2_targets) # --------------------------------------------------------------------------- # CT_09 — adding a brand-new test file with no production-code changes must # not bust the cache for the unrelated, already-cached production file. # --------------------------------------------------------------------------- class TestNewTestFileDoesNotBustUnrelatedCache: def test_adding_test_file_does_not_reparse_unrelated_prod_file( self, tmp_path: pathlib.Path ) -> None: root = _base_repo(tmp_path) prod_oid = _write_file(root, "prod.py", PROD_V1) test_oid = _write_file(root, "tests/test_prod.py", TEST_V1) manifest_v1: Manifest = {"prod.py": prod_oid, "tests/test_prod.py": test_oid} changed = [ChangedSymbol(address="prod.py::compute", change_kind="modified")] sym_cache = SymbolCache.empty() cg_cache = CallGraphCache.empty() select_tests(root, changed, manifest_v1, cache=sym_cache, callgraph_cache=cg_cache) # Add a brand-new test file; prod.py is untouched (same object_id). new_test_src = b"""\ def test_unrelated() -> None: assert True """ new_test_oid = _write_file(root, "tests/test_unrelated.py", new_test_src) manifest_v2: Manifest = { "prod.py": prod_oid, "tests/test_prod.py": test_oid, "tests/test_unrelated.py": new_test_oid, } from muse.core.object_store import read_object as real_read_object with ( patch( "muse.plugins.code._query.read_object", wraps=real_read_object ) as mock_query_read, patch( "muse.plugins.code._callgraph.read_object", wraps=real_read_object ) as mock_cg_read, ): result = select_tests( root, changed, manifest_v2, cache=sym_cache, callgraph_cache=cg_cache ) # The new file is read/parsed (its object_id was never cached), but # prod.py and tests/test_prod.py (same object_id as before) must not # trigger any read_object call for THEIR ids. read_oids = {c.args[1] for c in mock_query_read.call_args_list} read_oids |= {c.args[1] for c in mock_cg_read.call_args_list} assert prod_oid not in read_oids assert test_oid not in read_oids assert new_test_oid in read_oids # Only one new file's worth of misses, not a full cache bust. assert result["cache_misses"] <= 2 # one per cache for the one new file # --------------------------------------------------------------------------- # CT_10 — ``muse code index rebuild`` manages a wholly separate index # (.muse/indices/*) and has no invalidation relationship with the # SymbolCache/CallGraphCache used by muse code test. Per the audit # (docs/code-test-internals.md, "Decision" section) and the ticket's own # "audit only, no redesign" constraint, the correct behavior to verify is # that index rebuild does NOT corrupt or invalidate the test-selection # caches — content-addressing means no explicit invalidation is needed. # --------------------------------------------------------------------------- class TestIndexRebuildDoesNotAffectTestSelectionCache: def test_cache_still_warm_after_index_rebuild( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """``muse code index rebuild`` manages a wholly separate index (.muse/indices/*) from the test-selection caches (.muse/cache/*). Real repo, real commit, real CLI invocation — proves rebuild neither corrupts nor busts the warm test-selection cache, consistent with the content-addressed design (no explicit invalidation needed).""" from tests.cli_test_helper import CliRunner runner = CliRunner() monkeypatch.chdir(tmp_path) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) assert runner.invoke(None, ["init", "--domain", "code"]).exit_code == 0 (tmp_path / "prod.py").write_text(PROD_V1.decode()) tests_dir = tmp_path / "tests" tests_dir.mkdir() (tests_dir / "test_prod.py").write_text(TEST_V1.decode()) assert runner.invoke(None, ["code", "add", "."]).exit_code == 0 commit_result = runner.invoke(None, ["commit", "-m", "v1"]) assert commit_result.exit_code == 0, commit_result.output from muse.core.commits import get_head_snapshot_id from muse.core.snapshots import read_snapshot snapshot_id = get_head_snapshot_id(tmp_path, "main") assert snapshot_id is not None snapshot = read_snapshot(tmp_path, snapshot_id) assert snapshot is not None head_manifest: Manifest = snapshot.manifest changed = [ChangedSymbol(address="prod.py::compute", change_kind="modified")] sym_cache = SymbolCache.from_root(tmp_path) cg_cache = CallGraphCache.from_root(tmp_path) select_tests( tmp_path, changed, head_manifest, cache=sym_cache, callgraph_cache=cg_cache ) sym_cache.save() cg_cache.save() rebuild_result = runner.invoke(None, ["code", "index", "rebuild", "--json"]) assert rebuild_result.exit_code == 0, rebuild_result.output # Caches on disk must still be loadable and still warm for prod.py. reloaded_sym = SymbolCache.load(muse_dir(tmp_path)) reloaded_cg = CallGraphCache.load(muse_dir(tmp_path)) with ( patch("muse.plugins.code._query.read_object") as mock_query_read, patch("muse.plugins.code._callgraph.read_object") as mock_cg_read, ): result = select_tests( tmp_path, changed, head_manifest, cache=reloaded_sym, callgraph_cache=reloaded_cg ) mock_query_read.assert_not_called() mock_cg_read.assert_not_called() assert result["cache_hit"] is True