gabriel / muse public
test_core_stat_cache.py python
410 lines 15.0 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.stat_cache.
2
3 Coverage
4 --------
5 - Cache hit: file with unchanged (mtime, size) returns stored hash without I/O.
6 - Cache miss: new or modified file is re-hashed and entry is updated.
7 - Stale-entry pruning: entries for deleted files are removed.
8 - Dimension hash round-trip: set_dimension / get_dimension.
9 - Dimension eviction on object-hash miss: dimensions reset when file changes.
10 - Persistence: save() / load() round-trip via .muse/cache/stat.msgpack.
11 - Atomic write: temp file is cleaned up; no corruption on concurrent use.
12 - empty(): no-op — save() is a no-op without a cache_dir.
13 - load_cache() convenience helper.
14 - walk_workdir() integration: cache is used and persisted automatically.
15 """
16
17 from __future__ import annotations
18
19 import json
20 import pathlib
21 import time
22
23 import pytest
24
25 from muse.core._types import blob_id
26 from muse.core.stat_cache import FileCacheEntry, StatCache, _hash_bytes, load_cache
27 from muse.core.snapshot import walk_workdir
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34
35 def _make_muse_dir(tmp_path: pathlib.Path) -> pathlib.Path:
36 muse_dir = tmp_path / ".muse"
37 muse_dir.mkdir()
38 (muse_dir / "cache").mkdir()
39 return muse_dir
40
41
42 def _write(path: pathlib.Path, content: str = "hello") -> pathlib.Path:
43 path.parent.mkdir(parents=True, exist_ok=True)
44 path.write_text(content, encoding="utf-8")
45 return path
46
47
48 # ---------------------------------------------------------------------------
49 # _hash_bytes — canonical hash function
50 # ---------------------------------------------------------------------------
51
52
53 class TestHashBytes:
54 def test_matches_hashlib(self, tmp_path: pathlib.Path) -> None:
55 f = _write(tmp_path / "f.txt", "muse")
56 assert _hash_bytes(f) == blob_id(b"muse")
57
58 def test_empty_file(self, tmp_path: pathlib.Path) -> None:
59 f = tmp_path / "empty.txt"
60 f.write_bytes(b"")
61 assert _hash_bytes(f) == blob_id(b"")
62
63 def test_large_file_chunked(self, tmp_path: pathlib.Path) -> None:
64 data = b"x" * (200 * 1024) # 200 KiB — forces multiple 64 KiB chunks
65 f = tmp_path / "big.bin"
66 f.write_bytes(data)
67 assert _hash_bytes(f) == blob_id(data)
68
69
70 # ---------------------------------------------------------------------------
71 # StatCache — construction
72 # ---------------------------------------------------------------------------
73
74
75 class TestStatCacheConstruction:
76 def test_load_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
77 muse_dir = _make_muse_dir(tmp_path)
78 cache = StatCache.load(muse_dir)
79 assert cache._entries == {}
80
81 def test_load_corrupt_msgpack_returns_empty(self, tmp_path: pathlib.Path) -> None:
82 muse_dir = _make_muse_dir(tmp_path)
83 (muse_dir / "cache" / "stat.msgpack").write_bytes(b"not msgpack")
84 cache = StatCache.load(muse_dir)
85 assert cache._entries == {}
86
87 def test_load_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None:
88 import msgpack as _mp
89 muse_dir = _make_muse_dir(tmp_path)
90 (muse_dir / "cache" / "stat.msgpack").write_bytes(
91 _mp.packb({"version": 99, "entries": {}}, use_bin_type=True)
92 )
93 cache = StatCache.load(muse_dir)
94 assert cache._entries == {}
95
96 def test_empty_has_no_cache_dir(self, tmp_path: pathlib.Path) -> None:
97 cache = StatCache.empty()
98 assert cache._cache_dir is None
99 assert cache._entries == {}
100
101 def test_load_cache_helper_with_muse_dir(self, tmp_path: pathlib.Path) -> None:
102 _make_muse_dir(tmp_path)
103 cache = load_cache(tmp_path)
104 assert isinstance(cache, StatCache)
105 assert cache._cache_dir == tmp_path / ".muse" / "cache"
106
107 def test_load_cache_helper_without_muse_dir(self, tmp_path: pathlib.Path) -> None:
108 cache = load_cache(tmp_path)
109 assert cache._cache_dir is None
110
111
112 # ---------------------------------------------------------------------------
113 # StatCache — get_object_hash (hit / miss)
114 # ---------------------------------------------------------------------------
115
116
117 class TestGetObjectHash:
118 def test_first_call_is_cache_miss(self, tmp_path: pathlib.Path) -> None:
119 muse_dir = _make_muse_dir(tmp_path)
120 f = _write(tmp_path / "a.py", "x = 1")
121 cache = StatCache.load(muse_dir)
122
123 h = cache.get_object_hash(tmp_path, f)
124
125 assert h == _hash_bytes(f)
126 assert cache._dirty is True
127 assert "a.py" in cache._entries
128
129 def test_second_call_is_cache_hit_no_dirty(self, tmp_path: pathlib.Path) -> None:
130 muse_dir = _make_muse_dir(tmp_path)
131 f = _write(tmp_path / "a.py", "x = 1")
132 cache = StatCache.load(muse_dir)
133 cache.get_object_hash(tmp_path, f)
134 cache._dirty = False # reset after first miss
135
136 h2 = cache.get_object_hash(tmp_path, f)
137
138 assert h2 == _hash_bytes(f)
139 assert cache._dirty is False # no re-hash, no dirty flag
140
141 def test_modified_file_triggers_miss(self, tmp_path: pathlib.Path) -> None:
142 muse_dir = _make_muse_dir(tmp_path)
143 f = _write(tmp_path / "a.py", "x = 1")
144 cache = StatCache.load(muse_dir)
145 h1 = cache.get_object_hash(tmp_path, f)
146
147 # Modify file content (ensure mtime changes on this filesystem).
148 time.sleep(0.01)
149 f.write_text("x = 2", encoding="utf-8")
150 h2 = cache.get_object_hash(tmp_path, f)
151
152 assert h1 != h2
153 assert h2 == _hash_bytes(f)
154
155 def test_same_content_new_mtime_triggers_miss_but_same_hash(
156 self, tmp_path: pathlib.Path
157 ) -> None:
158 muse_dir = _make_muse_dir(tmp_path)
159 f = _write(tmp_path / "a.py", "identical")
160 cache = StatCache.load(muse_dir)
161 h1 = cache.get_object_hash(tmp_path, f)
162
163 time.sleep(0.01)
164 f.write_text("identical", encoding="utf-8")
165 h2 = cache.get_object_hash(tmp_path, f)
166
167 # Cache miss because mtime changed, but hash is still the same.
168 assert h1 == h2
169
170
171 # ---------------------------------------------------------------------------
172 # StatCache — dimension hashes
173 # ---------------------------------------------------------------------------
174
175
176 class TestDimensionHashes:
177 def test_set_and_get_dimension(self, tmp_path: pathlib.Path) -> None:
178 muse_dir = _make_muse_dir(tmp_path)
179 f = _write(tmp_path / "src.py")
180 cache = StatCache.load(muse_dir)
181 cache.get_object_hash(tmp_path, f) # ensure entry exists
182
183 cache.set_dimension(tmp_path, f, "symbols", "abc123")
184
185 assert cache.get_dimension(tmp_path, f, "symbols") == "abc123"
186
187 def test_get_dimension_missing_key_returns_none(self, tmp_path: pathlib.Path) -> None:
188 muse_dir = _make_muse_dir(tmp_path)
189 f = _write(tmp_path / "src.py")
190 cache = StatCache.load(muse_dir)
191 cache.get_object_hash(tmp_path, f)
192
193 assert cache.get_dimension(tmp_path, f, "nonexistent") is None
194
195 def test_get_dimension_missing_entry_returns_none(self, tmp_path: pathlib.Path) -> None:
196 muse_dir = _make_muse_dir(tmp_path)
197 f = _write(tmp_path / "src.py")
198 cache = StatCache.load(muse_dir)
199 # Never called get_object_hash, so no entry exists.
200 assert cache.get_dimension(tmp_path, f, "symbols") is None
201
202 def test_dimension_evicted_on_object_hash_miss(self, tmp_path: pathlib.Path) -> None:
203 """When a file changes, its dimension hashes must be cleared."""
204 muse_dir = _make_muse_dir(tmp_path)
205 f = _write(tmp_path / "src.py", "v1")
206 cache = StatCache.load(muse_dir)
207 cache.get_object_hash(tmp_path, f)
208 cache.set_dimension(tmp_path, f, "symbols", "stale-hash")
209
210 time.sleep(0.01)
211 f.write_text("v2", encoding="utf-8")
212 cache.get_object_hash(tmp_path, f) # triggers miss → evicts dimensions
213
214 assert cache.get_dimension(tmp_path, f, "symbols") is None
215
216 def test_multiple_dimensions(self, tmp_path: pathlib.Path) -> None:
217 muse_dir = _make_muse_dir(tmp_path)
218 f = _write(tmp_path / "src.py")
219 cache = StatCache.load(muse_dir)
220 cache.get_object_hash(tmp_path, f)
221 cache.set_dimension(tmp_path, f, "symbols", "sym-hash")
222 cache.set_dimension(tmp_path, f, "imports", "imp-hash")
223
224 assert cache.get_dimension(tmp_path, f, "symbols") == "sym-hash"
225 assert cache.get_dimension(tmp_path, f, "imports") == "imp-hash"
226
227 def test_set_dimension_noop_for_unknown_file(self, tmp_path: pathlib.Path) -> None:
228 """set_dimension on a file with no entry must not crash."""
229 muse_dir = _make_muse_dir(tmp_path)
230 f = _write(tmp_path / "ghost.py")
231 cache = StatCache.load(muse_dir)
232 # No get_object_hash call → no entry.
233 cache.set_dimension(tmp_path, f, "symbols", "x") # must not raise
234
235
236 # ---------------------------------------------------------------------------
237 # StatCache — prune
238 # ---------------------------------------------------------------------------
239
240
241 class TestPrune:
242 def test_prune_removes_stale_entries(self, tmp_path: pathlib.Path) -> None:
243 muse_dir = _make_muse_dir(tmp_path)
244 f1 = _write(tmp_path / "keep.py")
245 f2 = _write(tmp_path / "gone.py")
246 cache = StatCache.load(muse_dir)
247 cache.get_object_hash(tmp_path, f1)
248 cache.get_object_hash(tmp_path, f2)
249
250 cache.prune({"keep.py"})
251
252 assert "keep.py" in cache._entries
253 assert "gone.py" not in cache._entries
254
255 def test_prune_noop_when_all_present(self, tmp_path: pathlib.Path) -> None:
256 muse_dir = _make_muse_dir(tmp_path)
257 f = _write(tmp_path / "a.py")
258 cache = StatCache.load(muse_dir)
259 cache.get_object_hash(tmp_path, f)
260 cache._dirty = False
261
262 cache.prune({"a.py"})
263
264 assert cache._dirty is False
265
266 def test_prune_empty_known_set_clears_all(self, tmp_path: pathlib.Path) -> None:
267 muse_dir = _make_muse_dir(tmp_path)
268 f = _write(tmp_path / "a.py")
269 cache = StatCache.load(muse_dir)
270 cache.get_object_hash(tmp_path, f)
271
272 cache.prune(set())
273
274 assert cache._entries == {}
275
276
277 # ---------------------------------------------------------------------------
278 # StatCache — persistence (save / load round-trip)
279 # ---------------------------------------------------------------------------
280
281
282 class TestPersistence:
283 def test_save_and_reload(self, tmp_path: pathlib.Path) -> None:
284 muse_dir = _make_muse_dir(tmp_path)
285 f = _write(tmp_path / "mod.py", "print('hi')")
286 cache = StatCache.load(muse_dir)
287 h = cache.get_object_hash(tmp_path, f)
288 cache.save()
289
290 assert (muse_dir / "cache" / "stat.msgpack").is_file()
291
292 cache2 = StatCache.load(muse_dir)
293 cache2._dirty = False
294 h2 = cache2.get_object_hash(tmp_path, f)
295
296 assert h2 == h
297 assert cache2._dirty is False # served from cache, no re-hash
298
299 def test_save_is_atomic_no_tmp_left(self, tmp_path: pathlib.Path) -> None:
300 muse_dir = _make_muse_dir(tmp_path)
301 f = _write(tmp_path / "x.py")
302 cache = StatCache.load(muse_dir)
303 cache.get_object_hash(tmp_path, f)
304 cache.save()
305
306 assert not any((muse_dir / "cache").glob(".stat_cache_*.tmp"))
307
308 def test_save_noop_when_not_dirty(self, tmp_path: pathlib.Path) -> None:
309 muse_dir = _make_muse_dir(tmp_path)
310 cache = StatCache.load(muse_dir)
311 cache.save() # nothing written
312 assert not (muse_dir / "cache" / "stat.msgpack").exists()
313
314 def test_empty_cache_save_is_noop(self) -> None:
315 cache = StatCache.empty()
316 cache.save() # must not raise
317
318 def test_dimensions_persisted(self, tmp_path: pathlib.Path) -> None:
319 muse_dir = _make_muse_dir(tmp_path)
320 f = _write(tmp_path / "s.py")
321 cache = StatCache.load(muse_dir)
322 cache.get_object_hash(tmp_path, f)
323 cache.set_dimension(tmp_path, f, "symbols", "sym42")
324 cache.save()
325
326 cache2 = StatCache.load(muse_dir)
327 # Validate entry shape — mtime/size unchanged so entry is still valid.
328 assert cache2.get_dimension(tmp_path, f, "symbols") == "sym42"
329
330 def test_json_format_is_versioned(self, tmp_path: pathlib.Path) -> None:
331 muse_dir = _make_muse_dir(tmp_path)
332 f = _write(tmp_path / "v.py")
333 cache = StatCache.load(muse_dir)
334 cache.get_object_hash(tmp_path, f)
335 cache.save()
336
337 import msgpack as _msgpack
338 raw = _msgpack.unpackb((muse_dir / "cache" / "stat.msgpack").read_bytes(), raw=False)
339 assert raw["version"] == 2
340 assert "v.py" in raw["entries"]
341
342
343 # ---------------------------------------------------------------------------
344 # walk_workdir integration
345 # ---------------------------------------------------------------------------
346
347
348 class TestWalkWorkdirCacheIntegration:
349 def test_walk_creates_cache_file(self, tmp_path: pathlib.Path) -> None:
350 muse_dir = tmp_path / ".muse"
351 muse_dir.mkdir()
352 _write(tmp_path / "a.py", "x = 1")
353 _write(tmp_path / "b.py", "y = 2")
354
355 walk_workdir(tmp_path)
356
357 assert (muse_dir / "cache" / "stat.msgpack").is_file()
358
359 def test_walk_second_call_uses_cache(self, tmp_path: pathlib.Path) -> None:
360 """Second walk should hit cache for both files — no dirty flag set."""
361 muse_dir = tmp_path / ".muse"
362 muse_dir.mkdir()
363 _write(tmp_path / "a.py", "x = 1")
364
365 walk_workdir(tmp_path) # cold — populates cache
366
367 cache = StatCache.load(muse_dir)
368 cache._dirty = False
369 cache.get_object_hash(tmp_path, tmp_path / "a.py")
370 # Should not set dirty because mtime/size unchanged.
371 assert cache._dirty is False
372
373 def test_walk_excludes_secrets_from_cache(self, tmp_path: pathlib.Path) -> None:
374 """Secrets excluded by built-in blocklist must not appear in the manifest."""
375 muse_dir = tmp_path / ".muse"
376 muse_dir.mkdir()
377 _write(tmp_path / "visible.py")
378 _write(tmp_path / ".env")
379
380 manifest = walk_workdir(tmp_path)
381
382 assert "visible.py" in manifest
383 assert ".env" not in manifest
384
385 def test_walk_tracks_non_secret_dotfiles(self, tmp_path: pathlib.Path) -> None:
386 """Non-secret dotfiles like .cursorrules are now tracked by default."""
387 muse_dir = tmp_path / ".muse"
388 muse_dir.mkdir()
389 _write(tmp_path / ".cursorrules")
390 _write(tmp_path / ".editorconfig")
391
392 manifest = walk_workdir(tmp_path)
393
394 assert ".cursorrules" in manifest
395 assert ".editorconfig" in manifest
396
397 def test_walk_without_muse_dir_still_works(self, tmp_path: pathlib.Path) -> None:
398 """walk_workdir must work correctly even with no .muse directory."""
399 _write(tmp_path / "a.py", "ok")
400 manifest = walk_workdir(tmp_path)
401 assert "a.py" in manifest
402
403 def test_walk_hashes_match_direct_hash(self, tmp_path: pathlib.Path) -> None:
404 muse_dir = tmp_path / ".muse"
405 muse_dir.mkdir()
406 f = _write(tmp_path / "c.py", "content")
407
408 manifest = walk_workdir(tmp_path)
409
410 assert manifest["c.py"] == _hash_bytes(f)
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