diff --git a/CHANGELOG.md b/CHANGELOG.md index 86dc8f12..ac05a152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased — `graphify tree` subcommand + +- **New:** `graphify tree` — emits a self-contained D3 v7 collapsible-tree HTML + view of `graph.json`. Expand-all / collapse-all / reset-view buttons; + multi-line `wrapText` labels with separately-coloured name + count; + depth-based colour palette; click-to-toggle subtree; hover inspector + showing top-K outbound edges per symbol. +- Hierarchy is built from `source_file` longest-common-prefix; symbols are + grouped by their containing module so the tree mirrors the on-disk layout. +- Configuration: `--graph PATH`, `--output HTML`, `--root PATH`, + `--max-children N` (default 200), `--top-k-edges N` (default 12), + `--label NAME`. +- Implementation: `graphify/tree_html.py` (575 LOC, no external runtime + dependencies — D3 v7 is loaded from cdn.jsdelivr.net). + ## 0.4.23 (2026-04-18) - Fix: stale skill version warning persists after running `graphify install` when multiple platforms were previously installed — `graphify install` now refreshes `.graphify_version` in all other known skill directories so the warning clears across the board (#178) diff --git a/graphify/__main__.py b/graphify/__main__.py index be14274f..37fe4915 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1004,6 +1004,13 @@ def main() -> None: print(" --nodes N1 N2 ... source node labels cited in the answer") print(" --memory-dir DIR memory directory (default: graphify-out/memory)") print(" check-update check needs_update flag and notify if semantic re-extraction is pending (cron-safe)") + print(" tree emit a D3 v7 collapsible-tree HTML for graph.json") + print(" --graph PATH path to graph.json (default graphify-out/graph.json)") + print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") + print(" --root PATH filesystem root for the hierarchy") + 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(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") print(" hook install install post-commit/post-checkout git hooks (all platforms)") print(" hook uninstall remove git hooks") @@ -1422,6 +1429,62 @@ def main() -> None: from graphify.watch import check_update check_update(Path(sys.argv[2]).resolve()) sys.exit(0) + elif cmd == "tree": + # Emit a D3 v7 collapsible-tree HTML view of graph.json: + # expand-all / collapse-all / reset-view buttons, multi-line + # wrapText labels with separately-coloured name + count, + # depth-based palette, click-to-toggle subtree, hover inspector + # showing top-K outbound edges per symbol. + from typing import Optional as _Opt + from graphify.tree_html import write_tree_html, DEFAULT_MAX_CHILDREN + graph_path = Path("graphify-out/graph.json") + output_path: "_Opt[Path]" = None + root: "_Opt[str]" = None + max_children = DEFAULT_MAX_CHILDREN + top_k_edges = 0 + 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 == "--root" and i_arg + 1 < len(args): + root = args[i_arg + 1]; i_arg += 2 + elif a == "--max-children" and i_arg + 1 < len(args): + max_children = int(args[i_arg + 1]); i_arg += 2 + elif a == "--top-k-edges" and i_arg + 1 < len(args): + top_k_edges = int(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 tree [--graph PATH] [--output HTML]") + print(" --graph PATH path to graph.json (default graphify-out/graph.json)") + print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") + print(" --root PATH filesystem root (default: longest common dir of all source_files)") + print(" --max-children N cap visible children per node (default 200)") + print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)") + 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) + if output_path is None: + output_path = graph_path.parent / "GRAPH_TREE.html" + out = write_tree_html( + graph_path=graph_path, output_path=output_path, + root=root, max_children=max_children, + top_k_edges=top_k_edges, 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-graphs": # graphify merge-graphs graph1.json graph2.json ... --out merged.json args = sys.argv[2:] diff --git a/graphify/tree_html.py b/graphify/tree_html.py new file mode 100644 index 00000000..00cdfcb6 --- /dev/null +++ b/graphify/tree_html.py @@ -0,0 +1,576 @@ +"""tree_html — emit a D3 v7 collapsible-tree HTML view of a graph. + +A self-contained printable / browseable tree-of-modules view +intended to complement the existing force-directed ``graph.html``. +Key visual elements: + + * Expand-all / collapse-all / reset-view buttons. + * Multi-line label wrapping (``wrapText``) with separately-coloured + name and descendant-count. + * Depth-based colour palette (top-level directories get distinct + accent colours; deeper levels follow a level-specific palette). + * Click-to-toggle subtree. + +Tree-data shape: + + { + "name": "", + "total_count": , + "children": [ { "name", "total_count", "children": [...] }, ... ] + } + +CLI: ``graphify tree [--graph PATH] [--output HTML] [--root PATH] +[--max-children N] [--label NAME]``. + +Implementation notes: + - ``total_count`` is the descendant leaf count, so collapsed nodes + can show ``(Total Count: 95)`` without needing the children loaded. + - ``--max-children`` (default 200) caps how many children render + under any one node; a synthetic ``(+N more)`` leaf appears when the + cap fires so very wide directories stay usable. + - The first-level palette is auto-populated from the live top-level + directories so each gets a stable accent colour. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +DEFAULT_MAX_CHILDREN = 200 + + +# ── Tree builder (filesystem hierarchy → JSON) ────────────────── + + +def _common_root(paths: List[str]) -> str: + if not paths: + return "" + parts = [Path(p).parts for p in paths if p] + if not parts: + return "" + common = parts[0] + for p in parts[1:]: + i = 0 + while i < len(common) and i < len(p) and common[i] == p[i]: + i += 1 + common = common[:i] + return str(Path(*common)) if common else "" + + +def _make_truncation_leaf(extra: int) -> Dict[str, Any]: + return {"name": f"(+{extra} more)", "total_count": extra, "children": []} + + +def build_tree( + graph: Dict[str, Any], + *, + root: Optional[str] = None, + max_children: int = DEFAULT_MAX_CHILDREN, + project_label: Optional[str] = None, +) -> Dict[str, Any]: + """Build a ``{name, total_count, children}`` hierarchy. + + Each leaf is either a code symbol (class / top-level function) or + a synthetic "(+N more)" placeholder for truncated wide directories. + Each interior node carries ``total_count = sum of leaf counts``. + """ + nodes: List[Dict[str, Any]] = list(graph.get("nodes", [])) + file_nodes = [n for n in nodes if n.get("source_file")] + if not file_nodes: + return {"name": "(empty graph)", "total_count": 0, "children": []} + + if root is None: + root = _common_root([n["source_file"] for n in file_nodes]) + root_path = Path(root) + + by_file: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for n in file_nodes: + by_file[n["source_file"]].append(n) + + # Build dir tree. + dir_index: Dict[str, Dict[str, Any]] = {} + label_root = project_label or root_path.name or root or "/" + root_node: Dict[str, Any] = { + "name": label_root, "total_count": 0, "children": [], + } + dir_index[str(root_path)] = root_node + + def _ensure_dir(abs_path: Path) -> Dict[str, Any]: + key = str(abs_path) + if key in dir_index: + return dir_index[key] + if abs_path == abs_path.parent: + return root_node + parent = (_ensure_dir(abs_path.parent) + if abs_path.parent != abs_path else root_node) + node = {"name": abs_path.name, "total_count": 0, "children": []} + dir_index[key] = node + parent["children"].append(node) + return node + + for src_file, syms in sorted(by_file.items()): + src_path = Path(src_file) + try: + rel = src_path.relative_to(root_path) + parent_path = (root_path / rel).parent + except ValueError: + parent_path = root_path + parent_dir = _ensure_dir(parent_path) + + # File node — children are the symbols. + sym_children: List[Dict[str, Any]] = [] + for n in syms: + label = n.get("label", n.get("id", "?")) + # Skip the redundant file-name node graphify emits. + if label == src_path.name and n.get("file_type") == "code": + continue + sym_children.append({ + "name": label, + "total_count": 1, + "children": [], + }) + # Sort: code symbols first by name, then anything else. + sym_children.sort(key=lambda c: ( + c["name"].startswith("_"), + c["name"].lower(), + )) + if len(sym_children) > max_children: + extra = len(sym_children) - max_children + sym_children = sym_children[:max_children] + [ + _make_truncation_leaf(extra), + ] + file_node = { + "name": src_path.name, + "total_count": len(sym_children) or 1, + "children": sym_children, + } + parent_dir["children"].append(file_node) + + # Sort each dir's children + propagate total_count up. + def _finalise(d: Dict[str, Any]) -> int: + kids = d.get("children") or [] + kids.sort(key=lambda c: ( + 0 if (c.get("children") and len(c["children"]) > 0) else 1, + c["name"].lower(), + )) + if not kids: + return d.get("total_count") or 1 + n = 0 + for c in kids: + n += _finalise(c) + d["total_count"] = n or 1 + return d["total_count"] + + _finalise(root_node) + return root_node + + +# ── HTML emitter (single-data-blob substitution) ────────────────── + + +# We emit a Python f-string with literal CSS/JS braces escaped as {{ }}. +_HTML_TEMPLATE = r""" + + + + {title} + + + +

{header}

+
+ + + +
+
+ +
+ + + + + +""" + + +def emit_html( + tree: Dict[str, Any], + *, + title: str, + header: str, + svg_width: int = 6000, + svg_height: int = 8000, +) -> str: + return _HTML_TEMPLATE.format( + title=title, + header=header, + svg_width=svg_width, + svg_height=svg_height, + data_json=json.dumps(tree, ensure_ascii=False, separators=(",", ":")), + ) + + +def write_tree_html( + graph_path: Path, + output_path: Path, + *, + root: Optional[str] = None, + max_children: int = DEFAULT_MAX_CHILDREN, + project_label: Optional[str] = None, + # kept for CLI compatibility with the older signature; ignored now + top_k_edges: int = 0, +) -> Path: + graph = json.loads(graph_path.read_text(encoding="utf-8")) + tree = build_tree(graph, root=root, max_children=max_children, + project_label=project_label) + title = f"{tree['name']} — graphify tree viewer" + header = f"{tree['name']} — Knowledge Graph" + html = emit_html(tree, title=title, header=header) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(html, encoding="utf-8") + return output_path