mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
An AST cache entry is keyed by its own file's content, so after
pkg/slice.py is renamed or deleted, the unchanged importer pkg/user.py
is a hit and replays its import edges resolved while slice.py existed:
a target id minted from the absolute path and a target_file stamp to
the old file. extract() maps a stamped target to a portable id only
when it exists, so the edge kept the absolute-path id and the #2873
stub was minted with it: graph.json held the checkout folder and OS
username, and a warm update differed from a cold build (pkg_slice).
save_cached now records the target_file stamps that name an existing
file when an AST entry is written (made portable like the entry's
ids). load_cached returns a miss when any recorded file is gone, so
the importer is extracted again exactly as on a cold build. Stamps
that did not exist at write time are not recorded and never cause a
miss; entries without the list load as before.
A no-change warm rebuild of graphify's own repo has the same 16 cache
misses as before, and its cold build is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 48578d04f1)
108 lines
4.5 KiB
Python
108 lines
4.5 KiB
Python
"""An AST cache hit must not replay an import resolved to a file that is gone.
|
|
|
|
A cache entry is keyed by its own file's content, so an unchanged importer kept
|
|
its import edge to a module after that module was renamed or deleted. The edge
|
|
target was the id minted from the old file's absolute path, which extract() maps
|
|
to a portable id only for targets that exist, so the checkout path reached
|
|
graph.json and a warm update differed from a cold build of the same tree.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from graphify.cache import load_cached, save_cached
|
|
|
|
|
|
def _write(path: Path, text: str) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _import_result(importer: Path, target: Path) -> dict:
|
|
return {
|
|
"nodes": [{"id": "pkg_user", "source_file": str(importer)}],
|
|
"edges": [{
|
|
"source": "pkg_user", "target": "x", "relation": "imports_from",
|
|
"source_file": str(importer), "target_file": str(target),
|
|
}],
|
|
}
|
|
|
|
|
|
def test_hit_is_a_miss_once_a_recorded_import_target_is_gone(tmp_path: Path):
|
|
importer = _write(tmp_path / "pkg" / "user.py", "from pkg.slice import cut\n")
|
|
target = _write(tmp_path / "pkg" / "slice.py", "def cut(p):\n return p\n")
|
|
save_cached(importer, _import_result(importer, target), root=tmp_path, kind="ast")
|
|
|
|
hit = load_cached(importer, root=tmp_path, kind="ast")
|
|
assert hit is not None
|
|
assert "_cached_target_files" not in hit, "bookkeeping must not reach extraction results"
|
|
|
|
target.rename(tmp_path / "pkg" / "slicing.py")
|
|
assert load_cached(importer, root=tmp_path, kind="ast") is None
|
|
|
|
(tmp_path / "pkg" / "slicing.py").rename(target)
|
|
assert load_cached(importer, root=tmp_path, kind="ast") is not None
|
|
|
|
|
|
def test_target_missing_when_written_does_not_invalidate(tmp_path: Path):
|
|
"""Extractors may stamp a speculative target that does not exist yet; such an
|
|
entry stays a hit, as before."""
|
|
importer = _write(tmp_path / "pkg" / "user.py", "from .later import f\n")
|
|
save_cached(importer, _import_result(importer, tmp_path / "pkg" / "later.py"), root=tmp_path, kind="ast")
|
|
assert load_cached(importer, root=tmp_path, kind="ast") is not None
|
|
|
|
|
|
def test_recorded_targets_are_portable_across_roots(tmp_path: Path):
|
|
a = tmp_path / "a"
|
|
importer = _write(a / "pkg" / "user.py", "from pkg.slice import cut\n")
|
|
target = _write(a / "pkg" / "slice.py", "def cut(p):\n return p\n")
|
|
save_cached(importer, _import_result(importer, target), root=a, kind="ast", cache_root=tmp_path)
|
|
|
|
b = tmp_path / "b"
|
|
shutil.copytree(a, b)
|
|
assert load_cached(b / "pkg" / "user.py", root=b, kind="ast", cache_root=tmp_path) is not None
|
|
(b / "pkg" / "slice.py").unlink()
|
|
assert load_cached(b / "pkg" / "user.py", root=b, kind="ast", cache_root=tmp_path) is None
|
|
|
|
|
|
@pytest.mark.parametrize("change", ["rename", "delete"])
|
|
def test_warm_update_after_removing_an_imported_module_matches_a_cold_build(tmp_path: Path, change: str):
|
|
from graphify.watch import _rebuild_code
|
|
|
|
def corpus(root: Path) -> Path:
|
|
_write(root / "pkg" / "__init__.py", "")
|
|
_write(root / "pkg" / "slice.py", "def cut(p):\n return p\n")
|
|
_write(root / "pkg" / "user.py", "from pkg.slice import cut\nimport pkg.slice\n\n\ndef go(p):\n return cut(p)\n")
|
|
return root
|
|
|
|
def remove(root: Path) -> None:
|
|
if change == "rename":
|
|
(root / "pkg" / "slice.py").rename(root / "pkg" / "slicing.py")
|
|
else:
|
|
(root / "pkg" / "slice.py").unlink()
|
|
|
|
def graph(root: Path) -> tuple[set[str], set[tuple[str, str, str]]]:
|
|
data = json.loads((root / "graphify-out" / "graph.json").read_text(encoding="utf-8"))
|
|
return (
|
|
{n["id"] for n in data["nodes"]},
|
|
{(e["source"], e["target"], e["relation"]) for e in data["links"]},
|
|
)
|
|
|
|
warm = corpus(tmp_path / "checkout_warm" / "proj")
|
|
assert _rebuild_code(warm, no_cluster=True, acquire_lock=False) is True
|
|
remove(warm)
|
|
assert _rebuild_code(warm, no_cluster=True, force=True, acquire_lock=False) is True
|
|
|
|
cold = corpus(tmp_path / "checkout_cold" / "proj")
|
|
remove(cold)
|
|
assert _rebuild_code(cold, no_cluster=True, acquire_lock=False) is True
|
|
|
|
warm_ids, warm_edges = graph(warm)
|
|
assert not [i for i in warm_ids if "checkout_warm" in i], "a node id embeds the checkout path"
|
|
assert (warm_ids, warm_edges) == graph(cold)
|