context-mcp-server 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +29 -7
  2. package/codegraph/__pycache__/affected.cpython-313.pyc +0 -0
  3. package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
  4. package/codegraph/__pycache__/callflow_html.cpython-313.pyc +0 -0
  5. package/codegraph/__pycache__/export.cpython-313.pyc +0 -0
  6. package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
  7. package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
  8. package/codegraph/__pycache__/tree_html.cpython-313.pyc +0 -0
  9. package/codegraph/affected.py +233 -0
  10. package/codegraph/cache.py +51 -2
  11. package/codegraph/callflow_html.py +273 -0
  12. package/codegraph/export.py +544 -0
  13. package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
  14. package/codegraph/extractors/ast_extractor.py +143 -16
  15. package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
  16. package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
  17. package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
  18. package/codegraph/graph/__pycache__/symbol_resolution.cpython-313.pyc +0 -0
  19. package/codegraph/graph/builder.py +10 -0
  20. package/codegraph/graph/clustering.py +247 -10
  21. package/codegraph/graph/query.py +56 -0
  22. package/codegraph/graph/symbol_resolution.py +112 -0
  23. package/codegraph/report.py +53 -0
  24. package/codegraph/server.py +99 -10
  25. package/codegraph/tree_html.py +241 -0
  26. package/package.json +2 -2
  27. package/pyproject.toml +4 -1
  28. package/src/cli.js +277 -86
  29. package/src/server.js +7 -1
  30. package/src/templates/antigravity/GEMINI.md +96 -0
  31. package/src/templates/antigravity/hooks/context-mcp-post-tool-use.js +62 -0
  32. package/src/templates/antigravity/workflows/context-resume.md +20 -0
  33. package/src/templates/antigravity/workflows/graph-build.md +23 -0
  34. package/src/templates/antigravity/workflows/save-context.md +29 -0
  35. package/src/templates/{CLAUDE.md → claude/CLAUDE.md} +3 -0
  36. package/src/templates/claude/commands/graph-build.md +9 -0
  37. package/src/templates/claude/commands/save-context.md +19 -0
  38. package/src/templates/claude/hooks/context-mcp-post-tool-use.js +59 -0
  39. package/src/templates/claude/hooks/context-mcp-pre-tool-use.js +26 -0
  40. package/src/templates/{skills → claude/skills}/SKILL.md +3 -0
  41. package/src/templates/codex/AGENTS.md +107 -0
  42. package/src/templates/codex/hooks/context-mcp-post-tool-use.js +46 -0
  43. package/src/templates/codex/hooks/context-mcp-pre-tool-use.js +23 -0
  44. package/src/templates/codex/prompts/context-resume.md +15 -0
  45. package/src/templates/codex/prompts/graph-build.md +14 -0
  46. package/src/templates/codex/prompts/save-context.md +24 -0
  47. package/src/templates/cursor/commands/context-resume.md +7 -0
  48. package/src/templates/cursor/commands/graph-build.md +7 -0
  49. package/src/templates/cursor/commands/save-context.md +12 -0
  50. package/src/templates/{cursor-rules.mdc → cursor/cursor-rules.mdc} +13 -3
  51. package/src/templates/cursor/hooks/context-mcp-post-tool-use.js +55 -0
  52. package/src/templates/{GEMINI.md → gemini/GEMINI.md} +3 -1
  53. package/src/templates/gemini/commands/context-resume.toml +15 -0
  54. package/src/templates/gemini/commands/graph-build.toml +14 -0
  55. package/src/templates/gemini/commands/save-context.toml +24 -0
  56. package/src/templates/gemini/hooks/context-mcp-after-tool.js +59 -0
  57. package/src/templates/gemini/hooks/context-mcp-before-tool.js +26 -0
  58. package/src/templates/vscode/commands/context-resume.prompt.md +15 -0
  59. package/src/templates/vscode/commands/graph-build.prompt.md +10 -0
  60. package/src/templates/vscode/commands/save-context.prompt.md +16 -0
  61. package/src/templates/vscode/hooks/context-mcp-post-tool-use.js +58 -0
  62. package/src/templates/windsurf/hooks/context-mcp-post-run-command.js +57 -0
  63. package/src/templates/{windsurf-rules.md → windsurf/windsurf-rules.md} +6 -4
  64. package/src/templates/windsurf/workflows/context-resume.md +11 -0
  65. package/src/templates/windsurf/workflows/graph-build.md +11 -0
  66. package/src/templates/windsurf/workflows/save-context.md +18 -0
  67. package/src/tools/codegraph.js +37 -0
  68. package/uv.lock +1100 -3
  69. package/src/templates/AGENTS.md +0 -90
  70. package/src/templates/commands/graph-build.md +0 -5
  71. package/src/templates/commands/save-context.md +0 -12
  72. /package/src/templates/{commands → claude/commands}/context-resume.md +0 -0
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  Persistent memory and codebase knowledge graph for AI coding assistants — delivered as a single MCP server.
13
13
 
14
- One shared context store across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Claude.ai, and ChatGPT. Save context from one AI, pick it up in another.
14
+ One shared context store across Claude Code, Cursor, Gemini CLI, Codex, Windsurf, VS Code Copilot, Antigravity IDE, Claude.ai, and ChatGPT. Save context from one AI, pick it up in another.
15
15
 
16
16
  ---
17
17
 
@@ -77,9 +77,16 @@ ctx install --cursor # Cursor
77
77
  ctx install --vscode # VS Code Copilot
78
78
  ctx install --gemini # Gemini CLI
79
79
  ctx install --codex # Codex CLI
80
- ctx install --windsurf # Windsurf
80
+ ctx install --windsurf # Windsurf
81
+ ctx install --antigravity # Antigravity IDE
81
82
  ```
82
83
 
84
+ For Codex project installs, `ctx install --codex` writes:
85
+
86
+ - `.codex/config.toml` with `[mcp_servers.context-mcp]` MCP configuration.
87
+ - `AGENTS.md` with Context-MCP usage rules for Codex.
88
+ - `.codex/hooks/` pre/post shell hook scripts for project-local Codex sessions.
89
+
83
90
  For web clients (Claude.ai, ChatGPT), start the HTTP server:
84
91
 
85
92
  ```bash
@@ -113,8 +120,9 @@ ctx online # start HTTP server (idempotent)
113
120
  ctx online --restart # force stop + restart
114
121
  ctx settings # view and edit config interactively
115
122
 
116
- # Tools
117
- ctx benchmark # token savings report (memory + graph)
123
+ # Install
124
+ ctx install --initial # install / update Node.js + Python deps
125
+ ctx install --all # write config + rules for all platforms
118
126
  ```
119
127
 
120
128
  ---
@@ -152,18 +160,32 @@ Any file or git operation outside that directory is rejected. Applies to all HTT
152
160
  codegraph_build(path)
153
161
  ```
154
162
 
155
- Parses codebase via tree-sitter AST (16 languages, regex fallback). Extracts functions, classes, imports, call edges. Build metadata saved to `~/.context-mcp/projects/<name>/graph.json`.
163
+ Parses codebase via tree-sitter AST (16 languages, regex fallback). Extracts functions, classes, imports, and call edges. Automatically generates visualizations on every build. Metadata saved to `<project>/codegraph-cache/`.
156
164
 
157
165
  **Step 2 — Query** (instant, forever):
158
166
 
159
167
  ```
168
+ codegraph_arch(path, limit?) → module map: every file, its exports, its imports
160
169
  codegraph_query(path, question?, node?) → structural question OR single-node lookup (or both)
161
- codegraph_path(path, from, to) → shortest path between two concepts
162
170
  codegraph_nodes(path, type) → list all nodes of a type
163
171
  codegraph_report(path) → god nodes, clusters, surprising connections
172
+ codegraph_affected(path, node, depth?) → BFS blast radius — what breaks if you change X?
173
+ ```
174
+
175
+ `codegraph_query` accepts `question` (natural language), `node` (exact/partial name), or both. Use before reading any files.
176
+
177
+ **Step 3 — Visualize** (auto-generated on every build):
178
+
179
+ ```
180
+ codegraph_html(path, formats?) → regenerate visualizations on demand
164
181
  ```
165
182
 
166
- `codegraph_query` accepts `question` (natural language), `node` (exact/partial name for type + file + deps + callers), or both in one call. Use before reading any files.
183
+ Every `codegraph_build` automatically writes to `<project>/codegraph-cache/`:
184
+ - `graph.html` — interactive vis.js force graph (dark theme, search, community toggle)
185
+ - `tree.html` — D3 collapsible file hierarchy
186
+ - `callflow.html` — Mermaid architecture diagrams per community
187
+ - `graph.graphml` — Gephi / yEd export
188
+ - `obsidian/` — per-node `.md` vault with `[[wikilinks]]`
167
189
 
168
190
  ### File & Git Tools
169
191
 
@@ -0,0 +1,233 @@
1
+ """
2
+ affected.py — "what breaks if I change X?" BFS over the knowledge graph.
3
+
4
+ Ported from graphify's affected.py with field name adaptations:
5
+ graphify uses 'label' / 'source_file' / 'source_location'
6
+ context-mcp uses 'name' / 'file' / 'line'
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+ from typing import Iterable
13
+
14
+ import networkx as nx
15
+
16
+
17
+ DEFAULT_AFFECTED_RELATIONS = (
18
+ "calls",
19
+ "references",
20
+ "imports",
21
+ "imports_from",
22
+ "re_exports",
23
+ "inherits",
24
+ "extends",
25
+ "implements",
26
+ "uses",
27
+ "mixes_in",
28
+ "embeds",
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AffectedHit:
34
+ node_id: str
35
+ depth: int
36
+ via_relation: str
37
+
38
+
39
+ # ── Graph loading ─────────────────────────────────────────────────────────────
40
+
41
+ def graph_from_dict(graph_dict: dict) -> nx.DiGraph:
42
+ """Reconstruct nx.DiGraph from our graph.json dict format (edges use 'from'/'to')."""
43
+ G = nx.DiGraph()
44
+ for node in graph_dict.get("nodes", []):
45
+ nid = node.get("id", "")
46
+ if nid:
47
+ G.add_node(nid, **{k: v for k, v in node.items() if k != "id"})
48
+ for edge in graph_dict.get("edges", []):
49
+ src = edge.get("from", "")
50
+ tgt = edge.get("to", "")
51
+ if src and tgt:
52
+ G.add_edge(src, tgt, **{k: v for k, v in edge.items() if k not in ("from", "to")})
53
+ return G
54
+
55
+
56
+ # ── Node helpers ──────────────────────────────────────────────────────────────
57
+
58
+ def _node_label(graph: nx.Graph, node_id: str) -> str:
59
+ data = graph.nodes[node_id]
60
+ return str(data.get("name") or node_id)
61
+
62
+
63
+ def _format_location(data: dict) -> str:
64
+ source_file = data.get("file") or "-"
65
+ line = data.get("line")
66
+ if line:
67
+ return f"{source_file}:{line}"
68
+ return str(source_file)
69
+
70
+
71
+ def _bare_name(label: str) -> str:
72
+ label = label.lower()
73
+ return label[:-2] if label.endswith("()") else label
74
+
75
+
76
+ # ── Seed resolution ───────────────────────────────────────────────────────────
77
+
78
+ def resolve_seed(graph: nx.Graph, query: str) -> str | None:
79
+ """Find the node ID that best matches the query string.
80
+
81
+ Resolution order: exact ID → exact name → bare name (strips "()")
82
+ → exact file path → contains match.
83
+ """
84
+ if query in graph:
85
+ return query
86
+ query_lower = query.lower()
87
+ exact_name_matches = [
88
+ str(node_id)
89
+ for node_id, data in graph.nodes(data=True)
90
+ if str(data.get("name", "")).lower() == query_lower
91
+ ]
92
+ if len(exact_name_matches) == 1:
93
+ return exact_name_matches[0]
94
+
95
+ query_bare = _bare_name(query_lower)
96
+ bare_matches = [
97
+ str(node_id)
98
+ for node_id, data in graph.nodes(data=True)
99
+ if _bare_name(str(data.get("name", ""))) == query_bare
100
+ ]
101
+ if len(bare_matches) == 1:
102
+ return bare_matches[0]
103
+
104
+ exact_file_matches = [
105
+ str(node_id)
106
+ for node_id, data in graph.nodes(data=True)
107
+ if str(data.get("file", "")).lower() == query_lower
108
+ ]
109
+ if len(exact_file_matches) == 1:
110
+ return exact_file_matches[0]
111
+
112
+ contains_matches = [
113
+ str(node_id)
114
+ for node_id, data in graph.nodes(data=True)
115
+ if query_lower in str(data.get("name", "")).lower()
116
+ ]
117
+ if len(contains_matches) == 1:
118
+ return contains_matches[0]
119
+ return None
120
+
121
+
122
+ # ── BFS traversal ─────────────────────────────────────────────────────────────
123
+
124
+ def affected_nodes(
125
+ graph: nx.Graph,
126
+ seed: str,
127
+ *,
128
+ relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
129
+ depth: int = 2,
130
+ ) -> list[AffectedHit]:
131
+ """BFS from seed following incoming edges whose relation is in `relations`.
132
+
133
+ Returns nodes that would be affected by a change to the seed node.
134
+ """
135
+ relation_set = set(relations)
136
+ seen = {seed}
137
+ queue: deque[tuple[str, int]] = deque([(seed, 0)])
138
+ hits: list[AffectedHit] = []
139
+
140
+ while queue:
141
+ current, current_depth = queue.popleft()
142
+ if current_depth >= depth:
143
+ continue
144
+ if hasattr(graph, "in_edges"):
145
+ incoming = graph.in_edges(current, data=True)
146
+ else:
147
+ incoming = (
148
+ (source, target, data)
149
+ for source, target, data in graph.edges(data=True)
150
+ if target == current
151
+ )
152
+ for source, _target, data in incoming:
153
+ relation = str(data.get("relation", ""))
154
+ if relation not in relation_set:
155
+ continue
156
+ source = str(source)
157
+ if source in seen:
158
+ continue
159
+ seen.add(source)
160
+ hits.append(AffectedHit(source, current_depth + 1, relation))
161
+ queue.append((source, current_depth + 1))
162
+
163
+ return hits
164
+
165
+
166
+ # ── Formatted output ──────────────────────────────────────────────────────────
167
+
168
+ def format_affected(
169
+ graph: nx.Graph,
170
+ query: str,
171
+ *,
172
+ relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
173
+ depth: int = 2,
174
+ ) -> str:
175
+ """Return a human-readable summary of nodes affected by changing `query`."""
176
+ relation_list = tuple(relations)
177
+ seed = resolve_seed(graph, query)
178
+ if seed is None:
179
+ return f"No unique node match for '{query}'"
180
+
181
+ hits = affected_nodes(graph, seed, relations=relation_list, depth=depth)
182
+ lines = [
183
+ f"Affected nodes for: {_node_label(graph, seed)}",
184
+ f"Relations tracked: {', '.join(relation_list)}",
185
+ f"Depth: {depth}",
186
+ "",
187
+ ]
188
+ if not hits:
189
+ lines.append("No affected nodes found.")
190
+ return "\n".join(lines)
191
+
192
+ for hit in hits:
193
+ data = graph.nodes[hit.node_id]
194
+ lines.append(
195
+ f" depth={hit.depth} [{hit.via_relation}] "
196
+ f"{_node_label(graph, hit.node_id)} @ {_format_location(data)}"
197
+ )
198
+ return "\n".join(lines)
199
+
200
+
201
+ # ── Convenience entry point ───────────────────────────────────────────────────
202
+
203
+ def run_affected(graph_dict: dict, query: str, depth: int = 2) -> dict:
204
+ """Run affected analysis from a graph.json dict. Returns structured result."""
205
+ G = graph_from_dict(graph_dict)
206
+ seed = resolve_seed(G, query)
207
+ if seed is None:
208
+ return {"query": query, "seed": None, "hits": [], "text": f"No unique node match for '{query}'"}
209
+
210
+ hits = affected_nodes(G, seed, depth=depth)
211
+ seed_data = G.nodes[seed]
212
+
213
+ hit_list = []
214
+ for hit in hits:
215
+ data = G.nodes.get(hit.node_id, {})
216
+ hit_list.append({
217
+ "node_id": hit.node_id,
218
+ "name": data.get("name", hit.node_id),
219
+ "file": data.get("file", ""),
220
+ "line": data.get("line"),
221
+ "depth": hit.depth,
222
+ "via_relation": hit.via_relation,
223
+ })
224
+
225
+ return {
226
+ "query": query,
227
+ "seed": seed,
228
+ "seed_name": seed_data.get("name", seed),
229
+ "seed_file": seed_data.get("file", ""),
230
+ "depth": depth,
231
+ "hits": hit_list,
232
+ "text": format_affected(G, query, depth=depth),
233
+ }
@@ -10,6 +10,7 @@ Format: { "rel/path": { "hash": "...", "nodes": [...], "extracted_at": "..." } }
10
10
 
11
11
  import hashlib
12
12
  import json
13
+ import os
13
14
  from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
@@ -113,6 +114,18 @@ def file_hash(path: str) -> str:
113
114
  return h.hexdigest()
114
115
 
115
116
 
117
+ def stat_hit(path: str, cache_entry: dict) -> bool:
118
+ """Return True if (size, mtime_ns) matches the cached stat — skips SHA-256."""
119
+ cached_stat = cache_entry.get("stat")
120
+ if not cached_stat:
121
+ return False
122
+ try:
123
+ s = os.stat(path)
124
+ return cached_stat == [s.st_size, s.st_mtime_ns]
125
+ except OSError:
126
+ return False
127
+
128
+
116
129
  def get_cached_nodes(cache: dict, rel_path: str, current_hash: str) -> list | None:
117
130
  """Return cached nodes if hash matches, else None."""
118
131
  entry = cache.get(rel_path)
@@ -121,12 +134,48 @@ def get_cached_nodes(cache: dict, rel_path: str, current_hash: str) -> list | No
121
134
  return None
122
135
 
123
136
 
124
- def set_cached_nodes(cache: dict, rel_path: str, file_hash_val: str, nodes: list) -> None:
125
- cache[rel_path] = {
137
+ def get_cached_nodes_fast(cache: dict, rel_path: str, abs_path: str) -> list | None:
138
+ """Return cached nodes using stat fastpath, falling back to SHA-256 check.
139
+
140
+ Avoids reading and hashing large files on incremental rebuilds where
141
+ the file hasn't changed — (size, mtime_ns) is checked first.
142
+ Returns None if the file is new or has changed.
143
+ """
144
+ entry = cache.get(rel_path)
145
+ if not entry:
146
+ return None
147
+ # Fast path: stat match skips SHA-256
148
+ if stat_hit(abs_path, entry):
149
+ return entry.get("nodes", [])
150
+ # Slow path: full hash check
151
+ try:
152
+ h = file_hash(abs_path)
153
+ except OSError:
154
+ return None
155
+ if entry.get("hash") == h:
156
+ # Update stat so next call is fast
157
+ try:
158
+ s = os.stat(abs_path)
159
+ entry["stat"] = [s.st_size, s.st_mtime_ns]
160
+ except OSError:
161
+ pass
162
+ return entry.get("nodes", [])
163
+ return None
164
+
165
+
166
+ def set_cached_nodes(cache: dict, rel_path: str, file_hash_val: str, nodes: list, abs_path: str | None = None) -> None:
167
+ entry: dict = {
126
168
  "hash": file_hash_val,
127
169
  "nodes": nodes,
128
170
  "extracted_at": datetime.now(timezone.utc).isoformat(),
129
171
  }
172
+ if abs_path:
173
+ try:
174
+ s = os.stat(abs_path)
175
+ entry["stat"] = [s.st_size, s.st_mtime_ns]
176
+ except OSError:
177
+ pass
178
+ cache[rel_path] = entry
130
179
 
131
180
 
132
181
  def remove_deleted(cache: dict, existing_rel_paths: set) -> list:
@@ -0,0 +1,273 @@
1
+ """
2
+ callflow_html.py — Mermaid architecture flowcharts from graph.json communities.
3
+
4
+ Generates a dark-themed, self-contained HTML with:
5
+ - Overview architecture diagram (community-level)
6
+ - Per-community flowcharts showing key call/import edges
7
+ - Navigation bar
8
+ - Call detail tables
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections import defaultdict
15
+ from html import escape
16
+ from pathlib import Path
17
+
18
+
19
+ # ── CSS (dark theme) ──────────────────────────────────────────────────────────
20
+
21
+ _CSS = """:root {
22
+ --bg:#0f172a;--surface:#1e293b;--border:#334155;
23
+ --text:#e2e8f0;--muted:#94a3b8;--accent:#38bdf8;
24
+ --warn:#fbbf24;--ok:#34d399;
25
+ }
26
+ *{box-sizing:border-box;margin:0;padding:0;}
27
+ body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;}
28
+ .container{max-width:1200px;margin:0 auto;padding:40px 24px;}
29
+ h1{font-size:2.4rem;margin-bottom:8px;background:linear-gradient(135deg,var(--accent),#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
30
+ h2{font-size:1.7rem;margin:48px 0 16px;padding-bottom:8px;border-bottom:2px solid var(--accent);}
31
+ h3{font-size:1.25rem;margin:32px 0 12px;color:var(--accent);}
32
+ p{margin:8px 0;color:var(--muted);}
33
+ .subtitle{color:var(--muted);font-size:1.1rem;margin-bottom:32px;}
34
+ .mermaid{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;margin:20px 0;overflow-x:auto;}
35
+ .call-table{width:100%;border-collapse:collapse;margin:16px 0;font-size:.92rem;}
36
+ .call-table th{background:#1a2744;color:var(--accent);text-align:left;padding:10px 14px;border:1px solid var(--border);}
37
+ .call-table td{padding:8px 14px;border:1px solid var(--border);vertical-align:top;}
38
+ .call-table tr:nth-child(even){background:rgba(255,255,255,.02);}
39
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px;margin:16px 0;}
40
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px;margin:16px 0;}
41
+ code{font-family:'Fira Code',monospace;background:rgba(255,255,255,.06);padding:1px 6px;border-radius:3px;font-size:.88em;}
42
+ ul{margin:8px 0 8px 24px;color:var(--muted);}
43
+ li{margin:4px 0;}
44
+ a{color:var(--accent);}
45
+ hr{border:none;border-top:1px solid var(--border);margin:40px 0;}
46
+ .nav{position:sticky;top:0;background:var(--bg);z-index:10;padding:12px 0;border-bottom:1px solid var(--border);display:flex;gap:20px;flex-wrap:wrap;font-size:.9rem;}
47
+ .nav a{text-decoration:none;}
48
+ .nav a:hover{text-decoration:underline;}
49
+ """
50
+
51
+
52
+ # ── Mermaid helpers ───────────────────────────────────────────────────────────
53
+
54
+ def _mermaid_id(s: str) -> str:
55
+ """Sanitize string for use as a Mermaid node ID."""
56
+ return re.sub(r"[^a-zA-Z0-9_]", "_", s)[:40] or "node"
57
+
58
+
59
+ def _mermaid_label(s: str) -> str:
60
+ return s.replace('"', "'")[:60]
61
+
62
+
63
+ # ── Overview diagram: community meta-graph ────────────────────────────────────
64
+
65
+ def _overview_diagram(communities: list[dict], edges: list[dict], node_map: dict[str, dict]) -> str:
66
+ """Mermaid flowchart showing cross-community edges."""
67
+ node_community: dict[str, int] = {}
68
+ for c in communities:
69
+ for mid in c.get("members", []):
70
+ node_community[mid] = c["id"]
71
+
72
+ cross_counts: dict[tuple[int, int], int] = defaultdict(int)
73
+ for e in edges:
74
+ src_comm = node_community.get(e.get("from", ""))
75
+ tgt_comm = node_community.get(e.get("to", ""))
76
+ if src_comm is not None and tgt_comm is not None and src_comm != tgt_comm:
77
+ key = (src_comm, tgt_comm)
78
+ cross_counts[key] += 1
79
+
80
+ lines = ["flowchart LR"]
81
+ for c in communities:
82
+ cid = c["id"]
83
+ label = _mermaid_label(c.get("label", f"Community {cid}"))
84
+ n = len(c.get("members", []))
85
+ mid = _mermaid_id(f"c{cid}")
86
+ lines.append(f' {mid}["{label}\\n({n} nodes)"]')
87
+
88
+ top_cross = sorted(cross_counts.items(), key=lambda x: -x[1])[:30]
89
+ for (src_c, tgt_c), cnt in top_cross:
90
+ src_mid = _mermaid_id(f"c{src_c}")
91
+ tgt_mid = _mermaid_id(f"c{tgt_c}")
92
+ lines.append(f" {src_mid} -->|{cnt}| {tgt_mid}")
93
+
94
+ return "\n".join(lines)
95
+
96
+
97
+ # ── Per-community diagram ─────────────────────────────────────────────────────
98
+
99
+ def _community_diagram(community: dict, edges: list[dict], node_map: dict[str, dict], max_nodes: int = 15) -> str:
100
+ """Mermaid flowchart for a single community's internal call edges."""
101
+ members = set(community.get("members", []))
102
+ member_nodes = [node_map[m] for m in members if m in node_map]
103
+
104
+ # Pick top nodes by internal degree
105
+ internal_degree: dict[str, int] = defaultdict(int)
106
+ intra_edges = []
107
+ for e in edges:
108
+ src, tgt = e.get("from", ""), e.get("to", "")
109
+ if src in members and tgt in members:
110
+ intra_edges.append(e)
111
+ internal_degree[src] += 1
112
+ internal_degree[tgt] += 1
113
+
114
+ top_nodes = sorted(members, key=lambda n: -internal_degree.get(n, 0))[:max_nodes]
115
+ top_set = set(top_nodes)
116
+
117
+ lines = ["flowchart LR"]
118
+ for nid in top_nodes:
119
+ n = node_map.get(nid, {})
120
+ name = _mermaid_label(n.get("name", nid))
121
+ ntype = n.get("type", "")
122
+ shape_open, shape_close = "[", "]"
123
+ if ntype == "class":
124
+ shape_open, shape_close = "([", "])"
125
+ elif ntype in ("function", "method"):
126
+ shape_open, shape_close = "(", ")"
127
+ mid = _mermaid_id(nid)
128
+ lines.append(f' {mid}{shape_open}"{name}"{shape_close}')
129
+
130
+ shown_edges: set[tuple[str, str]] = set()
131
+ for e in intra_edges:
132
+ src, tgt = e.get("from", ""), e.get("to", "")
133
+ if src not in top_set or tgt not in top_set:
134
+ continue
135
+ key = (_mermaid_id(src), _mermaid_id(tgt))
136
+ if key in shown_edges:
137
+ continue
138
+ shown_edges.add(key)
139
+ rel = e.get("relation", "")
140
+ arrow = f" -->|{rel}| " if rel else " --> "
141
+ lines.append(f" {_mermaid_id(src)}{arrow}{_mermaid_id(tgt)}")
142
+ if len(shown_edges) >= 30:
143
+ break
144
+
145
+ if not shown_edges:
146
+ # Fallback: show node list if no intra edges
147
+ lines = ["flowchart LR"]
148
+ for nid in top_nodes[:8]:
149
+ n = node_map.get(nid, {})
150
+ name = _mermaid_label(n.get("name", nid))
151
+ lines.append(f' {_mermaid_id(nid)}["{name}"]')
152
+
153
+ return "\n".join(lines)
154
+
155
+
156
+ # ── Call detail table ─────────────────────────────────────────────────────────
157
+
158
+ def _call_table(community: dict, edges: list[dict], node_map: dict[str, dict]) -> str:
159
+ members = set(community.get("members", []))
160
+ cross_in = []
161
+ for e in edges:
162
+ src, tgt = e.get("from", ""), e.get("to", "")
163
+ if tgt in members and src not in members:
164
+ src_n = node_map.get(src, {})
165
+ tgt_n = node_map.get(tgt, {})
166
+ cross_in.append((src_n.get("name", src), tgt_n.get("name", tgt), e.get("relation", "→"), e.get("confidence", "")))
167
+
168
+ if not cross_in:
169
+ return ""
170
+
171
+ rows = "\n".join(
172
+ f"<tr><td><code>{escape(caller)}</code></td><td><code>{escape(callee)}</code></td><td>{escape(rel)}</td><td>{escape(conf)}</td></tr>"
173
+ for caller, callee, rel, conf in cross_in[:20]
174
+ )
175
+ return f"""<table class="call-table">
176
+ <thead><tr><th>Caller</th><th>Callee</th><th>Relation</th><th>Confidence</th></tr></thead>
177
+ <tbody>{rows}</tbody>
178
+ </table>"""
179
+
180
+
181
+ # ── Main entry point ──────────────────────────────────────────────────────────
182
+
183
+ def to_html(graph_dict: dict, output_path: str) -> str:
184
+ """Generate Mermaid call-flow HTML. Returns path written."""
185
+ nodes = graph_dict.get("nodes", [])
186
+ edges = graph_dict.get("edges", [])
187
+ communities = graph_dict.get("communities", [])
188
+ god_nodes = graph_dict.get("god_nodes", [])
189
+ generated = graph_dict.get("generated_at", "")
190
+
191
+ node_map = {n["id"]: n for n in nodes}
192
+
193
+ # Navigation
194
+ nav_links = '<nav class="nav"><a href="#overview">Overview</a>' + "".join(
195
+ '<a href="#comm-{}">{}</a>'.format(c["id"], escape(c.get("label", f"C{c['id']}")))
196
+ for c in communities[:20]
197
+ ) + "</nav>"
198
+
199
+ # Overview section
200
+ overview_mermaid = _overview_diagram(communities, edges, node_map)
201
+ god_names = [node_map.get(nid, {}).get("name", nid) for nid in god_nodes[:5]]
202
+ god_html = (
203
+ "<div class='card'><h3>God Nodes</h3><ul>" +
204
+ "".join(f"<li><code>{escape(n)}</code></li>" for n in god_names) +
205
+ "</ul></div>"
206
+ ) if god_names else ""
207
+
208
+ overview_section = f"""<section id="overview">
209
+ <h2>Architecture Overview</h2>
210
+ <p>{len(nodes)} nodes · {len(edges)} edges · {len(communities)} communities · generated {escape(generated)}</p>
211
+ {god_html}
212
+ <div class="mermaid">
213
+ {escape(overview_mermaid)}
214
+ </div>
215
+ </section>"""
216
+
217
+ # Per-community sections
218
+ comm_sections = []
219
+ for c in communities:
220
+ cid = c["id"]
221
+ label = c.get("label", f"Community {cid}")
222
+ members = c.get("members", [])
223
+ diagram = _community_diagram(c, edges, node_map)
224
+ table = _call_table(c, edges, node_map)
225
+
226
+ # Top files in this community
227
+ file_counts: dict[str, int] = defaultdict(int)
228
+ for mid in members:
229
+ f = node_map.get(mid, {}).get("file", "")
230
+ if f:
231
+ file_counts[f] += 1
232
+ top_files = sorted(file_counts.items(), key=lambda x: -x[1])[:5]
233
+ files_html = "".join(f"<li><code>{escape(fp)}</code> ({cnt} nodes)</li>" for fp, cnt in top_files)
234
+
235
+ comm_sections.append(f"""<section id="comm-{cid}">
236
+ <h2>{escape(label)}</h2>
237
+ <p>{len(members)} nodes</p>
238
+ <div class="grid">
239
+ <div class="card"><h3>Key Files</h3><ul>{files_html}</ul></div>
240
+ </div>
241
+ <div class="mermaid">
242
+ {escape(diagram)}
243
+ </div>
244
+ {"<h3>Incoming Cross-Community Calls</h3>" + table if table else ""}
245
+ <hr>
246
+ </section>""")
247
+
248
+ body = "\n".join(comm_sections)
249
+
250
+ html = f"""<!DOCTYPE html>
251
+ <html lang="en">
252
+ <head>
253
+ <meta charset="utf-8">
254
+ <title>CodeGraph Call Flow</title>
255
+ <style>{_CSS}</style>
256
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
257
+ <script>mermaid.initialize({{startOnLoad:true,theme:'dark',flowchart:{{curve:'basis'}}}});</script>
258
+ </head>
259
+ <body>
260
+ {nav_links}
261
+ <div class="container">
262
+ <h1>Call Flow Architecture</h1>
263
+ <p class="subtitle">Auto-generated from knowledge graph</p>
264
+ {overview_section}
265
+ {body}
266
+ </div>
267
+ </body>
268
+ </html>"""
269
+
270
+ out = Path(output_path)
271
+ out.parent.mkdir(parents=True, exist_ok=True)
272
+ out.write_text(html, encoding="utf-8")
273
+ return str(out)