Skip to content

Commit 1751ca7

Browse files
committed
fix(cache): store v2 Analysis envelope and rebuild stale v1 caches
The analysis cache now persists the full v2 Analysis envelope (with schema_version) instead of a bare PyApplication. On load, a payload that fails Analysis validation or lacks schema_version 2.0.0 (e.g. an old v1 cache) is detected and treated as a cache miss so the analysis rebuilds cleanly instead of silently reusing incompatible data.
1 parent cc86883 commit 1751ca7

2 files changed

Lines changed: 54 additions & 23 deletions

File tree

codeanalyzer/core.py

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -415,18 +415,19 @@ def analyze(self) -> Analysis:
415415
"""
416416
cache_file = self.cache_dir / "analysis_cache.json"
417417

418-
# Try to load existing cached analysis
419-
cached_pyapplication = None
418+
# Try to load existing cached analysis
419+
cached = None
420420
if not self.rebuild_analysis and cache_file.exists():
421421
try:
422-
cached_pyapplication = self._load_pyapplication_from_cache(cache_file)
423-
logger.info("Loaded cached analysis")
422+
cached = self._load_pyapplication_from_cache(cache_file)
423+
if cached is not None:
424+
logger.info("Loaded cached analysis")
424425
except Exception as e:
425426
logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
426-
cached_pyapplication = None
427+
cached = None
427428

428429
# Build symbol table from cached application if available (if no available, the build a new one)
429-
symbol_table = self._build_symbol_table(cached_pyapplication.symbol_table if cached_pyapplication else {})
430+
symbol_table = self._build_symbol_table(cached.application.symbol_table if cached else {})
430431

431432
resolve_unresolved_constructors(symbol_table)
432433

@@ -463,40 +464,57 @@ def analyze(self) -> Analysis:
463464

464465
# L3/L4 dataflow emission rebuilt on the v2 tree in Stage 3+
465466

466-
# Save to cache
467-
self._save_analysis_cache(app, cache_file)
468-
469-
return Analysis(
467+
# Build the v2 envelope, then persist it (the cache stores the full
468+
# ``Analysis`` envelope so a reused cache round-trips schema_version).
469+
analysis = Analysis(
470470
max_level=self.analysis_level,
471471
k_limit=self.options.graph_field_depth,
472472
application=app,
473473
)
474+
self._save_analysis_cache(analysis, cache_file)
475+
476+
return analysis
477+
478+
def _load_pyapplication_from_cache(self, cache_file: Path) -> Optional[Analysis]:
479+
"""Load a cached v2 ``Analysis`` envelope from file.
480+
481+
A cache written by an older (v1) analyzer stored a bare
482+
``PyApplication`` with no ``schema_version``; such a payload no longer
483+
validates as an ``Analysis`` (or carries the wrong ``schema_version``).
484+
In that case we log and return ``None`` so the caller treats it as a
485+
cache miss and rebuilds from scratch — rather than crashing.
474486
475-
def _load_pyapplication_from_cache(self, cache_file: Path) -> PyApplication:
476-
"""Load cached analysis from file.
477-
478487
Args:
479488
cache_file: Path to the cache file
480-
489+
481490
Returns:
482-
PyApplication: The cached application data
491+
Optional[Analysis]: The cached envelope, or ``None`` if the cache is
492+
stale/incompatible and should be rebuilt.
483493
"""
484494
with cache_file.open('r') as f:
485495
data = f.read()
486-
return model_validate_json(PyApplication, data)
487-
488-
def _save_analysis_cache(self, app: PyApplication, cache_file: Path) -> None:
489-
"""Save analysis to cache file.
490-
496+
try:
497+
cached = model_validate_json(Analysis, data)
498+
except Exception:
499+
logger.info("stale/incompatible analysis cache — rebuilding")
500+
return None
501+
if getattr(cached, "schema_version", None) != "2.0.0":
502+
logger.info("stale/incompatible analysis cache (schema_version) — rebuilding")
503+
return None
504+
return cached
505+
506+
def _save_analysis_cache(self, analysis: Analysis, cache_file: Path) -> None:
507+
"""Save the v2 ``Analysis`` envelope to the cache file.
508+
491509
Args:
492-
app: The PyApplication to cache
510+
analysis: The Analysis envelope to cache
493511
cache_file: Path to save the cache file
494512
"""
495513
# Ensure cache directory exists
496514
cache_file.parent.mkdir(parents=True, exist_ok=True)
497-
515+
498516
with cache_file.open('w') as f:
499-
f.write(model_dump_json(app, indent=2))
517+
f.write(model_dump_json(analysis, indent=2))
500518

501519
logger.info(f"Analysis cached to {cache_file}")
502520

test/test_v2_conformance.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import sys
44
from pathlib import Path
55

6+
from codeanalyzer.core import Codeanalyzer
7+
from codeanalyzer.options import AnalysisOptions
68
from codeanalyzer.schema.assign_ids import assign_ids
79
from codeanalyzer.schema.py_schema import PyApplication, PyModule, PyClass, PyCallable
810

@@ -45,3 +47,14 @@ def test_l1_output_is_conformant(tmp_path: Path):
4547
capture_output=True, text=True, check=True,
4648
).stdout
4749
assert_conformant(json.loads(out), max_level=1)
50+
51+
52+
def test_stale_v1_cache_is_ignored(tmp_path: Path):
53+
proj = tmp_path / "proj"; proj.mkdir()
54+
(proj / "m.py").write_text("def f():\n return 1\n", encoding="utf-8")
55+
cache = tmp_path / ".codeanalyzer"; cache.mkdir()
56+
(cache / "analysis_cache.json").write_text('{"symbol_table": {}}', encoding="utf-8") # v1 shape
57+
opts = AnalysisOptions(input=proj, cache_dir=tmp_path, no_venv=True, analysis_level=1)
58+
with Codeanalyzer(opts) as an:
59+
result = an.analyze() # must not raise
60+
assert result.schema_version == "2.0.0"

0 commit comments

Comments
 (0)