codecortex 0.10.0__tar.gz → 0.11.0__tar.gz
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.
- {codecortex-0.10.0/src/codecortex.egg-info → codecortex-0.11.0}/PKG-INFO +2 -1
- {codecortex-0.10.0 → codecortex-0.11.0}/README.md +1 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/pyproject.toml +4 -0
- {codecortex-0.10.0 → codecortex-0.11.0/src/codecortex.egg-info}/PKG-INFO +2 -1
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codecortex.egg-info/SOURCES.txt +4 -0
- codecortex-0.11.0/src/codeintel/__init__.py +1 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/__main__.py +32 -0
- codecortex-0.11.0/src/codeintel/grapher.py +115 -0
- codecortex-0.11.0/src/codeintel/viewer/__init__.py +1 -0
- codecortex-0.11.0/src/codeintel/viewer/graph_template.html +490 -0
- codecortex-0.11.0/tests/test_grapher.py +127 -0
- codecortex-0.10.0/src/codeintel/__init__.py +0 -1
- {codecortex-0.10.0 → codecortex-0.11.0}/LICENSE +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/setup.cfg +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codecortex.egg-info/entry_points.txt +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codecortex.egg-info/requires.txt +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codecortex.egg-info/top_level.txt +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/auth.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/cache.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/config.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/doctor.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/gateway.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/http_server.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/indexer.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/injector.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/installer.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/logconfig.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/mapper.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/metrics.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/onboarding.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/policy.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/provider.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/providers/__init__.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/providers/graph.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/providers/lsp.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/providers/none.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/providers/semantic.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/reindexer.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/reset.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/searcher.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/semantic_db.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/server.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/src/codeintel/term.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_cache.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_chunking.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_config.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_doctor.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_e2e.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_enterprise.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_gateway.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_graph_provider.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_graph_real.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_graph_stdin.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_hardening.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_http_auth.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_http_server.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_installer.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_integration.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_lsp_provider.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_lsp_real.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_mapper.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_mcp_server.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_model_dimension.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_never_raise.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_onboarding.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_rbac.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_reindexer.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_rerank.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_reset.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_semantic_provider.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_term.py +0 -0
- {codecortex-0.10.0 → codecortex-0.11.0}/tests/test_treesitter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -196,6 +196,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
196
196
|
| `codeintel status [project_root]` | Show engine availability and index age |
|
|
197
197
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
198
198
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
199
|
+
| `codeintel graph [project_root] [--html] [--out FILE] [--limit N]` | Emit the call graph as `{nodes,edges}` JSON, or `--html` a self-contained interactive viewer — see [docs/graph-viewer.md](docs/graph-viewer.md) |
|
|
199
200
|
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
200
201
|
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
201
202
|
|
|
@@ -162,6 +162,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
162
162
|
| `codeintel status [project_root]` | Show engine availability and index age |
|
|
163
163
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
164
164
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
165
|
+
| `codeintel graph [project_root] [--html] [--out FILE] [--limit N]` | Emit the call graph as `{nodes,edges}` JSON, or `--html` a self-contained interactive viewer — see [docs/graph-viewer.md](docs/graph-viewer.md) |
|
|
165
166
|
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
166
167
|
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
167
168
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecortex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
|
|
5
5
|
Author: Shammai Hamilton
|
|
6
6
|
License-Expression: MIT
|
|
@@ -196,6 +196,7 @@ Full system docs live in [`docs/`](docs/) — start with the index:
|
|
|
196
196
|
| `codeintel status [project_root]` | Show engine availability and index age |
|
|
197
197
|
| `codeintel doctor [project_root] [--deep] [--json]` | Diagnose per-engine health + repo index status, with a fix for each gap |
|
|
198
198
|
| `codeintel map [project_root]` | Generate the `CODE_INTEL.md` orientation file |
|
|
199
|
+
| `codeintel graph [project_root] [--html] [--out FILE] [--limit N]` | Emit the call graph as `{nodes,edges}` JSON, or `--html` a self-contained interactive viewer — see [docs/graph-viewer.md](docs/graph-viewer.md) |
|
|
199
200
|
| `codeintel reset [project_root] [--all] [--yes]` | Clear the semantic index (this repo, or `--all`) to recover from a corrupt/stale DB |
|
|
200
201
|
| `codeintel gen-token` | Print a secure random bearer token (for `serve-http` / RBAC `auth.toml`) |
|
|
201
202
|
|
|
@@ -14,6 +14,7 @@ src/codeintel/cache.py
|
|
|
14
14
|
src/codeintel/config.py
|
|
15
15
|
src/codeintel/doctor.py
|
|
16
16
|
src/codeintel/gateway.py
|
|
17
|
+
src/codeintel/grapher.py
|
|
17
18
|
src/codeintel/http_server.py
|
|
18
19
|
src/codeintel/indexer.py
|
|
19
20
|
src/codeintel/injector.py
|
|
@@ -35,6 +36,8 @@ src/codeintel/providers/graph.py
|
|
|
35
36
|
src/codeintel/providers/lsp.py
|
|
36
37
|
src/codeintel/providers/none.py
|
|
37
38
|
src/codeintel/providers/semantic.py
|
|
39
|
+
src/codeintel/viewer/__init__.py
|
|
40
|
+
src/codeintel/viewer/graph_template.html
|
|
38
41
|
tests/test_cache.py
|
|
39
42
|
tests/test_chunking.py
|
|
40
43
|
tests/test_config.py
|
|
@@ -45,6 +48,7 @@ tests/test_gateway.py
|
|
|
45
48
|
tests/test_graph_provider.py
|
|
46
49
|
tests/test_graph_real.py
|
|
47
50
|
tests/test_graph_stdin.py
|
|
51
|
+
tests/test_grapher.py
|
|
48
52
|
tests/test_hardening.py
|
|
49
53
|
tests/test_http_auth.py
|
|
50
54
|
tests/test_http_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.11.0"
|
|
@@ -68,6 +68,13 @@ def main() -> None:
|
|
|
68
68
|
map_parser.add_argument("--inject", action="store_true", help="Inject reference block into CLAUDE.md/AGENTS.md")
|
|
69
69
|
map_parser.add_argument("--budget", type=int, default=32768, help="Byte budget for CODE_INTEL.md (default: 32768)")
|
|
70
70
|
|
|
71
|
+
# graph subcommand — interactive call-graph view (HTML) or the raw {nodes,edges} JSON
|
|
72
|
+
graph_parser = subparsers.add_parser("graph", help="Build an interactive call-graph view (--html) or emit the graph as JSON — works on any indexed repo")
|
|
73
|
+
graph_parser.add_argument("project_root", nargs="?", default=None, help="Project root (default: cwd)")
|
|
74
|
+
graph_parser.add_argument("--html", action="store_true", help="Write a self-contained interactive HTML viewer (default: print JSON)")
|
|
75
|
+
graph_parser.add_argument("--out", default=None, help="Output path for --html (default: codeintel-graph.html)")
|
|
76
|
+
graph_parser.add_argument("--limit", type=int, default=220, help="Max call edges to include (default: 220)")
|
|
77
|
+
|
|
71
78
|
# doctor subcommand
|
|
72
79
|
doctor_parser = subparsers.add_parser("doctor", parents=[color_parent], help="Diagnose engine health + index status for a repo")
|
|
73
80
|
doctor_parser.add_argument("project_root", nargs="?", default=None, help="Project root (default: cwd)")
|
|
@@ -190,6 +197,31 @@ def main() -> None:
|
|
|
190
197
|
print(f"map failed: {exc}")
|
|
191
198
|
sys.exit(0)
|
|
192
199
|
|
|
200
|
+
elif args.command == "graph":
|
|
201
|
+
from codeintel import grapher
|
|
202
|
+
project_root = args.project_root or os.getcwd()
|
|
203
|
+
try:
|
|
204
|
+
payload = grapher.build_graph_payload(project_root, limit=args.limit)
|
|
205
|
+
n, e = len(payload.get("nodes", [])), len(payload.get("edges", []))
|
|
206
|
+
if args.html:
|
|
207
|
+
out = args.out or "codeintel-graph.html"
|
|
208
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
209
|
+
f.write(grapher.render_html(payload))
|
|
210
|
+
print(f"Wrote {out} ({n} nodes, {e} edges) — open it in any browser")
|
|
211
|
+
if not n:
|
|
212
|
+
reason = payload.get("reason")
|
|
213
|
+
if reason:
|
|
214
|
+
print(f" (graph empty: {reason} — run `codeintel doctor` to check the "
|
|
215
|
+
f"graph backend + index)")
|
|
216
|
+
else:
|
|
217
|
+
print(" (no internal call edges found for this repo)")
|
|
218
|
+
else:
|
|
219
|
+
import json as _json
|
|
220
|
+
print(_json.dumps(payload, indent=2))
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
print(f"graph failed: {exc}")
|
|
223
|
+
sys.exit(0)
|
|
224
|
+
|
|
193
225
|
elif args.command == "query":
|
|
194
226
|
try:
|
|
195
227
|
import time
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Build an interactive view of a project's call graph from the graph engine.
|
|
2
|
+
|
|
3
|
+
Headless-first, in board-differ's data→renderer spirit: `build_graph_payload` returns a plain
|
|
4
|
+
``{project, nodes, edges}`` dict (the machine-readable `--format json` shape); `render_html` wraps
|
|
5
|
+
that payload in the self-contained interactive viewer (``viewer/graph_template.html``). The engine
|
|
6
|
+
produces the *data*; the template is the *renderer*. Both never raise.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from codeintel.provider import log_swallowed
|
|
15
|
+
|
|
16
|
+
_EMPTY = {"project": "", "engine": "graph", "op": "callgraph", "nodes": [], "edges": []}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_graph_payload(project_root: Any, *, limit: int = 220, timeout_ms: int = 8000) -> dict:
|
|
20
|
+
"""Query the graph engine for a project's internal call graph → ``{project, nodes, edges}``.
|
|
21
|
+
|
|
22
|
+
Nodes are the symbols that participate in an internal call edge, enriched with the same
|
|
23
|
+
complexity metrics the ``hotspots`` op surfaces; edges are ``CALLS``/``USAGE`` relations.
|
|
24
|
+
Builtins/synthetic nodes are excluded. Returns an empty payload with a ``reason`` on any
|
|
25
|
+
failure — never raises (mirrors the safe-null contract)."""
|
|
26
|
+
try:
|
|
27
|
+
try:
|
|
28
|
+
limit = max(1, min(2000, int(limit)))
|
|
29
|
+
except Exception:
|
|
30
|
+
limit = 220
|
|
31
|
+
try:
|
|
32
|
+
timeout_ms = int(timeout_ms)
|
|
33
|
+
except Exception:
|
|
34
|
+
timeout_ms = 8000
|
|
35
|
+
from codeintel.providers.graph import GraphProvider
|
|
36
|
+
p = GraphProvider()
|
|
37
|
+
if not getattr(p, "available", False):
|
|
38
|
+
return {**_EMPTY, "reason": "engine-unavailable"}
|
|
39
|
+
project = p._resolve_project(str(project_root or ""))
|
|
40
|
+
if not project:
|
|
41
|
+
return {**_EMPTY, "reason": "project-not-indexed"}
|
|
42
|
+
|
|
43
|
+
# per-symbol metrics (same search_graph the `hotspots` op uses)
|
|
44
|
+
metrics: dict[str, dict] = {}
|
|
45
|
+
for r in (p._search_symbols({"label": "Function", "min_degree": 1, "limit": 600}, project, timeout_ms) or []):
|
|
46
|
+
qn = str(r.get("qualified_name") or "")
|
|
47
|
+
if qn:
|
|
48
|
+
metrics[qn] = r
|
|
49
|
+
|
|
50
|
+
# internal call edges — fetch generously and filter builtins/synthetic ('<...>') nodes
|
|
51
|
+
# CLIENT-SIDE (robust across Cypher dialects; mirrors how the hotspots op drops them).
|
|
52
|
+
cypher = (
|
|
53
|
+
"MATCH (a)-[c:CALLS|USAGE]->(b) "
|
|
54
|
+
"RETURN a.qualified_name, a.name, a.file_path, b.qualified_name, b.name, b.file_path, type(c) "
|
|
55
|
+
"LIMIT " + str(min(max(limit * 3, 400), 1500))
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _synth(fp: str) -> bool:
|
|
59
|
+
return (not fp) or fp.startswith("<")
|
|
60
|
+
|
|
61
|
+
nodes_by_id: dict[str, dict] = {}
|
|
62
|
+
edges: list[dict] = []
|
|
63
|
+
seen: set = set()
|
|
64
|
+
|
|
65
|
+
def _add(qn: str, name: str, fp: str) -> None:
|
|
66
|
+
if qn and qn not in nodes_by_id:
|
|
67
|
+
m = metrics.get(qn, {})
|
|
68
|
+
nodes_by_id[qn] = {
|
|
69
|
+
"id": qn, "label": name or qn.rsplit(".", 1)[-1], "file": str(fp or ""),
|
|
70
|
+
"complexity": m.get("complexity") or 0, "cognitive": m.get("cognitive") or 0,
|
|
71
|
+
"in_degree": m.get("in_degree") or 0, "out_degree": m.get("out_degree") or 0,
|
|
72
|
+
"lines": m.get("lines") or 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for e in (p._query_rows(cypher, project, timeout_ms) or []):
|
|
76
|
+
if len(edges) >= limit:
|
|
77
|
+
break
|
|
78
|
+
aq, bq = str(e.get("a.qualified_name") or ""), str(e.get("b.qualified_name") or "")
|
|
79
|
+
af, bf = str(e.get("a.file_path") or ""), str(e.get("b.file_path") or "")
|
|
80
|
+
if not aq or not bq or aq == bq or _synth(af) or _synth(bf):
|
|
81
|
+
continue
|
|
82
|
+
_add(aq, str(e.get("a.name") or ""), af)
|
|
83
|
+
_add(bq, str(e.get("b.name") or ""), bf)
|
|
84
|
+
if (aq, bq) not in seen:
|
|
85
|
+
seen.add((aq, bq))
|
|
86
|
+
edges.append({"from": aq, "to": bq, "type": str(e.get("type(c)") or "")})
|
|
87
|
+
|
|
88
|
+
nodes = list(nodes_by_id.values())
|
|
89
|
+
return {"project": project, "engine": "graph", "op": "callgraph", "nodes": nodes, "edges": edges}
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
log_swallowed("grapher.build_graph_payload", exc)
|
|
92
|
+
return {**_EMPTY, "reason": "error"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _template_path() -> str:
|
|
96
|
+
return os.path.join(os.path.dirname(__file__), "viewer", "graph_template.html")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_html(payload: dict) -> str:
|
|
100
|
+
"""Wrap a graph payload in the self-contained interactive viewer template. Never raises."""
|
|
101
|
+
try:
|
|
102
|
+
# default=str → serialization can't raise on an unexpected value; "</" escape keeps the
|
|
103
|
+
# JSON inside the <script> element (the template's viewer JS re-emits every string via
|
|
104
|
+
# textContent, so there is no innerHTML/XSS sink downstream).
|
|
105
|
+
data = json.dumps(payload, default=str).replace("</", "<\\/")
|
|
106
|
+
with open(_template_path(), encoding="utf-8") as f:
|
|
107
|
+
return f.read().replace("__DATA__", data)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
log_swallowed("grapher.render_html", exc)
|
|
110
|
+
try:
|
|
111
|
+
body = json.dumps(payload, indent=2, default=str).replace("&", "&").replace("<", "<")
|
|
112
|
+
except Exception:
|
|
113
|
+
body = "(graph payload unavailable)"
|
|
114
|
+
return ("<!doctype html><meta charset=utf-8><title>codeintel graph</title>"
|
|
115
|
+
"<pre style='font:13px ui-monospace,monospace;padding:16px'>" + body + "</pre>")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static assets for the self-contained interactive graph viewer (rendered by codeintel.grapher)."""
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
<meta charset="utf-8">
|
|
2
|
+
<title>codeintel Call Graph</title>
|
|
3
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<style>
|
|
5
|
+
/* ---- tokens: light on bare :root, dark via media (system) + [data-theme] (explicit) ---- */
|
|
6
|
+
:root{
|
|
7
|
+
--bg:#e9edf1; --canvas:#f6f8fb; --panel:#ffffff; --panel-2:#f2f5f8;
|
|
8
|
+
--ink:#182130; --muted:#586472; --faint:#8b96a4;
|
|
9
|
+
--line:#d7dee6; --edge:#aab6c3; --edge-strong:#7d8b99;
|
|
10
|
+
--accent:#0c8ba6; --accent-ink:#ffffff; --accent-soft:rgba(12,139,166,.12);
|
|
11
|
+
--shadow:0 6px 24px rgba(24,33,48,.10),0 1px 3px rgba(24,33,48,.08);
|
|
12
|
+
--node-stroke:rgba(255,255,255,.85);
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
|
|
15
|
+
--bg:#0b0f14; --canvas:#0e131a; --panel:#141b24; --panel-2:#101720;
|
|
16
|
+
--ink:#e0e8f1; --muted:#8695a5; --faint:#5d6b7b;
|
|
17
|
+
--line:#212a35; --edge:#38434f; --edge-strong:#5a6a79;
|
|
18
|
+
--accent:#34c3dc; --accent-ink:#05242b; --accent-soft:rgba(52,195,220,.14);
|
|
19
|
+
--shadow:0 8px 30px rgba(0,0,0,.5); --node-stroke:rgba(10,15,20,.9);
|
|
20
|
+
}}
|
|
21
|
+
:root[data-theme="dark"]{
|
|
22
|
+
--bg:#0b0f14; --canvas:#0e131a; --panel:#141b24; --panel-2:#101720;
|
|
23
|
+
--ink:#e0e8f1; --muted:#8695a5; --faint:#5d6b7b;
|
|
24
|
+
--line:#212a35; --edge:#38434f; --edge-strong:#5a6a79;
|
|
25
|
+
--accent:#34c3dc; --accent-ink:#05242b; --accent-soft:rgba(52,195,220,.14);
|
|
26
|
+
--shadow:0 8px 30px rgba(0,0,0,.5); --node-stroke:rgba(10,15,20,.9);
|
|
27
|
+
}
|
|
28
|
+
*{box-sizing:border-box}
|
|
29
|
+
html,body{height:100%}
|
|
30
|
+
body{
|
|
31
|
+
margin:0; background:var(--bg); color:var(--ink);
|
|
32
|
+
font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
|
33
|
+
-webkit-font-smoothing:antialiased; overflow:hidden;
|
|
34
|
+
}
|
|
35
|
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
36
|
+
#app{display:flex; flex-direction:column; height:100vh}
|
|
37
|
+
|
|
38
|
+
/* ---- header / toolbar ---- */
|
|
39
|
+
header{
|
|
40
|
+
display:flex; align-items:center; gap:16px; flex-wrap:wrap;
|
|
41
|
+
padding:12px 18px; background:var(--panel); border-bottom:1px solid var(--line);
|
|
42
|
+
z-index:5;
|
|
43
|
+
}
|
|
44
|
+
.brand{display:flex; flex-direction:column; gap:1px; margin-right:4px}
|
|
45
|
+
.brand h1{margin:0; font-size:15px; font-weight:650; letter-spacing:-.01em}
|
|
46
|
+
.brand h1 b{color:var(--accent)}
|
|
47
|
+
.brand .sub{font-size:11.5px; color:var(--muted); font-variant-numeric:tabular-nums}
|
|
48
|
+
.spacer{flex:1}
|
|
49
|
+
.search{
|
|
50
|
+
display:flex; align-items:center; gap:7px; background:var(--panel-2);
|
|
51
|
+
border:1px solid var(--line); border-radius:8px; padding:6px 10px; min-width:180px;
|
|
52
|
+
}
|
|
53
|
+
.search svg{width:14px;height:14px;color:var(--faint);flex:none}
|
|
54
|
+
.search input{border:0;background:transparent;color:var(--ink);font-size:12.5px;width:100%;outline:none}
|
|
55
|
+
.tools{display:flex; gap:6px; flex-wrap:wrap}
|
|
56
|
+
button.tool{
|
|
57
|
+
font:inherit; font-size:12px; font-weight:550; cursor:pointer;
|
|
58
|
+
color:var(--ink); background:var(--panel-2); border:1px solid var(--line);
|
|
59
|
+
padding:7px 11px; border-radius:8px; display:inline-flex; align-items:center; gap:6px;
|
|
60
|
+
transition:border-color .12s,background .12s,transform .06s;
|
|
61
|
+
}
|
|
62
|
+
button.tool:hover{border-color:var(--accent); color:var(--accent)}
|
|
63
|
+
button.tool:active{transform:translateY(1px)}
|
|
64
|
+
button.tool:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
|
65
|
+
button.tool svg{width:14px;height:14px}
|
|
66
|
+
.tool.ghost{background:transparent}
|
|
67
|
+
.seg{display:inline-flex; background:var(--panel-2); border:1px solid var(--line); border-radius:9px; padding:2px; gap:2px}
|
|
68
|
+
.seg-btn{font:inherit; font-size:12px; font-weight:550; cursor:pointer; color:var(--muted); background:transparent; border:0; padding:6px 11px; border-radius:7px; transition:color .12s,background .12s}
|
|
69
|
+
.seg-btn:hover{color:var(--ink)}
|
|
70
|
+
.seg-btn.active{background:var(--panel); color:var(--accent); box-shadow:0 1px 2px rgba(0,0,0,.10)}
|
|
71
|
+
.seg-btn:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
|
|
72
|
+
|
|
73
|
+
/* ---- stage ---- */
|
|
74
|
+
main{position:relative; flex:1; overflow:hidden; background:
|
|
75
|
+
radial-gradient(120% 120% at 50% 0%, color-mix(in srgb,var(--canvas) 100%, var(--accent) 3%), var(--canvas) 60%);}
|
|
76
|
+
svg#graph{position:absolute; inset:0; width:100%; height:100%; display:block; cursor:grab; touch-action:none}
|
|
77
|
+
svg#graph.panning{cursor:grabbing}
|
|
78
|
+
.edge{stroke:var(--edge); stroke-width:1; fill:none; transition:stroke .15s,opacity .15s}
|
|
79
|
+
.edge.usage{stroke-dasharray:2 3}
|
|
80
|
+
.node circle{stroke:var(--node-stroke); stroke-width:1.5; cursor:pointer; transition:stroke .12s}
|
|
81
|
+
.node text{
|
|
82
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:9.5px;
|
|
83
|
+
fill:var(--ink); paint-order:stroke; stroke:var(--canvas); stroke-width:2.6px; stroke-linejoin:round;
|
|
84
|
+
pointer-events:none; opacity:.9;
|
|
85
|
+
}
|
|
86
|
+
.node.dim{opacity:.16}
|
|
87
|
+
.edge.dim{opacity:.05}
|
|
88
|
+
.node.sel circle{stroke:var(--accent); stroke-width:3}
|
|
89
|
+
.edge.lit{stroke:var(--accent); stroke-width:1.8; opacity:.95}
|
|
90
|
+
|
|
91
|
+
/* ---- overlay panels ---- */
|
|
92
|
+
.panel{
|
|
93
|
+
position:absolute; background:color-mix(in srgb,var(--panel) 88%, transparent);
|
|
94
|
+
backdrop-filter:blur(8px); border:1px solid var(--line); border-radius:12px;
|
|
95
|
+
box-shadow:var(--shadow); z-index:4;
|
|
96
|
+
}
|
|
97
|
+
.legend{left:16px; bottom:16px; padding:12px 14px; max-width:210px}
|
|
98
|
+
.legend h3{margin:0 0 8px; font-size:10.5px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); font-weight:650}
|
|
99
|
+
.legend .row{display:flex; align-items:center; gap:8px; font-size:11.5px; margin:4px 0; color:var(--muted)}
|
|
100
|
+
.legend .sw{width:11px;height:11px;border-radius:50%;flex:none;border:1px solid var(--node-stroke)}
|
|
101
|
+
.legend .mods{display:flex; flex-direction:column; gap:2px; margin-top:8px; max-height:180px; overflow:auto}
|
|
102
|
+
.legend .size-key{display:flex;align-items:flex-end;gap:6px;margin-top:2px}
|
|
103
|
+
.legend .size-key .dot{background:var(--faint);border-radius:50%;border:1px solid var(--node-stroke)}
|
|
104
|
+
|
|
105
|
+
.detail{right:16px; top:16px; width:288px; padding:0; overflow:hidden; display:none}
|
|
106
|
+
.detail.show{display:block}
|
|
107
|
+
.detail .dhead{padding:13px 15px 11px; border-bottom:1px solid var(--line); background:var(--accent-soft)}
|
|
108
|
+
.detail .dhead .sym{font-size:13.5px; font-weight:600; word-break:break-word; line-height:1.3}
|
|
109
|
+
.detail .dhead .path{font-size:11px; color:var(--muted); margin-top:3px; word-break:break-all}
|
|
110
|
+
.detail .metrics{display:grid; grid-template-columns:1fr 1fr; gap:1px; background:var(--line)}
|
|
111
|
+
.detail .m{background:var(--panel); padding:10px 13px}
|
|
112
|
+
.detail .m .k{font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--faint)}
|
|
113
|
+
.detail .m .v{font-size:17px; font-weight:600; font-variant-numeric:tabular-nums; margin-top:2px}
|
|
114
|
+
.detail .m .v.hi{color:var(--accent)}
|
|
115
|
+
.detail .rel{padding:11px 15px 14px; font-size:11.5px; color:var(--muted)}
|
|
116
|
+
.detail .rel b{color:var(--ink); font-weight:600}
|
|
117
|
+
.detail .rel .chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
|
|
118
|
+
.detail .rel .chip{font-family:ui-monospace,monospace; font-size:10.5px; background:var(--panel-2); border:1px solid var(--line); border-radius:5px; padding:2px 6px; color:var(--ink); cursor:pointer}
|
|
119
|
+
.detail .rel .chip:hover{border-color:var(--accent);color:var(--accent)}
|
|
120
|
+
.detail .close{position:absolute; right:9px; top:9px; width:24px;height:24px; border:0; background:transparent; color:var(--muted); cursor:pointer; border-radius:6px; font-size:16px; line-height:1}
|
|
121
|
+
.detail .close:hover{background:var(--panel-2);color:var(--ink)}
|
|
122
|
+
|
|
123
|
+
.hint{position:absolute; right:16px; bottom:16px; font-size:11px; color:var(--faint); text-align:right; z-index:3; pointer-events:none; line-height:1.7}
|
|
124
|
+
.hint kbd{font-family:ui-monospace,monospace; background:var(--panel-2); border:1px solid var(--line); border-bottom-width:2px; border-radius:4px; padding:0 4px; color:var(--muted); font-size:10px}
|
|
125
|
+
|
|
126
|
+
/* ---- export modal ---- */
|
|
127
|
+
.scrim{position:fixed; inset:0; background:rgba(8,12,17,.55); z-index:20; display:none; align-items:center; justify-content:center; padding:24px}
|
|
128
|
+
.scrim.show{display:flex}
|
|
129
|
+
.modal{width:min(680px,100%); max-height:86vh; display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--line); border-radius:14px; box-shadow:var(--shadow); overflow:hidden}
|
|
130
|
+
.modal header{background:var(--panel); border-bottom:1px solid var(--line); padding:14px 18px}
|
|
131
|
+
.modal header h2{margin:0; font-size:14px; font-weight:600}
|
|
132
|
+
.modal header .mono{font-size:11.5px; color:var(--muted); margin-top:2px}
|
|
133
|
+
.modal .body{padding:14px 18px; overflow:auto}
|
|
134
|
+
.modal textarea{width:100%; height:300px; resize:vertical; border:1px solid var(--line); border-radius:9px; background:var(--panel-2); color:var(--ink); padding:12px; font-family:ui-monospace,monospace; font-size:11.5px; line-height:1.55; outline:none}
|
|
135
|
+
.modal textarea:focus{border-color:var(--accent)}
|
|
136
|
+
.modal .foot{display:flex; gap:8px; justify-content:flex-end; padding:12px 18px; border-top:1px solid var(--line)}
|
|
137
|
+
.btn-primary{background:var(--accent); color:var(--accent-ink); border:1px solid var(--accent); font-weight:600}
|
|
138
|
+
.btn-primary:hover{filter:brightness(1.06); color:var(--accent-ink)}
|
|
139
|
+
.toast{position:fixed; left:50%; bottom:26px; transform:translateX(-50%) translateY(20px); background:var(--ink); color:var(--bg); font-size:12.5px; font-weight:550; padding:9px 15px; border-radius:9px; opacity:0; transition:opacity .2s,transform .2s; z-index:30; pointer-events:none}
|
|
140
|
+
.toast.show{opacity:1; transform:translateX(-50%) translateY(0)}
|
|
141
|
+
|
|
142
|
+
@media (max-width:640px){
|
|
143
|
+
.detail{width:auto; left:16px; right:16px; top:auto; bottom:16px}
|
|
144
|
+
.legend{display:none}
|
|
145
|
+
.hint{display:none}
|
|
146
|
+
}
|
|
147
|
+
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
148
|
+
</style>
|
|
149
|
+
|
|
150
|
+
<div id="app">
|
|
151
|
+
<header>
|
|
152
|
+
<div class="brand">
|
|
153
|
+
<h1><b>code</b>intel · call graph</h1>
|
|
154
|
+
<div class="sub" id="sub">— nodes · — edges</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="search">
|
|
157
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
158
|
+
<input id="search" type="text" placeholder="Filter symbols…" spellcheck="false" autocomplete="off">
|
|
159
|
+
</div>
|
|
160
|
+
<div class="seg" id="layouts" role="group" aria-label="Layout">
|
|
161
|
+
<button class="seg-btn active" data-layout="force" title="Force-directed — organic clusters">Force</button>
|
|
162
|
+
<button class="seg-btn" data-layout="radial" title="Radial — rings by distance from the busiest hub">Radial</button>
|
|
163
|
+
<button class="seg-btn" data-layout="layered" title="Layered — top-down call flow">Layered</button>
|
|
164
|
+
<button class="seg-btn" data-layout="cluster" title="Modules — grouped by file/module">Modules</button>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="spacer"></div>
|
|
167
|
+
<div class="tools">
|
|
168
|
+
<button class="tool ghost" id="relayout" title="Re-run the layout"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>Relayout</button>
|
|
169
|
+
<button class="tool ghost" id="fit" title="Fit graph to view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3m0 8v3a2 2 0 0 0 2 2h3m8 0h3a2 2 0 0 0 2-2v-3m0-8V5a2 2 0 0 0-2-2h-3"/></svg>Fit</button>
|
|
170
|
+
<button class="tool" data-export="json"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg>JSON</button>
|
|
171
|
+
<button class="tool" data-export="md"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 15V9l3 3 3-3v6M17 9v6M20 12l-3 3"/></svg>Markdown</button>
|
|
172
|
+
<button class="tool" data-export="svg"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>SVG</button>
|
|
173
|
+
<button class="tool" data-export="png"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="1.6"/><path d="m21 15-5-5L5 21"/></svg>PNG</button>
|
|
174
|
+
</div>
|
|
175
|
+
</header>
|
|
176
|
+
|
|
177
|
+
<main>
|
|
178
|
+
<svg id="graph"><g id="viewport"><g id="edges"></g><g id="nodes"></g></g></svg>
|
|
179
|
+
|
|
180
|
+
<div class="panel legend">
|
|
181
|
+
<h3>Modules</h3>
|
|
182
|
+
<div class="mods" id="mods"></div>
|
|
183
|
+
<h3 style="margin-top:12px">Node size = complexity</h3>
|
|
184
|
+
<div class="size-key">
|
|
185
|
+
<span class="dot" style="width:8px;height:8px"></span><span class="dot" style="width:14px;height:14px"></span><span class="dot" style="width:22px;height:22px"></span>
|
|
186
|
+
<span style="font-size:11px;color:var(--muted);margin-left:2px">low → high</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div class="panel detail" id="detail">
|
|
191
|
+
<button class="close" id="dclose" aria-label="Close">×</button>
|
|
192
|
+
<div class="dhead"><div class="sym mono" id="d-sym"></div><div class="path mono" id="d-path"></div></div>
|
|
193
|
+
<div class="metrics">
|
|
194
|
+
<div class="m"><div class="k">Complexity</div><div class="v hi" id="d-cx">—</div></div>
|
|
195
|
+
<div class="m"><div class="k">Cognitive</div><div class="v" id="d-cog">—</div></div>
|
|
196
|
+
<div class="m"><div class="k">Callers (in)</div><div class="v" id="d-in">—</div></div>
|
|
197
|
+
<div class="m"><div class="k">Calls (out)</div><div class="v" id="d-out">—</div></div>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="rel" id="d-rel"></div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div class="hint">
|
|
203
|
+
drag <kbd>node</kbd> to pin · drag <kbd>space</kbd> to pan · <kbd>scroll</kbd> to zoom · <kbd>click</kbd> to inspect
|
|
204
|
+
</div>
|
|
205
|
+
</main>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div class="scrim" id="scrim">
|
|
209
|
+
<div class="modal">
|
|
210
|
+
<header><h2 id="mtitle">Export</h2><div class="mono" id="msub"></div></header>
|
|
211
|
+
<div class="body"><textarea id="mtext" readonly spellcheck="false"></textarea></div>
|
|
212
|
+
<div class="foot">
|
|
213
|
+
<button class="tool ghost" id="mclose">Close</button>
|
|
214
|
+
<button class="tool" id="mdownload">Download file</button>
|
|
215
|
+
<button class="tool btn-primary" id="mcopy">Copy to clipboard</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="toast" id="toast"></div>
|
|
220
|
+
|
|
221
|
+
<script>
|
|
222
|
+
const DATA = __DATA__;
|
|
223
|
+
(function(){
|
|
224
|
+
"use strict";
|
|
225
|
+
const reduce = matchMedia("(prefers-reduced-motion:reduce)").matches;
|
|
226
|
+
const PALETTE = ["#4c9fd6","#9a86e0","#d6895b","#5cbf92","#d97ba6","#c9a54b","#6f7fd6","#57b8c4","#b07cc9","#7f8c99","#d0687a","#63b06a"];
|
|
227
|
+
const nodes = DATA.nodes.map(n=>Object.assign({},n));
|
|
228
|
+
const idIndex = new Map(nodes.map((n,i)=>[n.id,i]));
|
|
229
|
+
const edges = DATA.edges.filter(e=>idIndex.has(e.from)&&idIndex.has(e.to))
|
|
230
|
+
.map(e=>({source:idIndex.get(e.from),target:idIndex.get(e.to),type:e.type}));
|
|
231
|
+
|
|
232
|
+
// module = the file's directory (generalizes to any repo layout); root files → "(root)"
|
|
233
|
+
const moduleOf = f => { const s=String(f||""); const i=Math.max(s.lastIndexOf("/"),s.lastIndexOf("\\")); return i>0 ? s.slice(0,i) : "(root)"; };
|
|
234
|
+
const modules=[]; const modColor=new Map();
|
|
235
|
+
nodes.forEach(n=>{ const m=moduleOf(n.file); if(!modColor.has(m)){ modColor.set(m,PALETTE[modules.length%PALETTE.length]); modules.push(m);} n._mod=m; });
|
|
236
|
+
|
|
237
|
+
// degree within this subgraph + node radius from complexity (fallback degree)
|
|
238
|
+
nodes.forEach(n=>{n._in=0;n._out=0;n._adj=new Set();});
|
|
239
|
+
edges.forEach(e=>{ nodes[e.source]._out++; nodes[e.target]._in++; nodes[e.source]._adj.add(e.target); nodes[e.target]._adj.add(e.source);});
|
|
240
|
+
const maxCx = Math.max(1,...nodes.map(n=>n.complexity||0));
|
|
241
|
+
nodes.forEach(n=>{ const c=n.complexity||0; const base=c>0?Math.sqrt(c/maxCx):Math.sqrt((n._in+n._out+1)/12);
|
|
242
|
+
n._r = Math.max(5, Math.min(23, 6+base*17)); });
|
|
243
|
+
|
|
244
|
+
// ---- layout: seed on a circle, then force sim ----
|
|
245
|
+
const W0=1200,H0=820;
|
|
246
|
+
nodes.forEach((n,i)=>{ const a=i/nodes.length*Math.PI*2; n.x=W0/2+Math.cos(a)*300+(i%7-3)*12; n.y=H0/2+Math.sin(a)*300+(i%5-2)*12; n.vx=0; n.vy=0; n.fx=null; n.fy=null;});
|
|
247
|
+
let alpha=1;
|
|
248
|
+
function tick(){
|
|
249
|
+
const k=alpha;
|
|
250
|
+
// repulsion (O(n^2), fine for ~120 nodes)
|
|
251
|
+
for(let i=0;i<nodes.length;i++){ const a=nodes[i];
|
|
252
|
+
for(let j=i+1;j<nodes.length;j++){ const b=nodes[j];
|
|
253
|
+
let dx=a.x-b.x, dy=a.y-b.y, d2=dx*dx+dy*dy||0.01; const d=Math.sqrt(d2);
|
|
254
|
+
const rep=(2600)/d2*k; const fx=dx/d*rep, fy=dy/d*rep;
|
|
255
|
+
a.vx+=fx; a.vy+=fy; b.vx-=fx; b.vy-=fy;
|
|
256
|
+
}}
|
|
257
|
+
// springs
|
|
258
|
+
for(const e of edges){ const a=nodes[e.source], b=nodes[e.target];
|
|
259
|
+
let dx=b.x-a.x, dy=b.y-a.y, d=Math.sqrt(dx*dx+dy*dy)||0.01; const ideal=90+a._r+b._r;
|
|
260
|
+
const f=(d-ideal)*0.028*k; const fx=dx/d*f, fy=dy/d*f;
|
|
261
|
+
a.vx+=fx; a.vy+=fy; b.vx-=fx; b.vy-=fy;
|
|
262
|
+
}
|
|
263
|
+
// gravity to center
|
|
264
|
+
for(const n of nodes){ n.vx+=(W0/2-n.x)*0.006*k; n.vy+=(H0/2-n.y)*0.006*k; }
|
|
265
|
+
for(const n of nodes){ if(n.fx!=null){n.x=n.fx;n.y=n.fy;n.vx=0;n.vy=0;continue;}
|
|
266
|
+
n.vx*=0.86; n.vy*=0.86; n.x+=Math.max(-30,Math.min(30,n.vx)); n.y+=Math.max(-30,Math.min(30,n.vy)); }
|
|
267
|
+
alpha*=0.985;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- render ----
|
|
271
|
+
const svg=document.getElementById("graph"), vp=document.getElementById("viewport");
|
|
272
|
+
const gEdges=document.getElementById("edges"), gNodes=document.getElementById("nodes");
|
|
273
|
+
const NS="http://www.w3.org/2000/svg";
|
|
274
|
+
const edgeEls=edges.map(e=>{ const l=document.createElementNS(NS,"line"); l.setAttribute("class","edge"+(e.type==="USAGE"?" usage":"")); gEdges.appendChild(l); return l;});
|
|
275
|
+
const nodeEls=nodes.map((n,i)=>{
|
|
276
|
+
const g=document.createElementNS(NS,"g"); g.setAttribute("class","node"); g.dataset.i=i;
|
|
277
|
+
const c=document.createElementNS(NS,"circle"); c.setAttribute("r",n._r); c.setAttribute("fill",modColor.get(n._mod));
|
|
278
|
+
const t=document.createElementNS(NS,"text"); t.setAttribute("y",n._r+10); t.setAttribute("text-anchor","middle"); t.textContent=n.label;
|
|
279
|
+
g.appendChild(c); g.appendChild(t); gNodes.appendChild(g); return g;
|
|
280
|
+
});
|
|
281
|
+
function draw(){
|
|
282
|
+
for(let i=0;i<edges.length;i++){ const e=edges[i], a=nodes[e.source], b=nodes[e.target], el=edgeEls[i];
|
|
283
|
+
el.setAttribute("x1",a.x); el.setAttribute("y1",a.y); el.setAttribute("x2",b.x); el.setAttribute("y2",b.y);}
|
|
284
|
+
for(let i=0;i<nodes.length;i++){ nodeEls[i].setAttribute("transform","translate("+nodes[i].x+","+nodes[i].y+")"); }
|
|
285
|
+
}
|
|
286
|
+
let running=true, ticks=0;
|
|
287
|
+
function loop(){ if(running){ tick(); draw(); ticks++; if(alpha<0.02||ticks>600){running=false; fit();} } requestAnimationFrame(loop); }
|
|
288
|
+
if(reduce){ for(let i=0;i<420;i++) tick(); draw(); fit(); } else loop();
|
|
289
|
+
|
|
290
|
+
// ---- pan / zoom ----
|
|
291
|
+
let view={x:0,y:0,k:1};
|
|
292
|
+
function apply(){ vp.setAttribute("transform","translate("+view.x+","+view.y+") scale("+view.k+")"); }
|
|
293
|
+
function fit(){
|
|
294
|
+
let minX=1e9,minY=1e9,maxX=-1e9,maxY=-1e9;
|
|
295
|
+
for(const n of nodes){ minX=Math.min(minX,n.x-n._r); minY=Math.min(minY,n.y-n._r); maxX=Math.max(maxX,n.x+n._r); maxY=Math.max(maxY,n.y+n._r);}
|
|
296
|
+
const bw=maxX-minX,bh=maxY-minY, rect=svg.getBoundingClientRect();
|
|
297
|
+
const k=Math.min(rect.width/(bw+80), rect.height/(bh+80), 1.6);
|
|
298
|
+
view.k=k; view.x=rect.width/2-(minX+bw/2)*k; view.y=rect.height/2-(minY+bh/2)*k; apply();
|
|
299
|
+
}
|
|
300
|
+
svg.addEventListener("wheel",ev=>{ ev.preventDefault(); const rect=svg.getBoundingClientRect();
|
|
301
|
+
const mx=ev.clientX-rect.left,my=ev.clientY-rect.top; const s=Math.exp(-ev.deltaY*0.0015);
|
|
302
|
+
const nk=Math.max(0.15,Math.min(5,view.k*s)); view.x=mx-(mx-view.x)*(nk/view.k); view.y=my-(my-view.y)*(nk/view.k); view.k=nk; apply();
|
|
303
|
+
},{passive:false});
|
|
304
|
+
|
|
305
|
+
// ---- drag (node) + pan (bg) + tap-to-select via pointer events ----
|
|
306
|
+
// A "tap" (pointerdown→up with no movement) selects; a drag moves. We resolve selection on
|
|
307
|
+
// pointerup rather than the native click event, which pointer-capture makes unreliable.
|
|
308
|
+
let drag=null,pan=null,down=null,moved=false;
|
|
309
|
+
svg.addEventListener("pointerdown",ev=>{
|
|
310
|
+
const g=ev.target.closest(".node");
|
|
311
|
+
const rect=svg.getBoundingClientRect(), px=ev.clientX-rect.left, py=ev.clientY-rect.top;
|
|
312
|
+
moved=false; down={px,py};
|
|
313
|
+
if(g){ const i=+g.dataset.i; drag={i}; nodes[i].fx=nodes[i].x; nodes[i].fy=nodes[i].y; alpha=Math.max(alpha,0.4); running=true; svg.setPointerCapture(ev.pointerId); }
|
|
314
|
+
else { pan={sx:px,sy:py,vx:view.x,vy:view.y}; svg.classList.add("panning"); svg.setPointerCapture(ev.pointerId); }
|
|
315
|
+
});
|
|
316
|
+
svg.addEventListener("pointermove",ev=>{
|
|
317
|
+
const rect=svg.getBoundingClientRect(), px=ev.clientX-rect.left, py=ev.clientY-rect.top;
|
|
318
|
+
if(down && (Math.abs(px-down.px)>4 || Math.abs(py-down.py)>4)) moved=true;
|
|
319
|
+
if(drag){ nodes[drag.i].fx=(px-view.x)/view.k; nodes[drag.i].fy=(py-view.y)/view.k; if(reduce) draw(); }
|
|
320
|
+
else if(pan){ view.x=pan.vx+(px-pan.sx); view.y=pan.vy+(py-pan.sy); apply(); }
|
|
321
|
+
});
|
|
322
|
+
function endPtr(){
|
|
323
|
+
if(drag){ if(!moved) select(drag.i); nodes[drag.i].fx=null; nodes[drag.i].fy=null; drag=null; }
|
|
324
|
+
else if(pan){ if(!moved) deselect(); pan=null; svg.classList.remove("panning"); }
|
|
325
|
+
down=null;
|
|
326
|
+
}
|
|
327
|
+
svg.addEventListener("pointerup",endPtr); svg.addEventListener("pointercancel",endPtr);
|
|
328
|
+
|
|
329
|
+
// ---- selection / detail ----
|
|
330
|
+
let selected=null;
|
|
331
|
+
const $=id=>document.getElementById(id);
|
|
332
|
+
function clearHi(){ nodeEls.forEach(e=>e.classList.remove("dim","sel")); edgeEls.forEach(e=>e.classList.remove("dim","lit")); }
|
|
333
|
+
function select(i){
|
|
334
|
+
selected=i; clearHi(); const n=nodes[i];
|
|
335
|
+
nodeEls.forEach((e,j)=>{ if(j!==i&&!n._adj.has(j)) e.classList.add("dim"); });
|
|
336
|
+
nodeEls[i].classList.add("sel");
|
|
337
|
+
edges.forEach((e,k)=>{ if(e.source===i||e.target===i) edgeEls[k].classList.add("lit"); else edgeEls[k].classList.add("dim"); });
|
|
338
|
+
$("d-sym").textContent=n.label; $("d-path").textContent=n.file;
|
|
339
|
+
$("d-cx").textContent=n.complexity||0; $("d-cog").textContent=n.cognitive||0;
|
|
340
|
+
$("d-in").textContent=n._in; $("d-out").textContent=n._out;
|
|
341
|
+
const nbrs=[...n._adj].map(j=>nodes[j]);
|
|
342
|
+
// Build with DOM + textContent (never parses markup) — untrusted symbol names must NOT reach innerHTML.
|
|
343
|
+
const rel=$("d-rel"); rel.textContent="";
|
|
344
|
+
const nb=document.createElement("b"); nb.textContent=String(nbrs.length);
|
|
345
|
+
rel.appendChild(nb); rel.appendChild(document.createTextNode(" connected symbol"+(nbrs.length===1?"":"s")));
|
|
346
|
+
if(nbrs.length){ const wrap=document.createElement("div"); wrap.className="chips";
|
|
347
|
+
nbrs.slice(0,14).forEach(m=>{ const c=document.createElement("span"); c.className="chip"; c.textContent=m.label;
|
|
348
|
+
c.onclick=()=>{ const idx=idIndex.get(m.id); if(idx!=null) select(idx); }; wrap.appendChild(c); });
|
|
349
|
+
rel.appendChild(wrap); }
|
|
350
|
+
$("detail").classList.add("show");
|
|
351
|
+
}
|
|
352
|
+
function deselect(){ selected=null; clearHi(); $("detail").classList.remove("show"); }
|
|
353
|
+
$("dclose").onclick=deselect;
|
|
354
|
+
nodeEls.forEach((g,i)=>{
|
|
355
|
+
g.addEventListener("mouseenter",()=>{ if(selected!=null) return; const n=nodes[i];
|
|
356
|
+
nodeEls.forEach((e,j)=>{ if(j!==i&&!n._adj.has(j)) e.classList.add("dim"); });
|
|
357
|
+
edges.forEach((e,k)=>{ if(e.source===i||e.target===i) edgeEls[k].classList.add("lit"); else edgeEls[k].classList.add("dim"); }); });
|
|
358
|
+
g.addEventListener("mouseleave",()=>{ if(selected==null) clearHi(); });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ---- search ----
|
|
362
|
+
$("search").addEventListener("input",e=>{
|
|
363
|
+
const q=e.target.value.trim().toLowerCase(); if(selected!=null) deselect();
|
|
364
|
+
if(!q){ clearHi(); return; }
|
|
365
|
+
nodeEls.forEach((el,i)=>{ const hit=nodes[i].label.toLowerCase().includes(q)||nodes[i].file.toLowerCase().includes(q); el.classList.toggle("dim",!hit); });
|
|
366
|
+
edgeEls.forEach(el=>el.classList.add("dim"));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ---- legend + header ----
|
|
370
|
+
$("sub").textContent=nodes.length+" nodes · "+edges.length+" edges · "+DATA.project;
|
|
371
|
+
{ const el=$("mods"); el.textContent=""; // module names are untrusted dir paths → DOM + textContent, never innerHTML
|
|
372
|
+
modules.forEach(m=>{ const row=document.createElement("div"); row.className="row";
|
|
373
|
+
const sw=document.createElement("span"); sw.className="sw"; sw.style.background=modColor.get(m);
|
|
374
|
+
row.appendChild(sw); row.appendChild(document.createTextNode(m)); el.appendChild(row); }); }
|
|
375
|
+
$("fit").onclick=fit;
|
|
376
|
+
|
|
377
|
+
// ---- layout options ----
|
|
378
|
+
let animRAF=null, curLayout="force";
|
|
379
|
+
function unpin(){ nodes.forEach(n=>{n.fx=null;n.fy=null;}); }
|
|
380
|
+
function hub(){ let bi=0,bd=-1; nodes.forEach((n,i)=>{const d=n._in+n._out; if(d>bd){bd=d;bi=i;}}); return bi; }
|
|
381
|
+
function radialTargets(){ // rings by BFS distance from the busiest hub
|
|
382
|
+
const root=hub(), dist=new Array(nodes.length).fill(-1); dist[root]=0; const q=[root];
|
|
383
|
+
while(q.length){ const c=q.shift(); for(const nb of nodes[c]._adj){ if(dist[nb]<0){dist[nb]=dist[c]+1; q.push(nb);} } }
|
|
384
|
+
let maxD=0; dist.forEach(d=>{ if(d>maxD)maxD=d; });
|
|
385
|
+
const layers={}; nodes.forEach((n,i)=>{ const d=dist[i]<0?maxD+1:dist[i]; (layers[d]=layers[d]||[]).push(i); });
|
|
386
|
+
const tg={};
|
|
387
|
+
Object.keys(layers).forEach(dk=>{ const d=+dk, arr=layers[dk], R=d*145;
|
|
388
|
+
arr.forEach((i,k)=>{ const a=(k/arr.length)*Math.PI*2 + d*0.5; tg[i]={x:W0/2+Math.cos(a)*R, y:H0/2+Math.sin(a)*R}; }); });
|
|
389
|
+
tg[root]={x:W0/2,y:H0/2}; return tg;
|
|
390
|
+
}
|
|
391
|
+
function layeredTargets(){ // top-down: sources at top, callees below (Kahn BFS; cycles get discovery layer)
|
|
392
|
+
const out=nodes.map(()=>[]), indeg=nodes.map(()=>0);
|
|
393
|
+
edges.forEach(e=>{ out[e.source].push(e.target); indeg[e.target]++; });
|
|
394
|
+
let cur=[]; nodes.forEach((n,i)=>{ if(indeg[i]===0) cur.push(i); }); if(!cur.length) cur=[hub()];
|
|
395
|
+
const layer=nodes.map(()=>0), seen=new Set(cur); let L=0;
|
|
396
|
+
while(cur.length){ const nx=[]; for(const c of cur){ layer[c]=L; for(const t of out[c]) if(!seen.has(t)){ seen.add(t); nx.push(t); } } cur=nx; L++; }
|
|
397
|
+
nodes.forEach((n,i)=>{ if(!seen.has(i)) layer[i]=L; });
|
|
398
|
+
const byL={}; nodes.forEach((n,i)=>{ (byL[layer[i]]=byL[layer[i]]||[]).push(i); });
|
|
399
|
+
const tg={};
|
|
400
|
+
Object.keys(byL).forEach(lk=>{ const arr=byL[lk], gx=Math.max(62,960/Math.max(arr.length,1));
|
|
401
|
+
arr.forEach((i,k)=>{ tg[i]={x:W0/2+(k-(arr.length-1)/2)*gx, y:70+(+lk)*104}; }); });
|
|
402
|
+
return tg;
|
|
403
|
+
}
|
|
404
|
+
function clusterTargets(){ // group by module into tight, separated discs
|
|
405
|
+
const byMod={}; nodes.forEach((n,i)=>{ (byMod[n._mod]=byMod[n._mod]||[]).push(i); });
|
|
406
|
+
const M=modules.length, centers={};
|
|
407
|
+
modules.forEach((m,k)=>{ const a=(k/M)*Math.PI*2 - Math.PI/2; centers[m]={x:W0/2+Math.cos(a)*400, y:H0/2+Math.sin(a)*330}; });
|
|
408
|
+
const tg={};
|
|
409
|
+
Object.keys(byMod).forEach(m=>{ const arr=byMod[m], c=centers[m];
|
|
410
|
+
arr.forEach((i,k)=>{ const r=10*Math.sqrt(k+0.5), a=k*2.399963229; tg[i]={x:c.x+Math.cos(a)*r, y:c.y+Math.sin(a)*r}; }); }); // phyllotaxis
|
|
411
|
+
return tg;
|
|
412
|
+
}
|
|
413
|
+
function animateTo(targets){
|
|
414
|
+
if(animRAF) cancelAnimationFrame(animRAF); running=false; unpin();
|
|
415
|
+
const start=nodes.map(n=>({x:n.x,y:n.y})); let f=0; const N=reduce?1:46;
|
|
416
|
+
(function step(){ f++; const t=Math.min(1,f/N), e=1-Math.pow(1-t,3);
|
|
417
|
+
nodes.forEach((n,i)=>{ const g=targets[i]; if(g){ n.x=start[i].x+(g.x-start[i].x)*e; n.y=start[i].y+(g.y-start[i].y)*e; n.vx=0; n.vy=0; } });
|
|
418
|
+
draw(); if(f<N){ animRAF=requestAnimationFrame(step); } else { animRAF=null; fit(); } })();
|
|
419
|
+
}
|
|
420
|
+
function runForce(){ if(animRAF){ cancelAnimationFrame(animRAF); animRAF=null; } unpin(); alpha=1; ticks=0; running=true;
|
|
421
|
+
if(reduce){ for(let i=0;i<440;i++) tick(); draw(); fit(); running=false; } }
|
|
422
|
+
function setLayout(name){
|
|
423
|
+
curLayout=name; if(selected!=null) deselect();
|
|
424
|
+
document.querySelectorAll(".seg-btn").forEach(b=>b.classList.toggle("active", b.dataset.layout===name));
|
|
425
|
+
if(name==="force") runForce();
|
|
426
|
+
else if(name==="radial") animateTo(radialTargets());
|
|
427
|
+
else if(name==="layered") animateTo(layeredTargets());
|
|
428
|
+
else if(name==="cluster") animateTo(clusterTargets());
|
|
429
|
+
}
|
|
430
|
+
document.querySelectorAll(".seg-btn").forEach(b=>b.onclick=()=>setLayout(b.dataset.layout));
|
|
431
|
+
$("relayout").onclick=()=>setLayout(curLayout);
|
|
432
|
+
|
|
433
|
+
// ---- exports ----
|
|
434
|
+
function download(name,text,mime){ try{ const b=new Blob([text],{type:mime||"text/plain"}); const u=URL.createObjectURL(b); const a=document.createElement("a"); a.href=u; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(u),1500); toast("Downloaded "+name); }catch(e){ toast("Download blocked here — use Copy"); } }
|
|
435
|
+
function toMarkdown(){
|
|
436
|
+
const byCx=[...nodes].sort((a,b)=>(b.complexity||0)-(a.complexity||0));
|
|
437
|
+
let s="# codeintel call graph — "+DATA.project+"\n\n"+nodes.length+" symbols · "+edges.length+" call edges\n\n## Hotspots (by complexity)\n\n| symbol | file | cx | cog | in | out |\n|---|---|--:|--:|--:|--:|\n";
|
|
438
|
+
byCx.slice(0,25).forEach(n=>{ s+="| `"+n.label+"` | "+n.file+" | "+(n.complexity||0)+" | "+(n.cognitive||0)+" | "+n._in+" | "+n._out+" |\n"; });
|
|
439
|
+
s+="\n## Call edges\n\n";
|
|
440
|
+
edges.forEach(e=>{ s+="- `"+nodes[e.source].label+"` → `"+nodes[e.target].label+"` ("+e.type+")\n"; });
|
|
441
|
+
return s;
|
|
442
|
+
}
|
|
443
|
+
function serializeSVG(){
|
|
444
|
+
const rect=svg.getBoundingClientRect();
|
|
445
|
+
const cs=getComputedStyle(document.body); const bg=cs.getPropertyValue("--canvas").trim()||"#0e131a";
|
|
446
|
+
const ink=cs.getPropertyValue("--ink").trim(), edge=cs.getPropertyValue("--edge").trim(), estr=cs.getPropertyValue("--node-stroke").trim();
|
|
447
|
+
const clone=svg.cloneNode(true); clone.setAttribute("xmlns",NS); clone.setAttribute("width",rect.width); clone.setAttribute("height",rect.height);
|
|
448
|
+
clone.setAttribute("viewBox","0 0 "+rect.width+" "+rect.height);
|
|
449
|
+
// inline the theme colors so the exported file is standalone
|
|
450
|
+
clone.querySelectorAll(".edge").forEach(e=>{e.setAttribute("stroke",edge);e.setAttribute("stroke-width","1");});
|
|
451
|
+
clone.querySelectorAll(".node text").forEach(t=>{t.setAttribute("fill",ink);t.setAttribute("stroke",bg);t.setAttribute("stroke-width","2.6");t.style.fontFamily="ui-monospace,Menlo,monospace";t.style.fontSize="9.5px";});
|
|
452
|
+
clone.querySelectorAll(".node circle").forEach(c=>{c.setAttribute("stroke",estr);c.setAttribute("stroke-width","1.5");});
|
|
453
|
+
const style=document.createElementNS(NS,"style"); style.textContent=".node text{font-family:ui-monospace,Menlo,monospace;font-size:9.5px}";
|
|
454
|
+
clone.insertBefore(style,clone.firstChild);
|
|
455
|
+
const rc=document.createElementNS(NS,"rect"); rc.setAttribute("x",0);rc.setAttribute("y",0);rc.setAttribute("width",rect.width);rc.setAttribute("height",rect.height);rc.setAttribute("fill",bg);
|
|
456
|
+
clone.insertBefore(rc,clone.firstChild);
|
|
457
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n'+new XMLSerializer().serializeToString(clone);
|
|
458
|
+
}
|
|
459
|
+
const EX={
|
|
460
|
+
json:{title:"Export · JSON",sub:"the graph payload — the shape `code.query --format json` emits",name:"codeintel-graph.json",mime:"application/json",make:()=>JSON.stringify(DATA,null,2)},
|
|
461
|
+
md:{title:"Export · Markdown",sub:"hotspots table + call edges — the same content `code.query` returns as text",name:"codeintel-graph.md",mime:"text/markdown",make:toMarkdown},
|
|
462
|
+
svg:{title:"Export · SVG",sub:"the current view as a standalone vector image",name:"codeintel-graph.svg",mime:"image/svg+xml",make:serializeSVG},
|
|
463
|
+
};
|
|
464
|
+
let cur=null;
|
|
465
|
+
function openModal(kind){
|
|
466
|
+
if(kind==="png"){ return exportPNG(); }
|
|
467
|
+
const spec=EX[kind]; cur=spec; $("mtitle").textContent=spec.title; $("msub").textContent=spec.sub;
|
|
468
|
+
const txt=spec.make(); $("mtext").value=txt; $("scrim").classList.add("show"); $("mtext").scrollTop=0;
|
|
469
|
+
}
|
|
470
|
+
document.querySelectorAll("[data-export]").forEach(b=>b.onclick=()=>openModal(b.dataset.export));
|
|
471
|
+
$("mclose").onclick=()=>$("scrim").classList.remove("show");
|
|
472
|
+
$("scrim").addEventListener("click",e=>{ if(e.target===$("scrim")) $("scrim").classList.remove("show"); });
|
|
473
|
+
$("mcopy").onclick=async()=>{ try{ await navigator.clipboard.writeText($("mtext").value); toast("Copied to clipboard"); }catch(e){ $("mtext").select(); document.execCommand&&document.execCommand("copy"); toast("Copied"); } };
|
|
474
|
+
$("mdownload").onclick=()=>{ if(cur) download(cur.name,$("mtext").value,cur.mime); };
|
|
475
|
+
|
|
476
|
+
function exportPNG(){
|
|
477
|
+
const svgText=serializeSVG(); const rect=svg.getBoundingClientRect(); const scale=2;
|
|
478
|
+
const img=new Image(); const blob=new Blob([svgText],{type:"image/svg+xml"}); const url=URL.createObjectURL(blob);
|
|
479
|
+
img.onload=()=>{ const cv=document.createElement("canvas"); cv.width=rect.width*scale; cv.height=rect.height*scale;
|
|
480
|
+
const ctx=cv.getContext("2d"); ctx.scale(scale,scale); ctx.drawImage(img,0,0); URL.revokeObjectURL(url);
|
|
481
|
+
cv.toBlob(b=>{ if(!b){toast("PNG export blocked here");return;} const u=URL.createObjectURL(b); const a=document.createElement("a"); a.href=u; a.download="codeintel-graph.png"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(u),1500); toast("Downloaded codeintel-graph.png"); },"image/png"); };
|
|
482
|
+
img.onerror=()=>{toast("PNG export unavailable here");URL.revokeObjectURL(url);};
|
|
483
|
+
img.src=url;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---- toast ----
|
|
487
|
+
let tt; function toast(m){ const el=$("toast"); el.textContent=m; el.classList.add("show"); clearTimeout(tt); tt=setTimeout(()=>el.classList.remove("show"),1900); }
|
|
488
|
+
addEventListener("keydown",e=>{ if(e.key==="Escape"){ $("scrim").classList.remove("show"); deselect(); } });
|
|
489
|
+
})();
|
|
490
|
+
</script>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Tests for the graph viewer builder (grapher.py) — never-raise, real-shape fixtures, HTML injection.
|
|
2
|
+
|
|
3
|
+
Follows the repo's real-boundary style: the graph methods are patched with the SHAPES the real
|
|
4
|
+
backend returns (search_graph result dicts, query_graph column→value row dicts), not fabricated ones.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from codeintel import grapher
|
|
9
|
+
from codeintel.providers.graph import GraphProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _wire(monkeypatch, *, available=True, search=None, rows=None, project="proj"):
|
|
13
|
+
monkeypatch.setattr("codeintel.providers.graph.shutil.which",
|
|
14
|
+
lambda x: "/fake/cm" if available else None)
|
|
15
|
+
monkeypatch.setattr(GraphProvider, "_resolve_project", lambda self, r: project if available else None)
|
|
16
|
+
monkeypatch.setattr(GraphProvider, "_search_symbols", lambda self, extra, proj, t: search or [])
|
|
17
|
+
monkeypatch.setattr(GraphProvider, "_query_rows", lambda self, cy, proj, t: rows or [])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SEARCH = [{"qualified_name": "pkg.a.foo", "name": "foo", "file_path": "src/pkg/a.py",
|
|
21
|
+
"complexity": 5, "cognitive": 8, "in_degree": 2, "out_degree": 1, "lines": 20}]
|
|
22
|
+
ROWS = [
|
|
23
|
+
{"a.qualified_name": "pkg.a.foo", "a.name": "foo", "a.file_path": "src/pkg/a.py",
|
|
24
|
+
"b.qualified_name": "pkg.b.bar", "b.name": "bar", "b.file_path": "src/pkg/b.py", "type(c)": "CALLS"},
|
|
25
|
+
# a builtin call — synthetic '<python-builtins>' file → must be filtered out
|
|
26
|
+
{"a.qualified_name": "pkg.b.bar", "a.name": "bar", "a.file_path": "src/pkg/b.py",
|
|
27
|
+
"b.qualified_name": "builtins.len", "b.name": "len", "b.file_path": "<python-builtins>", "type(c)": "CALLS"},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_build_payload_shapes_nodes_edges_and_filters_builtins(monkeypatch):
|
|
32
|
+
_wire(monkeypatch, search=SEARCH, rows=ROWS)
|
|
33
|
+
p = grapher.build_graph_payload("/repo")
|
|
34
|
+
labels = {n["label"] for n in p["nodes"]}
|
|
35
|
+
assert labels == {"foo", "bar"} # builtin 'len' edge dropped (synthetic file_path)
|
|
36
|
+
assert len(p["edges"]) == 1 and p["edges"][0]["type"] == "CALLS"
|
|
37
|
+
foo = next(n for n in p["nodes"] if n["label"] == "foo")
|
|
38
|
+
assert foo["complexity"] == 5 and foo["file"] == "src/pkg/a.py" # metrics enriched from search_graph
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_build_payload_engine_unavailable_is_safe(monkeypatch):
|
|
42
|
+
_wire(monkeypatch, available=False)
|
|
43
|
+
p = grapher.build_graph_payload("/repo")
|
|
44
|
+
assert p["nodes"] == [] and p["edges"] == [] and p["reason"] == "engine-unavailable"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_build_payload_not_indexed_is_safe(monkeypatch):
|
|
48
|
+
_wire(monkeypatch, project=None) # available but unresolved
|
|
49
|
+
monkeypatch.setattr("codeintel.providers.graph.shutil.which", lambda x: "/fake/cm")
|
|
50
|
+
monkeypatch.setattr(GraphProvider, "_resolve_project", lambda self, r: None)
|
|
51
|
+
p = grapher.build_graph_payload("/repo")
|
|
52
|
+
assert p["nodes"] == [] and p["reason"] == "project-not-indexed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_build_payload_never_raises(monkeypatch):
|
|
56
|
+
_wire(monkeypatch)
|
|
57
|
+
def _boom(*a, **k):
|
|
58
|
+
raise RuntimeError("backend exploded")
|
|
59
|
+
monkeypatch.setattr(GraphProvider, "_query_rows", _boom)
|
|
60
|
+
p = grapher.build_graph_payload("/repo")
|
|
61
|
+
assert p["nodes"] == [] and p.get("reason") == "error" # degraded, not raised
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_build_payload_respects_edge_limit(monkeypatch):
|
|
65
|
+
many = [{"a.qualified_name": f"m.f{i}", "a.name": f"f{i}", "a.file_path": "src/m.py",
|
|
66
|
+
"b.qualified_name": f"m.g{i}", "b.name": f"g{i}", "b.file_path": "src/m.py", "type(c)": "CALLS"}
|
|
67
|
+
for i in range(50)]
|
|
68
|
+
_wire(monkeypatch, rows=many)
|
|
69
|
+
p = grapher.build_graph_payload("/repo", limit=10)
|
|
70
|
+
assert len(p["edges"]) == 10
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_render_html_injects_and_is_self_contained():
|
|
74
|
+
payload = {"project": "x", "engine": "graph", "op": "callgraph",
|
|
75
|
+
"nodes": [{"id": "a", "label": "alpha", "file": "a.py"}], "edges": []}
|
|
76
|
+
html = grapher.render_html(payload)
|
|
77
|
+
assert "__DATA__" not in html # placeholder replaced
|
|
78
|
+
assert "alpha" in html # data embedded
|
|
79
|
+
assert "<title>" in html
|
|
80
|
+
low = html.lower()
|
|
81
|
+
assert 'src="http' not in low and 'href="http' not in low and "cdn" not in low # no external refs
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_render_html_falls_back_when_template_missing(monkeypatch):
|
|
85
|
+
monkeypatch.setattr(grapher, "_template_path", lambda: "/nonexistent/does-not-exist.html")
|
|
86
|
+
html = grapher.render_html({"project": "x", "nodes": [], "edges": []})
|
|
87
|
+
assert "codeintel graph" in html and "__DATA__" not in html # minimal page, never raised
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_render_html_escapes_script_close():
|
|
91
|
+
# a symbol containing '</script>' must not break out of the inline data block
|
|
92
|
+
payload = {"project": "x", "nodes": [{"id": "a", "label": "a</script><b>", "file": "a.py"}], "edges": []}
|
|
93
|
+
html = grapher.render_html(payload)
|
|
94
|
+
assert "</script><b>" not in html # the sequence is escaped in the embedded JSON
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_viewer_template_has_no_innerhtml_sink():
|
|
98
|
+
# Regression for the DOM-XSS finding: untrusted symbol/directory names must NEVER reach
|
|
99
|
+
# `.innerHTML` — the viewer builds every label via createElement + textContent.
|
|
100
|
+
tpl = open(grapher._template_path(), encoding="utf-8").read()
|
|
101
|
+
assert ".innerHTML=" not in tpl and ".innerHTML =" not in tpl
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_render_html_never_raises_on_unserializable_payload():
|
|
105
|
+
# `default=str` keeps json.dumps from raising on an unexpected value → contract: never raises.
|
|
106
|
+
html = grapher.render_html({"project": "x", "nodes": [{"id": "a", "label": "a", "bad": {1, 2}}], "edges": []})
|
|
107
|
+
assert isinstance(html, str) and "<title>" in html
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_build_payload_node_count_bounded_by_edges(monkeypatch):
|
|
111
|
+
many = [{"a.qualified_name": f"m.f{i}", "a.name": f"f{i}", "a.file_path": "src/m.py",
|
|
112
|
+
"b.qualified_name": f"m.g{i}", "b.name": f"g{i}", "b.file_path": "src/m.py", "type(c)": "CALLS"}
|
|
113
|
+
for i in range(200)]
|
|
114
|
+
_wire(monkeypatch, rows=many)
|
|
115
|
+
p = grapher.build_graph_payload("/repo", limit=15)
|
|
116
|
+
assert len(p["edges"]) == 15
|
|
117
|
+
assert len(p["nodes"]) <= 2 * len(p["edges"]) # nodes provably bounded by edges
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_build_payload_tolerates_none_metrics(monkeypatch):
|
|
121
|
+
search = [{"qualified_name": "m.f", "name": "f", "file_path": "src/m.py", "complexity": None}]
|
|
122
|
+
rows = [{"a.qualified_name": "m.f", "a.name": "f", "a.file_path": "src/m.py",
|
|
123
|
+
"b.qualified_name": "m.g", "b.name": "g", "b.file_path": "src/m.py", "type(c)": "CALLS"}]
|
|
124
|
+
_wire(monkeypatch, search=search, rows=rows)
|
|
125
|
+
p = grapher.build_graph_payload("/repo")
|
|
126
|
+
f = next(n for n in p["nodes"] if n["label"] == "f")
|
|
127
|
+
assert f["complexity"] == 0 # None metric → 0, never crashes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.10.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|