gabriel / muse public
test_core_symbol_cache.py python
384 lines 13.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for muse.core.symbol_cache.
2
3 Coverage
4 --------
5 - Cache hit: get() returns stored tree without calling parse_symbols.
6 - Cache miss: get() returns None; put() stores a tree; subsequent get() hits.
7 - Content-addressed key: different content → different key → independent entries.
8 - Persistence: save() / load() round-trip via .muse/cache/symbols.msgpack.
9 - Atomic write: tmp file replaced; no corruption.
10 - empty(): no-op — save() is a no-op without a muse_dir.
11 - load_symbol_cache() convenience helper.
12 - Corrupt file: gracefully returns empty cache.
13 - Wrong version: gracefully returns empty cache.
14 - prune(): removes stale entries, marks dirty.
15 - Integration with symbols_for_snapshot: warm cache skips parse_symbols.
16 - Working-tree key: disk bytes SHA-256 ≠ object_id when file is edited.
17 """
18
19 from __future__ import annotations
20
21 import pathlib
22 from unittest.mock import patch
23
24 import msgpack
25 import pytest
26
27 from muse.core.store import MsgpackValue
28 from muse.core.symbol_cache import (
29 SymbolCache,
30 _object_id_of,
31 _is_symbol_record,
32 load_symbol_cache,
33 )
34 from muse.core._types import blob_id, fake_id
35 from muse.core.object_store import write_object
36 from muse.plugins.code.ast_parser import SymbolKind, SymbolRecord, SymbolTree
37
38
39 # ---------------------------------------------------------------------------
40 # Fixtures
41 # ---------------------------------------------------------------------------
42
43
44 def _make_muse_dir(tmp_path: pathlib.Path) -> pathlib.Path:
45 muse_dir = tmp_path / ".muse"
46 muse_dir.mkdir()
47 (muse_dir / "cache").mkdir()
48 return muse_dir
49
50
51 def _make_record(
52 name: str = "my_func",
53 kind: SymbolKind = "function",
54 lineno: int = 1,
55 end_lineno: int = 5,
56 ) -> SymbolRecord:
57 return SymbolRecord(
58 kind=kind,
59 name=name,
60 qualified_name=name,
61 content_id=fake_id(name),
62 body_hash=blob_id(b"body"),
63 signature_id=blob_id(b"sig"),
64 metadata_id=blob_id(b"meta"),
65 canonical_key=name,
66 lineno=lineno,
67 end_lineno=end_lineno,
68 )
69
70
71 def _make_tree(*names: str) -> SymbolTree:
72 return {f"billing.py::{n}": _make_record(n) for n in names}
73
74
75 def _make_raw(name: str = "my_func", kind: str = "function", lineno: int = 1, end_lineno: int = 5) -> MsgpackDict:
76 """Build a raw msgpack-compatible record dict for guard testing."""
77 return {
78 "kind": kind,
79 "name": name,
80 "qualified_name": name,
81 "content_id": "a" * 64,
82 "body_hash": "b" * 64,
83 "signature_id": "c" * 64,
84 "metadata_id": "",
85 "canonical_key": name,
86 "lineno": lineno,
87 "end_lineno": end_lineno,
88 }
89
90
91 # ---------------------------------------------------------------------------
92 # _object_id_of
93 # ---------------------------------------------------------------------------
94
95
96 class TestObjectIdOf:
97 def test_sha256_of_bytes(self) -> None:
98 raw = b"hello"
99 assert _object_id_of(raw) == blob_id(b"hello")
100
101 def test_different_content_different_id(self) -> None:
102 assert _object_id_of(b"a") != _object_id_of(b"b")
103
104 def test_same_content_same_id(self) -> None:
105 assert _object_id_of(b"stable") == _object_id_of(b"stable")
106
107
108 # ---------------------------------------------------------------------------
109 # _is_symbol_record
110 # ---------------------------------------------------------------------------
111
112
113 class TestIsSymbolRecord:
114 def test_valid_record_passes(self) -> None:
115 assert _is_symbol_record(_make_raw())
116
117 def test_not_a_dict_fails(self) -> None:
118 assert not _is_symbol_record("string")
119 assert not _is_symbol_record(42)
120 assert not _is_symbol_record(None)
121
122 def test_missing_field_fails(self) -> None:
123 rec = _make_raw()
124 del rec["kind"]
125 assert not _is_symbol_record(rec)
126
127 def test_wrong_type_for_str_field_fails(self) -> None:
128 rec = _make_raw()
129 rec["name"] = 123 # should be str
130 assert not _is_symbol_record(rec)
131
132 def test_wrong_type_for_int_field_fails(self) -> None:
133 rec = _make_raw()
134 rec["lineno"] = "not_an_int"
135 assert not _is_symbol_record(rec)
136
137 def test_invalid_kind_fails(self) -> None:
138 rec = _make_raw()
139 rec["kind"] = "not_a_valid_kind"
140 assert not _is_symbol_record(rec)
141
142
143 # ---------------------------------------------------------------------------
144 # SymbolCache — in-memory operations
145 # ---------------------------------------------------------------------------
146
147
148 class TestSymbolCacheMemory:
149 def test_get_miss_returns_none(self) -> None:
150 cache = SymbolCache.empty()
151 assert cache.get("nonexistent_id") is None
152
153 def test_put_then_get_hits(self) -> None:
154 cache = SymbolCache.empty()
155 tree = _make_tree("run", "setup")
156 cache.put("abc123", tree)
157 assert cache.get("abc123") == tree
158
159 def test_put_marks_dirty(self) -> None:
160 cache = SymbolCache.empty()
161 assert not cache._dirty
162 cache.put("id1", _make_tree("fn"))
163 assert cache._dirty
164
165 def test_different_ids_independent(self) -> None:
166 cache = SymbolCache.empty()
167 tree_a = _make_tree("alpha")
168 tree_b = _make_tree("beta")
169 cache.put("id_a", tree_a)
170 cache.put("id_b", tree_b)
171 assert cache.get("id_a") == tree_a
172 assert cache.get("id_b") == tree_b
173
174 def test_size_property(self) -> None:
175 cache = SymbolCache.empty()
176 assert cache.size == 0
177 cache.put("x", _make_tree("f"))
178 cache.put("y", _make_tree("g"))
179 assert cache.size == 2
180
181 def test_prune_removes_stale(self) -> None:
182 cache = SymbolCache.empty()
183 cache.put("keep", _make_tree("f"))
184 cache.put("drop", _make_tree("g"))
185 cache.prune({"keep"})
186 assert cache.get("keep") is not None
187 assert cache.get("drop") is None
188 assert cache._dirty
189
190 def test_prune_no_stale_not_dirty(self) -> None:
191 cache = SymbolCache.empty()
192 cache.put("keep", _make_tree("f"))
193 cache._dirty = False # reset after put
194 cache.prune({"keep", "other"})
195 assert not cache._dirty
196
197 def test_empty_save_is_noop(self, tmp_path: pathlib.Path) -> None:
198 cache = SymbolCache.empty()
199 cache.put("id", _make_tree("f"))
200 cache.save() # should not raise — muse_dir is None
201 assert not (tmp_path / ".muse" / "cache" / "symbols.msgpack").exists()
202
203
204 # ---------------------------------------------------------------------------
205 # SymbolCache — persistence (save / load round-trip)
206 # ---------------------------------------------------------------------------
207
208
209 class TestSymbolCachePersistence:
210 def test_save_creates_file(self, tmp_path: pathlib.Path) -> None:
211 muse_dir = _make_muse_dir(tmp_path)
212 cache = SymbolCache.load(muse_dir)
213 cache.put("id1", _make_tree("fn_a"))
214 cache.save()
215 assert (muse_dir / "cache" / "symbols.msgpack").is_file()
216
217 def test_save_then_load_round_trip(self, tmp_path: pathlib.Path) -> None:
218 muse_dir = _make_muse_dir(tmp_path)
219 tree = _make_tree("compute", "validate")
220 cache = SymbolCache.load(muse_dir)
221 cache.put("deadbeef" * 8, tree)
222 cache.save()
223
224 loaded = SymbolCache.load(muse_dir)
225 result = loaded.get("deadbeef" * 8)
226 assert result is not None
227 assert set(result) == set(tree)
228 first_addr = next(iter(tree))
229 assert result[first_addr]["kind"] == tree[first_addr]["kind"]
230 assert result[first_addr]["name"] == tree[first_addr]["name"]
231 assert result[first_addr]["lineno"] == tree[first_addr]["lineno"]
232 assert result[first_addr]["end_lineno"] == tree[first_addr]["end_lineno"]
233
234 def test_save_no_dirty_skips_write(self, tmp_path: pathlib.Path) -> None:
235 muse_dir = _make_muse_dir(tmp_path)
236 cache = SymbolCache.load(muse_dir)
237 cache.save() # _dirty is False — no file should appear
238 assert not (muse_dir / "cache" / "symbols.msgpack").is_file()
239
240 def test_save_dirty_false_after_save(self, tmp_path: pathlib.Path) -> None:
241 muse_dir = _make_muse_dir(tmp_path)
242 cache = SymbolCache.load(muse_dir)
243 cache.put("id", _make_tree("fn"))
244 cache.save()
245 assert not cache._dirty
246
247 def test_multiple_saves_second_is_noop(self, tmp_path: pathlib.Path) -> None:
248 muse_dir = _make_muse_dir(tmp_path)
249 cache = SymbolCache.load(muse_dir)
250 cache.put("id", _make_tree("fn"))
251 cache.save()
252 mtime1 = (muse_dir / "cache" / "symbols.msgpack").stat().st_mtime_ns
253 cache.save() # not dirty — should not touch file
254 mtime2 = (muse_dir / "cache" / "symbols.msgpack").stat().st_mtime_ns
255 assert mtime1 == mtime2
256
257
258 # ---------------------------------------------------------------------------
259 # SymbolCache — graceful error handling on load
260 # ---------------------------------------------------------------------------
261
262
263 class TestSymbolCacheGracefulLoad:
264 def test_absent_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
265 muse_dir = _make_muse_dir(tmp_path)
266 cache = SymbolCache.load(muse_dir)
267 assert cache.size == 0
268
269 def test_corrupt_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
270 muse_dir = _make_muse_dir(tmp_path)
271 (muse_dir / "cache" / "symbols.msgpack").write_bytes(b"not valid msgpack !!!")
272 cache = SymbolCache.load(muse_dir)
273 assert cache.size == 0
274
275 def test_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None:
276 muse_dir = _make_muse_dir(tmp_path)
277 doc = {"version": 999, "entries": {}}
278 (muse_dir / "cache" / "symbols.msgpack").write_bytes(
279 msgpack.packb(doc, use_bin_type=True)
280 )
281 cache = SymbolCache.load(muse_dir)
282 assert cache.size == 0
283
284 def test_invalid_entry_skipped(self, tmp_path: pathlib.Path) -> None:
285 """A single malformed tree entry is skipped; valid entries survive."""
286 muse_dir = _make_muse_dir(tmp_path)
287 good_tree = {"billing.py::run": dict(_make_record("run"))}
288 bad_tree = {"billing.py::broken": {"kind": "INVALID_KIND", "name": 123}}
289 doc = {
290 "version": 1,
291 "entries": {
292 "good_id": good_tree,
293 "bad_id": bad_tree,
294 },
295 }
296 (muse_dir / "cache" / "symbols.msgpack").write_bytes(
297 msgpack.packb(doc, use_bin_type=True)
298 )
299 cache = SymbolCache.load(muse_dir)
300 assert cache.get("good_id") is not None
301 assert cache.get("bad_id") is None
302
303 def test_load_symbol_cache_no_muse_dir(self, tmp_path: pathlib.Path) -> None:
304 """load_symbol_cache returns empty when there is no .muse directory."""
305 cache = load_symbol_cache(tmp_path)
306 assert cache.size == 0
307
308 def test_load_symbol_cache_with_muse_dir(self, tmp_path: pathlib.Path) -> None:
309 muse_dir = _make_muse_dir(tmp_path)
310 tree = _make_tree("fn")
311 seed = SymbolCache.load(muse_dir)
312 seed.put("myid", tree)
313 seed.save()
314
315 cache = load_symbol_cache(tmp_path)
316 assert cache.get("myid") is not None
317
318
319 # ---------------------------------------------------------------------------
320 # Integration: symbols_for_snapshot uses cache
321 # ---------------------------------------------------------------------------
322
323
324 class TestSymbolsForSnapshotCache:
325 """Verify that symbols_for_snapshot calls parse_symbols only on cache miss."""
326
327 def _make_manifest(
328 self, tmp_path: pathlib.Path, content: bytes = b"def run(): pass\n"
329 ) -> tuple[pathlib.Path, dict[str, str]]:
330 """Write a .muse object and return (root, manifest)."""
331 root = tmp_path / "repo"
332 root.mkdir()
333 muse_dir = root / ".muse"
334 muse_dir.mkdir()
335
336 oid = blob_id(content)
337 write_object(root, oid, content)
338
339 manifest = {"billing.py": oid}
340 return root, manifest
341
342 def test_cold_cache_calls_parse(self, tmp_path: pathlib.Path) -> None:
343 root, manifest = self._make_manifest(tmp_path)
344 from muse.plugins.code._query import symbols_for_snapshot
345 with patch("muse.plugins.code._query.parse_symbols", wraps=__import__("muse.plugins.code.ast_parser", fromlist=["parse_symbols"]).parse_symbols) as mock_parse:
346 result = symbols_for_snapshot(root, manifest)
347 assert mock_parse.call_count >= 1
348
349 def test_warm_cache_skips_parse(self, tmp_path: pathlib.Path) -> None:
350 content = b"def run(): pass\n"
351 root, manifest = self._make_manifest(tmp_path, content)
352 from muse.plugins.code._query import symbols_for_snapshot
353
354 # First call — populates cache
355 symbols_for_snapshot(root, manifest)
356
357 # Second call — should hit cache, never call parse_symbols
358 with patch("muse.plugins.code._query.parse_symbols") as mock_parse:
359 result2 = symbols_for_snapshot(root, manifest)
360 mock_parse.assert_not_called()
361 assert "billing.py" in result2
362
363 def test_working_tree_edit_invalidates_cache(self, tmp_path: pathlib.Path) -> None:
364 """Editing a file produces a new SHA-256 → cache miss → re-parse."""
365 content_v1 = b"def run(): pass\n"
366 content_v2 = b"def run(): pass\ndef brand_new(): pass\n"
367 root, manifest = self._make_manifest(tmp_path, content_v1)
368
369 # Write v1 to disk
370 (root / "billing.py").write_bytes(content_v1)
371
372 from muse.plugins.code._query import symbols_for_snapshot
373 result1 = symbols_for_snapshot(root, manifest, workdir=root)
374 syms1 = set(result1.get("billing.py", {}).keys())
375
376 # Edit file on disk (v2) — cache key changes because SHA-256 changes
377 (root / "billing.py").write_bytes(content_v2)
378 result2 = symbols_for_snapshot(root, manifest, workdir=root)
379 syms2 = set(result2.get("billing.py", {}).keys())
380
381 # v2 has brand_new — the working-tree edit was picked up
382 assert any("brand_new" in addr for addr in syms2), (
383 f"Expected brand_new in {syms2}"
384 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago