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