#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:
co-authored by
Claude Opus 4.8
parent
5d053721ab
commit
d168de999a
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user