gabriel / muse public
test_phase6_caches_indices_json.py python
360 lines 14.1 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago
1 """TDD — Phase 6: caches and indices from binary msgpack to plain JSON.
2
3 Phase 6 requirements (issue #12):
4 - All cache files under .muse/cache/ use plain JSON (not msgpack)
5 - All index files under .muse/indices/ use plain JSON (not msgpack)
6 - File extensions change from .msgpack to .json
7 - Cache schema versions bumped to invalidate old msgpack files
8 - Old msgpack bytes in any cache/index file → cache miss (no crash)
9 """
10
11 from __future__ import annotations
12
13 import json
14 import pathlib
15 from collections.abc import Mapping
16
17 import msgpack
18 import pytest
19
20
21 # ---------------------------------------------------------------------------
22 # Helpers
23 # ---------------------------------------------------------------------------
24
25 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
26 (tmp_path / ".muse").mkdir(parents=True)
27 return tmp_path
28
29
30 def _write_msgpack_cache(path: pathlib.Path, data: Mapping[str, object]) -> None:
31 """Write a file in old binary msgpack format."""
32 path.parent.mkdir(parents=True, exist_ok=True)
33 path.write_bytes(msgpack.packb(data, use_bin_type=True))
34
35
36 # ---------------------------------------------------------------------------
37 # stat_cache — .json extension, JSON roundtrip
38 # ---------------------------------------------------------------------------
39
40 class TestStatCacheJson:
41 def test_cache_file_has_json_extension(self, tmp_path: pathlib.Path) -> None:
42 """StatCache.save() must write a .json file, not .msgpack."""
43 from muse.core.stat_cache import StatCache
44 from muse.core.paths import cache_dir
45 repo = _make_repo(tmp_path)
46 muse_dir = repo / ".muse"
47 cache = StatCache(muse_dir / "cache", {})
48 cache._dirty = True
49 cache.save()
50
51 cache_files = list((muse_dir / "cache").glob("stat.*"))
52 assert len(cache_files) == 1
53 assert cache_files[0].suffix == ".json", f"Expected .json, got {cache_files[0].suffix}"
54
55 def test_stat_cache_json_roundtrip(self, tmp_path: pathlib.Path) -> None:
56 """StatCache survives a save → load cycle with all entry fields intact."""
57 from muse.core.stat_cache import StatCache, FileCacheEntry
58 from muse.core.ids import hash_blob
59 repo = _make_repo(tmp_path)
60 muse_dir = repo / ".muse"
61
62 cache = StatCache(muse_dir / "cache", {})
63 cache._entries["src/main.py"] = FileCacheEntry(
64 mtime=1234567890.5,
65 size=1024,
66 ino=99,
67 object_hash=hash_blob(b"main"),
68 dimensions={"symbols": hash_blob(b"syms")},
69 )
70 cache._dirty = True
71 cache.save()
72
73 loaded = StatCache.load(muse_dir)
74 assert "src/main.py" in loaded._entries
75 entry = loaded._entries["src/main.py"]
76 assert entry["size"] == 1024
77 assert entry["ino"] == 99
78 assert entry["dimensions"]["symbols"] == hash_blob(b"syms")
79
80 def test_stat_cache_file_is_json(self, tmp_path: pathlib.Path) -> None:
81 """The stat cache file on disk must be valid UTF-8 JSON."""
82 from muse.core.stat_cache import StatCache, FileCacheEntry
83 from muse.core.ids import hash_blob
84 repo = _make_repo(tmp_path)
85 muse_dir = repo / ".muse"
86
87 cache = StatCache(muse_dir / "cache", {})
88 cache._entries["a.py"] = FileCacheEntry(
89 mtime=1.0, size=10, ino=1,
90 object_hash=hash_blob(b"a"), dimensions={},
91 )
92 cache._dirty = True
93 cache.save()
94
95 cache_file = next((muse_dir / "cache").glob("stat.*"))
96 data = json.loads(cache_file.read_bytes())
97 assert isinstance(data, dict)
98 assert "entries" in data
99
100 def test_stale_msgpack_stat_cache_is_miss(self, tmp_path: pathlib.Path) -> None:
101 """Old binary msgpack stat cache → StatCache.load() returns empty cache."""
102 from muse.core.stat_cache import StatCache, _CACHE_FILENAME
103 repo = _make_repo(tmp_path)
104 muse_dir = repo / ".muse"
105 cache_dir = muse_dir / "cache"
106 cache_dir.mkdir()
107 _write_msgpack_cache(cache_dir / _CACHE_FILENAME, {
108 "version": 3, "entries": {"x.py": {"mtime": 1.0, "size": 1, "ino": 1,
109 "object_hash": "sha256:" + "a" * 64, "dimensions": {}}}
110 })
111
112 loaded = StatCache.load(muse_dir)
113 assert loaded._entries == {}
114
115
116 # ---------------------------------------------------------------------------
117 # callgraph_cache — JSON via MsgpackCache base
118 # ---------------------------------------------------------------------------
119
120 class TestCallgraphCacheJson:
121 def test_callgraph_cache_json_roundtrip(self, tmp_path: pathlib.Path) -> None:
122 """CallGraphCache survives save → load with all callee sets intact."""
123 from muse.core.callgraph_cache import CallGraphCache
124 repo = _make_repo(tmp_path)
125 muse_dir = repo / ".muse"
126
127 cache = CallGraphCache.empty()
128 cache._cache_dir = muse_dir / "cache"
129 cache.put("sha256:" + "a" * 64, {"callees": ["src/a.py::foo", "src/b.py::bar"]})
130 cache.save()
131
132 loaded = CallGraphCache.load(muse_dir)
133 entry = loaded.get("sha256:" + "a" * 64)
134 assert entry is not None
135 assert set(entry["callees"]) == {"src/a.py::foo", "src/b.py::bar"}
136
137 def test_callgraph_cache_file_is_json(self, tmp_path: pathlib.Path) -> None:
138 """The callgraph cache file must be valid UTF-8 JSON."""
139 from muse.core.callgraph_cache import CallGraphCache, _CACHE_FILENAME
140 repo = _make_repo(tmp_path)
141 muse_dir = repo / ".muse"
142
143 cache = CallGraphCache.empty()
144 cache._cache_dir = muse_dir / "cache"
145 cache.put("sha256:" + "b" * 64, {"callees": []})
146 cache.save()
147
148 cache_file = (muse_dir / "cache") / _CACHE_FILENAME
149 data = json.loads(cache_file.read_bytes())
150 assert isinstance(data, dict)
151
152 def test_callgraph_cache_has_json_extension(self, tmp_path: pathlib.Path) -> None:
153 from muse.core.callgraph_cache import _CACHE_FILENAME
154 assert _CACHE_FILENAME.endswith(".json"), f"Expected .json, got {_CACHE_FILENAME}"
155
156 def test_stale_msgpack_callgraph_is_miss(self, tmp_path: pathlib.Path) -> None:
157 from muse.core.callgraph_cache import CallGraphCache, _CACHE_FILENAME
158 repo = _make_repo(tmp_path)
159 muse_dir = repo / ".muse"
160 (muse_dir / "cache").mkdir()
161 _write_msgpack_cache((muse_dir / "cache") / _CACHE_FILENAME,
162 {"version": 1, "entries": {"k": {"callees": ["x"]}}})
163
164 loaded = CallGraphCache.load(muse_dir)
165 assert loaded.size == 0
166
167
168 # ---------------------------------------------------------------------------
169 # symbol_cache — JSON via MsgpackCache base
170 # ---------------------------------------------------------------------------
171
172 class TestSymbolCacheJson:
173 def test_symbol_cache_json_roundtrip(self, tmp_path: pathlib.Path) -> None:
174 """SymbolCache survives save → load with symbol tree intact."""
175 from muse.core.symbol_cache import SymbolCache
176 repo = _make_repo(tmp_path)
177 muse_dir = repo / ".muse"
178
179 cache = SymbolCache.empty()
180 cache._cache_dir = muse_dir / "cache"
181 obj_id = "sha256:" + "c" * 64
182 tree = {
183 "src/main.py::foo": {
184 "kind": "function",
185 "name": "foo",
186 "qualified_name": "foo",
187 "content_id": "sha256:" + "a" * 64,
188 "body_hash": "sha256:" + "b" * 64,
189 "signature_id": "sha256:" + "d" * 64,
190 "metadata_id": "sha256:" + "e" * 64,
191 "canonical_key": "src/main.py#foo#function#foo#1",
192 "lineno": 1,
193 "end_lineno": 5,
194 }
195 }
196 cache.put(obj_id, tree)
197 cache.save()
198
199 loaded = SymbolCache.load(muse_dir)
200 result = loaded.get(obj_id)
201 assert result is not None
202 assert "src/main.py::foo" in result
203 assert result["src/main.py::foo"]["name"] == "foo"
204
205 def test_symbol_cache_has_json_extension(self, tmp_path: pathlib.Path) -> None:
206 from muse.core.symbol_cache import _CACHE_FILENAME
207 assert _CACHE_FILENAME.endswith(".json"), f"Expected .json, got {_CACHE_FILENAME}"
208
209 def test_stale_msgpack_symbol_cache_is_miss(self, tmp_path: pathlib.Path) -> None:
210 from muse.core.symbol_cache import SymbolCache, _CACHE_FILENAME
211 repo = _make_repo(tmp_path)
212 muse_dir = repo / ".muse"
213 (muse_dir / "cache").mkdir()
214 _write_msgpack_cache((muse_dir / "cache") / _CACHE_FILENAME,
215 {"version": 1, "entries": {}})
216
217 loaded = SymbolCache.load(muse_dir)
218 assert loaded.size == 0
219
220
221 # ---------------------------------------------------------------------------
222 # indices — JSON, .json extension
223 # ---------------------------------------------------------------------------
224
225 class TestIndicesJson:
226 def test_indices_json_roundtrip(self, tmp_path: pathlib.Path) -> None:
227 """save_symbol_history → load_symbol_history roundtrip preserves entries."""
228 from muse.core.indices import save_symbol_history, load_symbol_history, SymbolHistoryEntry
229 repo = _make_repo(tmp_path)
230
231 entry = SymbolHistoryEntry(
232 commit_id="sha256:" + "d" * 64,
233 committed_at="2026-05-21T00:00:00+00:00",
234 op="added",
235 content_id="sha256:" + "f" * 64,
236 body_hash="sha256:" + "e" * 64,
237 signature_id="sha256:" + "a" * 64,
238 )
239 index = {"src/main.py::foo": [entry]}
240 save_symbol_history(repo, index)
241
242 loaded = load_symbol_history(repo)
243 assert "src/main.py::foo" in loaded
244 assert loaded["src/main.py::foo"][0].op == "added"
245
246 def test_symbol_history_file_is_json(self, tmp_path: pathlib.Path) -> None:
247 """The symbol_history index file must be valid UTF-8 JSON."""
248 from muse.core.indices import save_symbol_history, SymbolHistoryEntry, _index_path
249 repo = _make_repo(tmp_path)
250
251 entry = SymbolHistoryEntry(
252 commit_id="sha256:" + "f" * 64,
253 committed_at="2026-05-21T00:00:00+00:00",
254 op="added",
255 content_id="sha256:" + "b" * 64,
256 body_hash="sha256:" + "a" * 64,
257 signature_id="sha256:" + "c" * 64,
258 )
259 save_symbol_history(repo, {"a.py::fn": [entry]})
260
261 path = _index_path(repo, "symbol_history")
262 data = json.loads(path.read_bytes())
263 assert isinstance(data, dict)
264
265 def test_index_file_has_json_extension(self, tmp_path: pathlib.Path) -> None:
266 """Index files must have .json extension."""
267 from muse.core.indices import _index_path
268 repo = _make_repo(tmp_path)
269 p = _index_path(repo, "symbol_history")
270 assert p.suffix == ".json", f"Expected .json, got {p.suffix}"
271
272 def test_hash_occurrence_json_roundtrip(self, tmp_path: pathlib.Path) -> None:
273 """save_hash_occurrence → load_hash_occurrence roundtrip."""
274 from muse.core.indices import save_hash_occurrence, load_hash_occurrence
275 repo = _make_repo(tmp_path)
276
277 index = {"sha256:" + "a" * 64: ["src/main.py::foo", "src/util.py::bar"]}
278 save_hash_occurrence(repo, index)
279
280 loaded = load_hash_occurrence(repo)
281 assert "sha256:" + "a" * 64 in loaded
282 assert "src/main.py::foo" in loaded["sha256:" + "a" * 64]
283
284 def test_stale_msgpack_index_is_miss(self, tmp_path: pathlib.Path) -> None:
285 """Old msgpack index file → load returns empty dict, no crash."""
286 from muse.core.indices import load_symbol_history, _index_path
287 repo = _make_repo(tmp_path)
288
289 path = _index_path(repo, "symbol_history")
290 path.parent.mkdir(parents=True, exist_ok=True)
291 _write_msgpack_cache(path, {"schema_version": "0.1", "entries": {}})
292
293 result = load_symbol_history(repo)
294 assert result == {}
295
296
297 # ---------------------------------------------------------------------------
298 # test_history — JSON
299 # ---------------------------------------------------------------------------
300
301 class TestHistoryJson:
302 def test_test_history_json_roundtrip(self, tmp_path: pathlib.Path) -> None:
303 """save_history → load_history roundtrip preserves run records."""
304 from muse.core.test_history import save_history, load_history, RunRecord, CaseRecord
305 repo = _make_repo(tmp_path)
306
307 record = RunRecord(
308 run_id="sha256:" + "a" * 64,
309 timestamp="2026-05-21T00:00:00Z",
310 commit_id=None,
311 branch="dev",
312 total=3,
313 passed=2,
314 failed=1,
315 errored=0,
316 skipped=0,
317 results=[
318 CaseRecord(
319 node_id="tests/test_foo.py::test_bar",
320 outcome="passed",
321 duration_ms=12.5,
322 symbol_addresses=["src/foo.py::bar"],
323 )
324 ],
325 )
326 save_history(repo, [record])
327
328 loaded = load_history(repo)
329 assert len(loaded) == 1
330 assert loaded[0]["run_id"] == record["run_id"]
331 assert loaded[0]["passed"] == 2
332
333 def test_test_history_file_is_json(self, tmp_path: pathlib.Path) -> None:
334 """The test history file on disk must be valid UTF-8 JSON."""
335 from muse.core.test_history import save_history, load_history, RunRecord, _history_path
336 repo = _make_repo(tmp_path)
337
338 save_history(repo, [])
339
340 path = _history_path(repo)
341 data = json.loads(path.read_bytes())
342 assert isinstance(data, dict)
343
344 def test_test_history_file_has_json_extension(self, tmp_path: pathlib.Path) -> None:
345 from muse.core.test_history import _history_path
346 repo = _make_repo(tmp_path)
347 p = _history_path(repo)
348 assert p.suffix == ".json", f"Expected .json, got {p.suffix}"
349
350 def test_stale_msgpack_history_is_miss(self, tmp_path: pathlib.Path) -> None:
351 """Old binary msgpack test history → load_history returns []."""
352 from muse.core.test_history import load_history, _history_path
353 repo = _make_repo(tmp_path)
354
355 path = _history_path(repo)
356 path.parent.mkdir(parents=True, exist_ok=True)
357 _write_msgpack_cache(path, {"version": 1, "runs": []})
358
359 result = load_history(repo)
360 assert result == []
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago