gabriel / muse public
test_core_symbol_cache.py python
384 lines 13.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 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/symbol_cache.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 hashlib
22 import pathlib
23 from unittest.mock import patch
24
25 import msgpack
26 import pytest
27
28 from muse.core.store import MsgpackValue
29 from muse.core.symbol_cache import (
30 SymbolCache,
31 _object_id_of,
32 _is_symbol_record,
33 load_symbol_cache,
34 )
35 from muse.core._types import blob_id
36 from muse.core.object_store import write_object
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 muse_dir = tmp_path / ".muse"
47 muse_dir.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=hashlib.sha256(name.encode()).hexdigest(),
62 body_hash=hashlib.sha256(b"body").hexdigest(),
63 signature_id=hashlib.sha256(b"sig").hexdigest(),
64 metadata_id=hashlib.sha256(b"meta").hexdigest(),
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) == hashlib.sha256(b"hello").hexdigest()
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 / "symbol_cache.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 / "symbol_cache.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 / "symbol_cache.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 / "symbol_cache.msgpack").stat().st_mtime_ns
253 cache.save() # not dirty — should not touch file
254 mtime2 = (muse_dir / "symbol_cache.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 / "symbol_cache.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 / "symbol_cache.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 / "symbol_cache.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 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago