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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ __pycache__/
dist/
build/
.pytest_cache/
.hypothesis/
.mypy_cache/
.ruff_cache/
*.so
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

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

## Unreleased

- Feature: `--viz 3d` renders `graph.html` as a navigable, fully offline WebGL
view with search, community filters, hop-focused inspection, bounded labels,
and community aggregation above the visualization node limit. The exact
3d-force-graph browser bundle is packaged and embedded with its license and
integrity digest; the default 2D renderer remains unchanged.

## 0.9.31 (2026-07-30)

- Feature: the MCP server is dual-compatible with SDK 1.x AND 2.x (#2308, thanks @NiSHoW), lifting the `mcp<2` cap 0.9.30 introduced to `mcp>=1,<3`. The 2.0 SDK removed the low-level decorator API (`Server.list_tools`/`call_tool`/...); `_build_server` now binds the same handlers via the 1.x decorators or the 2.x `on_*` constructor callbacks, picked at runtime, and adapts `Tool.inputSchema`, `Resource.uri` (plain `str` in 2.x), and the dropped `AnyUrl` re-export. Verified with full stdio handshakes under both mcp 1.29 and 2.0.
Expand Down Expand Up @@ -33,7 +41,6 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: committed `.env.example` / `.env.sample` / `.env.template` templates are indexed instead of dropped by the sensitive-file filter, while real `.env` files (and templates under a secrets directory) stay excluded (#2184, thanks @SyedFahad7).
- Fix: Obsidian export no longer hides notes whose label starts with a dot (`.env` -> `dot-env`); an all-dot label falls back to `unnamed` (#2205, thanks @SyedFahad7).
- Fix: Scala `self`-type annotations (`self: A with B =>`) now emit `requires` edges to the required traits (#2052, thanks @Yyunozor).

## 0.9.28 (2026-07-27)

- Fix: incremental extraction no longer drops cross-file edges whose target file wasn't in the batch (#2211, #2213). Python relative imports and markdown reference links emitted absolute-path-derived target ids without the `target_file` stamp the incremental canonicalization needs, so a re-extracted file's imports/references dangled or vanished; both now stamp the resolved target and canonicalize to the root-relative node.
Expand Down
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ That's it. You get **three files**:

```
graphify-out/
├── graph.html open in any browser — click nodes, filter, search
├── graph.html open in any browser — click nodes, filter, search (`--viz 3d` for the WebGL 3D view)
├── GRAPH_REPORT.md the highlights: key concepts, surprising connections, suggested questions
└── graph.json the full graph — query it anytime without re-reading your files
```
Expand Down Expand Up @@ -371,6 +371,7 @@ You can also set `GRAPHIFY_GOOGLE_WORKSPACE=1`. Graphify exports shortcuts into
/graphify . --cluster-only --resolution 1.5 # more granular communities
/graphify . --cluster-only --exclude-hubs 99 # suppress utility super-hubs from god-node rankings
/graphify . --no-viz # skip the HTML, just the report + JSON
/graphify . --viz 3d # render graph.html as a navigable WebGL 3D view
/graphify . --wiki # build a markdown wiki from the graph
graphify export callflow-html # Mermaid architecture/call-flow HTML (auto-regenerates on every git commit if hook is installed)

Expand All @@ -392,6 +393,28 @@ graphify prs --conflicts # PRs sharing graph communities — merge-ord

See the [full command reference](#full-command-reference) below.

### Offline 3D HTML export

Use the opt-in WebGL renderer when a dense graph is easier to inspect in three
dimensions:

```bash
graphify export html --viz 3d
# or build and render in one step
/graphify . --viz 3d
```

The generated `graphify-out/graph.html` embeds the graph data and the pinned
3D rendering engine. It can be copied to another machine and opened without a
server or network connection. Community names come from
`.graphify_labels.json`; node colors identify communities and node size
reflects degree (or member count in an aggregated view).

The 2D renderer remains the default. Use `--viz 2d` explicitly when WebGL or
hardware acceleration is unavailable. Graphs above the visualization node
limit retain the existing safe behavior: Graphify exports a community-level
aggregate instead of attempting an unbounded browser layout.

---

## Ignoring files
Expand Down Expand Up @@ -594,8 +617,9 @@ graphify extract . --mode deep --token-budget 4000 # smaller inpu
```
With a cloud gateway like OpenRouter, prefer `--backend openai` (set `OPENAI_BASE_URL`) over the Ollama shim — it's a cleaner OpenAI-compatible path. If the model has its own max-output ceiling, lowering `--token-budget` is the reliable lever.

**Graph HTML is too large to open in a browser (>5000 nodes)**
Skip HTML generation and use the JSON directly:
**Graph HTML has more than 5000 nodes**
Graphify automatically exports a community-level aggregate at the default
visualization limit. To skip HTML and query the full graph directly:
```bash
graphify cluster-only ./my-project --no-viz
graphify query "..."
Expand Down
39 changes: 36 additions & 3 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
from pathlib import Path

# Accepted `--viz` values. Kept as a literal so `graphify --help` and the arg
# validation below don't drag the exporter (and networkx) into every startup;
# the tuple is asserted against exporters.html.VIZ_MODES in the test suite.
_VIZ_MODES = ("2d", "3d")


_SEARCH_NUDGE = json.dumps({
"hookSpecificOutput": {
Expand Down Expand Up @@ -1591,6 +1596,7 @@ def dispatch_command(cmd: str) -> None:
label_model = _model_arg.split("=", 1)[1] if _model_arg else None
_min_cs_arg = next((a for a in sys.argv if a.startswith("--min-community-size=")), None)
min_community_size = int(_min_cs_arg.split("=")[1]) if _min_cs_arg else 3
viz_mode: str | None = None
args = sys.argv[2:]
watch_path: Path | None = None
graph_override: Path | None = None
Expand Down Expand Up @@ -1627,6 +1633,16 @@ def dispatch_command(cmd: str) -> None:
label_batch_size = int(args[i_arg + 1]); i_arg += 2
elif a.startswith("--batch-size="):
label_batch_size = int(a.split("=", 1)[1]); i_arg += 1
# Must precede the `--`-prefixed catch-all: without an explicit arm
# the flag is skipped and its value falls through to the positional
# path slot, so `--viz 3d` would try to cluster a directory "3d".
elif a == "--viz" and i_arg + 1 < len(args):
viz_mode = args[i_arg + 1]; i_arg += 2
elif a == "--viz":
print("error: --viz requires a value (2d or 3d)", file=sys.stderr)
sys.exit(1)
elif a.startswith("--viz="):
viz_mode = a.split("=", 1)[1]; i_arg += 1
elif a in ("--no-viz", "--missing-only") or a.startswith("--min-community-size="):
i_arg += 1
elif a.startswith("--"):
Expand All @@ -1637,6 +1653,10 @@ def dispatch_command(cmd: str) -> None:
i_arg += 1
if watch_path is None:
watch_path = Path(".")
if viz_mode is not None and viz_mode.strip().lower() not in _VIZ_MODES:
print(f"error: --viz must be one of {', '.join(_VIZ_MODES)} (got {viz_mode!r})",
file=sys.stderr)
sys.exit(1)
graph_json = graph_override if graph_override is not None else watch_path / _GRAPHIFY_OUT / "graph.json"
if not graph_json.exists():
print(
Expand Down Expand Up @@ -1889,7 +1909,7 @@ def dispatch_command(cmd: str) -> None:
# path so an oversized graph still renders a usable graph.html.
_node_limit = 5000 if _over_cap else None
to_html(G, communities, str(html_target), community_labels=labels or None,
node_limit=_node_limit)
node_limit=_node_limit, mode=viz_mode)
stages.mark("export"); stages.total()
print(f"Done - {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
except ValueError as viz_err:
Expand Down Expand Up @@ -2218,7 +2238,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"):
print("Usage: graphify export <format>", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz] [--viz 2d|3d]", file=sys.stderr)
print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr)
print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr)
Expand Down Expand Up @@ -2249,6 +2269,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json"
node_limit = 5000
no_viz = False
export_viz_mode: str | None = None
obsidian_dir = Path(_GRAPHIFY_OUT) / "obsidian"
# Shared push-connection settings for the graph-database sinks (neo4j,
# falkordb), parsed from the generic --push/--user/--password flags below.
Expand Down Expand Up @@ -2309,6 +2330,13 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
node_limit = int(args[i + 1]); i += 2
elif a == "--no-viz":
no_viz = True; i += 1
elif a == "--viz" and i + 1 < len(args):
export_viz_mode = args[i + 1]; i += 2
elif a == "--viz":
print("error: --viz requires a value (2d or 3d)", file=sys.stderr)
sys.exit(1)
elif a.startswith("--viz="):
export_viz_mode = a.split("=", 1)[1]; i += 1
elif a == "--dir" and i + 1 < len(args):
obsidian_dir = Path(args[i + 1]); i += 2
elif a == "--push" and i + 1 < len(args):
Expand Down Expand Up @@ -2438,6 +2466,10 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":

if subcmd == "html":
from graphify.export import to_html as _to_html
if export_viz_mode is not None and export_viz_mode.strip().lower() not in _VIZ_MODES:
print(f"error: --viz must be one of {', '.join(_VIZ_MODES)} (got {export_viz_mode!r})",
file=sys.stderr)
sys.exit(1)
if no_viz:
html_target = out_dir / "graph.html"
if html_target.exists():
Expand All @@ -2448,7 +2480,8 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
# path so the oversized graph still renders a usable artifact.
_effective_node_limit = 5000 if _over_cap else node_limit
_to_html(G, communities, str(out_dir / "graph.html"),
community_labels=labels or None, node_limit=_effective_node_limit)
community_labels=labels or None, node_limit=_effective_node_limit,
mode=export_viz_mode)
if G.number_of_nodes() <= _effective_node_limit:
print(f"graph.html written - open in any browser, no server needed")
if _over_cap:
Expand Down
76 changes: 62 additions & 14 deletions graphify/exporters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@

MAX_NODES_FOR_VIZ = 5_000

#: Renderers `to_html` can dispatch to. "2d" is the long-standing vis.js canvas
#: view; "3d" is the WebGL view in graphify.exporters.html3d.
VIZ_MODES = ("2d", "3d")

def _resolve_viz_mode(mode: str | None) -> str:
"""Pick the renderer: explicit argument > GRAPHIFY_VIZ_MODE > "2d".

Defaults to 2d so existing callers keep their byte-identical output; an
unknown value falls back to 2d rather than raising, matching how
_viz_node_limit() treats a malformed env var.
"""
import os
raw = mode if mode is not None else os.environ.get("GRAPHIFY_VIZ_MODE", "")
candidate = (raw or "").strip().lower()
return candidate if candidate in VIZ_MODES else "2d"

def _viz_node_limit() -> int:
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.

Expand Down Expand Up @@ -330,19 +346,25 @@ def to_html(
member_counts: dict[int, int] | None = None,
node_limit: int | None = None,
learning_overlay: dict | None = None,
mode: str | None = None,
) -> None:
"""Generate an interactive vis.js HTML visualization of the graph.
"""Generate an interactive HTML visualization of the graph.

Features: node size by degree, click-to-inspect panel, search box,
community filter, physics clustering by community, confidence-styled edges.
Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.

`mode` selects the renderer — "2d" (vis.js canvas, the default) or "3d"
(WebGL via 3d-force-graph). Both are fed the identical node/edge/legend
model built below, so the two views always describe the same graph.

If member_counts is provided (aggregated community view), node sizes are
based on community member counts rather than graph degree.

If node_limit is set and the graph exceeds it, automatically builds an
aggregated community-level meta-graph instead of raising ValueError.
"""
mode = _resolve_viz_mode(mode)
limit = node_limit if node_limit is not None else _viz_node_limit()
if G.number_of_nodes() > limit:
if node_limit is not None:
Expand Down Expand Up @@ -392,7 +414,7 @@ def to_html(
})
meta.graph["hyperedges"] = remapped
to_html(meta, meta_communities, output_path,
community_labels=community_labels, member_counts=mc)
community_labels=community_labels, member_counts=mc, mode=mode)
print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)")
print("Tip: run with --obsidian for full node-level detail.")
return
Expand Down Expand Up @@ -511,18 +533,46 @@ def to_html(
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})

# Escape </script> sequences so embedded JSON cannot break out of the script tag
def _js_safe(obj) -> str:
return json.dumps(obj).replace("</", "<\\/")
# The renderer-agnostic view model. Both the 2d and the 3d renderer consume
# exactly this dict, so a node styled one way in vis.js is the same node,
# with the same fields, in the WebGL view.
model = {
"nodes": vis_nodes,
"edges": vis_edges,
"legend": legend_data,
"hyperedges": getattr(G, "graph", {}).get("hyperedges", []),
"title": _html.escape(sanitize_label(str(output_path))),
"stats": f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities",
}

if mode == "3d":
from graphify.exporters.html3d import render as _render_force3d
html = _render_force3d(model)
else:
html = _render_visjs(model)

Path(output_path).write_text(html, encoding="utf-8") # nosec

nodes_json = _js_safe(vis_nodes)
edges_json = _js_safe(vis_edges)
legend_json = _js_safe(legend_data)
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
title = _html.escape(sanitize_label(str(output_path)))
stats = f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"

html = f"""<!DOCTYPE html>
def js_safe(obj) -> str:
"""Serialize `obj` for embedding in a <script> block.

Escaping `</` keeps a node label containing a literal `</script>` from
closing the tag early and turning graph data into markup.
"""
return json.dumps(obj).replace("</", "<\\/")


def _render_visjs(model: dict) -> str:
"""Render the view model as the vis.js 2D canvas viewer."""
nodes_json = js_safe(model["nodes"])
edges_json = js_safe(model["edges"])
legend_json = js_safe(model["legend"])
hyperedges_json = js_safe(model["hyperedges"])
title = model["title"]
stats = model["stats"]

return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
Expand Down Expand Up @@ -556,5 +606,3 @@ def _js_safe(obj) -> str:
{_hyperedge_script(hyperedges_json)}
</body>
</html>"""

Path(output_path).write_text(html, encoding="utf-8") # nosec
Loading