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) |
| `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 |
| `callflow_html.py` | `write_callflow_html(...)` | graphify-out files → Mermaid architecture/call-flow HTML |
| `ingest.py` | `ingest(url, ...)` | URL → file saved to corpus dir |
| `cache.py` | `check_semantic_cache / save_semantic_cache` | files → (cached, uncached) split |
| `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
```
For a readable architecture page with Mermaid call-flow diagrams, run:
```bash
graphify export callflow-html
```
---
## 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 . --no-viz # skip the HTML, just the report + JSON
/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 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_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 remove myrepo # remove a project from the global graph
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 path print path to the global graph file")
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 uninstall remove git hooks")
print(" hook status check if git hooks are installed")
@@ -1923,9 +1924,11 @@ def main() -> None:
elif cmd == "export":
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(" 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(" wiki [--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
args = sys.argv[3:]
graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
graph_path_explicit = False
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"
node_limit = 5000
no_viz = False
@@ -1952,9 +1966,45 @@ def main() -> None:
while i < len(args):
a = args[i]
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):
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):
node_limit = int(args[i + 1]); i += 2
elif a == "--no-viz":
@@ -1967,13 +2017,51 @@ def main() -> None:
neo4j_user = args[i + 1]; i += 2
elif a == "--password" and i + 1 < len(args):
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:
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():
print(f"error: graph not found: {graph_path}. Run /graphify <path> first.", file=sys.stderr)
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 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