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
+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] = []