diff --git a/CHANGELOG.md b/CHANGELOG.md index 531071fda..556c27cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/graphify/__main__.py b/graphify/__main__.py index 924ae986d..2cafd3d2c 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 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,") diff --git a/graphify/cli.py b/graphify/cli.py index 91df09672..6337c9392 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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 diff --git a/graphify/graph3d_html.py b/graphify/graph3d_html.py new file mode 100644 index 000000000..68ffd0659 --- /dev/null +++ b/graphify/graph3d_html.py @@ -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 ` 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(" 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 = """ +%%TITLE%% + + + +

%%HEADER%%

+ Drag: rotate · Wheel: zoom · Click node: focus · Arrows = direction (source→target)
+
Click a node to inspect it.
+ +
+""".replace("%%CDN%%", _CDN) diff --git a/tests/test_graph3d_html.py b/tests/test_graph3d_html.py new file mode 100644 index 000000000..0a750138c --- /dev/null +++ b/tests/test_graph3d_html.py @@ -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": "", "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 " 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")