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