feat: add callflow HTML export with Mermaid architecture diagrams
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 "<script>alert(1)</script>" 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
|
||||||
Reference in New Issue
Block a user