Honour GRAPHIFY_OUT end-to-end, not just in the path guards (#1423)

The GRAPHIFY_OUT override (custom output-dir name / absolute path, #686) was only
respected by some readers. `graphify extract` and several commands hardcoded the
literal "graphify-out", so `GRAPHIFY_OUT=custom-out graphify extract` still wrote
to graphify-out/ and downstream query/serve/update looked in the wrong place.

Resolve the output-dir name through graphify.paths everywhere it matters:
- new graphify.paths.out_path()/default_graph_json() helpers
- __main__: extract write dir, cluster-only/label, query/affected/benchmark
  defaults, save-result --memory-dir, uninstall --purge, cache-check
- detect: _MANIFEST_PATH, memory/ + converted/ dirs, and the scan-exclude (a
  renamed output dir is no longer re-ingested as source input)
- transcribe._TRANSCRIPTS_DIR; build_merge/serve/benchmark/prs graph-path defaults

Default behaviour is unchanged: with no env var everything still uses graphify-out/.
Verified end-to-end (extract -> cluster-only -> query under GRAPHIFY_OUT=custom-out
writes/reads custom-out/, no stray graphify-out/) and added a CLI regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-22 21:15:51 +01:00
co-authored by Claude Opus 4.8
parent 6954a28da8
commit b8dc31f760
10 changed files with 76 additions and 23 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: `GRAPHIFY_OUT` is now honoured end-to-end. The override (a custom output-dir name or absolute path, for worktrees/shared setups — #686) was only respected by some readers; `graphify extract` and several commands hardcoded `graphify-out/`, so a `GRAPHIFY_OUT=custom-out graphify extract` still wrote to `graphify-out/`, and downstream `query`/`serve`/`update` looked in the wrong place. The output-dir name is now resolved through `graphify.paths` everywhere it matters: the `extract` write dir, `cluster-only`/`label`, `query`/`affected`/`benchmark` defaults, `save-result --memory-dir`, `uninstall --purge`, `cache-check`, the `manifest.json`/`transcripts`/`memory`/`converted` paths in `detect`/`transcribe`, the `build_merge`/`serve`/`benchmark`/`prs` graph-path defaults, and the `detect` scan-exclude (so a renamed output dir is never re-ingested as source). Default behaviour is unchanged — without the env var everything still uses `graphify-out/` (#1423).
- Fix: the `GRAPH_REPORT.md` header now shows the actual scan root instead of a literal `.`. The split-skill runbook passed `'.'` as the `root` argument to `report.generate` in Steps 4 and 5, so a `/graphify /some/path` run produced a report titled `# Graph Report - .`. It now passes `'INPUT_PATH'` (matching the monoliths, which were already correct). Display-only — no path written to `graph.json`/`manifest.json` was affected (#1419).
- Fix: the skill runbooks now write a portable `manifest.json`. Step 9 (full build) and the `--update` reference called `save_manifest(...)` without `root=`, so manifest keys were stored as absolute paths; cloning or moving the repo then broke `graphify --update` — every cached file missed and the whole corpus re-extracted. All four runbook call sites (the lean-core `skill.md`, the Aider/Devin monoliths, and the shared `--update` reference) now pass `root='INPUT_PATH'`, relativizing keys to the scan root to match the native `graphify update` path. The monolith change is registered as a new sanctioned change-class in the round-trip guard (#1417).
- Fix: hyperedge `source_file` is now relativized to the scan root like nodes and edges. `build_from_json(root=...)` relativized `source_file` on `nodes[]` and `links[]`, but stored `graph.hyperedges[]` verbatim, so a semantic subagent's absolute path (e.g. `/Users/.../CLAUDE.md`) leaked into `graph.json`. The fix lives in `build_from_json` (not `to_json`, which has no `root` to relativize against) and mirrors the existing node/edge handling (#1418).
+10 -10
View File
@@ -1884,12 +1884,12 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
if purge:
import shutil as _shutil
out = pd / "graphify-out"
out = pd / _GRAPHIFY_OUT
if out.exists():
_shutil.rmtree(out)
print(f"\n graphify-out/ -> deleted (--purge)")
print(f"\n {_GRAPHIFY_OUT}/ -> deleted (--purge)")
else:
print("\n graphify-out/ -> not found (nothing to purge)")
print(f"\n {_GRAPHIFY_OUT}/ -> not found (nothing to purge)")
print("\nDone. Run 'pip uninstall graphifyy' to remove the package itself.")
@@ -2763,7 +2763,7 @@ def main() -> None:
sys.exit(1)
from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph
query = sys.argv[2]
graph_path = "graphify-out/graph.json"
graph_path = _default_graph_path()
depth = 2
relations: list[str] = []
args = sys.argv[3:]
@@ -2826,7 +2826,7 @@ def main() -> None:
p.add_argument("--answer", required=True)
p.add_argument("--type", dest="query_type", default="query")
p.add_argument("--nodes", nargs="*", default=[])
p.add_argument("--memory-dir", default="graphify-out/memory")
p.add_argument("--memory-dir", default=str(Path(_GRAPHIFY_OUT) / "memory"))
opts = p.parse_args(sys.argv[2:])
from graphify.ingest import save_query_result as _sqr
@@ -3190,7 +3190,7 @@ def main() -> None:
i_arg += 1
if watch_path is None:
watch_path = Path(".")
graph_json = graph_override if graph_override is not None else watch_path / "graphify-out" / "graph.json"
graph_json = graph_override if graph_override is not None else watch_path / _GRAPHIFY_OUT / "graph.json"
if not graph_json.exists():
print(
f"error: no graph found at {graph_json} — run /graphify first",
@@ -3249,7 +3249,7 @@ def main() -> None:
cohesion = score_all(G, communities)
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
out = watch_path / "graphify-out"
out = watch_path / _GRAPHIFY_OUT
out.mkdir(parents=True, exist_ok=True)
labels_path = out / ".graphify_labels.json"
if labels_path.exists() and not force_relabel:
@@ -3882,7 +3882,7 @@ def main() -> None:
elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark
graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
graph_path = sys.argv[2] if len(sys.argv) > 2 else _default_graph_path()
_enforce_graph_size_cap_or_exit(Path(graph_path))
# Try to load corpus_words from detect output
corpus_words = None
@@ -4117,7 +4117,7 @@ def main() -> None:
# so a fresh checkout writes graphify-out/ at the project root, matching
# the skill.md pipeline.
out_root = (out_dir.resolve() if out_dir else target)
graphify_out = out_root / "graphify-out"
graphify_out = out_root / _GRAPHIFY_OUT
graphify_out.mkdir(parents=True, exist_ok=True)
from graphify.detect import (
@@ -4638,7 +4638,7 @@ def main() -> None:
i += 1
files = [f for f in files_from.read_text(encoding="utf-8").splitlines() if f.strip()]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(files, root)
out = root / "graphify-out"
out = root / _GRAPHIFY_OUT
out.mkdir(parents=True, exist_ok=True)
if cached_nodes or cached_edges or cached_hyperedges:
(out / ".graphify_cached.json").write_text(
+3 -1
View File
@@ -8,6 +8,7 @@ from networkx.readwrite import json_graph
from graphify.build import edge_data
from graphify.serve import _query_terms
from graphify.paths import default_graph_json as _default_graph_json
_CHARS_PER_TOKEN = 4 # standard approximation
@@ -85,7 +86,7 @@ _SAMPLE_QUESTIONS = [
def run_benchmark(
graph_path: str = "graphify-out/graph.json",
graph_path: str | None = None,
corpus_words: int | None = None,
questions: list[str] | None = None,
) -> dict:
@@ -98,6 +99,7 @@ def run_benchmark(
Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question
"""
graph_path = graph_path or _default_graph_json()
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(Path(graph_path))
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
+3 -2
View File
@@ -29,6 +29,7 @@ import unicodedata
from pathlib import Path
import networkx as nx
from .ids import normalize_id as _normalize_id
from .paths import default_graph_json as _default_graph_json
from .validate import validate_extraction
@@ -435,7 +436,7 @@ def deduplicate_by_label(nodes: list[dict], edges: list[dict]) -> tuple[list[dic
def build_merge(
new_chunks: list[dict],
graph_path: str | Path = "graphify-out/graph.json",
graph_path: str | Path | None = None,
prune_sources: list[str] | None = None,
*,
directed: bool = False,
@@ -452,7 +453,7 @@ def build_merge(
Safe to call repeatedly.
root: if given, absolute source_file paths in new_chunks are made relative (#932).
"""
graph_path = Path(graph_path)
graph_path = Path(graph_path if graph_path is not None else _default_graph_json())
if graph_path.exists():
# Read JSON directly instead of going through node_link_graph().
# The latter rebuilds an undirected nx.Graph and then enumerating
+5 -4
View File
@@ -14,6 +14,7 @@ from graphify.google_workspace import (
convert_google_workspace_file,
google_workspace_enabled,
)
from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME, out_path
class FileType(str, Enum):
@@ -24,7 +25,7 @@ class FileType(str, Enum):
VIDEO = "video"
_MANIFEST_PATH = "graphify-out/manifest.json"
_MANIFEST_PATH = str(out_path("manifest.json"))
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
@@ -663,7 +664,7 @@ _SKIP_DIRS = {
"site-packages", "lib64",
".pytest_cache", ".mypy_cache", ".ruff_cache",
".tox", ".eggs", "*.egg-info",
"graphify-out", # never treat own output as source input (#524)
"graphify-out", GRAPHIFY_OUT_NAME, # never treat own output as source input (#524); honour GRAPHIFY_OUT (#1423)
# Coverage/test-artefact dirs — generated, never architecturally meaningful
"coverage", "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870)
"visual-tests", "visual-test", # Playwright/visual-regression bundles (#869)
@@ -1035,7 +1036,7 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
include_patterns = _load_graphifyinclude(root)
# Always include graphify-out/memory/ - query results filed back into the graph
memory_dir = root / "graphify-out" / "memory"
memory_dir = root / GRAPHIFY_OUT / "memory"
scan_paths = [root]
if memory_dir.exists():
scan_paths.append(memory_dir)
@@ -1081,7 +1082,7 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
all_files.sort(key=lambda p: str(p))
converted_dir = root / "graphify-out" / "converted"
converted_dir = root / GRAPHIFY_OUT / "converted"
for p in all_files:
# For memory dir files, skip hidden/noise filtering
+22 -1
View File
@@ -17,9 +17,30 @@ flow) and every reader honours it.
from __future__ import annotations
import os
from pathlib import Path
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
# Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by the
# path guards that walk parents looking for the output dir by name.
# path guards that walk parents looking for the output dir by name, and by the
# detect scan-exclude so a custom output dir is never re-ingested as source.
GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT))
def out_path(*parts: str) -> Path:
"""A path inside the configured output dir, e.g. ``out_path("cache")``.
``Path(GRAPHIFY_OUT) / ...`` resolves correctly for both a relative name
("graphify-out") and an absolute override ("/shared/graphify-out").
"""
return Path(GRAPHIFY_OUT, *parts)
def default_graph_json() -> str:
"""Default ``graph.json`` path under the configured output dir.
The package-wide fallback used by serve/build/benchmark/prs and the CLI read
commands so a ``GRAPHIFY_OUT`` override is honoured everywhere, not just where
the path is passed explicitly (#1423).
"""
return str(out_path("graph.json"))
+3 -1
View File
@@ -26,6 +26,8 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from graphify.paths import default_graph_json as _default_graph_json
# ── ANSI colours ─────────────────────────────────────────────────────────────
@@ -676,7 +678,7 @@ def cmd_prs(argv: list[str]) -> None:
do_conflicts = False
show_wrong_base = False
pr_number: int | None = None
graph_path = Path("graphify-out/graph.json")
graph_path = Path(_default_graph_json())
i = 0
while i < len(argv):
+6 -3
View File
@@ -9,6 +9,7 @@ import networkx as nx
from networkx.readwrite import json_graph
from graphify.security import sanitize_label, check_graph_file_size_cap
from graphify.build import edge_data
from graphify.paths import default_graph_json as _default_graph_json
try:
import jieba as _jieba # type: ignore[import-untyped]
@@ -1038,8 +1039,9 @@ def _build_server(graph_path: str):
return server
def serve(graph_path: str = "graphify-out/graph.json") -> None:
def serve(graph_path: str | None = None) -> None:
"""Start the MCP server over stdio (the default, per-developer transport)."""
graph_path = graph_path or _default_graph_json()
try:
from mcp.server.stdio import stdio_server
except ImportError as e:
@@ -1196,7 +1198,7 @@ def _build_http_app(
def serve_http(
graph_path: str = "graphify-out/graph.json",
graph_path: str | None = None,
*,
host: str = "127.0.0.1",
port: int = 8080,
@@ -1217,6 +1219,7 @@ def serve_http(
deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond
localhost set an api_key when you do.
"""
graph_path = graph_path or _default_graph_json()
try:
import uvicorn
except ImportError as e:
@@ -1304,7 +1307,7 @@ def _main(argv: list[str] | None = None) -> None:
help="Reap stateful sessions idle this many seconds (default: 3600; 0 disables)",
)
args = parser.parse_args(argv)
graph_path = args.graph_flag or args.graph_path or "graphify-out/graph.json"
graph_path = args.graph_flag or args.graph_path or _default_graph_json()
if args.transport == "http":
serve_http(
+3 -1
View File
@@ -5,12 +5,14 @@ from __future__ import annotations
import os
from pathlib import Path
from graphify.paths import out_path as _out_path
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
URL_PREFIXES = ('http://', 'https://', 'www.')
_DEFAULT_MODEL = "base"
_TRANSCRIPTS_DIR = "graphify-out/transcripts"
_TRANSCRIPTS_DIR = str(_out_path("transcripts"))
_FALLBACK_PROMPT = "Use proper punctuation and paragraph breaks."
+20
View File
@@ -206,6 +206,26 @@ def test_query_uses_graphify_out_env(tmp_path):
assert len(r.stdout) > 0
def test_extract_writes_to_graphify_out_env(tmp_path):
"""#1423: `graphify extract` honours GRAPHIFY_OUT for where it WRITES, not only
where readers look previously it hardcoded graphify-out/ and ignored the
override. Code-only corpus, so no LLM backend is needed."""
(tmp_path / "m.py").write_text("def a():\n return b()\n\n\ndef b():\n return 1\n")
env = os.environ.copy()
env["GRAPHIFY_OUT"] = "custom-out"
r = _run(["extract", "."], tmp_path, env=env)
assert r.returncode == 0, r.stderr
assert (tmp_path / "custom-out" / "graph.json").exists(), r.stdout
assert (tmp_path / "custom-out" / "manifest.json").exists()
# The default dir must NOT be created when the override is set.
assert not (tmp_path / "graphify-out").exists(), "extract ignored GRAPHIFY_OUT and wrote graphify-out/"
# Manifest keys are relative to the scan root (portable) — #1417.
keys = list(json.loads((tmp_path / "custom-out" / "manifest.json").read_text()).keys())
assert keys == ["m.py"], keys
# ── graphify path ────────────────────────────────────────────────────────────
def test_path_runs_without_error(tmp_path):