diff --git a/CHANGELOG.md b/CHANGELOG.md index 577882c5..7caffdf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/graphify/__main__.py b/graphify/__main__.py index 2505a56b..19f08a3e 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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( diff --git a/graphify/benchmark.py b/graphify/benchmark.py index eabade29..ee9c3925 100644 --- a/graphify/benchmark.py +++ b/graphify/benchmark.py @@ -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")) diff --git a/graphify/build.py b/graphify/build.py index e7fc9eec..5af60238 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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 diff --git a/graphify/detect.py b/graphify/detect.py index 6c8e71d8..642c6934 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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 diff --git a/graphify/paths.py b/graphify/paths.py index 6f8bd62f..3700f295 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -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")) diff --git a/graphify/prs.py b/graphify/prs.py index 42943f8a..4304e610 100644 --- a/graphify/prs.py +++ b/graphify/prs.py @@ -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): diff --git a/graphify/serve.py b/graphify/serve.py index e3204ffc..990a32de 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -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( diff --git a/graphify/transcribe.py b/graphify/transcribe.py index 6d21038f..8e45bcaa 100644 --- a/graphify/transcribe.py +++ b/graphify/transcribe.py @@ -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." diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index e8a5bc25..3169d0c7 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -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):