mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-27 16:06:11 +00:00
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) |
|
||||
| `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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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