Relativize hyperedge source_file and honour GRAPHIFY_OUT everywhere (#1418, #1423)

#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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-22 17:02:29 +01:00
co-authored by Claude Opus 4.8
parent 5d053721ab
commit d168de999a
12 changed files with 112 additions and 13 deletions
+3
View File
@@ -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)
+3 -1
View File
@@ -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)
+6
View File
@@ -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
+3 -2
View File
@@ -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
+5 -3
View File
@@ -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"
+4 -2
View File
@@ -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:
+25
View File
@@ -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))
+4 -2
View File
@@ -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():
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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 <output-dir>/.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.
+30
View File
@@ -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)
+22
View File
@@ -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