gabriel / muse public
test_invariant_file_cache.py python
427 lines 15.4 KB
Raw
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70 feat: migrate invariants cache to .muse/cache/invariants.ms… Sonnet 4.6 patch 131 days ago
1 """Tests for _InvariantFileCache — the persistent per-file AST analysis cache.
2
3 Coverage
4 --------
5 The cache lives at ``.muse/cache/invariants.msgpack`` and maps a file's
6 content hash (SHA-256) to the ``_FileData`` struct produced by a single
7 ``ast.parse`` pass. On a warm cache, ``muse code invariants`` skips every
8 ``ast.parse`` call — O(N×R) → O(1).
9
10 Tier 1 — Unit
11 In-memory operations: get/put/prune/size/empty/dirty flag. No I/O.
12
13 Tier 2 — Integration
14 Real filesystem via ``tmp_path``. Verifies the correct on-disk path,
15 save/load round-trip fidelity, dirty-flag lifecycle, and no-op behaviour.
16
17 Tier 5 — Data integrity
18 Adversarial on-disk state: corrupt bytes, wrong version, missing keys,
19 invalid entries, non-string content hashes. Also verifies atomic write
20 (no ``.tmp`` leftover after a successful save).
21
22 Tier 6 — Performance
23 Asserts that a warm cache skips ``ast.parse`` entirely (zero calls).
24 The mechanism — I/O patching — is more reliable than wall-clock ratios
25 across CI hardware and proves the exact property we care about.
26
27 Tier 7 — Security
28 Mode-000 cache file: ``load()`` must return empty and never raise.
29 Deeply nested msgpack payload: ``load()`` must not crash or hang.
30 """
31
32 from __future__ import annotations
33
34 import os
35 import pathlib
36 import stat
37 import time
38
39 import msgpack
40 import pytest
41
42 from muse.plugins.code._invariants import (
43 _FileData,
44 _InvariantFileCache,
45 _FILE_CACHE_VERSION,
46 )
47
48
49 # ---------------------------------------------------------------------------
50 # Helpers
51 # ---------------------------------------------------------------------------
52
53
54 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
55 """Return a minimal repo root with ``.muse/cache/`` created."""
56 (tmp_path / ".muse" / "cache").mkdir(parents=True)
57 return tmp_path
58
59
60 def _file_data(
61 imports: list[str] | None = None,
62 fns: list[str] | None = None,
63 classes: list[str] | None = None,
64 has_all: bool = False,
65 complexity: dict[str, int] | None = None,
66 ) -> _FileData:
67 """Build a minimal ``_FileData`` struct for testing."""
68 return _FileData(
69 raw_module_imports=imports or [],
70 from_module=[],
71 from_name=[],
72 top_level_fns=fns or [],
73 top_level_classes=classes or [],
74 has_all=has_all,
75 complexity=complexity or {},
76 )
77
78
79 def _cache_path(root: pathlib.Path) -> pathlib.Path:
80 return root / ".muse" / "cache" / "invariants.msgpack"
81
82
83 # ---------------------------------------------------------------------------
84 # Tier 1 — Unit (in-memory, no filesystem)
85 # ---------------------------------------------------------------------------
86
87
88 class TestUnit:
89 """In-memory get/put/prune/size/empty operations — no I/O."""
90
91 def test_get_miss_returns_none(self) -> None:
92 cache = _InvariantFileCache.empty()
93 assert cache.get("no_such_hash") is None
94
95 def test_put_then_get_hit(self) -> None:
96 cache = _InvariantFileCache.empty()
97 fd = _file_data(imports=["os"], fns=["main"])
98 cache.put("abc123", fd)
99 assert cache.get("abc123") == fd
100
101 def test_put_marks_dirty(self) -> None:
102 cache = _InvariantFileCache.empty()
103 assert not cache._dirty
104 cache.put("id1", _file_data())
105 assert cache._dirty
106
107 def test_put_same_key_overwrites(self) -> None:
108 cache = _InvariantFileCache.empty()
109 cache.put("k", _file_data(fns=["old"]))
110 cache.put("k", _file_data(fns=["new"]))
111 assert cache.get("k")["top_level_fns"] == ["new"]
112 assert cache.size == 1
113
114 def test_different_keys_independent(self) -> None:
115 cache = _InvariantFileCache.empty()
116 a = _file_data(fns=["alpha"])
117 b = _file_data(fns=["beta"])
118 cache.put("k_a", a)
119 cache.put("k_b", b)
120 assert cache.get("k_a") == a
121 assert cache.get("k_b") == b
122
123 def test_size_starts_zero(self) -> None:
124 assert _InvariantFileCache.empty().size == 0
125
126 def test_size_grows_with_put(self) -> None:
127 cache = _InvariantFileCache.empty()
128 cache.put("x", _file_data())
129 cache.put("y", _file_data())
130 assert cache.size == 2
131
132 def test_prune_removes_stale_sets_dirty(self) -> None:
133 cache = _InvariantFileCache.empty()
134 cache.put("keep", _file_data())
135 cache.put("drop", _file_data())
136 cache._dirty = False
137 cache.prune({"keep"})
138 assert cache.get("keep") is not None
139 assert cache.get("drop") is None
140 assert cache._dirty
141
142 def test_prune_noop_when_all_live(self) -> None:
143 cache = _InvariantFileCache.empty()
144 cache.put("keep", _file_data())
145 cache._dirty = False
146 cache.prune({"keep", "other"})
147 assert not cache._dirty
148
149 def test_empty_cache_dir_is_none(self) -> None:
150 cache = _InvariantFileCache.empty()
151 assert cache._cache_dir is None
152
153 def test_empty_save_is_noop(self, tmp_path: pathlib.Path) -> None:
154 cache = _InvariantFileCache.empty()
155 cache.put("id", _file_data())
156 cache.save()
157 assert not any(tmp_path.rglob("invariants.msgpack"))
158
159
160 # ---------------------------------------------------------------------------
161 # Tier 2 — Integration (real filesystem)
162 # ---------------------------------------------------------------------------
163
164
165 class TestIntegration:
166 """Real filesystem via ``tmp_path``."""
167
168 def test_load_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
169 root = _make_repo(tmp_path)
170 cache = _InvariantFileCache.load(root)
171 assert cache.size == 0
172
173 def test_save_creates_file_at_correct_path(self, tmp_path: pathlib.Path) -> None:
174 root = _make_repo(tmp_path)
175 cache = _InvariantFileCache.load(root)
176 cache.put("h1", _file_data(fns=["compute"]))
177 cache.save()
178 assert _cache_path(root).is_file()
179
180 def test_save_load_round_trip_preserves_all_fields(self, tmp_path: pathlib.Path) -> None:
181 root = _make_repo(tmp_path)
182 fd = _file_data(
183 imports=["os", "sys"],
184 fns=["compute", "validate"],
185 classes=["MyClass"],
186 has_all=True,
187 complexity={"billing.py::compute": 5},
188 )
189 cache = _InvariantFileCache.load(root)
190 cache.put("deadbeef", fd)
191 cache.save()
192
193 loaded = _InvariantFileCache.load(root)
194 result = loaded.get("deadbeef")
195 assert result is not None
196 assert result["raw_module_imports"] == ["os", "sys"]
197 assert result["top_level_fns"] == ["compute", "validate"]
198 assert result["top_level_classes"] == ["MyClass"]
199 assert result["has_all"] is True
200 assert result["complexity"] == {"billing.py::compute": 5}
201
202 def test_save_noop_when_not_dirty(self, tmp_path: pathlib.Path) -> None:
203 root = _make_repo(tmp_path)
204 cache = _InvariantFileCache.load(root)
205 cache.save()
206 assert not _cache_path(root).exists()
207
208 def test_dirty_false_after_successful_save(self, tmp_path: pathlib.Path) -> None:
209 root = _make_repo(tmp_path)
210 cache = _InvariantFileCache.load(root)
211 cache.put("h", _file_data())
212 cache.save()
213 assert not cache._dirty
214
215 def test_second_save_does_not_update_mtime(self, tmp_path: pathlib.Path) -> None:
216 root = _make_repo(tmp_path)
217 cache = _InvariantFileCache.load(root)
218 cache.put("h", _file_data())
219 cache.save()
220 mtime1 = _cache_path(root).stat().st_mtime_ns
221 cache.save() # not dirty — must not touch the file
222 mtime2 = _cache_path(root).stat().st_mtime_ns
223 assert mtime1 == mtime2
224
225 def test_load_without_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
226 # No .muse/ at all — cache_dir is None, returns empty gracefully.
227 cache = _InvariantFileCache.load(tmp_path)
228 assert cache.size == 0
229 assert cache._cache_dir is None
230
231 def test_multiple_entries_round_trip(self, tmp_path: pathlib.Path) -> None:
232 root = _make_repo(tmp_path)
233 cache = _InvariantFileCache.load(root)
234 for i in range(10):
235 cache.put(f"hash_{i}", _file_data(fns=[f"fn_{i}"]))
236 cache.save()
237
238 loaded = _InvariantFileCache.load(root)
239 assert loaded.size == 10
240 for i in range(10):
241 assert loaded.get(f"hash_{i}")["top_level_fns"] == [f"fn_{i}"]
242
243
244 # ---------------------------------------------------------------------------
245 # Tier 5 — Data integrity
246 # ---------------------------------------------------------------------------
247
248
249 class TestDataIntegrity:
250 """Adversarial on-disk state."""
251
252 def test_corrupt_bytes_returns_empty(self, tmp_path: pathlib.Path) -> None:
253 root = _make_repo(tmp_path)
254 _cache_path(root).write_bytes(b"not valid msgpack !!!")
255 cache = _InvariantFileCache.load(root)
256 assert cache.size == 0
257
258 def test_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None:
259 root = _make_repo(tmp_path)
260 _cache_path(root).write_bytes(
261 msgpack.packb({"version": 999, "entries": {}}, use_bin_type=True)
262 )
263 cache = _InvariantFileCache.load(root)
264 assert cache.size == 0
265
266 def test_missing_entries_key_returns_empty(self, tmp_path: pathlib.Path) -> None:
267 root = _make_repo(tmp_path)
268 _cache_path(root).write_bytes(
269 msgpack.packb({"version": _FILE_CACHE_VERSION}, use_bin_type=True)
270 )
271 cache = _InvariantFileCache.load(root)
272 assert cache.size == 0
273
274 def test_invalid_entry_skipped_valid_survives(self, tmp_path: pathlib.Path) -> None:
275 root = _make_repo(tmp_path)
276 good = {
277 "raw_module_imports": ["os"],
278 "from_module": [],
279 "from_name": [],
280 "top_level_fns": ["run"],
281 "top_level_classes": [],
282 "has_all": False,
283 "complexity": {},
284 }
285 doc = {
286 "version": _FILE_CACHE_VERSION,
287 "entries": {
288 "good_hash": good,
289 "bad_hash": ["not", "a", "dict"], # non-dict value — entry skipped
290 },
291 }
292 _cache_path(root).write_bytes(msgpack.packb(doc, use_bin_type=True))
293 cache = _InvariantFileCache.load(root)
294 assert cache.get("good_hash") is not None
295 assert cache.size == 1
296
297 def test_non_dict_entry_value_skipped(self, tmp_path: pathlib.Path) -> None:
298 root = _make_repo(tmp_path)
299 doc = {
300 "version": _FILE_CACHE_VERSION,
301 "entries": {"bad_hash": "not_a_dict"},
302 }
303 _cache_path(root).write_bytes(msgpack.packb(doc, use_bin_type=True))
304 cache = _InvariantFileCache.load(root)
305 assert cache.size == 0
306
307 def test_no_tmp_file_leftover_after_save(self, tmp_path: pathlib.Path) -> None:
308 root = _make_repo(tmp_path)
309 cache = _InvariantFileCache.load(root)
310 cache.put("h", _file_data())
311 cache.save()
312 cache_dir = root / ".muse" / "cache"
313 assert not any(cache_dir.glob("*.tmp"))
314
315 def test_old_location_file_is_ignored(self, tmp_path: pathlib.Path) -> None:
316 """A ``code_invariants_cache.msgpack`` at the old ``.muse/`` root is not loaded."""
317 root = _make_repo(tmp_path)
318 old_location = root / ".muse" / "code_invariants_cache.msgpack"
319 stale = {
320 "version": _FILE_CACHE_VERSION,
321 "entries": {
322 "stale_hash": {
323 "raw_module_imports": ["stale"],
324 "from_module": [],
325 "from_name": [],
326 "top_level_fns": ["stale_fn"],
327 "top_level_classes": [],
328 "has_all": False,
329 "complexity": {},
330 }
331 },
332 }
333 old_location.write_bytes(msgpack.packb(stale, use_bin_type=True))
334 cache = _InvariantFileCache.load(root)
335 assert cache.get("stale_hash") is None
336
337
338 # ---------------------------------------------------------------------------
339 # Tier 6 — Performance (warm path skips ast.parse)
340 # ---------------------------------------------------------------------------
341
342
343 class TestPerformance:
344 """Warm cache must not call ``ast.parse``."""
345
346 def test_warm_cache_skips_ast_parse(self, tmp_path: pathlib.Path) -> None:
347 """Pre-populated cache: ``_build_file_data`` must not call ``ast.parse``."""
348 from unittest.mock import patch
349 from muse.core.object_store import write_object
350 from muse.core._types import blob_id
351 from muse.plugins.code._invariants import _build_file_data
352
353 root = _make_repo(tmp_path)
354 src = b"def compute(x: int) -> int:\n return x * 2\n"
355 oid = blob_id(src)
356 write_object(root, oid, src)
357 manifest = {"billing.py": oid}
358
359 # Cold run — populates cache in memory.
360 cold_cache = _InvariantFileCache.load(root)
361 _build_file_data(manifest, root, cold_cache)
362 cold_cache.save()
363
364 # Warm run — patch ast.parse to detect any call.
365 warm_cache = _InvariantFileCache.load(root)
366 parse_calls: list[str] = []
367
368 import ast as _ast
369 original_parse = _ast.parse
370
371 def counting_parse(source: str | bytes, *args, **kwargs):
372 parse_calls.append("called")
373 return original_parse(source, *args, **kwargs)
374
375 with patch("muse.plugins.code._invariants.ast.parse", counting_parse):
376 _build_file_data(manifest, root, warm_cache)
377
378 assert parse_calls == [], (
379 f"ast.parse called {len(parse_calls)} time(s) on warm cache — "
380 "cold run should have populated the cache"
381 )
382
383
384 # ---------------------------------------------------------------------------
385 # Tier 7 — Security
386 # ---------------------------------------------------------------------------
387
388
389 class TestSecurity:
390 """Untrusted cache content and unreadable files."""
391
392 @pytest.mark.skipif(os.getuid() == 0, reason="root bypasses file permissions")
393 def test_mode_000_file_returns_empty_no_raise(self, tmp_path: pathlib.Path) -> None:
394 """An unreadable cache file must be handled gracefully — never raises."""
395 root = _make_repo(tmp_path)
396 cache_file = _cache_path(root)
397 cache_file.write_bytes(
398 msgpack.packb(
399 {"version": _FILE_CACHE_VERSION, "entries": {}}, use_bin_type=True
400 )
401 )
402 cache_file.chmod(0o000)
403 try:
404 cache = _InvariantFileCache.load(root)
405 assert cache.size == 0
406 finally:
407 cache_file.chmod(0o644) # restore so tmp_path cleanup succeeds
408
409 def test_deeply_nested_payload_does_not_crash(self, tmp_path: pathlib.Path) -> None:
410 """A pathologically nested msgpack structure must not crash or hang.
411
412 The load code accepts any dict as a ``_FileData`` (filling in defaults
413 for missing fields), so deeply nested dicts won't be *rejected* — but
414 they must not raise an exception or exhaust the stack.
415 """
416 root = _make_repo(tmp_path)
417 nested: object = "leaf"
418 for _ in range(200):
419 nested = {"k": nested}
420 doc = {
421 "version": _FILE_CACHE_VERSION,
422 "entries": {"bomb": nested},
423 }
424 _cache_path(root).write_bytes(msgpack.packb(doc, use_bin_type=True))
425 # Must complete without raising — size is 1 (loaded with empty defaults).
426 cache = _InvariantFileCache.load(root)
427 assert isinstance(cache, _InvariantFileCache)
File History 1 commit
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70 feat: migrate invariants cache to .muse/cache/invariants.ms… Sonnet 4.6 patch 131 days ago