gabriel / muse public
test_callgraph_cache.py python
678 lines 27.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """TDD tests for CallGraphCache — persistent per-file call-graph cache.
2
3 Architecture
4 ------------
5 ``build_forward_graph`` re-reads every Python blob from the object store and
6 re-parses every AST on every CLI invocation. For a 778-file Python codebase
7 that costs ~15 s cold; with a warm cache it should be ~1 s.
8
9 ``CallGraphCache`` mirrors ``SymbolCache`` exactly, but stores a per-file
10 **subgraph** rather than a symbol tree. The subgraph is the portion of the
11 forward call graph contributed by one source file:
12
13 ``{caller_address: frozenset[callee_bare_name]}``
14
15 This preserves full per-address granularity so the warm path produces an
16 identical ``ForwardGraph`` to the cold path.
17
18 Key: SHA-256 of file bytes (``object_id`` from the manifest).
19 Value: ``dict[str, frozenset[str]]`` — per-file forward subgraph.
20 File: ``.muse/cache/callgraph.msgpack`` (msgpack, atomic write).
21 API: ``load(muse_dir)`` / ``empty()`` / ``get`` / ``put`` / ``prune`` /
22 ``size`` / ``save`` + convenience ``load_callgraph_cache(root)``.
23
24 Coverage matrix
25 ---------------
26 - Memory operations: get/put/dirty/size/prune/empty class method
27 - Persistence: save creates file; save/load round-trip; no-dirty skip;
28 dirty=False after save; second save is no-op; atomic write
29 - Graceful load: missing file → empty; corrupt → empty; wrong version → empty;
30 invalid entry skipped, valid entries survive
31 - Convenience helper: load_callgraph_cache with and without .muse dir
32 - Integration cold: build_forward_graph with callgraph_cache=None produces correct graph
33 - Integration warm: pre-populated cache skips read_object and ast.parse entirely
34 - Integration save: cache entries populated after build; build does NOT auto-save
35 - Non-Python files: not cached, not added to graph
36 - Syntax errors: parse failure → entry not cached
37 - Correctness: warm graph == cold graph for all addresses
38 - Performance: warm call ≥ 5× faster than cold; < 100 ms for 30 files
39 """
40
41 from __future__ import annotations
42
43 import pathlib
44 import time
45 from unittest.mock import patch
46
47 import msgpack
48 import pytest
49
50 from muse.core.object_store import write_object
51 from muse.core.paths import muse_dir
52 from muse.core.types import Manifest, blob_id
53
54 # Subgraph type alias: the per-file portion of ForwardGraph
55 _Subgraph = dict[str, frozenset[str]]
56
57
58 # ---------------------------------------------------------------------------
59 # Helpers — shared across all test classes
60 # ---------------------------------------------------------------------------
61
62
63 def _muse_dir(tmp_path: pathlib.Path) -> pathlib.Path:
64 d = muse_dir(tmp_path)
65 d.mkdir(exist_ok=True)
66 (d / "cache").mkdir(exist_ok=True)
67 return d
68
69
70 def _write_py(
71 tmp_path: pathlib.Path, rel_path: str, source: str
72 ) -> tuple[str, bytes]:
73 """Write source to object store; return (object_id, raw_bytes).
74
75 object_id uses the canonical ``sha256:<hex>`` prefix required by the
76 object store's ``validate_object_id`` guard.
77 """
78 raw = source.encode()
79 oid = blob_id(raw)
80 write_object(tmp_path, oid, raw)
81 return oid, raw
82
83
84 def _make_manifest(
85 tmp_path: pathlib.Path, files: dict[str, str]
86 ) -> Manifest:
87 manifest: Manifest = {}
88 for rel_path, source in files.items():
89 oid, _ = _write_py(tmp_path, rel_path, source)
90 manifest[rel_path] = oid
91 return manifest
92
93
94 def _subgraph(
95 file_path: str, caller: str, callees: set[str]
96 ) -> _Subgraph:
97 """Helper to build a minimal per-file subgraph for a single caller."""
98 return {f"{file_path}::{caller}": frozenset(callees)}
99
100
101 # ---------------------------------------------------------------------------
102 # TestCallGraphCacheMemory
103 # ---------------------------------------------------------------------------
104
105
106 class TestCallGraphCacheMemory:
107 """In-memory get/put/prune/size/empty operations."""
108
109 def test_empty_get_miss(self) -> None:
110 from muse.core.callgraph_cache import CallGraphCache
111 cache = CallGraphCache.empty()
112 assert cache.get("no_such_id") is None
113
114 def test_put_then_get_hit(self) -> None:
115 from muse.core.callgraph_cache import CallGraphCache
116 cache = CallGraphCache.empty()
117 subgraph: _Subgraph = {
118 "mod.py::caller": frozenset({"compute", "validate"}),
119 "mod.py::leaf": frozenset(),
120 }
121 cache.put("abc123", subgraph)
122 assert cache.get("abc123") == subgraph
123
124 def test_put_marks_dirty(self) -> None:
125 from muse.core.callgraph_cache import CallGraphCache
126 cache = CallGraphCache.empty()
127 assert not cache._dirty
128 cache.put("id1", {"mod.py::fn": frozenset()})
129 assert cache._dirty
130
131 def test_different_ids_independent(self) -> None:
132 from muse.core.callgraph_cache import CallGraphCache
133 cache = CallGraphCache.empty()
134 sg_a: _Subgraph = {"a.py::alpha": frozenset({"beta"})}
135 sg_b: _Subgraph = {"b.py::gamma": frozenset({"delta"})}
136 cache.put("id_a", sg_a)
137 cache.put("id_b", sg_b)
138 assert cache.get("id_a") == sg_a
139 assert cache.get("id_b") == sg_b
140
141 def test_put_same_id_overwrites(self) -> None:
142 from muse.core.callgraph_cache import CallGraphCache
143 cache = CallGraphCache.empty()
144 cache.put("same", {"f.py::old": frozenset({"x"})})
145 cache.put("same", {"f.py::new": frozenset({"y"})})
146 result = cache.get("same")
147 assert "f.py::new" in result
148 assert "f.py::old" not in result
149 assert cache.size == 1
150
151 def test_size_starts_zero(self) -> None:
152 from muse.core.callgraph_cache import CallGraphCache
153 assert CallGraphCache.empty().size == 0
154
155 def test_size_grows_with_put(self) -> None:
156 from muse.core.callgraph_cache import CallGraphCache
157 cache = CallGraphCache.empty()
158 cache.put("x", {"m.py::f": frozenset()})
159 cache.put("y", {"m.py::g": frozenset()})
160 assert cache.size == 2
161
162 def test_prune_removes_stale_entries(self) -> None:
163 from muse.core.callgraph_cache import CallGraphCache
164 cache = CallGraphCache.empty()
165 cache.put("keep", {"a.py::f": frozenset()})
166 cache.put("drop", {"b.py::g": frozenset()})
167 cache.prune({"keep"})
168 assert cache.get("keep") is not None
169 assert cache.get("drop") is None
170
171 def test_prune_marks_dirty_when_stale(self) -> None:
172 from muse.core.callgraph_cache import CallGraphCache
173 cache = CallGraphCache.empty()
174 cache.put("drop", {"a.py::f": frozenset()})
175 cache._dirty = False
176 cache.prune(set())
177 assert cache._dirty
178
179 def test_prune_no_stale_not_dirty(self) -> None:
180 from muse.core.callgraph_cache import CallGraphCache
181 cache = CallGraphCache.empty()
182 cache.put("keep", {"a.py::f": frozenset()})
183 cache._dirty = False
184 cache.prune({"keep", "other"})
185 assert not cache._dirty
186
187 def test_empty_save_is_noop(self, tmp_path: pathlib.Path) -> None:
188 from muse.core.callgraph_cache import CallGraphCache
189 cache = CallGraphCache.empty()
190 cache.put("id", {"m.py::fn": frozenset({"fn"})})
191 cache.save() # muse_dir is None — must not raise
192 assert not any(tmp_path.rglob("callgraph.msgpack"))
193
194 def test_empty_subgraph_is_valid(self) -> None:
195 from muse.core.callgraph_cache import CallGraphCache
196 cache = CallGraphCache.empty()
197 cache.put("leaf_fn", {})
198 result = cache.get("leaf_fn")
199 assert result == {}
200
201 def test_frozensets_preserved_in_subgraph(self) -> None:
202 from muse.core.callgraph_cache import CallGraphCache
203 cache = CallGraphCache.empty()
204 sg: _Subgraph = {"m.py::fn": frozenset({"a", "b", "c"})}
205 cache.put("id", sg)
206 result = cache.get("id")
207 assert isinstance(result["m.py::fn"], frozenset)
208 assert result["m.py::fn"] == frozenset({"a", "b", "c"})
209
210
211 # ---------------------------------------------------------------------------
212 # TestCallGraphCachePersistence
213 # ---------------------------------------------------------------------------
214
215
216 class TestCallGraphCachePersistence:
217 """save() / load() round-trip via .muse/cache/callgraph.msgpack."""
218
219 def test_save_creates_file(self, tmp_path: pathlib.Path) -> None:
220 from muse.core.callgraph_cache import CallGraphCache
221 md = _muse_dir(tmp_path)
222 cache = CallGraphCache.load(md)
223 cache.put("id1", {"m.py::fn": frozenset({"compute"})})
224 cache.save()
225 assert (md / "cache" / "callgraph.msgpack").is_file()
226
227 def test_save_then_load_round_trip(self, tmp_path: pathlib.Path) -> None:
228 from muse.core.callgraph_cache import CallGraphCache
229 md = _muse_dir(tmp_path)
230 sg: _Subgraph = {
231 "billing.py::compute": frozenset({"validate", "send"}),
232 "billing.py::validate": frozenset(),
233 }
234 oid = "deadbeef" * 8
235
236 cache = CallGraphCache.load(md)
237 cache.put(oid, sg)
238 cache.save()
239
240 loaded = CallGraphCache.load(md)
241 result = loaded.get(oid)
242 assert result is not None
243 assert result == sg
244
245 def test_round_trip_preserves_frozenset_type(self, tmp_path: pathlib.Path) -> None:
246 from muse.core.callgraph_cache import CallGraphCache
247 md = _muse_dir(tmp_path)
248 cache = CallGraphCache.load(md)
249 cache.put("id", {"m.py::fn": frozenset({"a", "b"})})
250 cache.save()
251
252 loaded = CallGraphCache.load(md)
253 result = loaded.get("id")
254 assert isinstance(result["m.py::fn"], frozenset)
255
256 def test_save_no_dirty_skips_write(self, tmp_path: pathlib.Path) -> None:
257 from muse.core.callgraph_cache import CallGraphCache
258 md = _muse_dir(tmp_path)
259 cache = CallGraphCache.load(md)
260 cache.save()
261 assert not (md / "cache" / "callgraph.msgpack").is_file()
262
263 def test_save_clears_dirty_flag(self, tmp_path: pathlib.Path) -> None:
264 from muse.core.callgraph_cache import CallGraphCache
265 md = _muse_dir(tmp_path)
266 cache = CallGraphCache.load(md)
267 cache.put("id", {"m.py::fn": frozenset()})
268 cache.save()
269 assert not cache._dirty
270
271 def test_second_save_is_noop(self, tmp_path: pathlib.Path) -> None:
272 from muse.core.callgraph_cache import CallGraphCache
273 md = _muse_dir(tmp_path)
274 cache = CallGraphCache.load(md)
275 cache.put("id", {"m.py::fn": frozenset()})
276 cache.save()
277 mtime1 = (md / "cache" / "callgraph.msgpack").stat().st_mtime_ns
278 cache.save()
279 mtime2 = (md / "cache" / "callgraph.msgpack").stat().st_mtime_ns
280 assert mtime1 == mtime2
281
282 def test_multiple_entries_survive_round_trip(self, tmp_path: pathlib.Path) -> None:
283 from muse.core.callgraph_cache import CallGraphCache
284 md = _muse_dir(tmp_path)
285 cache = CallGraphCache.load(md)
286 entries: dict[str, _Subgraph] = {
287 "id_a": {"a.py::alpha": frozenset({"beta"})},
288 "id_b": {},
289 "id_c": {"c.py::gamma": frozenset({"delta", "epsilon"})},
290 }
291 for oid, sg in entries.items():
292 cache.put(oid, sg)
293 cache.save()
294
295 loaded = CallGraphCache.load(md)
296 for oid, sg in entries.items():
297 assert loaded.get(oid) == sg
298
299 def test_atomic_write_no_tmp_leftover(self, tmp_path: pathlib.Path) -> None:
300 from muse.core.callgraph_cache import CallGraphCache
301 md = _muse_dir(tmp_path)
302 cache = CallGraphCache.load(md)
303 cache.put("id", {"m.py::fn": frozenset()})
304 cache.save()
305 assert not any((md / "cache").glob("*.tmp"))
306
307 def test_orphaned_tmp_swept_on_startup(self, tmp_path: pathlib.Path) -> None:
308 """A stale ``.callgraph_*.tmp`` left by a crash is removed by the startup sweep."""
309 from muse.core.repo import _cleanup_muse_dir_temps
310 md = _muse_dir(tmp_path)
311 orphan = md / "cache" / ".callgraph_abc123.tmp"
312 orphan.write_bytes(b"stale")
313 _cleanup_muse_dir_temps(md)
314 assert not orphan.exists()
315
316
317 # ---------------------------------------------------------------------------
318 # TestCallGraphCacheGracefulLoad
319 # ---------------------------------------------------------------------------
320
321
322 class TestCallGraphCacheGracefulLoad:
323 """load() never raises — returns empty cache on any error."""
324
325 def test_absent_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
326 from muse.core.callgraph_cache import CallGraphCache
327 md = _muse_dir(tmp_path)
328 assert CallGraphCache.load(md).size == 0
329
330 def test_corrupt_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
331 from muse.core.callgraph_cache import CallGraphCache
332 md = _muse_dir(tmp_path)
333 (md / "cache" / "callgraph.msgpack").write_bytes(b"not valid msgpack !!!")
334 assert CallGraphCache.load(md).size == 0
335
336 def test_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None:
337 from muse.core.callgraph_cache import CallGraphCache
338 md = _muse_dir(tmp_path)
339 doc = {"version": 999, "entries": {}}
340 (md / "cache" / "callgraph.msgpack").write_bytes(msgpack.packb(doc, use_bin_type=True))
341 assert CallGraphCache.load(md).size == 0
342
343 def test_non_dict_entries_returns_empty(self, tmp_path: pathlib.Path) -> None:
344 from muse.core.callgraph_cache import CallGraphCache
345 md = _muse_dir(tmp_path)
346 doc = {"version": 1, "entries": "not a dict"}
347 (md / "cache" / "callgraph.msgpack").write_bytes(msgpack.packb(doc, use_bin_type=True))
348 assert CallGraphCache.load(md).size == 0
349
350 def test_invalid_entry_skipped_valid_survive(self, tmp_path: pathlib.Path) -> None:
351 """A single malformed subgraph entry is skipped; valid ones survive."""
352 from muse.core.callgraph_cache import CallGraphCache
353 md = _muse_dir(tmp_path)
354 doc = {
355 "version": 1,
356 "entries": {
357 "good_id": {"m.py::fn": ["compute", "validate"]}, # valid
358 "bad_id": {"m.py::fn": "not_a_list"}, # invalid — str not list
359 "empty_id": {}, # valid empty subgraph
360 },
361 }
362 (md / "cache" / "callgraph.msgpack").write_bytes(msgpack.packb(doc, use_bin_type=True))
363 cache = CallGraphCache.load(md)
364 assert cache.get("good_id") == {"m.py::fn": frozenset({"compute", "validate"})}
365 assert cache.get("bad_id") is None
366 assert cache.get("empty_id") == {}
367
368 def test_entry_with_non_str_callee_skipped(self, tmp_path: pathlib.Path) -> None:
369 from muse.core.callgraph_cache import CallGraphCache
370 md = _muse_dir(tmp_path)
371 doc = {
372 "version": 1,
373 "entries": {
374 "bad": {"m.py::fn": [123, "valid"]}, # 123 is not str → skip entry
375 },
376 }
377 (md / "cache" / "callgraph.msgpack").write_bytes(msgpack.packb(doc, use_bin_type=True))
378 assert CallGraphCache.load(md).get("bad") is None
379
380 def test_entry_subgraph_not_dict_skipped(self, tmp_path: pathlib.Path) -> None:
381 from muse.core.callgraph_cache import CallGraphCache
382 md = _muse_dir(tmp_path)
383 doc = {
384 "version": 1,
385 "entries": {
386 "bad": "not_a_dict_at_all", # subgraph must be a dict
387 },
388 }
389 (md / "cache" / "callgraph.msgpack").write_bytes(msgpack.packb(doc, use_bin_type=True))
390 assert CallGraphCache.load(md).get("bad") is None
391
392
393 # ---------------------------------------------------------------------------
394 # TestLoadCallGraphCache — convenience helper
395 # ---------------------------------------------------------------------------
396
397
398 class TestLoadCallGraphCache:
399 """load_callgraph_cache(root) convenience loader."""
400
401 def test_no_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
402 from muse.core.callgraph_cache import load_callgraph_cache
403 assert load_callgraph_cache(tmp_path).size == 0
404
405 def test_with_muse_dir_loads_existing(self, tmp_path: pathlib.Path) -> None:
406 from muse.core.callgraph_cache import CallGraphCache, load_callgraph_cache
407 md = _muse_dir(tmp_path)
408 seed = CallGraphCache.load(md)
409 seed.put("myid", {"m.py::fn": frozenset({"fn"})})
410 seed.save()
411
412 cache = load_callgraph_cache(tmp_path)
413 assert cache.get("myid") == {"m.py::fn": frozenset({"fn"})}
414
415 def test_with_empty_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
416 from muse.core.callgraph_cache import load_callgraph_cache
417 _muse_dir(tmp_path)
418 assert load_callgraph_cache(tmp_path).size == 0
419
420
421 # ---------------------------------------------------------------------------
422 # TestBuildForwardGraphWithCache — integration
423 # ---------------------------------------------------------------------------
424
425
426 class TestBuildForwardGraphWithCache:
427 """build_forward_graph accepts an optional CallGraphCache and uses it."""
428
429 def _repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
430 _muse_dir(tmp_path)
431 return tmp_path
432
433 def test_cold_cache_none_correct_graph(self, tmp_path: pathlib.Path) -> None:
434 """Without a cache, build_forward_graph still returns the correct graph."""
435 root = self._repo(tmp_path)
436 src = "def caller():\n callee()\n\ndef callee():\n pass\n"
437 manifest = _make_manifest(root, {"mod.py": src})
438
439 from muse.plugins.code._callgraph import build_forward_graph
440 graph = build_forward_graph(root, manifest)
441 caller_addr = next(k for k in graph if "caller" in k)
442 assert "callee" in graph[caller_addr]
443
444 def test_explicit_cache_hit_skips_read_object(self, tmp_path: pathlib.Path) -> None:
445 """When the cache has an entry for object_id, read_object is not called."""
446 from muse.core.callgraph_cache import CallGraphCache
447 from muse.plugins.code._callgraph import build_forward_graph
448
449 root = self._repo(tmp_path)
450 src = "def caller():\n callee()\n"
451 oid, _ = _write_py(root, "mod.py", src)
452 manifest = {"mod.py": oid}
453
454 # Pre-populate with the known subgraph
455 cache = CallGraphCache.empty()
456 cache.put(oid, {"mod.py::caller": frozenset({"callee"})})
457
458 with patch("muse.plugins.code._callgraph.read_object") as mock_read:
459 build_forward_graph(root, manifest, callgraph_cache=cache)
460 mock_read.assert_not_called()
461
462 def test_cache_miss_calls_read_object(self, tmp_path: pathlib.Path) -> None:
463 """On a cache miss, read_object IS called (the normal cold path)."""
464 from muse.core.callgraph_cache import CallGraphCache
465 from muse.plugins.code._callgraph import build_forward_graph
466
467 root = self._repo(tmp_path)
468 src = "def caller():\n callee()\n"
469 oid, _ = _write_py(root, "mod.py", src)
470 manifest = {"mod.py": oid}
471
472 empty_cache = CallGraphCache.empty()
473 with patch(
474 "muse.plugins.code._callgraph.read_object",
475 wraps=__import__(
476 "muse.core.object_store", fromlist=["read_object"]
477 ).read_object,
478 ) as mock_read:
479 build_forward_graph(root, manifest, callgraph_cache=empty_cache)
480 assert mock_read.call_count >= 1
481
482 def test_cache_populated_after_build(self, tmp_path: pathlib.Path) -> None:
483 """After build_forward_graph, the cache contains a subgraph for each parsed file."""
484 from muse.core.callgraph_cache import CallGraphCache
485 from muse.plugins.code._callgraph import build_forward_graph
486
487 root = self._repo(tmp_path)
488 src = "def caller():\n callee()\n"
489 oid, _ = _write_py(root, "mod.py", src)
490 manifest = {"mod.py": oid}
491
492 cache = CallGraphCache.empty()
493 build_forward_graph(root, manifest, callgraph_cache=cache)
494 result = cache.get(oid)
495 assert result is not None
496 assert isinstance(result, dict)
497
498 def test_cache_subgraph_has_correct_callees(self, tmp_path: pathlib.Path) -> None:
499 """The subgraph stored in the cache matches the cold graph output."""
500 from muse.core.callgraph_cache import CallGraphCache
501 from muse.plugins.code._callgraph import build_forward_graph
502
503 root = self._repo(tmp_path)
504 src = "def caller():\n callee_a()\n callee_b()\n"
505 oid, _ = _write_py(root, "mod.py", src)
506 manifest = {"mod.py": oid}
507
508 cache = CallGraphCache.empty()
509 graph = build_forward_graph(root, manifest, callgraph_cache=cache)
510
511 # The subgraph in the cache must match the graph output for this file
512 subgraph = cache.get(oid)
513 assert subgraph is not None
514 for addr, callees in graph.items():
515 assert addr in subgraph
516 assert subgraph[addr] == callees
517
518 def test_warm_graph_equals_cold_graph(self, tmp_path: pathlib.Path) -> None:
519 """Graph built with a warm cache equals graph built cold."""
520 from muse.core.callgraph_cache import CallGraphCache
521 from muse.plugins.code._callgraph import build_forward_graph
522
523 root = self._repo(tmp_path)
524 src = "def a():\n b()\n c()\n\ndef b():\n pass\n\ndef c():\n pass\n"
525 oid, _ = _write_py(root, "mod.py", src)
526 manifest = {"mod.py": oid}
527
528 cold_graph = build_forward_graph(root, manifest)
529
530 cache = CallGraphCache.empty()
531 build_forward_graph(root, manifest, callgraph_cache=cache) # populates cache
532 warm_graph = build_forward_graph(root, manifest, callgraph_cache=cache) # warm
533
534 for addr in cold_graph:
535 assert addr in warm_graph
536 assert cold_graph[addr] == warm_graph[addr]
537
538 def test_non_python_files_not_cached(self, tmp_path: pathlib.Path) -> None:
539 """Non-Python files are not added to the cache or graph."""
540 from muse.core.callgraph_cache import CallGraphCache
541 from muse.plugins.code._callgraph import build_forward_graph
542
543 root = self._repo(tmp_path)
544 md_oid, _ = _write_py(root, "README.md", "# My README\n")
545 py_oid, _ = _write_py(root, "mod.py", "def fn():\n pass\n")
546 manifest = {"README.md": md_oid, "mod.py": py_oid}
547
548 cache = CallGraphCache.empty()
549 build_forward_graph(root, manifest, callgraph_cache=cache)
550
551 assert cache.get(md_oid) is None # markdown — not cached
552 assert cache.get(py_oid) is not None # Python — cached
553
554 def test_build_does_not_call_cache_save(self, tmp_path: pathlib.Path) -> None:
555 """build_forward_graph must not call cache.save() — that is the caller's job."""
556 from muse.core.callgraph_cache import CallGraphCache
557 from muse.plugins.code._callgraph import build_forward_graph
558
559 root = self._repo(tmp_path)
560 src = "def fn():\n pass\n"
561 oid, _ = _write_py(root, "mod.py", src)
562 manifest = {"mod.py": oid}
563
564 md = _muse_dir(tmp_path)
565 cache = CallGraphCache.load(md)
566 build_forward_graph(root, manifest, callgraph_cache=cache)
567 # If build_forward_graph called save(), the file would exist now
568 assert not (md / "cache" / "callgraph.msgpack").is_file()
569
570 def test_syntax_error_file_not_cached(self, tmp_path: pathlib.Path) -> None:
571 """Files with syntax errors are skipped — nothing added to cache."""
572 from muse.core.callgraph_cache import CallGraphCache
573 from muse.plugins.code._callgraph import build_forward_graph
574
575 root = self._repo(tmp_path)
576 bad_src = "def broken(:\n pass\n"
577 oid, _ = _write_py(root, "broken.py", bad_src)
578 manifest = {"broken.py": oid}
579
580 cache = CallGraphCache.empty()
581 build_forward_graph(root, manifest, callgraph_cache=cache)
582 assert cache.get(oid) is None # parse failed → not cached
583
584 def test_second_call_skips_ast_parse(self, tmp_path: pathlib.Path) -> None:
585 """On the second call with a warm cache, ast.parse is never called."""
586 from muse.core.callgraph_cache import CallGraphCache
587 from muse.plugins.code._callgraph import build_forward_graph
588
589 root = self._repo(tmp_path)
590 src = "def caller():\n callee()\n"
591 oid, _ = _write_py(root, "mod.py", src)
592 manifest = {"mod.py": oid}
593
594 cache = CallGraphCache.empty()
595 build_forward_graph(root, manifest, callgraph_cache=cache) # cold
596
597 with patch("muse.plugins.code._callgraph.ast") as mock_ast:
598 build_forward_graph(root, manifest, callgraph_cache=cache) # warm
599 mock_ast.parse.assert_not_called()
600
601
602 # ---------------------------------------------------------------------------
603 # TestCallGraphCachePerformance
604 # ---------------------------------------------------------------------------
605
606
607 class TestCallGraphCachePerformance:
608 """Second call with a warm cache must be substantially faster than cold."""
609
610 def _build_repo(
611 self, tmp_path: pathlib.Path, n_files: int = 30
612 ) -> tuple[pathlib.Path, Manifest]:
613 root = tmp_path
614 _muse_dir(root)
615 manifest: Manifest = {}
616 for i in range(n_files):
617 src = (
618 f"def fn_{i}():\n helper_{i}()\n util_{i}()\n\n"
619 f"def helper_{i}():\n pass\n\n"
620 f"def util_{i}():\n pass\n"
621 )
622 oid, _ = _write_py(root, f"mod_{i}.py", src)
623 manifest[f"mod_{i}.py"] = oid
624 return root, manifest
625
626 def test_warm_cache_at_least_5x_faster(self, tmp_path: pathlib.Path) -> None:
627 from muse.core.callgraph_cache import CallGraphCache
628 from muse.plugins.code._callgraph import build_forward_graph
629
630 root, manifest = self._build_repo(tmp_path, n_files=30)
631
632 cache = CallGraphCache.empty()
633 t0 = time.perf_counter()
634 build_forward_graph(root, manifest, callgraph_cache=cache)
635 cold_ms = (time.perf_counter() - t0) * 1000
636
637 t1 = time.perf_counter()
638 build_forward_graph(root, manifest, callgraph_cache=cache)
639 warm_ms = (time.perf_counter() - t1) * 1000
640
641 assert warm_ms < cold_ms / 5, (
642 f"Warm ({warm_ms:.1f} ms) should be ≥5× faster than cold ({cold_ms:.1f} ms)"
643 )
644
645 def test_warm_cache_under_100ms_for_30_files(self, tmp_path: pathlib.Path) -> None:
646 from muse.core.callgraph_cache import CallGraphCache
647 from muse.plugins.code._callgraph import build_forward_graph
648
649 root, manifest = self._build_repo(tmp_path, n_files=30)
650
651 cache = CallGraphCache.empty()
652 build_forward_graph(root, manifest, callgraph_cache=cache) # warm the cache
653
654 t0 = time.perf_counter()
655 build_forward_graph(root, manifest, callgraph_cache=cache)
656 warm_ms = (time.perf_counter() - t0) * 1000
657
658 assert warm_ms < 100, f"Warm call took {warm_ms:.1f} ms — expected < 100 ms"
659
660 def test_graph_correctness_not_degraded_by_cache(self, tmp_path: pathlib.Path) -> None:
661 """Warm-cache graph is identical to cold-cache graph for all addresses."""
662 from muse.core.callgraph_cache import CallGraphCache
663 from muse.plugins.code._callgraph import build_forward_graph
664
665 root, manifest = self._build_repo(tmp_path, n_files=10)
666
667 cold_graph = build_forward_graph(root, manifest)
668
669 cache = CallGraphCache.empty()
670 build_forward_graph(root, manifest, callgraph_cache=cache) # warm
671 warm_graph = build_forward_graph(root, manifest, callgraph_cache=cache)
672
673 for addr in cold_graph:
674 assert addr in warm_graph, f"Address {addr!r} missing from warm graph"
675 assert cold_graph[addr] == warm_graph[addr], (
676 f"Callee mismatch at {addr!r}: "
677 f"cold={cold_graph[addr]} warm={warm_graph[addr]}"
678 )
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago