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