diff --git a/graphify/extract.py b/graphify/extract.py index 59d4f8283..9170cdf78 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4245,9 +4245,12 @@ def extract( Args: paths: files to extract from - cache_root: explicit root for graphify-out/cache/ (overrides the - inferred common path prefix). Pass Path('.') when running on a - subdirectory so the cache stays at ./graphify-out/cache/. + cache_root: explicit root for graphify-out/cache/. Defaults to the + current working directory — the cache is an output, so it must + not land inside the analyzed source tree (#1774). The inferred + common path prefix of ``paths`` still anchors node ids and + symbol resolution; it just no longer decides where the cache + is written. parallel: if True and there are >= _PARALLEL_THRESHOLD uncached files, use ProcessPoolExecutor for multi-core extraction. max_workers: max subprocess count. Defaults to cpu_count (or the @@ -4281,7 +4284,10 @@ def extract( root = cache_root root = root.resolve() - effective_root = cache_root or root + # #1774: the cache is an output, so it defaults to CWD — never the + # analyzed source tree. `root` (inferred above) still anchors node ids + # and symbol resolution; only the cache location diverges from it. + effective_root = cache_root if cache_root is not None else Path(".") total = len(paths) # Phase 1: separate cached hits from uncached work diff --git a/tests/test_extract_cache_location.py b/tests/test_extract_cache_location.py new file mode 100644 index 000000000..9da8722c0 --- /dev/null +++ b/tests/test_extract_cache_location.py @@ -0,0 +1,64 @@ +"""#1774 — extract() must never write its AST cache into the analyzed source tree. + +The cache is an output. When no cache_root is given it used to default to the +inferred common parent of the input files — the source tree — so analyzing a +read-only corpus (someone else's repo, a knowledge base) silently created +graphify-out/cache/ inside it. It now defaults to the current working +directory, matching cache_dir()'s own default; an explicit cache_root still +wins. +""" +from __future__ import annotations + +from pathlib import Path + +import graphify.extract as ex +from graphify.cache import load_cached + + +def _make_corpus(base: Path) -> Path: + corpus = base / "corpus" + corpus.mkdir() + (corpus / "a.py").write_text("class Base:\n def hello(self):\n return 1\n") + (corpus / "b.py").write_text("from a import Base\n\nclass Sub(Base):\n pass\n") + return corpus + + +def test_default_cache_lands_in_cwd_not_source_tree(tmp_path, monkeypatch): + corpus = _make_corpus(tmp_path) + work = tmp_path / "work" + work.mkdir() + monkeypatch.chdir(work) + + result = ex.extract([corpus / "a.py", corpus / "b.py"], parallel=False) + + assert result["nodes"], "extraction should still produce nodes" + assert not (corpus / "graphify-out").exists(), ( + "cache written into the analyzed source tree (#1774)" + ) + assert (work / "graphify-out" / "cache").is_dir(), "cache should land under CWD" + + +def test_default_cache_round_trips(tmp_path, monkeypatch): + corpus = _make_corpus(tmp_path) + work = tmp_path / "work" + work.mkdir() + monkeypatch.chdir(work) + + ex.extract([corpus / "a.py"], parallel=False) + assert load_cached(corpus / "a.py", Path(".")) is not None, ( + "second run should hit the CWD cache written by the first" + ) + + +def test_explicit_cache_root_still_wins(tmp_path, monkeypatch): + corpus = _make_corpus(tmp_path) + work = tmp_path / "work" + work.mkdir() + out = tmp_path / "out" + monkeypatch.chdir(work) + + ex.extract([corpus / "a.py"], cache_root=out, parallel=False) + + assert (out / "graphify-out" / "cache").is_dir() + assert not (corpus / "graphify-out").exists() + assert not (work / "graphify-out").exists()