Skip to content

Commit b8dc31f

Browse files
safishamsiclaude
andcommitted
Honour GRAPHIFY_OUT end-to-end, not just in the path guards (Graphify-Labs#1423)
The GRAPHIFY_OUT override (custom output-dir name / absolute path, Graphify-Labs#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>
1 parent 6954a28 commit b8dc31f

10 files changed

Lines changed: 76 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
44

55
## Unreleased
66

7+
- 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).
78
- 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).
89
- 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).
910
- 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).

‎graphify/__main__.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,12 +1884,12 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
18841884

18851885
if purge:
18861886
import shutil as _shutil
1887-
out = pd / "graphify-out"
1887+
out = pd / _GRAPHIFY_OUT
18881888
if out.exists():
18891889
_shutil.rmtree(out)
1890-
print(f"\n graphify-out/ -> deleted (--purge)")
1890+
print(f"\n {_GRAPHIFY_OUT}/ -> deleted (--purge)")
18911891
else:
1892-
print("\n graphify-out/ -> not found (nothing to purge)")
1892+
print(f"\n {_GRAPHIFY_OUT}/ -> not found (nothing to purge)")
18931893

18941894
print("\nDone. Run 'pip uninstall graphifyy' to remove the package itself.")
18951895

@@ -2763,7 +2763,7 @@ def main() -> None:
27632763
sys.exit(1)
27642764
from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph
27652765
query = sys.argv[2]
2766-
graph_path = "graphify-out/graph.json"
2766+
graph_path = _default_graph_path()
27672767
depth = 2
27682768
relations: list[str] = []
27692769
args = sys.argv[3:]
@@ -2826,7 +2826,7 @@ def main() -> None:
28262826
p.add_argument("--answer", required=True)
28272827
p.add_argument("--type", dest="query_type", default="query")
28282828
p.add_argument("--nodes", nargs="*", default=[])
2829-
p.add_argument("--memory-dir", default="graphify-out/memory")
2829+
p.add_argument("--memory-dir", default=str(Path(_GRAPHIFY_OUT) / "memory"))
28302830
opts = p.parse_args(sys.argv[2:])
28312831
from graphify.ingest import save_query_result as _sqr
28322832

@@ -3190,7 +3190,7 @@ def main() -> None:
31903190
i_arg += 1
31913191
if watch_path is None:
31923192
watch_path = Path(".")
3193-
graph_json = graph_override if graph_override is not None else watch_path / "graphify-out" / "graph.json"
3193+
graph_json = graph_override if graph_override is not None else watch_path / _GRAPHIFY_OUT / "graph.json"
31943194
if not graph_json.exists():
31953195
print(
31963196
f"error: no graph found at {graph_json} — run /graphify first",
@@ -3249,7 +3249,7 @@ def main() -> None:
32493249
cohesion = score_all(G, communities)
32503250
gods = god_nodes(G)
32513251
surprises = surprising_connections(G, communities)
3252-
out = watch_path / "graphify-out"
3252+
out = watch_path / _GRAPHIFY_OUT
32533253
out.mkdir(parents=True, exist_ok=True)
32543254
labels_path = out / ".graphify_labels.json"
32553255
if labels_path.exists() and not force_relabel:
@@ -3882,7 +3882,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
38823882
elif cmd == "benchmark":
38833883
from graphify.benchmark import run_benchmark, print_benchmark
38843884

3885-
graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json"
3885+
graph_path = sys.argv[2] if len(sys.argv) > 2 else _default_graph_path()
38863886
_enforce_graph_size_cap_or_exit(Path(graph_path))
38873887
# Try to load corpus_words from detect output
38883888
corpus_words = None
@@ -4117,7 +4117,7 @@ def _parse_float(name: str, raw: str) -> float:
41174117
# so a fresh checkout writes graphify-out/ at the project root, matching
41184118
# the skill.md pipeline.
41194119
out_root = (out_dir.resolve() if out_dir else target)
4120-
graphify_out = out_root / "graphify-out"
4120+
graphify_out = out_root / _GRAPHIFY_OUT
41214121
graphify_out.mkdir(parents=True, exist_ok=True)
41224122

41234123
from graphify.detect import (
@@ -4638,7 +4638,7 @@ def _progress(idx: int, total: int, _result: dict) -> None:
46384638
i += 1
46394639
files = [f for f in files_from.read_text(encoding="utf-8").splitlines() if f.strip()]
46404640
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(files, root)
4641-
out = root / "graphify-out"
4641+
out = root / _GRAPHIFY_OUT
46424642
out.mkdir(parents=True, exist_ok=True)
46434643
if cached_nodes or cached_edges or cached_hyperedges:
46444644
(out / ".graphify_cached.json").write_text(

‎graphify/benchmark.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from graphify.build import edge_data
1010
from graphify.serve import _query_terms
11+
from graphify.paths import default_graph_json as _default_graph_json
1112

1213

1314
_CHARS_PER_TOKEN = 4 # standard approximation
@@ -85,7 +86,7 @@ def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int:
8586

8687

8788
def run_benchmark(
88-
graph_path: str = "graphify-out/graph.json",
89+
graph_path: str | None = None,
8990
corpus_words: int | None = None,
9091
questions: list[str] | None = None,
9192
) -> dict:
@@ -98,6 +99,7 @@ def run_benchmark(
9899
99100
Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question
100101
"""
102+
graph_path = graph_path or _default_graph_json()
101103
from graphify.security import check_graph_file_size_cap
102104
check_graph_file_size_cap(Path(graph_path))
103105
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))

‎graphify/build.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from pathlib import Path
3030
import networkx as nx
3131
from .ids import normalize_id as _normalize_id
32+
from .paths import default_graph_json as _default_graph_json
3233
from .validate import validate_extraction
3334

3435

@@ -435,7 +436,7 @@ def deduplicate_by_label(nodes: list[dict], edges: list[dict]) -> tuple[list[dic
435436

436437
def build_merge(
437438
new_chunks: list[dict],
438-
graph_path: str | Path = "graphify-out/graph.json",
439+
graph_path: str | Path | None = None,
439440
prune_sources: list[str] | None = None,
440441
*,
441442
directed: bool = False,
@@ -452,7 +453,7 @@ def build_merge(
452453
Safe to call repeatedly.
453454
root: if given, absolute source_file paths in new_chunks are made relative (#932).
454455
"""
455-
graph_path = Path(graph_path)
456+
graph_path = Path(graph_path if graph_path is not None else _default_graph_json())
456457
if graph_path.exists():
457458
# Read JSON directly instead of going through node_link_graph().
458459
# The latter rebuilds an undirected nx.Graph and then enumerating

‎graphify/detect.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
convert_google_workspace_file,
1515
google_workspace_enabled,
1616
)
17+
from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME, out_path
1718

1819

1920
class FileType(str, Enum):
@@ -24,7 +25,7 @@ class FileType(str, Enum):
2425
VIDEO = "video"
2526

2627

27-
_MANIFEST_PATH = "graphify-out/manifest.json"
28+
_MANIFEST_PATH = str(out_path("manifest.json"))
2829

2930
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'}
3031
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
@@ -663,7 +664,7 @@ def count_words(path: Path) -> int:
663664
"site-packages", "lib64",
664665
".pytest_cache", ".mypy_cache", ".ruff_cache",
665666
".tox", ".eggs", "*.egg-info",
666-
"graphify-out", # never treat own output as source input (#524)
667+
"graphify-out", GRAPHIFY_OUT_NAME, # never treat own output as source input (#524); honour GRAPHIFY_OUT (#1423)
667668
# Coverage/test-artefact dirs — generated, never architecturally meaningful
668669
"coverage", "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870)
669670
"visual-tests", "visual-test", # Playwright/visual-regression bundles (#869)
@@ -1035,7 +1036,7 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
10351036
include_patterns = _load_graphifyinclude(root)
10361037

10371038
# Always include graphify-out/memory/ - query results filed back into the graph
1038-
memory_dir = root / "graphify-out" / "memory"
1039+
memory_dir = root / GRAPHIFY_OUT / "memory"
10391040
scan_paths = [root]
10401041
if memory_dir.exists():
10411042
scan_paths.append(memory_dir)
@@ -1081,7 +1082,7 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
10811082

10821083
all_files.sort(key=lambda p: str(p))
10831084

1084-
converted_dir = root / "graphify-out" / "converted"
1085+
converted_dir = root / GRAPHIFY_OUT / "converted"
10851086

10861087
for p in all_files:
10871088
# For memory dir files, skip hidden/noise filtering

‎graphify/paths.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,30 @@
1717
from __future__ import annotations
1818

1919
import os
20+
from pathlib import Path
2021

2122
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
2223

2324
# Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by the
24-
# path guards that walk parents looking for the output dir by name.
25+
# path guards that walk parents looking for the output dir by name, and by the
26+
# detect scan-exclude so a custom output dir is never re-ingested as source.
2527
GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT))
28+
29+
30+
def out_path(*parts: str) -> Path:
31+
"""A path inside the configured output dir, e.g. ``out_path("cache")``.
32+
33+
``Path(GRAPHIFY_OUT) / ...`` resolves correctly for both a relative name
34+
("graphify-out") and an absolute override ("/shared/graphify-out").
35+
"""
36+
return Path(GRAPHIFY_OUT, *parts)
37+
38+
39+
def default_graph_json() -> str:
40+
"""Default ``graph.json`` path under the configured output dir.
41+
42+
The package-wide fallback used by serve/build/benchmark/prs and the CLI read
43+
commands so a ``GRAPHIFY_OUT`` override is honoured everywhere, not just where
44+
the path is passed explicitly (#1423).
45+
"""
46+
return str(out_path("graph.json"))

‎graphify/prs.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from datetime import datetime, timezone
2727
from pathlib import Path
2828

29+
from graphify.paths import default_graph_json as _default_graph_json
30+
2931

3032
# ── ANSI colours ─────────────────────────────────────────────────────────────
3133

@@ -676,7 +678,7 @@ def cmd_prs(argv: list[str]) -> None:
676678
do_conflicts = False
677679
show_wrong_base = False
678680
pr_number: int | None = None
679-
graph_path = Path("graphify-out/graph.json")
681+
graph_path = Path(_default_graph_json())
680682

681683
i = 0
682684
while i < len(argv):

‎graphify/serve.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from networkx.readwrite import json_graph
1010
from graphify.security import sanitize_label, check_graph_file_size_cap
1111
from graphify.build import edge_data
12+
from graphify.paths import default_graph_json as _default_graph_json
1213

1314
try:
1415
import jieba as _jieba # type: ignore[import-untyped]
@@ -1038,8 +1039,9 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
10381039
return server
10391040

10401041

1041-
def serve(graph_path: str = "graphify-out/graph.json") -> None:
1042+
def serve(graph_path: str | None = None) -> None:
10421043
"""Start the MCP server over stdio (the default, per-developer transport)."""
1044+
graph_path = graph_path or _default_graph_json()
10431045
try:
10441046
from mcp.server.stdio import stdio_server
10451047
except ImportError as e:
@@ -1196,7 +1198,7 @@ async def lifespan(_app):
11961198

11971199

11981200
def serve_http(
1199-
graph_path: str = "graphify-out/graph.json",
1201+
graph_path: str | None = None,
12001202
*,
12011203
host: str = "127.0.0.1",
12021204
port: int = 8080,
@@ -1217,6 +1219,7 @@ def serve_http(
12171219
deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond
12181220
localhost — set an api_key when you do.
12191221
"""
1222+
graph_path = graph_path or _default_graph_json()
12201223
try:
12211224
import uvicorn
12221225
except ImportError as e:
@@ -1304,7 +1307,7 @@ def _main(argv: list[str] | None = None) -> None:
13041307
help="Reap stateful sessions idle this many seconds (default: 3600; 0 disables)",
13051308
)
13061309
args = parser.parse_args(argv)
1307-
graph_path = args.graph_flag or args.graph_path or "graphify-out/graph.json"
1310+
graph_path = args.graph_flag or args.graph_path or _default_graph_json()
13081311

13091312
if args.transport == "http":
13101313
serve_http(

‎graphify/transcribe.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import os
66
from pathlib import Path
77

8+
from graphify.paths import out_path as _out_path
9+
810

911
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
1012
URL_PREFIXES = ('http://', 'https://', 'www.')
1113

1214
_DEFAULT_MODEL = "base"
13-
_TRANSCRIPTS_DIR = "graphify-out/transcripts"
15+
_TRANSCRIPTS_DIR = str(_out_path("transcripts"))
1416
_FALLBACK_PROMPT = "Use proper punctuation and paragraph breaks."
1517

1618

‎tests/test_cli_export.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,26 @@ def test_query_uses_graphify_out_env(tmp_path):
206206
assert len(r.stdout) > 0
207207

208208

209+
def test_extract_writes_to_graphify_out_env(tmp_path):
210+
"""#1423: `graphify extract` honours GRAPHIFY_OUT for where it WRITES, not only
211+
where readers look — previously it hardcoded graphify-out/ and ignored the
212+
override. Code-only corpus, so no LLM backend is needed."""
213+
(tmp_path / "m.py").write_text("def a():\n return b()\n\n\ndef b():\n return 1\n")
214+
env = os.environ.copy()
215+
env["GRAPHIFY_OUT"] = "custom-out"
216+
217+
r = _run(["extract", "."], tmp_path, env=env)
218+
219+
assert r.returncode == 0, r.stderr
220+
assert (tmp_path / "custom-out" / "graph.json").exists(), r.stdout
221+
assert (tmp_path / "custom-out" / "manifest.json").exists()
222+
# The default dir must NOT be created when the override is set.
223+
assert not (tmp_path / "graphify-out").exists(), "extract ignored GRAPHIFY_OUT and wrote graphify-out/"
224+
# Manifest keys are relative to the scan root (portable) — #1417.
225+
keys = list(json.loads((tmp_path / "custom-out" / "manifest.json").read_text()).keys())
226+
assert keys == ["m.py"], keys
227+
228+
209229
# ── graphify path ────────────────────────────────────────────────────────────
210230

211231
def test_path_runs_without_error(tmp_path):

0 commit comments

Comments
 (0)