gabriel / muse public
test_callgraph_cache.py python
687 lines 27.8 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 5 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.json`` (JSON, 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 pytest
48
49 from muse.core.object_store import write_object
50 from muse.core.paths import muse_dir
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 = muse_dir(tmp_path)
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.json"))
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.json."""
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.json").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.json").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.json").stat().st_mtime_ns
277 cache.save()
278 mtime2 = (md / "cache" / "callgraph.json").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 any((md / "cache").glob("*.tmp"))
305
306 def test_orphaned_tmp_swept_on_startup(self, tmp_path: pathlib.Path) -> None:
307 """A stale ``.callgraph_*.tmp`` left by a crash is removed by the startup sweep."""
308 from muse.core.repo import _cleanup_muse_dir_temps
309 md = _muse_dir(tmp_path)
310 orphan = md / "cache" / ".callgraph_abc123.tmp"
311 orphan.write_bytes(b"stale")
312 _cleanup_muse_dir_temps(md)
313 assert not orphan.exists()
314
315
316 # ---------------------------------------------------------------------------
317 # TestCallGraphCacheGracefulLoad
318 # ---------------------------------------------------------------------------
319
320
321 class TestCallGraphCacheGracefulLoad:
322 """load() never raises — returns empty cache on any error."""
323
324 def test_absent_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
325 from muse.core.callgraph_cache import CallGraphCache
326 md = _muse_dir(tmp_path)
327 assert CallGraphCache.load(md).size == 0
328
329 def test_corrupt_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
330 from muse.core.callgraph_cache import CallGraphCache
331 md = _muse_dir(tmp_path)
332 (md / "cache").mkdir(parents=True, exist_ok=True)
333 (md / "cache" / "callgraph.json").write_bytes(b"not valid JSON !!!")
334 assert CallGraphCache.load(md).size == 0
335
336 def test_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None:
337 import json as _json
338 from muse.core.callgraph_cache import CallGraphCache
339 md = _muse_dir(tmp_path)
340 doc = {"version": 999, "entries": {}}
341 (md / "cache").mkdir(parents=True, exist_ok=True)
342 (md / "cache" / "callgraph.json").write_bytes(_json.dumps(doc).encode())
343 assert CallGraphCache.load(md).size == 0
344
345 def test_non_dict_entries_returns_empty(self, tmp_path: pathlib.Path) -> None:
346 import json as _json
347 from muse.core.callgraph_cache import CallGraphCache
348 md = _muse_dir(tmp_path)
349 doc = {"version": 1, "entries": "not a dict"}
350 (md / "cache").mkdir(parents=True, exist_ok=True)
351 (md / "cache" / "callgraph.json").write_bytes(_json.dumps(doc).encode())
352 assert CallGraphCache.load(md).size == 0
353
354 def test_invalid_entry_skipped_valid_survive(self, tmp_path: pathlib.Path) -> None:
355 """A single malformed subgraph entry is skipped; valid ones survive."""
356 import json as _json
357 from muse.core.callgraph_cache import CallGraphCache, _CACHE_VERSION
358 md = _muse_dir(tmp_path)
359 doc = {
360 "version": _CACHE_VERSION,
361 "entries": {
362 "good_id": {"m.py::fn": ["compute", "validate"]}, # valid
363 "bad_id": {"m.py::fn": "not_a_list"}, # invalid — str not list
364 "empty_id": {}, # valid empty subgraph
365 },
366 }
367 (md / "cache" / "callgraph.json").write_bytes(_json.dumps(doc).encode())
368 cache = CallGraphCache.load(md)
369 assert cache.get("good_id") == {"m.py::fn": frozenset({"compute", "validate"})}
370 assert cache.get("bad_id") is None
371 assert cache.get("empty_id") == {}
372
373 def test_entry_with_non_str_callee_skipped(self, tmp_path: pathlib.Path) -> None:
374 import json as _json
375 from muse.core.callgraph_cache import CallGraphCache, _CACHE_VERSION
376 md = _muse_dir(tmp_path)
377 doc = {
378 "version": _CACHE_VERSION,
379 "entries": {
380 "bad": {"m.py::fn": [123, "valid"]}, # 123 is not str → skip entry
381 },
382 }
383 (md / "cache").mkdir(parents=True, exist_ok=True)
384 (md / "cache" / "callgraph.json").write_bytes(_json.dumps(doc).encode())
385 assert CallGraphCache.load(md).get("bad") is None
386
387 def test_entry_subgraph_not_dict_skipped(self, tmp_path: pathlib.Path) -> None:
388 import json as _json
389 from muse.core.callgraph_cache import CallGraphCache, _CACHE_VERSION
390 md = _muse_dir(tmp_path)
391 doc = {
392 "version": _CACHE_VERSION,
393 "entries": {
394 "bad": "not_a_dict_at_all", # subgraph must be a dict
395 },
396 }
397 (md / "cache").mkdir(parents=True, exist_ok=True)
398 (md / "cache" / "callgraph.json").write_bytes(_json.dumps(doc).encode())
399 assert CallGraphCache.load(md).get("bad") is None
400
401
402 # ---------------------------------------------------------------------------
403 # TestLoadCallGraphCache — convenience helper
404 # ---------------------------------------------------------------------------
405
406
407 class TestLoadCallGraphCache:
408 """load_callgraph_cache(root) convenience loader."""
409
410 def test_no_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
411 from muse.core.callgraph_cache import load_callgraph_cache
412 assert load_callgraph_cache(tmp_path).size == 0
413
414 def test_with_muse_dir_loads_existing(self, tmp_path: pathlib.Path) -> None:
415 from muse.core.callgraph_cache import CallGraphCache, load_callgraph_cache
416 md = _muse_dir(tmp_path)
417 seed = CallGraphCache.load(md)
418 seed.put("myid", {"m.py::fn": frozenset({"fn"})})
419 seed.save()
420
421 cache = load_callgraph_cache(tmp_path)
422 assert cache.get("myid") == {"m.py::fn": frozenset({"fn"})}
423
424 def test_with_empty_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
425 from muse.core.callgraph_cache import load_callgraph_cache
426 _muse_dir(tmp_path)
427 assert load_callgraph_cache(tmp_path).size == 0
428
429
430 # ---------------------------------------------------------------------------
431 # TestBuildForwardGraphWithCache — integration
432 # ---------------------------------------------------------------------------
433
434
435 class TestBuildForwardGraphWithCache:
436 """build_forward_graph accepts an optional CallGraphCache and uses it."""
437
438 def _repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
439 _muse_dir(tmp_path)
440 return tmp_path
441
442 def test_cold_cache_none_correct_graph(self, tmp_path: pathlib.Path) -> None:
443 """Without a cache, build_forward_graph still returns the correct graph."""
444 root = self._repo(tmp_path)
445 src = "def caller():\n callee()\n\ndef callee():\n pass\n"
446 manifest = _make_manifest(root, {"mod.py": src})
447
448 from muse.plugins.code._callgraph import build_forward_graph
449 graph = build_forward_graph(root, manifest)
450 caller_addr = next(k for k in graph if "caller" in k)
451 assert "callee" in graph[caller_addr]
452
453 def test_explicit_cache_hit_skips_read_object(self, tmp_path: pathlib.Path) -> None:
454 """When the cache has an entry for object_id, read_object is not called."""
455 from muse.core.callgraph_cache import CallGraphCache
456 from muse.plugins.code._callgraph import build_forward_graph
457
458 root = self._repo(tmp_path)
459 src = "def caller():\n callee()\n"
460 oid, _ = _write_py(root, "mod.py", src)
461 manifest = {"mod.py": oid}
462
463 # Pre-populate with the known subgraph
464 cache = CallGraphCache.empty()
465 cache.put(oid, {"mod.py::caller": frozenset({"callee"})})
466
467 with patch("muse.plugins.code._callgraph.read_object") as mock_read:
468 build_forward_graph(root, manifest, callgraph_cache=cache)
469 mock_read.assert_not_called()
470
471 def test_cache_miss_calls_read_object(self, tmp_path: pathlib.Path) -> None:
472 """On a cache miss, read_object IS called (the normal cold path)."""
473 from muse.core.callgraph_cache import CallGraphCache
474 from muse.plugins.code._callgraph import build_forward_graph
475
476 root = self._repo(tmp_path)
477 src = "def caller():\n callee()\n"
478 oid, _ = _write_py(root, "mod.py", src)
479 manifest = {"mod.py": oid}
480
481 empty_cache = CallGraphCache.empty()
482 with patch(
483 "muse.plugins.code._callgraph.read_object",
484 wraps=__import__(
485 "muse.core.object_store", fromlist=["read_object"]
486 ).read_object,
487 ) as mock_read:
488 build_forward_graph(root, manifest, callgraph_cache=empty_cache)
489 assert mock_read.call_count >= 1
490
491 def test_cache_populated_after_build(self, tmp_path: pathlib.Path) -> None:
492 """After build_forward_graph, the cache contains a subgraph for each parsed file."""
493 from muse.core.callgraph_cache import CallGraphCache
494 from muse.plugins.code._callgraph import build_forward_graph
495
496 root = self._repo(tmp_path)
497 src = "def caller():\n callee()\n"
498 oid, _ = _write_py(root, "mod.py", src)
499 manifest = {"mod.py": oid}
500
501 cache = CallGraphCache.empty()
502 build_forward_graph(root, manifest, callgraph_cache=cache)
503 result = cache.get(oid)
504 assert result is not None
505 assert isinstance(result, dict)
506
507 def test_cache_subgraph_has_correct_callees(self, tmp_path: pathlib.Path) -> None:
508 """The subgraph stored in the cache matches the cold graph output."""
509 from muse.core.callgraph_cache import CallGraphCache
510 from muse.plugins.code._callgraph import build_forward_graph
511
512 root = self._repo(tmp_path)
513 src = "def caller():\n callee_a()\n callee_b()\n"
514 oid, _ = _write_py(root, "mod.py", src)
515 manifest = {"mod.py": oid}
516
517 cache = CallGraphCache.empty()
518 graph = build_forward_graph(root, manifest, callgraph_cache=cache)
519
520 # The subgraph in the cache must match the graph output for this file
521 subgraph = cache.get(oid)
522 assert subgraph is not None
523 for addr, callees in graph.items():
524 assert addr in subgraph
525 assert subgraph[addr] == callees
526
527 def test_warm_graph_equals_cold_graph(self, tmp_path: pathlib.Path) -> None:
528 """Graph built with a warm cache equals graph built cold."""
529 from muse.core.callgraph_cache import CallGraphCache
530 from muse.plugins.code._callgraph import build_forward_graph
531
532 root = self._repo(tmp_path)
533 src = "def a():\n b()\n c()\n\ndef b():\n pass\n\ndef c():\n pass\n"
534 oid, _ = _write_py(root, "mod.py", src)
535 manifest = {"mod.py": oid}
536
537 cold_graph = build_forward_graph(root, manifest)
538
539 cache = CallGraphCache.empty()
540 build_forward_graph(root, manifest, callgraph_cache=cache) # populates cache
541 warm_graph = build_forward_graph(root, manifest, callgraph_cache=cache) # warm
542
543 for addr in cold_graph:
544 assert addr in warm_graph
545 assert cold_graph[addr] == warm_graph[addr]
546
547 def test_non_python_files_not_cached(self, tmp_path: pathlib.Path) -> None:
548 """Non-Python files are not added to the cache or graph."""
549 from muse.core.callgraph_cache import CallGraphCache
550 from muse.plugins.code._callgraph import build_forward_graph
551
552 root = self._repo(tmp_path)
553 md_oid, _ = _write_py(root, "README.md", "# My README\n")
554 py_oid, _ = _write_py(root, "mod.py", "def fn():\n pass\n")
555 manifest = {"README.md": md_oid, "mod.py": py_oid}
556
557 cache = CallGraphCache.empty()
558 build_forward_graph(root, manifest, callgraph_cache=cache)
559
560 assert cache.get(md_oid) is None # markdown — not cached
561 assert cache.get(py_oid) is not None # Python — cached
562
563 def test_build_does_not_call_cache_save(self, tmp_path: pathlib.Path) -> None:
564 """build_forward_graph must not call cache.save() — that is the caller's job."""
565 from muse.core.callgraph_cache import CallGraphCache
566 from muse.plugins.code._callgraph import build_forward_graph
567
568 root = self._repo(tmp_path)
569 src = "def fn():\n pass\n"
570 oid, _ = _write_py(root, "mod.py", src)
571 manifest = {"mod.py": oid}
572
573 md = _muse_dir(tmp_path)
574 cache = CallGraphCache.load(md)
575 build_forward_graph(root, manifest, callgraph_cache=cache)
576 # If build_forward_graph called save(), the file would exist now
577 assert not (md / "cache" / "callgraph.json").is_file()
578
579 def test_syntax_error_file_not_cached(self, tmp_path: pathlib.Path) -> None:
580 """Files with syntax errors are skipped — nothing added to cache."""
581 from muse.core.callgraph_cache import CallGraphCache
582 from muse.plugins.code._callgraph import build_forward_graph
583
584 root = self._repo(tmp_path)
585 bad_src = "def broken(:\n pass\n"
586 oid, _ = _write_py(root, "broken.py", bad_src)
587 manifest = {"broken.py": oid}
588
589 cache = CallGraphCache.empty()
590 build_forward_graph(root, manifest, callgraph_cache=cache)
591 assert cache.get(oid) is None # parse failed → not cached
592
593 def test_second_call_skips_ast_parse(self, tmp_path: pathlib.Path) -> None:
594 """On the second call with a warm cache, ast.parse is never called."""
595 from muse.core.callgraph_cache import CallGraphCache
596 from muse.plugins.code._callgraph import build_forward_graph
597
598 root = self._repo(tmp_path)
599 src = "def caller():\n callee()\n"
600 oid, _ = _write_py(root, "mod.py", src)
601 manifest = {"mod.py": oid}
602
603 cache = CallGraphCache.empty()
604 build_forward_graph(root, manifest, callgraph_cache=cache) # cold
605
606 with patch("muse.plugins.code._callgraph.ast") as mock_ast:
607 build_forward_graph(root, manifest, callgraph_cache=cache) # warm
608 mock_ast.parse.assert_not_called()
609
610
611 # ---------------------------------------------------------------------------
612 # TestCallGraphCachePerformance
613 # ---------------------------------------------------------------------------
614
615
616 class TestCallGraphCachePerformance:
617 """Second call with a warm cache must be substantially faster than cold."""
618
619 def _build_repo(
620 self, tmp_path: pathlib.Path, n_files: int = 30
621 ) -> tuple[pathlib.Path, Manifest]:
622 root = tmp_path
623 _muse_dir(root)
624 manifest: Manifest = {}
625 for i in range(n_files):
626 src = (
627 f"def fn_{i}():\n helper_{i}()\n util_{i}()\n\n"
628 f"def helper_{i}():\n pass\n\n"
629 f"def util_{i}():\n pass\n"
630 )
631 oid, _ = _write_py(root, f"mod_{i}.py", src)
632 manifest[f"mod_{i}.py"] = oid
633 return root, manifest
634
635 def test_warm_cache_at_least_5x_faster(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 t0 = time.perf_counter()
643 build_forward_graph(root, manifest, callgraph_cache=cache)
644 cold_ms = (time.perf_counter() - t0) * 1000
645
646 t1 = time.perf_counter()
647 build_forward_graph(root, manifest, callgraph_cache=cache)
648 warm_ms = (time.perf_counter() - t1) * 1000
649
650 assert warm_ms < cold_ms / 5, (
651 f"Warm ({warm_ms:.1f} ms) should be ≥5× faster than cold ({cold_ms:.1f} ms)"
652 )
653
654 def test_warm_cache_under_100ms_for_30_files(self, tmp_path: pathlib.Path) -> None:
655 from muse.core.callgraph_cache import CallGraphCache
656 from muse.plugins.code._callgraph import build_forward_graph
657
658 root, manifest = self._build_repo(tmp_path, n_files=30)
659
660 cache = CallGraphCache.empty()
661 build_forward_graph(root, manifest, callgraph_cache=cache) # warm the cache
662
663 t0 = time.perf_counter()
664 build_forward_graph(root, manifest, callgraph_cache=cache)
665 warm_ms = (time.perf_counter() - t0) * 1000
666
667 assert warm_ms < 100, f"Warm call took {warm_ms:.1f} ms — expected < 100 ms"
668
669 def test_graph_correctness_not_degraded_by_cache(self, tmp_path: pathlib.Path) -> None:
670 """Warm-cache graph is identical to cold-cache graph for all addresses."""
671 from muse.core.callgraph_cache import CallGraphCache
672 from muse.plugins.code._callgraph import build_forward_graph
673
674 root, manifest = self._build_repo(tmp_path, n_files=10)
675
676 cold_graph = build_forward_graph(root, manifest)
677
678 cache = CallGraphCache.empty()
679 build_forward_graph(root, manifest, callgraph_cache=cache) # warm
680 warm_graph = build_forward_graph(root, manifest, callgraph_cache=cache)
681
682 for addr in cold_graph:
683 assert addr in warm_graph, f"Address {addr!r} missing from warm graph"
684 assert cold_graph[addr] == warm_graph[addr], (
685 f"Callee mismatch at {addr!r}: "
686 f"cold={cold_graph[addr]} warm={warm_graph[addr]}"
687 )
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 5 days ago