fix(cli): keep outputs/cache with --out / --graph, not the corpus or CWD (#1747)

Case 1 — `extract <corpus> --out <dir>`: the graph went to <dir> (cache_root is
already passed to the AST extractor), but detect()'s word-count/stat-index cache
uses the scan root, so a stray graphify-out/cache/ was created inside the corpus
(and left behind even when the run aborted at the no-LLM-key gate). Thread an
optional cache_root through detect() -> cached_word_count() -> _ensure_stat_index()
and pass out_root from the extract CLI, so the stat index lives under --out. Entry
keys are absolute paths, so relocating the index file is safe.

Case 2 — `cluster-only --graph <elsewhere>/graphify-out/graph.json`: outputs
(GRAPH_REPORT.md, re-clustered graph.json, labels, analysis, html) were written to
the CWD's graphify-out/, ignoring where --graph lives. They now write beside the
input graph when it sits in a graphify-out/ dir (another project/tenant's output),
while still falling back to the CWD for an arbitrary archived backup/graph.json —
the restore-into-place workflow #934 pins.

Regression tests for both cases; #934 still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbqboogiedwonsen
2026-07-10 02:06:26 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent bdc6e531a6
commit c5db9ffb06
5 changed files with 64 additions and 8 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.12 (unreleased)
- Fix: output/cache artifacts no longer land in the scanned corpus or CWD when `--out`/`--graph` point elsewhere (#1747, thanks @bbqboogiedwonsen). `extract <corpus> --out <dir>` correctly wrote the graph to `<dir>` but `detect()`'s word-count/stat-index cache still created a stray `graphify-out/cache/` inside the corpus (it uses the scan root); it now honors the `--out` dir via a threaded `cache_root`. And `cluster-only --graph <elsewhere>/graphify-out/graph.json` wrote `GRAPH_REPORT.md`/labels/analysis/re-clustered graph to the CWD instead of beside the input; it now writes beside `--graph` when that graph lives in a `graphify-out/` dir, while still restoring into the CWD for an archived `backup/graph.json` (#934).
- Fix: `imports`/`references` edges no longer bind across a language boundary (#1749, thanks @philberndt). The spec already forbids cross-language `calls`, but an unresolved Python `import time` could still resolve by bare stem onto a `src/time.ts` file node — welding a polyglot repo's halves together at a phantom edge (in the reporter's repo, 3 such edges were the *only* thing bridging 2409 Python nodes to 1403 TS nodes, inflating `time.ts` betweenness ~90x and making it the #1 "god node"). The build-time cross-language guard now covers `imports`/`imports_from`/`references` in addition to `calls`, dropping an edge only when both endpoints are known code languages of different interop families (so a config/manifest → code reference is untouched).
- Fix: files whose extractor bailed out for a missing optional dependency no longer vanish without a trace (#1745, thanks @rithyKabir). `.sql` files (and other extra-gated languages) have a dispatch entry, so the #1689 no-extractor warning can't fire, and `extract_sql` returns an error result when `tree-sitter-sql` is absent, so the #1666 zero-node warning skips it too — the graph built "successfully" while an entire SQL corpus contributed nothing. `extract()` now surfaces these grouped by extension, naming the extra that restores the language (e.g. `pip install "graphifyy[sql]"`).
+8 -4
View File
@@ -100,11 +100,15 @@ def _stat_index_file(root: Path) -> Path:
return base / "cache" / "stat-index.json"
def _ensure_stat_index(root: Path) -> None:
def _ensure_stat_index(root: Path, cache_root: "Path | None" = None) -> None:
global _stat_index, _stat_index_root, _stat_index_dirty
if _stat_index_root is not None:
return
_stat_index_root = Path(root).resolve()
# The stat index only determines the cache FILE location (entry keys are
# absolute paths), so honoring an explicit cache_root keeps detect()'s
# word-count cache under the requested --out dir instead of polluting the
# scanned corpus with a stray graphify-out/ (#1747).
_stat_index_root = Path(cache_root if cache_root is not None else root).resolve()
p = _stat_index_file(_stat_index_root)
if p.exists():
try:
@@ -212,7 +216,7 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
return digest
def cached_word_count(path: Path, root: Path, compute) -> int:
def cached_word_count(path: Path, root: Path, compute, cache_root: "Path | None" = None) -> int:
"""Word count with the same (size, mtime_ns) stat-fastpath cache as
:func:`file_hash`, persisted in the shared stat index.
@@ -228,7 +232,7 @@ def cached_word_count(path: Path, root: Path, compute) -> int:
global _stat_index_dirty
p = _normalize_path(Path(path))
root = _normalize_path(Path(root))
_ensure_stat_index(root)
_ensure_stat_index(root, cache_root=cache_root)
abs_key = str(p.resolve())
st: "os.stat_result | None" = None
try:
+14 -2
View File
@@ -1046,7 +1046,19 @@ def dispatch_command(cmd: str) -> None:
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
stages.mark("analyze")
out = watch_path / _GRAPHIFY_OUT
# Where outputs (GRAPH_REPORT.md, re-clustered graph.json, labels,
# analysis, html) land. When `--graph` points at a graph INSIDE a
# graphify-out/ dir (another project/tenant's output), write beside it,
# not into a stray graphify-out/ in the CWD (#1747). But when `--graph`
# points at an arbitrary path — e.g. a `backup/graph.json` archived
# before re-clustering (#934) — fall back to the CWD's graphify-out/,
# which is the restore-into-place workflow that test pins. The default
# (no --graph) case already has graph_json under watch_path/graphify-out.
_out_name = Path(_GRAPHIFY_OUT).name
if graph_override is not None and graph_json.parent.name == _out_name:
out = graph_json.parent
else:
out = watch_path / _GRAPHIFY_OUT
out.mkdir(parents=True, exist_ok=True)
labels_path = out / ".graphify_labels.json"
existing_labels: dict[int, str] = {}
@@ -2090,7 +2102,7 @@ def dispatch_command(cmd: str) -> None:
unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values())
else:
print(f"[graphify extract] scanning {target}")
detection = _detect(target, google_workspace=google_workspace or None, extra_excludes=cli_excludes or None)
detection = _detect(target, google_workspace=google_workspace or None, extra_excludes=cli_excludes or None, cache_root=out_root)
files_by_type = detection.get("files", {})
code_files = [Path(p) for p in files_by_type.get("code", [])]
doc_files = [Path(p) for p in files_by_type.get("document", [])]
+4 -2
View File
@@ -1065,7 +1065,7 @@ def _resolves_under_root(path: Path, root: Path) -> bool:
return True
def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None) -> dict:
def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None) -> dict:
root = root.resolve()
if follow_symlinks is None:
follow_symlinks = False
@@ -1082,8 +1082,10 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
def _wc(path: Path) -> int:
# Cache word counts against each file's stat signature so unchanged
# PDFs/docx aren't re-parsed on every run just to size the corpus (#1656).
# cache_root (when given, e.g. from `extract --out`) keeps this cache out
# of the scanned corpus (#1747).
from graphify import cache as _cache
return _cache.cached_word_count(path, root, count_words)
return _cache.cached_word_count(path, root, count_words, cache_root=cache_root)
skipped_sensitive: list[str] = []
unclassified: list[str] = []
+36
View File
@@ -321,6 +321,42 @@ def test_cluster_only_creates_output_dir_when_missing(tmp_path):
assert (tmp_path / "graphify-out" / "GRAPH_REPORT.md").exists()
def test_cluster_only_graph_in_graphify_out_writes_beside_it(tmp_path):
"""#1747 Case 2: `cluster-only --graph <elsewhere>/graphify-out/graph.json`
must write GRAPH_REPORT.md and the re-clustered graph beside that graph, not
into a stray graphify-out/ in the CWD."""
project = tmp_path / "project"
project.mkdir()
out_dir = _make_graph(project) # project/graphify-out/graph.json
cwd = tmp_path / "elsewhere"
cwd.mkdir()
r = _run(
["cluster-only", ".", "--graph", str(out_dir / "graph.json"), "--no-viz", "--no-label"],
cwd,
)
assert r.returncode == 0, r.stderr
assert (out_dir / "GRAPH_REPORT.md").exists() # beside --graph
assert not (cwd / "graphify-out").exists() # no CWD pollution
def test_extract_out_does_not_pollute_corpus(tmp_path):
"""#1747 Case 1: `extract <corpus> --out <elsewhere>` must not leave a stray
graphify-out/ (cache, stat-index) inside the scanned corpus."""
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text("def main():\n return 1\n")
out = tmp_path / "scratch"
r = _run(
["extract", str(corpus), "--out", str(out), "--no-cluster", "--code-only"],
tmp_path,
)
assert r.returncode == 0, r.stderr
assert (out / "graphify-out" / "graph.json").exists() # graph in --out
assert not (corpus / "graphify-out").exists() # corpus untouched
# Regression test for #1027 - cluster-only must remap labels via node overlap
def test_cluster_only_persists_analysis_sidecar(tmp_path):