Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Feat: `graphify graph3d` renders an interactive 3D force-directed HTML view of `graph.json` (WebGL via `3d-force-graph`, MIT, CDN-loaded — the same habit as the `tree` viewer's D3). Unlike the flat `graph.html`, which lays the graph out undirected with no arrowheads, the 3D view *draws edge direction* (`source → target`) as arrows, so `calls` / `imports` / `inherits` read directionally; rotate/zoom, click a node to focus it and list its typed relations, and search by label. Community colors (`exporters.base.COMMUNITY_COLORS`) and the graph-size cap are shared with the existing exporters, and node labels are HTML-escaped at render time so a hostile label can't inject markup into the local report. A minimal, focused alternative to the stalled #225.

## 0.9.26 (2026-07-25)

- Fix: `graphify query`/`explain` no longer fabricate `indirect_call` edges to class definitions (#2137, thanks @Rishet11). The callable guard admitted classes, so passing a class as a value (`select(Model)`, `db.get(Model, id)`, `except (ErrorA, ErrorB)`, `getattr(obj, "Name", 0)`) produced a false inferred call edge in both the intra-file and cross-file paths. Classes are now tracked separately and excluded from `indirect_call`; direct instantiation still emits its `calls` edge. The suppression is context-blind, so a genuine higher-order class callback that is actually invoked (e.g. `map(Point, coords)`) also loses its edge, which is the intended tradeoff.
Expand Down
4 changes: 4 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,10 @@ def _run_cli() -> None:
print(" --max-children N cap children per node (default 200)")
print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)")
print(" --label NAME project label in header")
print(" graph3d emit an interactive 3D force-directed HTML (WebGL) for graph.json")
print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
print(" --output HTML output path (default graphify-out/GRAPH_3D.html)")
print(" --label NAME project label in header")
print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)")
print(" openai also reaches self-hosted OpenAI-compatible servers (llama.cpp,")
Expand Down
43 changes: 43 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,49 @@ def dispatch_command(cmd: str) -> None:
print(f"open with: xdg-open {out} (or file://{out.resolve()})")
sys.exit(0)

elif cmd == "graph3d":
# Emit an interactive 3D force-directed HTML view of graph.json (WebGL via
# 3d-force-graph). Unlike the flat graph.html, it *draws edge direction*
# (source -> target) as arrows; rotate/zoom, click a node to focus it and
# list its typed relations, search by label.
from typing import Optional as _Opt
from graphify.graph3d_html import write_graph3d_html
graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
output_path: "_Opt[Path]" = None
project_label: "_Opt[str]" = None
args = sys.argv[2:]
i_arg = 0
while i_arg < len(args):
a = args[i_arg]
if a == "--graph" and i_arg + 1 < len(args):
graph_path = Path(args[i_arg + 1]); i_arg += 2
elif a == "--output" and i_arg + 1 < len(args):
output_path = Path(args[i_arg + 1]); i_arg += 2
elif a == "--label" and i_arg + 1 < len(args):
project_label = args[i_arg + 1]; i_arg += 2
elif a in ("-h", "--help"):
print("Usage: graphify graph3d [--graph PATH] [--output HTML] [--label NAME]")
print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
print(" --output HTML output path (default graphify-out/GRAPH_3D.html)")
print(" --label NAME project label shown in the page header")
return
else:
i_arg += 1
if not graph_path.is_file():
print(f"error: graph.json not found at {graph_path}", file=sys.stderr)
sys.exit(1)
_enforce_graph_size_cap_or_exit(graph_path)
if output_path is None:
output_path = graph_path.parent / "GRAPH_3D.html"
out = write_graph3d_html(
graph_path=graph_path, output_path=output_path,
project_label=project_label,
)
size_kb = out.stat().st_size / 1024
print(f"wrote {out} ({size_kb:.1f} KB)")
print(f"open with: xdg-open {out} (or file://{out.resolve()})")
sys.exit(0)

elif cmd == "merge-driver":
# git merge driver for graph.json — takes (base, current, other) and writes
# the union of current+other nodes/edges back to current. Exits 1 on
Expand Down
187 changes: 187 additions & 0 deletions graphify/graph3d_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Interactive 3D force-directed HTML view of graph.json.

A WebGL counterpart to ``graphify tree``: where the 2D ``graph.html`` (vis-network,
``exporters/html.py``) draws an undirected force layout with no arrowheads, this
renders the graph as a rotatable 3D force graph and *draws the edge direction*
(``source -> target``) as arrows — so ``calls`` / ``imports`` / ``inherits`` read
directionally, which the flat view cannot show.

Self-contained single HTML file. Loads the ``3d-force-graph`` library (MIT) from a
CDN, matching how ``tree_html`` loads D3 from ``d3js.org``. Graph data is embedded
inline (no fetch, so it opens over ``file://`` without a CORS shim).
"""
from __future__ import annotations

import html as _html
import json
from collections import Counter
from pathlib import Path
from typing import Any, Dict, List, Optional

from graphify.exporters.base import COMMUNITY_COLORS

# 3d-force-graph bundles three.js; pinned major matches the tree viewer's CDN habit.
_CDN = "https://unpkg.com/3d-force-graph@1"


def build_graph3d_data(
graph: Dict[str, Any], labels: Optional[Dict[int, str]] = None
) -> Dict[str, Any]:
"""Shape graph.json into ``{nodes, links}`` for 3d-force-graph.

Node size scales with degree; ``community`` drives the color; ``community_name``
is taken from the labels sidecar (or the node) and falls back to ``Community N``.
Links keep ``relation`` and ``confidence`` so the tooltip can name the edge and
the renderer can dim ``INFERRED`` edges. Dangling links (an endpoint absent from
``nodes``) are dropped so the force engine never fabricates ghost nodes.
"""
labels = labels or {}
raw_links: List[Dict[str, Any]] = list(graph.get("links") or graph.get("edges") or [])

degree: Counter = Counter()
for e in raw_links:
degree[e.get("source")] += 1
degree[e.get("target")] += 1

node_ids = {n["id"] for n in graph.get("nodes", [])}
nodes: List[Dict[str, Any]] = []
for n in graph.get("nodes", []):
cid = n.get("community")
cname = n.get("community_name") or labels.get(cid) or (
f"Community {cid}" if cid is not None else "unknown"
)
nodes.append({
"id": n["id"],
"label": n.get("label") or n["id"],
"community": cid,
"community_name": cname,
"source_file": n.get("source_file") or "",
"source_location": n.get("source_location") or "",
"degree": degree.get(n["id"], 0),
})

links = [{
"source": e["source"],
"target": e["target"],
"relation": e.get("relation") or "",
"confidence": e.get("confidence") or "",
} for e in raw_links if e.get("source") in node_ids and e.get("target") in node_ids]

return {"nodes": nodes, "links": links}


def emit_html(data: Dict[str, Any], *, title: str, header: str) -> str:
# ensure_ascii + escaping `</` keeps embedded JSON from breaking out of the
# <script> tag; every value that later lands in innerHTML is run through the
# JS esc() helper below, so a hostile node label (e.g. from a scraped doc)
# cannot inject markup into the local report (cf. exporters/html.py, #1838).
data_json = json.dumps(data, ensure_ascii=True, separators=(",", ":")).replace("</", "<\\/")
palette_json = json.dumps(COMMUNITY_COLORS)
return (
_HTML_TEMPLATE
.replace("%%TITLE%%", _html.escape(title))
.replace("%%HEADER%%", _html.escape(header))
.replace("%%PALETTE%%", palette_json)
.replace("%%DATA%%", data_json)
)


def write_graph3d_html(
graph_path: Path,
output_path: Path,
*,
project_label: Optional[str] = None,
) -> Path:
from graphify.security import check_graph_file_size_cap

check_graph_file_size_cap(graph_path)
graph = json.loads(graph_path.read_text(encoding="utf-8"))

labels: Dict[int, str] = {}
labels_path = graph_path.parent / ".graphify_labels.json"
if labels_path.is_file():
try:
labels = {
int(k): v
for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()
if isinstance(v, str)
}
except Exception:
labels = {}

data = build_graph3d_data(graph, labels)
name = project_label or "Knowledge Graph"
html = emit_html(
data,
title=f"{name} — graphify 3D viewer",
header=f"{name} — {len(data['nodes'])} nodes / {len(data['links'])} edges",
)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(html, encoding="utf-8")
return output_path


_HTML_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>%%TITLE%%</title>
<style>
body{margin:0;background:#0b0d17;color:#cdd3e0;font:13px/1.4 system-ui,sans-serif;overflow:hidden}
#g{width:100vw;height:100vh}
#hud{position:fixed;top:0;left:0;padding:10px 14px;z-index:5;pointer-events:none}
#hud h1{margin:0 0 4px;font-size:15px;color:#fff}
#hud small{opacity:.7}
#info{position:fixed;top:10px;right:10px;width:280px;background:#141726ee;
border:1px solid #2a2f45;border-radius:8px;padding:12px;z-index:5;max-height:80vh;overflow:auto}
#info b{color:#8ab4ff}
#search{position:fixed;bottom:14px;left:14px;z-index:5;padding:7px 10px;border-radius:6px;
border:1px solid #2a2f45;background:#141726;color:#cdd3e0;width:240px}
.rel{color:#e0b070}
</style>
<script src="%%CDN%%"></script>
</head><body>
<div id="hud"><h1>%%HEADER%%</h1>
<small>Drag: rotate &middot; Wheel: zoom &middot; Click node: focus &middot; Arrows = direction (source&rarr;target)</small></div>
<div id="info">Click a node to inspect it.</div>
<input id="search" placeholder="Search node... (Enter)">
<div id="g"></div>
<script>
const DATA = %%DATA%%;
const PALETTE = %%PALETTE%%;
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
const col = c => c==null ? '#8892a6' : PALETTE[((c%PALETTE.length)+PALETTE.length)%PALETTE.length];
const byId = new Map(DATA.nodes.map(n=>[n.id,n]));
const G = ForceGraph3D()(document.getElementById('g'))
.graphData(DATA).backgroundColor('#0b0d17').nodeId('id')
.nodeVal(n => Math.max(1, Math.sqrt(n.degree))).nodeRelSize(3)
.nodeColor(n => col(n.community))
.nodeLabel(n => `<div style="background:#141726;padding:6px 8px;border-radius:6px;border:1px solid #2a2f45">`
+ `<b>${esc(n.label)}</b><br><span style="opacity:.7">${esc(n.community_name)}</span><br>`
+ `<span style="opacity:.5;font-size:11px">${esc(n.source_file)}${n.source_location?':'+esc(n.source_location):''}</span><br>`
+ `<span style="opacity:.5;font-size:11px">${n.degree} connections</span></div>`)
.linkColor(l => l.confidence==='INFERRED' ? 'rgba(224,176,112,0.22)' : 'rgba(140,150,200,0.30)')
.linkOpacity(0.35).linkWidth(0)
.linkDirectionalArrowLength(3.2).linkDirectionalArrowRelPos(1)
.linkLabel(l => `<span class="rel">${esc(l.relation)}</span> ${esc(l.confidence)}`)
.cooldownTime(15000)
.onNodeClick(n => {
const r = 1 + 90/Math.hypot(n.x||1,n.y||1,n.z||1);
G.cameraPosition({x:n.x*r,y:n.y*r,z:n.z*r}, n, 1200);
const out=[];
DATA.links.forEach(l => {
const s=l.source.id||l.source, t=l.target.id||l.target;
if(s===n.id) out.push(`&rarr; <span class="rel">${esc(l.relation)}</span> ${esc((byId.get(t)||{}).label||t)}`);
if(t===n.id) out.push(`&larr; <span class="rel">${esc(l.relation)}</span> ${esc((byId.get(s)||{}).label||s)}`);
});
document.getElementById('info').innerHTML =
`<b>${esc(n.label)}</b><br><small>${esc(n.community_name)}</small>`
+ `<br><small style="opacity:.6">${esc(n.source_file)}${n.source_location?':'+esc(n.source_location):''}</small>`
+ `<hr style="border-color:#2a2f45">${out.slice(0,60).join('<br>')||'(no relations)'}`
+ (out.length>60?`<br>&hellip; +${out.length-60} more`:'');
});
document.getElementById('search').addEventListener('keydown', e => {
if(e.key!=='Enter') return;
const q=e.target.value.toLowerCase();
const hit=DATA.nodes.find(n => (n.label||'').toLowerCase().includes(q));
if(hit){const r=1+90/Math.hypot(hit.x||1,hit.y||1,hit.z||1);
G.cameraPosition({x:hit.x*r,y:hit.y*r,z:hit.z*r}, hit, 1200);}
});
</script></body></html>""".replace("%%CDN%%", _CDN)
78 changes: 78 additions & 0 deletions tests/test_graph3d_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import json
import subprocess
import sys
from pathlib import Path

from graphify.exporters.base import COMMUNITY_COLORS
from graphify.graph3d_html import build_graph3d_data, write_graph3d_html


def _make_graphify_out(tmp_path: Path) -> Path:
out = tmp_path / "graphify-out"
out.mkdir()
graph = {
"directed": False,
"multigraph": False,
"graph": {},
"nodes": [
{"id": "api", "label": "ApiClient", "source_file": "src/api.py", "file_type": "code", "community": 0},
{"id": "run", "label": "run()", "source_file": "src/main.py", "file_type": "code", "community": 0},
{"id": "exp", "label": "write_html()", "source_file": "src/export.py", "file_type": "code", "community": 1},
{"id": "evil", "label": "<script>alert(1)</script>", "source_file": "src/evil.py", "file_type": "code", "community": 1},
],
"links": [
{"source": "run", "target": "api", "relation": "calls", "confidence": "EXTRACTED"},
{"source": "api", "target": "exp", "relation": "uses", "confidence": "INFERRED"},
# dangling: target not in nodes -> must be dropped
{"source": "exp", "target": "ghost", "relation": "calls", "confidence": "EXTRACTED"},
],
"hyperedges": [],
}
(out / "graph.json").write_text(json.dumps(graph), encoding="utf-8")
(out / ".graphify_labels.json").write_text(
json.dumps({"0": "Runtime", "1": "Export"}), encoding="utf-8"
)
return out


def test_build_graph3d_data_drops_dangling_and_maps_labels(tmp_path):
out = _make_graphify_out(tmp_path)
graph = json.loads((out / "graph.json").read_text())
data = build_graph3d_data(graph, labels={0: "Runtime", 1: "Export"})

assert len(data["nodes"]) == 4
# the ghost-target link is dropped, the two real links survive
assert len(data["links"]) == 2
api = next(n for n in data["nodes"] if n["id"] == "api")
assert api["community_name"] == "Runtime"
assert api["degree"] == 2 # run->api and api->exp


def test_write_graph3d_html_renders_and_escapes(tmp_path):
out = _make_graphify_out(tmp_path)
dest = out / "GRAPH_3D.html"
write_graph3d_html(graph_path=out / "graph.json", output_path=dest, project_label="demo")
html = dest.read_text(encoding="utf-8")

# the 3D engine, the direction arrows, and the runtime escaper are all present
assert "ForceGraph3D()" in html
assert "linkDirectionalArrowLength" in html
assert "function esc(" in html
# community palette is embedded for coloring
assert COMMUNITY_COLORS[0] in html
# the malicious label cannot break out of the <script> data block
assert "alert(1)</script>" not in html
assert "alert(1)<\\/script>" in html


def test_cli_graph3d_creates_file(tmp_path):
out = _make_graphify_out(tmp_path)
dest = tmp_path / "viewer.html"
result = subprocess.run(
[sys.executable, "-m", "graphify", "graph3d",
"--graph", str(out / "graph.json"), "--output", str(dest), "--label", "demo"],
capture_output=True, text=True,
)
assert result.returncode == 0, result.stderr
assert dest.is_file()
assert "ForceGraph3D()" in dest.read_text(encoding="utf-8")