mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 00:36:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
6954a28da8
commit
b8dc31f760
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user