feat: add callflow HTML export with Mermaid architecture diagrams

This commit is contained in:
Porun
2026-05-09 23:26:01 +01:00
committed by GitHub
parent 8c00287e84
commit db66b8727b
5 changed files with 2288 additions and 3 deletions
+1
View File
@@ -21,6 +21,7 @@ Each stage is a single function in its own module. They communicate through plai
| `analyze.py` | `analyze(G)` | graph → analysis dict (god nodes, surprises, questions) | | `analyze.py` | `analyze(G)` | graph → analysis dict (god nodes, surprises, questions) |
| `report.py` | `render_report(G, analysis)` | graph + analysis → GRAPH_REPORT.md string | | `report.py` | `render_report(G, analysis)` | graph + analysis → GRAPH_REPORT.md string |
| `export.py` | `export(G, out_dir, ...)` | graph → Obsidian vault, graph.json, graph.html, graph.svg | | `export.py` | `export(G, out_dir, ...)` | graph → Obsidian vault, graph.json, graph.html, graph.svg |
| `callflow_html.py` | `write_callflow_html(...)` | graphify-out files → Mermaid architecture/call-flow HTML |
| `ingest.py` | `ingest(url, ...)` | URL → file saved to corpus dir | | `ingest.py` | `ingest(url, ...)` | URL → file saved to corpus dir |
| `cache.py` | `check_semantic_cache / save_semantic_cache` | files → (cached, uncached) split | | `cache.py` | `check_semantic_cache / save_semantic_cache` | files → (cached, uncached) split |
| `security.py` | validation helpers | URL / path / label → validated or raises | | `security.py` | validation helpers | URL / path / label → validated or raises |
+12
View File
@@ -39,6 +39,12 @@ graphify-out/
└── graph.json the full graph — query it anytime without re-reading your files └── graph.json the full graph — query it anytime without re-reading your files
``` ```
For a readable architecture page with Mermaid call-flow diagrams, run:
```bash
graphify export callflow-html
```
--- ---
## Install ## Install
@@ -164,6 +170,7 @@ You can also set `GRAPHIFY_GOOGLE_WORKSPACE=1`. Graphify exports shortcuts into
/graphify . --cluster-only # rerun clustering without re-extracting /graphify . --cluster-only # rerun clustering without re-extracting
/graphify . --no-viz # skip the HTML, just the report + JSON /graphify . --no-viz # skip the HTML, just the report + JSON
/graphify . --wiki # build a markdown wiki from the graph /graphify . --wiki # build a markdown wiki from the graph
graphify export callflow-html # architecture/call-flow HTML from graphify-out/
/graphify query "what connects auth to the database?" /graphify query "what connects auth to the database?"
/graphify path "UserService" "DatabasePool" /graphify path "UserService" "DatabasePool"
@@ -314,6 +321,11 @@ graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous en
graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora
graphify export callflow-html # graphify-out/<project>-callflow.html
graphify export callflow-html --max-sections 8 # cap generated architecture sections
graphify export callflow-html --output docs/arch.html
graphify export callflow-html ./some-repo/graphify-out
graphify global add graphify-out/graph.json myrepo # register a project graph into ~/.graphify/global.json graphify global add graphify-out/graph.json myrepo # register a project graph into ~/.graphify/global.json
graphify global remove myrepo # remove a project from the global graph graphify global remove myrepo # remove a project from the global graph
graphify global list # show all registered repos + node/edge counts graphify global list # show all registered repos + node/edge counts
+91 -3
View File
@@ -1185,6 +1185,7 @@ def main() -> None:
print(" global list list repos in the global graph") print(" global list list repos in the global graph")
print(" global path print path to the global graph file") print(" global path print path to the global graph file")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" export callflow-html emit Mermaid-based architecture/call-flow HTML")
print(" hook install install post-commit/post-checkout git hooks (all platforms)") print(" hook install install post-commit/post-checkout git hooks (all platforms)")
print(" hook uninstall remove git hooks") print(" hook uninstall remove git hooks")
print(" hook status check if git hooks are installed") print(" hook status check if git hooks are installed")
@@ -1923,9 +1924,11 @@ def main() -> None:
elif cmd == "export": elif cmd == "export":
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd not in ("html", "obsidian", "wiki", "svg", "graphml", "neo4j"): if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j"):
print("Usage: graphify export <format>", file=sys.stderr) 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]", 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) print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr)
print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr) print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr)
print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr) print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr)
@@ -1937,7 +1940,18 @@ def main() -> None:
# Parse shared args # Parse shared args
args = sys.argv[3:] args = sys.argv[3:]
graph_path = Path(_GRAPHIFY_OUT) / "graph.json" graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
graph_path_explicit = False
labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json" labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json"
labels_path_explicit = False
report_path = Path(_GRAPHIFY_OUT) / "GRAPH_REPORT.md"
report_path_explicit = False
sections_path: Path | None = None
callflow_output: Path | None = None
callflow_lang = "auto"
callflow_max_sections = 15
callflow_diagram_scale = 1.0
callflow_max_diagram_nodes = 18
callflow_max_diagram_edges = 24
analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json" analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json"
node_limit = 5000 node_limit = 5000
no_viz = False no_viz = False
@@ -1952,9 +1966,45 @@ def main() -> None:
while i < len(args): while i < len(args):
a = args[i] a = args[i]
if a == "--graph" and i + 1 < len(args): if a == "--graph" and i + 1 < len(args):
graph_path = Path(args[i + 1]); i += 2 graph_path = Path(args[i + 1])
graph_path_explicit = True
i += 2
elif a == "--labels" and i + 1 < len(args): elif a == "--labels" and i + 1 < len(args):
labels_path = Path(args[i + 1]); i += 2 labels_path = Path(args[i + 1])
labels_path_explicit = True
i += 2
elif a == "--report" and i + 1 < len(args):
report_path = Path(args[i + 1])
report_path_explicit = True
i += 2
elif a == "--sections" and i + 1 < len(args):
sections_path = Path(args[i + 1]); i += 2
elif a == "--output" and i + 1 < len(args):
callflow_output = Path(args[i + 1]).expanduser()
if not callflow_output.is_absolute():
callflow_output = Path.cwd() / callflow_output
i += 2
elif a == "--lang" and i + 1 < len(args):
callflow_lang = args[i + 1]; i += 2
elif a == "--max-sections" and i + 1 < len(args):
callflow_max_sections = int(args[i + 1]); i += 2
elif a == "--diagram-scale" and i + 1 < len(args):
callflow_diagram_scale = float(args[i + 1]); i += 2
elif a == "--max-diagram-nodes" and i + 1 < len(args):
callflow_max_diagram_nodes = int(args[i + 1]); i += 2
elif a == "--max-diagram-edges" and i + 1 < len(args):
callflow_max_diagram_edges = int(args[i + 1]); i += 2
elif a in ("-h", "--help") and subcmd == "callflow-html":
print("Usage: graphify export callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH]")
print(" --report PATH path to GRAPH_REPORT.md")
print(" --sections PATH JSON section definitions")
print(" --output HTML output path (default graphify-out/<project>-callflow.html)")
print(" --lang LANG auto, zh-CN, en, etc. (default auto)")
print(" --max-sections N maximum auto-derived sections (default 15)")
print(" --diagram-scale N Mermaid diagram scale (default 1.0)")
print(" --max-diagram-nodes N representative nodes per section (default 18)")
print(" --max-diagram-edges N representative edges per section (default 24)")
sys.exit(0)
elif a == "--node-limit" and i + 1 < len(args): elif a == "--node-limit" and i + 1 < len(args):
node_limit = int(args[i + 1]); i += 2 node_limit = int(args[i + 1]); i += 2
elif a == "--no-viz": elif a == "--no-viz":
@@ -1967,13 +2017,51 @@ def main() -> None:
neo4j_user = args[i + 1]; i += 2 neo4j_user = args[i + 1]; i += 2
elif a == "--password" and i + 1 < len(args): elif a == "--password" and i + 1 < len(args):
neo4j_password = args[i + 1]; i += 2 neo4j_password = args[i + 1]; i += 2
elif subcmd == "callflow-html" and not a.startswith("-") and not graph_path_explicit:
candidate = Path(a)
if candidate.name == "graph.json" or candidate.suffix.lower() == ".json":
graph_path = candidate
elif (candidate / "graph.json").exists():
graph_path = candidate / "graph.json"
else:
graph_path = candidate / _GRAPHIFY_OUT / "graph.json"
graph_path_explicit = True
i += 1
else: else:
i += 1 i += 1
graph_path = graph_path.expanduser()
if graph_path_explicit:
graph_out_dir = graph_path.parent
if not labels_path_explicit:
labels_path = graph_out_dir / ".graphify_labels.json"
if not report_path_explicit:
report_path = graph_out_dir / "GRAPH_REPORT.md"
labels_path = labels_path.expanduser()
report_path = report_path.expanduser()
if not graph_path.exists(): if not graph_path.exists():
print(f"error: graph not found: {graph_path}. Run /graphify <path> first.", file=sys.stderr) print(f"error: graph not found: {graph_path}. Run /graphify <path> first.", file=sys.stderr)
sys.exit(1) sys.exit(1)
if subcmd == "callflow-html":
from graphify.callflow_html import write_callflow_html as _write_callflow_html
out = _write_callflow_html(
graph=graph_path,
report=report_path,
labels=labels_path,
sections=sections_path,
output=callflow_output,
lang=callflow_lang,
max_sections=callflow_max_sections,
diagram_scale=callflow_diagram_scale,
max_diagram_nodes=callflow_max_diagram_nodes,
max_diagram_edges=callflow_max_diagram_edges,
verbose=True,
)
print(f"callflow HTML written - open in any browser: {out}")
sys.exit(0)
from networkx.readwrite import json_graph as _jg from networkx.readwrite import json_graph as _jg
from graphify.build import build_from_json as _bfj from graphify.build import build_from_json as _bfj
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
import json
import subprocess
import sys
from pathlib import Path
from graphify.callflow_html import derive_sections_from_communities, write_callflow_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": "export", "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", "confidence_score": 1.0},
{"source": "api", "target": "export", "relation": "uses", "confidence": "EXTRACTED", "confidence_score": 1.0},
{"source": "export", "target": "evil", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
],
"hyperedges": [],
"built_at_commit": "abcdef123456",
}
(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",
)
(out / "GRAPH_REPORT.md").write_text(
"\n".join(
[
"# Graph Report - sample",
"",
"## Summary",
"- 3 nodes · 2 edges · 1 communities detected",
"",
"## God Nodes (most connected - your core abstractions)",
"1. `Transformer` - 2 edges",
]
),
encoding="utf-8",
)
return out
def test_write_callflow_html_creates_file_and_uses_report(tmp_path):
out = _make_graphify_out(tmp_path)
html_path = write_callflow_html(
tmp_path,
output="graphify-out/callflow.html",
max_sections=4,
)
assert html_path == out / "callflow.html"
content = html_path.read_text(encoding="utf-8")
assert "mermaid" in content
assert "Graph Report Highlights" in content
assert "Transformer" in content
assert "ApiClient" in content
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in content
assert "<script>alert(1)</script>" not in content
def test_export_callflow_html_cli_creates_file(tmp_path):
_make_graphify_out(tmp_path)
result = subprocess.run(
[
sys.executable,
"-m",
"graphify",
"export",
"callflow-html",
"--output",
"graphify-out/from-cli.html",
"--max-sections",
"4",
],
cwd=tmp_path,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
html_path = tmp_path / "graphify-out" / "from-cli.html"
assert html_path.exists()
assert "callflow HTML written" in result.stdout
def test_export_callflow_html_cli_accepts_positional_graph_path(tmp_path):
_make_graphify_out(tmp_path)
external_out = tmp_path / "GitNexus" / "graphify-out"
external_out.mkdir(parents=True)
graph = {
"directed": False,
"multigraph": False,
"graph": {},
"nodes": [
{"id": "external", "label": "ExternalOnly", "source_file": "src/external.py", "file_type": "code", "community": 0},
{"id": "writer", "label": "write_external()", "source_file": "src/writer.py", "file_type": "code", "community": 1},
],
"links": [
{"source": "external", "target": "writer", "relation": "calls", "confidence": "EXTRACTED", "confidence_score": 1.0},
],
"hyperedges": [],
}
(external_out / "graph.json").write_text(json.dumps(graph), encoding="utf-8")
(external_out / ".graphify_labels.json").write_text(json.dumps({"0": "External Runtime", "1": "External Export"}), encoding="utf-8")
(external_out / "GRAPH_REPORT.md").write_text(
"\n".join(
[
"# Graph Report - external",
"",
"## Summary",
"- 2 nodes · 1 edges · 2 communities detected",
"",
"## God Nodes (most connected - your core abstractions)",
"1. `ExternalGod` - 1 edges",
]
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
"-m",
"graphify",
"export",
"callflow-html",
str(external_out / "graph.json"),
"--output",
"positional.html",
"--max-sections",
"4",
],
cwd=tmp_path,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
html = (tmp_path / "positional.html").read_text(encoding="utf-8")
assert "ExternalOnly" in html
assert "ExternalGod" in html
assert "ApiClient" not in html
assert "Transformer" not in html
def test_derive_sections_groups_by_architecture_keywords():
nodes = [
{"id": "extract_py", "label": "extract_python", "source_file": "graphify/extract.py", "community": 0},
{"id": "extract_js", "label": "extract_js", "source_file": "graphify/extract.py", "community": 0},
{"id": "to_html", "label": "to_html", "source_file": "graphify/export.py", "community": 1},
{"id": "test_html", "label": "test_export_html", "source_file": "tests/test_export.py", "community": 2},
]
sections = derive_sections_from_communities(nodes, {}, "en", 6)
ids = {section["id"] for section in sections}
assert "extract-pipeline" in ids
assert "outputs-docs" in ids
assert "tests-fixtures" in ids