From c5db9ffb06dc45c50bc7d5d118fb679fcf33ffb5 Mon Sep 17 00:00:00 2001 From: bbqboogiedwonsen Date: Fri, 10 Jul 2026 02:06:26 +0100 Subject: [PATCH] fix(cli): keep outputs/cache with --out / --graph, not the corpus or CWD (#1747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 1 — `extract --out `: the graph went to (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 /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) --- CHANGELOG.md | 2 ++ graphify/cache.py | 12 ++++++++---- graphify/cli.py | 16 ++++++++++++++-- graphify/detect.py | 6 ++++-- tests/test_cli_export.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0452e5..860f2aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --out ` correctly wrote the graph to `` 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 /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]"`). diff --git a/graphify/cache.py b/graphify/cache.py index a71f07b1..31c945a2 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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: diff --git a/graphify/cli.py b/graphify/cli.py index 540a4351..380bee50 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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", [])] diff --git a/graphify/detect.py b/graphify/detect.py index d6eaf331..8c0f64a8 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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] = [] diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 879cb68b..d2580820 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -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 /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 --out ` 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):