From d168de999a699bfe9f8a8e0e77eb810f21da7ff5 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 22 Jun 2026 17:02:29 +0100 Subject: [PATCH] Relativize hyperedge source_file and honour GRAPHIFY_OUT everywhere (#1418, #1423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1418: build_from_json relativized source_file on nodes and edges but stored graph.hyperedges[] verbatim, so a semantic subagent's absolute path leaked into graph.json. Relativize hyperedges in build_from_json (to_json has no root to relativize against), mirroring the existing node/edge handling. #1423: consolidate the GRAPHIFY_OUT output-dir name into a single graphify.paths module (was duplicated in __main__, cache, watch) and route the path guards through it — security.validate_graph_path's base=None discovery + fallback, callflow_html's project-root resolution, and the post-commit/post-checkout hook bodies (which now read the env var at hook-run time). A renamed output dir is no longer validated against the wrong base or missed by the hook. Tests: hyperedge relativization (test_hypergraph), GRAPHIFY_OUT discovery (test_security), updated the hook-body contract assertion (test_hooks). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ graphify/__main__.py | 4 +++- graphify/build.py | 6 ++++++ graphify/cache.py | 5 +++-- graphify/callflow_html.py | 8 +++++--- graphify/hooks.py | 6 ++++-- graphify/paths.py | 25 +++++++++++++++++++++++++ graphify/security.py | 6 ++++-- graphify/watch.py | 3 ++- tests/test_hooks.py | 7 +++++-- tests/test_hypergraph.py | 30 ++++++++++++++++++++++++++++++ tests/test_security.py | 22 ++++++++++++++++++++++ 12 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 graphify/paths.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a4af2f3..4626cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- 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). +- Fix: the `GRAPHIFY_OUT` override is now honoured everywhere instead of a hardcoded `"graphify-out"` literal. The name is consolidated into a single `graphify.paths` module (was duplicated across `__main__`, `cache`, and `watch`); `security.validate_graph_path`'s `base=None` discovery + fallback, `callflow_html`'s project-root resolution, and the post-commit/post-checkout hook bodies (which now read the env var at hook-run time) all use it. Previously a renamed output dir validated against the wrong base or made the hook miss `.graphify_root` (#1423). + - Fix: the Aider and Devin monolith skills now carry the #1392 runbook fixes that the split skill got in 0.8.44. These single-file skills are hand-maintained and frozen against a pinned pristine-v8 blob by a round-trip guard, so they had been excluded. The guard is now a multiset diff that classifies every added/removed line against documented sanctioned change-classes (rather than a positional zip that forbade any line-count change), which lets the multi-line fixes land while still failing on any unsanctioned drift. Both monoliths now propagate `directed=IS_DIRECTED` into every `build_from_json` call (a `--directed` run no longer collapses reciprocal edges), scope semantic extraction to document/paper/image (code is covered by the AST pass), delete `.graphify_cached.json` on a cache miss, and run Step 4's zero-node guard before any write with the report/analysis gated on `to_json` actually persisting the graph (#1392). ## 0.8.44 (2026-06-19) diff --git a/graphify/__main__.py b/graphify/__main__.py index f4d1697..2505a56 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -19,7 +19,9 @@ except Exception: # Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups. # Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out"). -_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") +# Defined once in graphify.paths so the security/callflow path guards honour the +# same override (#1423). +from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT @functools.lru_cache(maxsize=None) diff --git a/graphify/build.py b/graphify/build.py index b33f9e6..e7fc9ee 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -331,6 +331,12 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat G.add_edge(src, tgt, **attrs) hyperedges = extraction.get("hyperedges", []) if hyperedges: + # Relativize hyperedge source_file the same way nodes and edges are + # (above), so to_json — which has no root and writes G.graph["hyperedges"] + # verbatim — never leaks an absolute path from a semantic subagent (#1418). + for he in hyperedges: + if isinstance(he, dict) and he.get("source_file"): + he["source_file"] = _norm_source_file(he["source_file"], _root) G.graph["hyperedges"] = hyperedges return G diff --git a/graphify/cache.py b/graphify/cache.py index 9cb6d5f..c00eaa5 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -11,8 +11,9 @@ from pathlib import Path # Output directory name — override with GRAPHIFY_OUT env var for worktrees or # shared-output setups. Accepts a relative name ("graphify-out-feature") or an -# absolute path ("/shared/graphify-out"). -_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") +# absolute path ("/shared/graphify-out"). Single source of truth in graphify.paths +# (#1423); re-exported here as _GRAPHIFY_OUT for the existing call sites. +from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT # AST cache entries are the output of graphify's own extractor code, so they # are only valid for the version that wrote them: keying purely on file diff --git a/graphify/callflow_html.py b/graphify/callflow_html.py index 6195adb..181e749 100644 --- a/graphify/callflow_html.py +++ b/graphify/callflow_html.py @@ -30,6 +30,8 @@ from collections import Counter, defaultdict from datetime import datetime, timezone from html import escape +from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME + # ────────────────────────────────────────────── # 1. CSS template (fixed, project-agnostic) @@ -404,7 +406,7 @@ def infer_project_name(graph_path: str, meta: dict) -> str: if meta.get("project_name"): return meta["project_name"] path = Path(graph_path).resolve() - if path.parent.name == "graphify-out" and len(path.parents) > 1: + if path.parent.name == GRAPHIFY_OUT_NAME and len(path.parents) > 1: return path.parents[1].name return path.parent.name or "Project" @@ -419,9 +421,9 @@ def resolve_graphify_paths(args) -> dict: elif (base / "graph.json").exists(): graphify_out = base else: - graphify_out = base / "graphify-out" + graphify_out = base / GRAPHIFY_OUT - project_root = graphify_out.parent if graphify_out.name == "graphify-out" else base + project_root = graphify_out.parent if graphify_out.name == GRAPHIFY_OUT_NAME else base graph = Path(args.graph).expanduser() if args.graph else graphify_out / "graph.json" report = Path(args.report).expanduser() if args.report else graphify_out / "GRAPH_REPORT.md" labels = Path(args.labels).expanduser() if args.labels else graphify_out / ".graphify_labels.json" diff --git a/graphify/hooks.py b/graphify/hooks.py index f4cd3a9..c4ff455 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -99,7 +99,8 @@ try: signal.alarm(_timeout) _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') _root = Path('.') - _saved = Path('graphify-out/.graphify_root') + _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out') + _saved = Path(_out) / '.graphify_root' if _saved.exists(): _txt = _saved.read_text(encoding='utf-8').strip() if _txt: @@ -128,7 +129,8 @@ try: # (no changed_paths) is correct here. The flock inside _rebuild_code still # prevents pile-ups when commit + checkout fire back-to-back. _root = Path('.') - _saved = Path('graphify-out/.graphify_root') + _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out') + _saved = Path(_out) / '.graphify_root' if _saved.exists(): _txt = _saved.read_text(encoding='utf-8').strip() if _txt: diff --git a/graphify/paths.py b/graphify/paths.py new file mode 100644 index 0000000..6f8bd62 --- /dev/null +++ b/graphify/paths.py @@ -0,0 +1,25 @@ +"""Single source of truth for the graphify output-directory name. + +The output directory is ``graphify-out`` by default and overridable with the +``GRAPHIFY_OUT`` env var (worktrees or shared-output setups, #686). It accepts a +relative name (``"graphify-out-feature"``) or an absolute path +(``"/shared/graphify-out"``). + +This used to be duplicated as an identical ``_GRAPHIFY_OUT`` constant in +``__main__``, ``cache``, and ``watch``, while ``security`` and ``callflow_html`` +hardcoded the literal ``"graphify-out"`` and silently ignored the override +(#1423). Centralising it here keeps the name in one place. The value is read +once at import time, matching the previous per-module constants — set +``GRAPHIFY_OUT`` before the process starts (the normal worktree/shared-output +flow) and every reader honours it. +""" + +from __future__ import annotations + +import os + +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. +GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT)) diff --git a/graphify/security.py b/graphify/security.py index b9fa49e..d371e53 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -15,6 +15,8 @@ from typing import Any import ipaddress import socket +from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME + _ALLOWED_SCHEMES = {"http", "https"} _MAX_FETCH_BYTES = 52_428_800 # 50 MB hard cap for binary downloads _MAX_TEXT_BYTES = 10_485_760 # 10 MB hard cap for HTML / text @@ -323,11 +325,11 @@ def validate_graph_path(path: str | Path, base: Path | None = None) -> Path: if base is None: resolved_hint = Path(path).resolve() for candidate in [resolved_hint, *resolved_hint.parents]: - if candidate.name == "graphify-out": + if candidate.name == GRAPHIFY_OUT_NAME: base = candidate break if base is None: - base = Path("graphify-out").resolve() + base = Path(GRAPHIFY_OUT).resolve() base = base.resolve() if not base.exists(): diff --git a/graphify/watch.py b/graphify/watch.py index 0d08b1f..1b7fadb 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -8,7 +8,8 @@ import sys import time from pathlib import Path -_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") +# Single source of truth in graphify.paths (#1423); re-exported as _GRAPHIFY_OUT. +from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT _PENDING_FILENAME = ".pending_changes" _PENDING_DRAIN_MAX_PASSES = 20 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 1080d1e..3abb4df 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -301,9 +301,12 @@ def test_rebuild_bodies_are_shell_quote_safe(): ) def test_rebuild_bodies_read_graphify_root(name, body): """The rebuild must honour the persisted scan root rather than hardcoding the - repo top (#1173). Both bodies read graphify-out/.graphify_root and pass the + repo top (#1173). Both bodies read /.graphify_root and pass the recovered root to _rebuild_code instead of the bare Path('.').""" - assert "graphify-out/.graphify_root" in body, f"{name} ignores .graphify_root (#1173)" + assert ".graphify_root" in body, f"{name} ignores .graphify_root (#1173)" + # The output dir is resolved from GRAPHIFY_OUT at hook-run time, not hardcoded + # to graphify-out/, so a renamed output dir is still found (#1423). + assert "GRAPHIFY_OUT" in body, f"{name} ignores the GRAPHIFY_OUT override (#1423)" # The recovered root is what gets rebuilt, not a hardcoded cwd. assert "_rebuild_code(_root" in body, f"{name} does not pass the recovered root" # Quote-safe inside the shell-double-quoted launcher: single quotes only. diff --git a/tests/test_hypergraph.py b/tests/test_hypergraph.py index dda8ac7..ac4ceb5 100644 --- a/tests/test_hypergraph.py +++ b/tests/test_hypergraph.py @@ -62,6 +62,36 @@ def test_build_from_json_stores_hyperedges(): assert G.graph["hyperedges"][0]["id"] == "auth_flow" +def test_build_from_json_relativizes_hyperedge_source_file(tmp_path): + """build_from_json(root=...) must relativize hyperedge source_file like it + already does for nodes and edges. to_json writes G.graph['hyperedges'] + verbatim and has no root parameter, so an absolute path emitted by a semantic + subagent would otherwise leak into graph.json (#1418).""" + base = tmp_path.resolve() + abs_doc = base / "docs" / "CLAUDE.md" + extraction = { + "nodes": [ + {"id": "a", "label": "A", "file_type": "document", "source_file": str(abs_doc)}, + ], + "edges": [], + "hyperedges": [ + { + "id": "arch", + "label": "Architecture", + "nodes": ["a"], + "relation": "participate_in", + "confidence": "INFERRED", + "confidence_score": 0.75, + "source_file": str(abs_doc), + } + ], + } + G = build_from_json(extraction, root=str(base)) + assert G.graph["hyperedges"][0]["source_file"] == "docs/CLAUDE.md" + # Anchor: the node path is relativized the same way (the contract this mirrors). + assert G.nodes["a"]["source_file"] == "docs/CLAUDE.md" + + def test_build_from_json_no_hyperedges(): extraction = {**SAMPLE_EXTRACTION, "hyperedges": []} G = build_from_json(extraction) diff --git a/tests/test_security.py b/tests/test_security.py index c547ab8..d2e08c0 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -171,6 +171,28 @@ def test_validate_graph_path_raises_if_file_missing(tmp_path): with pytest.raises(FileNotFoundError): validate_graph_path(str(base / "missing.json"), base=base) +def test_validate_graph_path_default_base_discovers_output_dir(tmp_path): + """With base omitted, the output dir is discovered by walking the path's + parents for the configured output-dir name (default 'graphify-out').""" + base = tmp_path / "graphify-out" + base.mkdir() + graph = base / "graph.json" + graph.write_text("{}") + assert validate_graph_path(str(graph)) == graph.resolve() + +def test_validate_graph_path_default_base_honours_graphify_out_override(tmp_path, monkeypatch): + """The base=None discovery must honour GRAPHIFY_OUT, not the hardcoded + 'graphify-out' literal — otherwise a renamed output dir validates against the + wrong base or raises spuriously (#1423).""" + monkeypatch.setattr("graphify.security.GRAPHIFY_OUT_NAME", "custom-out") + monkeypatch.setattr("graphify.security.GRAPHIFY_OUT", "custom-out") + out = tmp_path / "custom-out" + out.mkdir() + graph = out / "graph.json" + graph.write_text("{}") + # No base passed → must discover custom-out by name rather than graphify-out. + assert validate_graph_path(str(graph)) == graph.resolve() + # --------------------------------------------------------------------------- # sanitize_label