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