graphlens-cli 0.4.0__py3-none-any.whl
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.
- graphlens_cli/__init__.py +8 -0
- graphlens_cli/_analyze.py +80 -0
- graphlens_cli/_app.py +113 -0
- graphlens_cli/_neo4j.py +214 -0
- graphlens_cli/_visualize.py +631 -0
- graphlens_cli-0.4.0.dist-info/METADATA +17 -0
- graphlens_cli-0.4.0.dist-info/RECORD +9 -0
- graphlens_cli-0.4.0.dist-info/WHEEL +4 -0
- graphlens_cli-0.4.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""graphlens-cli — command-line interface for graphlens code analysis."""
|
|
2
|
+
|
|
3
|
+
import graphlens_cli._analyze
|
|
4
|
+
import graphlens_cli._neo4j
|
|
5
|
+
import graphlens_cli._visualize # noqa: F401 — registers visualize command
|
|
6
|
+
from graphlens_cli._app import app
|
|
7
|
+
|
|
8
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""graphlens analyze — print graph statistics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from graphlens import NodeKind, RelationKind
|
|
11
|
+
|
|
12
|
+
from graphlens_cli._app import app, resolve_langs, run_analysis
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def analyze(
|
|
17
|
+
root: Annotated[
|
|
18
|
+
Path,
|
|
19
|
+
typer.Argument(
|
|
20
|
+
help="Project root to analyse",
|
|
21
|
+
exists=True,
|
|
22
|
+
file_okay=False,
|
|
23
|
+
resolve_path=True,
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
lang: Annotated[
|
|
27
|
+
str,
|
|
28
|
+
typer.Option(
|
|
29
|
+
help=(
|
|
30
|
+
"Adapter(s) to use: auto | python | typescript"
|
|
31
|
+
" | python,typescript"
|
|
32
|
+
),
|
|
33
|
+
show_default=True,
|
|
34
|
+
),
|
|
35
|
+
] = "auto",
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Print node and relation statistics for a project."""
|
|
38
|
+
langs = resolve_langs(lang, root)
|
|
39
|
+
typer.echo(f"Analysing {root} [lang={', '.join(langs)}]\n")
|
|
40
|
+
|
|
41
|
+
graph, elapsed = run_analysis(root, langs)
|
|
42
|
+
nodes = graph.nodes
|
|
43
|
+
|
|
44
|
+
typer.echo(
|
|
45
|
+
f"{len(nodes)} nodes · "
|
|
46
|
+
f"{len(graph.relations)} relations · "
|
|
47
|
+
f"{elapsed:.2f}s\n"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
by_kind = Counter(n.kind.value for n in nodes.values())
|
|
51
|
+
typer.echo("Nodes by kind:")
|
|
52
|
+
for k, c in by_kind.most_common():
|
|
53
|
+
typer.echo(f" {k:<20} {c}")
|
|
54
|
+
|
|
55
|
+
by_rel = Counter(r.kind.value for r in graph.relations)
|
|
56
|
+
typer.echo("\nRelations by kind:")
|
|
57
|
+
for k, c in by_rel.most_common():
|
|
58
|
+
typer.echo(f" {k:<20} {c}")
|
|
59
|
+
|
|
60
|
+
ext_origin = Counter(
|
|
61
|
+
str(n.metadata.get("origin", "?"))
|
|
62
|
+
for n in nodes.values()
|
|
63
|
+
if n.kind == NodeKind.EXTERNAL_SYMBOL
|
|
64
|
+
)
|
|
65
|
+
if ext_origin:
|
|
66
|
+
typer.echo("\nExternal symbols by origin:")
|
|
67
|
+
for o, c in ext_origin.most_common():
|
|
68
|
+
typer.echo(f" {o:<20} {c}")
|
|
69
|
+
|
|
70
|
+
caller_counts: Counter[str] = Counter()
|
|
71
|
+
for r in graph.relations:
|
|
72
|
+
if r.kind == RelationKind.CALLS:
|
|
73
|
+
caller_counts[r.source_id] += 1
|
|
74
|
+
|
|
75
|
+
if caller_counts:
|
|
76
|
+
typer.echo("\nTop callers (by outgoing CALLS):")
|
|
77
|
+
for nid, count in caller_counts.most_common(10):
|
|
78
|
+
n = nodes.get(nid)
|
|
79
|
+
name = n.qualified_name if n else nid
|
|
80
|
+
typer.echo(f" {count:>4} {name}")
|
graphlens_cli/_app.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""graphlens CLI — typer app and shared analysis helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from graphlens import GraphLens, adapter_registry
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from graphlens.contracts import LanguageAdapter
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="graphlens",
|
|
18
|
+
help="Polyglot code graph analysis and export.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
pretty_exceptions_show_locals=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_langs(lang: str, root: Path) -> list[str]:
|
|
25
|
+
"""
|
|
26
|
+
Expand the *lang* value to a concrete list of adapter names.
|
|
27
|
+
|
|
28
|
+
``"auto"`` queries the adapter registry and filters by ``can_handle``.
|
|
29
|
+
Any other value is split on commas and returned as-is.
|
|
30
|
+
"""
|
|
31
|
+
if lang != "auto":
|
|
32
|
+
return [s.strip() for s in lang.split(",") if s.strip()]
|
|
33
|
+
|
|
34
|
+
available = adapter_registry.available()
|
|
35
|
+
if not available:
|
|
36
|
+
msg = (
|
|
37
|
+
"No graphlens adapters installed. "
|
|
38
|
+
"Install graphlens-python or graphlens-typescript."
|
|
39
|
+
)
|
|
40
|
+
raise typer.BadParameter(msg)
|
|
41
|
+
|
|
42
|
+
matched: list[str] = []
|
|
43
|
+
for name in available:
|
|
44
|
+
try:
|
|
45
|
+
if adapter_registry.load(name)().can_handle(root):
|
|
46
|
+
matched.append(name)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
if not matched:
|
|
50
|
+
msg = (
|
|
51
|
+
f"No adapter can handle {root}. "
|
|
52
|
+
f"Available: {available}. "
|
|
53
|
+
"Use --lang to specify explicitly."
|
|
54
|
+
)
|
|
55
|
+
raise typer.BadParameter(msg)
|
|
56
|
+
return matched
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_adapter(lang: str) -> LanguageAdapter:
|
|
60
|
+
"""
|
|
61
|
+
Return an initialised adapter for *lang*.
|
|
62
|
+
|
|
63
|
+
Tries the registry first; falls back to direct import for adapters that
|
|
64
|
+
may not yet be registered via entry points.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
return adapter_registry.load(lang)()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
if lang == "python":
|
|
72
|
+
from graphlens_python import PythonAdapter
|
|
73
|
+
|
|
74
|
+
return PythonAdapter()
|
|
75
|
+
if lang == "typescript":
|
|
76
|
+
from graphlens_typescript import TypescriptAdapter
|
|
77
|
+
|
|
78
|
+
return TypescriptAdapter()
|
|
79
|
+
|
|
80
|
+
msg = f"Unknown or unavailable adapter: {lang!r}"
|
|
81
|
+
raise typer.BadParameter(msg)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def merge_graph(target: GraphLens, source: GraphLens) -> None:
|
|
85
|
+
"""Merge *source* into *target* in-place, skipping duplicate node IDs."""
|
|
86
|
+
for nid, node in source.nodes.items():
|
|
87
|
+
if nid not in target.nodes:
|
|
88
|
+
target.add_node(node)
|
|
89
|
+
for rel in source.relations:
|
|
90
|
+
target.add_relation(rel)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_analysis(
|
|
94
|
+
root: Path,
|
|
95
|
+
langs: list[str],
|
|
96
|
+
*,
|
|
97
|
+
verbose: bool = True,
|
|
98
|
+
) -> tuple[GraphLens, float]:
|
|
99
|
+
"""Analyse *root* with each adapter; return merged graph and elapsed."""
|
|
100
|
+
combined = GraphLens()
|
|
101
|
+
t0 = time.monotonic()
|
|
102
|
+
for lang in langs:
|
|
103
|
+
if verbose:
|
|
104
|
+
typer.echo(f" [{lang}] analysing {root} …")
|
|
105
|
+
adapter = load_adapter(lang)
|
|
106
|
+
g = adapter.analyze(root)
|
|
107
|
+
if verbose:
|
|
108
|
+
typer.echo(
|
|
109
|
+
f" [{lang}] {len(g.nodes)} nodes,"
|
|
110
|
+
f" {len(g.relations)} relations"
|
|
111
|
+
)
|
|
112
|
+
merge_graph(combined, g)
|
|
113
|
+
return combined, time.monotonic() - t0
|
graphlens_cli/_neo4j.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""graphlens neo4j — export a code graph to Neo4j."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from graphlens_cli._app import app, resolve_langs, run_analysis
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from neo4j import Driver
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Serialisation helpers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_SIMPLE_TYPES = (str, int, float, bool)
|
|
22
|
+
|
|
23
|
+
_CONSTRAINT_CYPHER = """\
|
|
24
|
+
CREATE CONSTRAINT code_node_id IF NOT EXISTS
|
|
25
|
+
FOR (n:Code) REQUIRE n.id IS UNIQUE
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_WIPE_CYPHER = "MATCH (n:Code) DETACH DELETE n"
|
|
29
|
+
|
|
30
|
+
_NODE_CYPHER = """\
|
|
31
|
+
UNWIND $batch AS props
|
|
32
|
+
MERGE (n:Code {{id: props.id}})
|
|
33
|
+
SET n += props, n:{label}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_REL_CYPHER = """\
|
|
37
|
+
UNWIND $batch AS row
|
|
38
|
+
MATCH (src:Code {{id: row.source_id}})
|
|
39
|
+
MATCH (dst:Code {{id: row.target_id}})
|
|
40
|
+
MERGE (src)-[r:{rel_type}]->(dst)
|
|
41
|
+
SET r += row.props
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _node_props(node: Any) -> dict[str, Any]:
|
|
46
|
+
"""Flatten a graphlens Node into Neo4j-safe scalar properties."""
|
|
47
|
+
props: dict[str, Any] = {
|
|
48
|
+
"id": node.id,
|
|
49
|
+
"kind": node.kind.value,
|
|
50
|
+
"name": node.name,
|
|
51
|
+
"qualified_name": node.qualified_name,
|
|
52
|
+
}
|
|
53
|
+
if node.file_path is not None:
|
|
54
|
+
props["file_path"] = node.file_path
|
|
55
|
+
if node.span is not None:
|
|
56
|
+
props["span_start_line"] = node.span.start_line
|
|
57
|
+
props["span_start_col"] = node.span.start_col
|
|
58
|
+
props["span_end_line"] = node.span.end_line
|
|
59
|
+
props["span_end_col"] = node.span.end_col
|
|
60
|
+
for k, v in node.metadata.items():
|
|
61
|
+
if isinstance(v, _SIMPLE_TYPES):
|
|
62
|
+
props[f"meta_{k}"] = v
|
|
63
|
+
return props
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _rel_props(rel: Any) -> dict[str, Any]:
|
|
67
|
+
"""Flatten relation metadata into Neo4j-safe scalar properties."""
|
|
68
|
+
return {
|
|
69
|
+
f"meta_{k}": v
|
|
70
|
+
for k, v in rel.metadata.items()
|
|
71
|
+
if isinstance(v, _SIMPLE_TYPES)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _node_label(node: Any) -> str:
|
|
76
|
+
"""Map NodeKind to a PascalCase Neo4j label (e.g. ExternalSymbol)."""
|
|
77
|
+
return node.kind.value.replace("_", " ").title().replace(" ", "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _batches(items: list[Any], size: int) -> Iterator[list[Any]]:
|
|
81
|
+
"""Yield successive *size*-length slices of *items*."""
|
|
82
|
+
for i in range(0, len(items), size):
|
|
83
|
+
yield items[i : i + size]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _import_nodes(driver: Driver, nodes: list[Any], batch_size: int) -> int:
|
|
87
|
+
"""Merge all *nodes* into Neo4j; return the total count written."""
|
|
88
|
+
by_label: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
89
|
+
for n in nodes:
|
|
90
|
+
by_label[_node_label(n)].append(_node_props(n))
|
|
91
|
+
|
|
92
|
+
total = 0
|
|
93
|
+
with driver.session() as session:
|
|
94
|
+
for lbl, props_list in by_label.items():
|
|
95
|
+
cypher = _NODE_CYPHER.format(label=lbl)
|
|
96
|
+
for batch in _batches(props_list, batch_size):
|
|
97
|
+
session.run(cypher, batch=batch)
|
|
98
|
+
total += len(batch)
|
|
99
|
+
return total
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _import_relations(
|
|
103
|
+
driver: Driver, relations: list[Any], batch_size: int
|
|
104
|
+
) -> int:
|
|
105
|
+
"""Merge all *relations* into Neo4j grouped by type; return count."""
|
|
106
|
+
by_type: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
107
|
+
for r in relations:
|
|
108
|
+
by_type[r.kind.value.upper()].append({
|
|
109
|
+
"source_id": r.source_id,
|
|
110
|
+
"target_id": r.target_id,
|
|
111
|
+
"props": _rel_props(r),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
total = 0
|
|
115
|
+
with driver.session() as session:
|
|
116
|
+
for rel_type, rows in by_type.items():
|
|
117
|
+
cypher = _REL_CYPHER.format(rel_type=rel_type)
|
|
118
|
+
for batch in _batches(rows, batch_size):
|
|
119
|
+
session.run(cypher, batch=batch)
|
|
120
|
+
total += len(batch)
|
|
121
|
+
return total
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# CLI command
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def neo4j(
|
|
130
|
+
root: Annotated[
|
|
131
|
+
Path,
|
|
132
|
+
typer.Argument(
|
|
133
|
+
help="Project root to analyse",
|
|
134
|
+
exists=True,
|
|
135
|
+
file_okay=False,
|
|
136
|
+
resolve_path=True,
|
|
137
|
+
),
|
|
138
|
+
],
|
|
139
|
+
lang: Annotated[
|
|
140
|
+
str,
|
|
141
|
+
typer.Option(
|
|
142
|
+
help="Adapter(s): auto | python | typescript | python,typescript",
|
|
143
|
+
show_default=True,
|
|
144
|
+
),
|
|
145
|
+
] = "auto",
|
|
146
|
+
uri: Annotated[
|
|
147
|
+
str,
|
|
148
|
+
typer.Option(help="Neo4j Bolt URI", show_default=True),
|
|
149
|
+
] = "bolt://localhost:7687",
|
|
150
|
+
user: Annotated[
|
|
151
|
+
str,
|
|
152
|
+
typer.Option(help="Neo4j username", show_default=True),
|
|
153
|
+
] = "neo4j",
|
|
154
|
+
password: Annotated[
|
|
155
|
+
str,
|
|
156
|
+
typer.Option(help="Neo4j password", show_default=True),
|
|
157
|
+
] = "password",
|
|
158
|
+
wipe: Annotated[
|
|
159
|
+
bool,
|
|
160
|
+
typer.Option("--wipe/--no-wipe", help="Wipe :Code nodes first"),
|
|
161
|
+
] = False,
|
|
162
|
+
batch_size: Annotated[
|
|
163
|
+
int,
|
|
164
|
+
typer.Option("--batch-size", help="Items per Cypher batch", min=1),
|
|
165
|
+
] = 500,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Export a code graph to a running Neo4j instance."""
|
|
168
|
+
try:
|
|
169
|
+
from neo4j import GraphDatabase
|
|
170
|
+
except ImportError:
|
|
171
|
+
typer.echo(
|
|
172
|
+
"neo4j driver not installed. Run: pip install neo4j",
|
|
173
|
+
err=True,
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(1) from None
|
|
176
|
+
|
|
177
|
+
langs = resolve_langs(lang, root)
|
|
178
|
+
typer.echo(f"Analysing {root} [lang={', '.join(langs)}]")
|
|
179
|
+
graph, elapsed = run_analysis(root, langs)
|
|
180
|
+
typer.echo(
|
|
181
|
+
f" {len(graph.nodes)} nodes, {len(graph.relations)} relations"
|
|
182
|
+
f" ({elapsed:.2f}s)\n"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
typer.echo(f"Connecting to {uri} …")
|
|
186
|
+
driver = GraphDatabase.driver(uri, auth=(user, password))
|
|
187
|
+
try:
|
|
188
|
+
driver.verify_connectivity()
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
typer.echo(f"error: cannot connect to Neo4j: {exc}", err=True)
|
|
191
|
+
driver.close()
|
|
192
|
+
raise typer.Exit(1) from exc
|
|
193
|
+
|
|
194
|
+
with driver.session() as session:
|
|
195
|
+
session.run(_CONSTRAINT_CYPHER)
|
|
196
|
+
if wipe:
|
|
197
|
+
result = session.run(_WIPE_CYPHER)
|
|
198
|
+
deleted = result.consume().counters.nodes_deleted
|
|
199
|
+
typer.echo(f" wiped {deleted} existing :Code nodes\n")
|
|
200
|
+
|
|
201
|
+
import time as _time
|
|
202
|
+
|
|
203
|
+
typer.echo("Importing nodes …")
|
|
204
|
+
t1 = _time.monotonic()
|
|
205
|
+
n_nodes = _import_nodes(driver, list(graph.nodes.values()), batch_size)
|
|
206
|
+
typer.echo(f" {n_nodes} nodes ({_time.monotonic() - t1:.2f}s)")
|
|
207
|
+
|
|
208
|
+
typer.echo("Importing relations …")
|
|
209
|
+
t2 = _time.monotonic()
|
|
210
|
+
n_rels = _import_relations(driver, graph.relations, batch_size)
|
|
211
|
+
typer.echo(f" {n_rels} relations ({_time.monotonic() - t2:.2f}s)")
|
|
212
|
+
|
|
213
|
+
driver.close()
|
|
214
|
+
typer.echo("\nDone. Explore at http://localhost:7474")
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""graphlens visualize — interactive HTML code-graph viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import webbrowser
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from graphlens import GraphLens, NodeKind, RelationKind
|
|
13
|
+
|
|
14
|
+
from graphlens_cli._app import app, resolve_langs, run_analysis
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Visual style constants
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
_NODE_COLOR: dict[str, str] = {
|
|
21
|
+
"project": "#FFD700",
|
|
22
|
+
"module": "#4A90D9",
|
|
23
|
+
"file": "#7BB3E0",
|
|
24
|
+
"class": "#5CB85C",
|
|
25
|
+
"function": "#F0A830",
|
|
26
|
+
"method": "#F5C842",
|
|
27
|
+
"parameter": "#AAAAAA",
|
|
28
|
+
"import": "#9B59B6",
|
|
29
|
+
"external_symbol": "#888888",
|
|
30
|
+
"dependency": "#FF6B9D",
|
|
31
|
+
"variable": "#7FC97F",
|
|
32
|
+
"attribute": "#A8D8A8",
|
|
33
|
+
"type_alias": "#DDA0DD",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_ORIGIN_COLOR: dict[str, str] = {
|
|
37
|
+
"stdlib": "#606060",
|
|
38
|
+
"third_party": "#994444",
|
|
39
|
+
"internal": "#3A6080",
|
|
40
|
+
"unknown": "#555555",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_EDGE_COLOR: dict[str, str] = {
|
|
44
|
+
"contains": "#444466",
|
|
45
|
+
"declares": "#444466",
|
|
46
|
+
"calls": "#E74C3C",
|
|
47
|
+
"references": "#E67E22",
|
|
48
|
+
"inherits_from": "#8E44AD",
|
|
49
|
+
"has_type": "#2980B9",
|
|
50
|
+
"imports": "#27AE60",
|
|
51
|
+
"resolves_to": "#1A7A40",
|
|
52
|
+
"depends_on": "#FF6B9D",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_NODE_SHAPE: dict[NodeKind, str] = {
|
|
56
|
+
NodeKind.PROJECT: "star",
|
|
57
|
+
NodeKind.MODULE: "box",
|
|
58
|
+
NodeKind.FILE: "box",
|
|
59
|
+
NodeKind.CLASS: "ellipse",
|
|
60
|
+
NodeKind.FUNCTION: "dot",
|
|
61
|
+
NodeKind.METHOD: "dot",
|
|
62
|
+
NodeKind.PARAMETER: "triangle",
|
|
63
|
+
NodeKind.IMPORT: "diamond",
|
|
64
|
+
NodeKind.EXTERNAL_SYMBOL: "square",
|
|
65
|
+
NodeKind.DEPENDENCY: "hexagon",
|
|
66
|
+
NodeKind.VARIABLE: "triangleDown",
|
|
67
|
+
NodeKind.ATTRIBUTE: "triangleDown",
|
|
68
|
+
NodeKind.TYPE_ALIAS: "diamond",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_STRUCTURAL = {RelationKind.CONTAINS, RelationKind.DECLARES}
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Graph → vis.js data
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_vis_data(
|
|
79
|
+
graph: GraphLens,
|
|
80
|
+
*,
|
|
81
|
+
show_external: bool = False,
|
|
82
|
+
show_structure: bool = False,
|
|
83
|
+
max_nodes: int = 1500,
|
|
84
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
85
|
+
"""
|
|
86
|
+
Convert a *graph* into vis.js-compatible node/edge dicts.
|
|
87
|
+
|
|
88
|
+
Nodes above *max_nodes* are pruned by degree (lowest first), but
|
|
89
|
+
PROJECT/MODULE/FILE nodes are always kept.
|
|
90
|
+
"""
|
|
91
|
+
nodes_map = graph.nodes
|
|
92
|
+
|
|
93
|
+
degree: dict[str, int] = defaultdict(int)
|
|
94
|
+
for r in graph.relations:
|
|
95
|
+
degree[r.source_id] += 1
|
|
96
|
+
degree[r.target_id] += 1
|
|
97
|
+
|
|
98
|
+
candidate_ids: set[str] = set()
|
|
99
|
+
for nid, node in nodes_map.items():
|
|
100
|
+
if node.kind == NodeKind.EXTERNAL_SYMBOL and not show_external:
|
|
101
|
+
continue
|
|
102
|
+
candidate_ids.add(nid)
|
|
103
|
+
|
|
104
|
+
if len(candidate_ids) > max_nodes:
|
|
105
|
+
pinned = {
|
|
106
|
+
nid
|
|
107
|
+
for nid in candidate_ids
|
|
108
|
+
if nodes_map[nid].kind
|
|
109
|
+
in (NodeKind.PROJECT, NodeKind.MODULE, NodeKind.FILE)
|
|
110
|
+
}
|
|
111
|
+
others = sorted(
|
|
112
|
+
candidate_ids - pinned,
|
|
113
|
+
key=lambda nid: degree[nid],
|
|
114
|
+
reverse=True,
|
|
115
|
+
)
|
|
116
|
+
cap = max(0, max_nodes - len(pinned))
|
|
117
|
+
candidate_ids = pinned | set(others[:cap])
|
|
118
|
+
|
|
119
|
+
vis_nodes: list[dict[str, Any]] = []
|
|
120
|
+
for nid in candidate_ids:
|
|
121
|
+
node = nodes_map[nid]
|
|
122
|
+
kind = node.kind.value
|
|
123
|
+
|
|
124
|
+
if node.kind == NodeKind.EXTERNAL_SYMBOL:
|
|
125
|
+
origin = str(node.metadata.get("origin", "unknown"))
|
|
126
|
+
bg = _ORIGIN_COLOR.get(origin, "#555555")
|
|
127
|
+
else:
|
|
128
|
+
bg = _NODE_COLOR.get(kind, "#888888")
|
|
129
|
+
|
|
130
|
+
meta_skip = {"name_span"}
|
|
131
|
+
meta_lines = "".join(
|
|
132
|
+
f"<br/><span style='color:#aaa'>{k}:</span> {v}"
|
|
133
|
+
for k, v in node.metadata.items()
|
|
134
|
+
if k not in meta_skip
|
|
135
|
+
)
|
|
136
|
+
is_callable = node.kind in (NodeKind.FUNCTION, NodeKind.METHOD)
|
|
137
|
+
callers_btn = (
|
|
138
|
+
"<br/><br/>"
|
|
139
|
+
"<button onclick=\"showCallers('"
|
|
140
|
+
+ nid
|
|
141
|
+
+ "',this)\" "
|
|
142
|
+
"style='background:#1f2937;border:1px solid #e94560;color:#ff7b7b;"
|
|
143
|
+
"padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px'>"
|
|
144
|
+
"Show callers</button>"
|
|
145
|
+
) if is_callable else ""
|
|
146
|
+
|
|
147
|
+
tooltip = (
|
|
148
|
+
"<div style='font-family:monospace;max-width:320px'>"
|
|
149
|
+
f"<b style='color:#FFD700'>{node.qualified_name}</b>"
|
|
150
|
+
f"<br/><i style='color:#aaa'>{kind}</i>"
|
|
151
|
+
f"{meta_lines}"
|
|
152
|
+
f"{callers_btn}"
|
|
153
|
+
"</div>"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
label = node.name or node.qualified_name.split(".")[-1] or kind
|
|
157
|
+
vis_nodes.append({
|
|
158
|
+
"id": nid,
|
|
159
|
+
"label": label,
|
|
160
|
+
"title": tooltip,
|
|
161
|
+
"color": {
|
|
162
|
+
"background": bg,
|
|
163
|
+
"border": "#222",
|
|
164
|
+
"highlight": {"background": bg, "border": "#fff"},
|
|
165
|
+
"hover": {"background": bg, "border": "#eee"},
|
|
166
|
+
},
|
|
167
|
+
"group": kind,
|
|
168
|
+
"shape": _NODE_SHAPE.get(node.kind, "dot"),
|
|
169
|
+
"font": {"size": 12, "color": "#e0e0e0"},
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
vis_edges: list[dict[str, Any]] = []
|
|
173
|
+
for idx, rel in enumerate(graph.relations):
|
|
174
|
+
if (
|
|
175
|
+
rel.source_id not in candidate_ids
|
|
176
|
+
or rel.target_id not in candidate_ids
|
|
177
|
+
):
|
|
178
|
+
continue
|
|
179
|
+
if rel.kind in _STRUCTURAL and not show_structure:
|
|
180
|
+
continue
|
|
181
|
+
kind = rel.kind.value
|
|
182
|
+
color = _EDGE_COLOR.get(kind, "#666666")
|
|
183
|
+
is_structural = rel.kind in _STRUCTURAL
|
|
184
|
+
vis_edges.append({
|
|
185
|
+
"id": idx,
|
|
186
|
+
"from": rel.source_id,
|
|
187
|
+
"to": rel.target_id,
|
|
188
|
+
"_kind": kind,
|
|
189
|
+
"title": kind,
|
|
190
|
+
"color": {
|
|
191
|
+
"color": color,
|
|
192
|
+
"highlight": color,
|
|
193
|
+
"hover": "#ffffff",
|
|
194
|
+
"opacity": 0.75,
|
|
195
|
+
},
|
|
196
|
+
"arrows": "to",
|
|
197
|
+
"width": 1 if is_structural else 2,
|
|
198
|
+
"dashes": is_structural,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return vis_nodes, vis_edges
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# HTML rendering
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def render_html(
|
|
210
|
+
project_name: str,
|
|
211
|
+
vis_nodes: list[dict[str, Any]],
|
|
212
|
+
vis_edges: list[dict[str, Any]],
|
|
213
|
+
stats: dict[str, Any],
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Render a self-contained vis.js HTML page for the given graph data."""
|
|
216
|
+
nodes_json = json.dumps(vis_nodes, ensure_ascii=False)
|
|
217
|
+
edges_json = json.dumps(vis_edges, ensure_ascii=False)
|
|
218
|
+
|
|
219
|
+
node_kinds = sorted({n["group"] for n in vis_nodes})
|
|
220
|
+
edge_kinds = sorted({e["_kind"] for e in vis_edges})
|
|
221
|
+
|
|
222
|
+
def node_filter_row(k: str) -> str:
|
|
223
|
+
color = _NODE_COLOR.get(k, "#888")
|
|
224
|
+
return (
|
|
225
|
+
f'<label class="filter-row">'
|
|
226
|
+
f'<input type="checkbox" class="nf" value="{k}" checked>'
|
|
227
|
+
f'<span class="chip" style="background:{color}"></span>'
|
|
228
|
+
f"{k}</label>"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def edge_filter_row(k: str) -> str:
|
|
232
|
+
color = _EDGE_COLOR.get(k, "#666")
|
|
233
|
+
return (
|
|
234
|
+
f'<label class="filter-row">'
|
|
235
|
+
f'<input type="checkbox" class="ef" value="{k}" checked>'
|
|
236
|
+
f'<span class="chip" style="background:{color}"></span>'
|
|
237
|
+
f"{k}</label>"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
node_filters = "\n".join(node_filter_row(k) for k in node_kinds)
|
|
241
|
+
edge_filters = "\n".join(edge_filter_row(k) for k in edge_kinds)
|
|
242
|
+
is_pruned = len(vis_nodes) < stats.get("total_nodes", 0)
|
|
243
|
+
|
|
244
|
+
return f"""<!DOCTYPE html>
|
|
245
|
+
<html lang="en">
|
|
246
|
+
<head>
|
|
247
|
+
<meta charset="utf-8"/>
|
|
248
|
+
<title>graphlens — {project_name}</title>
|
|
249
|
+
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
|
250
|
+
<style>
|
|
251
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
252
|
+
body {{ display: flex; height: 100vh; font-family: -apple-system,system-ui,sans-serif;
|
|
253
|
+
background: #0d1117; color: #c9d1d9; overflow: hidden; }}
|
|
254
|
+
#sidebar {{
|
|
255
|
+
width: 220px; min-width: 220px; background: #161b22;
|
|
256
|
+
border-right: 1px solid #30363d; display: flex; flex-direction: column;
|
|
257
|
+
overflow: hidden;
|
|
258
|
+
}}
|
|
259
|
+
#sidebar-header {{ padding: 12px; border-bottom: 1px solid #30363d; flex-shrink: 0; }}
|
|
260
|
+
#sidebar-header h2 {{ font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
|
|
261
|
+
color: #e94560; margin-bottom: 4px; }}
|
|
262
|
+
#project-name {{ font-size: 13px; font-weight: 600; color: #FFD700;
|
|
263
|
+
word-break: break-all; line-height: 1.3; }}
|
|
264
|
+
#sidebar-body {{ flex: 1; overflow-y: auto; padding: 10px; display: flex;
|
|
265
|
+
flex-direction: column; gap: 12px; }}
|
|
266
|
+
.stats-grid {{ display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; font-size: 11px; }}
|
|
267
|
+
.stats-grid .k {{ color: #8b949e; }}
|
|
268
|
+
.stats-grid .v {{ color: #e6edf3; font-weight: 600; text-align: right; }}
|
|
269
|
+
.section-title {{ font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
|
|
270
|
+
color: #8b949e; margin-bottom: 4px; }}
|
|
271
|
+
.filter-section {{ display: flex; flex-direction: column; gap: 1px; }}
|
|
272
|
+
.filter-row {{ display: flex; align-items: center; gap: 6px; font-size: 11px;
|
|
273
|
+
cursor: pointer; padding: 2px 3px; border-radius: 3px; }}
|
|
274
|
+
.filter-row:hover {{ background: #21262d; }}
|
|
275
|
+
.filter-row input {{ cursor: pointer; accent-color: #58a6ff; }}
|
|
276
|
+
.chip {{ width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }}
|
|
277
|
+
.btn-group {{ display: flex; flex-direction: column; gap: 4px; }}
|
|
278
|
+
.btn {{
|
|
279
|
+
background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
|
|
280
|
+
padding: 5px 8px; border-radius: 6px; cursor: pointer; font-size: 11px;
|
|
281
|
+
text-align: left; transition: background 0.15s;
|
|
282
|
+
}}
|
|
283
|
+
.btn:hover {{ background: #30363d; }}
|
|
284
|
+
.btn.primary {{ border-color: #e94560; color: #ff7b7b; }}
|
|
285
|
+
.btn.primary:hover {{ background: #e94560; color: #fff; }}
|
|
286
|
+
#search {{ width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
|
287
|
+
padding: 5px 8px; border-radius: 6px; font-size: 11px; }}
|
|
288
|
+
#search::placeholder {{ color: #484f58; }}
|
|
289
|
+
#search:focus {{ outline: none; border-color: #58a6ff; }}
|
|
290
|
+
#callers-panel {{ display: none; flex-direction: column; gap: 4px; }}
|
|
291
|
+
#callers-list {{ display: flex; flex-direction: column; gap: 1px;
|
|
292
|
+
max-height: 300px; overflow-y: auto; }}
|
|
293
|
+
.caller-row {{
|
|
294
|
+
font-size: 11px; padding: 3px 6px; border-radius: 3px; cursor: pointer;
|
|
295
|
+
color: #c9d1d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
296
|
+
}}
|
|
297
|
+
.caller-row:hover {{ background: #21262d; }}
|
|
298
|
+
.caller-row .caller-kind {{ color: #E74C3C; font-size: 10px; margin-right: 4px; }}
|
|
299
|
+
#graph-wrap {{ flex: 1; display: flex; flex-direction: column; position: relative; }}
|
|
300
|
+
#focus-bar {{
|
|
301
|
+
display: none; align-items: center; gap: 10px;
|
|
302
|
+
padding: 7px 14px; background: #1c2128; border-bottom: 1px solid #e94560;
|
|
303
|
+
flex-shrink: 0;
|
|
304
|
+
}}
|
|
305
|
+
#focus-bar .back-btn {{
|
|
306
|
+
background: none; border: 1px solid #e94560; color: #ff7b7b;
|
|
307
|
+
padding: 3px 10px; border-radius: 4px; cursor: pointer; font-size: 11px;
|
|
308
|
+
}}
|
|
309
|
+
#focus-bar .back-btn:hover {{ background: #e94560; color: #fff; }}
|
|
310
|
+
#focus-label {{ font-size: 12px; color: #c9d1d9; }}
|
|
311
|
+
#focus-count {{ font-size: 11px; color: #8b949e; margin-left: auto; }}
|
|
312
|
+
#graph {{ flex: 1; position: relative; }}
|
|
313
|
+
#info-panel {{
|
|
314
|
+
position: absolute; bottom: 16px; right: 16px;
|
|
315
|
+
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
316
|
+
padding: 12px 14px; max-width: 380px; font-size: 12px;
|
|
317
|
+
display: none; z-index: 10; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
318
|
+
max-height: 50vh; overflow-y: auto;
|
|
319
|
+
}}
|
|
320
|
+
#info-panel b {{ color: #FFD700; }}
|
|
321
|
+
#info-close {{ float: right; cursor: pointer; color: #8b949e; font-size: 14px; line-height: 1; }}
|
|
322
|
+
#info-close:hover {{ color: #fff; }}
|
|
323
|
+
#pruned-warning {{
|
|
324
|
+
position: absolute; top: 10px; right: 10px;
|
|
325
|
+
background: #3d1f00; border: 1px solid #e67e22; border-radius: 6px;
|
|
326
|
+
padding: 6px 10px; font-size: 11px; color: #e67e22; z-index: 10;
|
|
327
|
+
display: {"block" if is_pruned else "none"};
|
|
328
|
+
}}
|
|
329
|
+
</style>
|
|
330
|
+
</head>
|
|
331
|
+
<body>
|
|
332
|
+
<div id="sidebar">
|
|
333
|
+
<div id="sidebar-header">
|
|
334
|
+
<h2>graphlens</h2>
|
|
335
|
+
<div id="project-name">{project_name}</div>
|
|
336
|
+
</div>
|
|
337
|
+
<div id="sidebar-body">
|
|
338
|
+
<div>
|
|
339
|
+
<div class="section-title">Visible</div>
|
|
340
|
+
<div class="stats-grid">
|
|
341
|
+
<span class="k">Nodes</span><span class="v" id="s-nodes">{len(vis_nodes)}</span>
|
|
342
|
+
<span class="k">Edges</span><span class="v" id="s-edges">{len(vis_edges)}</span>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
<div>
|
|
346
|
+
<div class="section-title">Total (analysed)</div>
|
|
347
|
+
<div class="stats-grid">
|
|
348
|
+
<span class="k">Nodes</span><span class="v">{stats.get("total_nodes", 0)}</span>
|
|
349
|
+
<span class="k">Edges</span><span class="v">{stats.get("total_relations", 0)}</span>
|
|
350
|
+
<span class="k">Lang</span><span class="v">{stats.get("lang", "?")}</span>
|
|
351
|
+
<span class="k">Time</span><span class="v">{stats.get("elapsed", 0):.2f}s</span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div>
|
|
355
|
+
<div class="section-title">Search</div>
|
|
356
|
+
<input id="search" type="text" placeholder="Filter by label…"/>
|
|
357
|
+
</div>
|
|
358
|
+
<div id="normal-filters">
|
|
359
|
+
<div>
|
|
360
|
+
<div class="section-title">Node kinds</div>
|
|
361
|
+
<div class="filter-section" id="node-filters">
|
|
362
|
+
{node_filters}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
<div>
|
|
366
|
+
<div class="section-title">Edge kinds</div>
|
|
367
|
+
<div class="filter-section" id="edge-filters">
|
|
368
|
+
{edge_filters}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
<div id="callers-panel">
|
|
373
|
+
<div class="section-title" id="callers-title">Callers</div>
|
|
374
|
+
<div id="callers-list"></div>
|
|
375
|
+
</div>
|
|
376
|
+
<div>
|
|
377
|
+
<div class="section-title">Controls</div>
|
|
378
|
+
<div class="btn-group">
|
|
379
|
+
<button class="btn primary" onclick="network.fit()">Fit to screen</button>
|
|
380
|
+
<button class="btn" id="physics-btn" onclick="togglePhysics()">Disable physics</button>
|
|
381
|
+
<button class="btn" onclick="selectAll(true)">Check all</button>
|
|
382
|
+
<button class="btn" onclick="selectAll(false)">Uncheck all</button>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
<div id="graph-wrap">
|
|
388
|
+
<div id="focus-bar">
|
|
389
|
+
<button class="back-btn" onclick="exitFocus()">← Back</button>
|
|
390
|
+
<span id="focus-label"></span>
|
|
391
|
+
<span id="focus-count"></span>
|
|
392
|
+
</div>
|
|
393
|
+
<div id="graph">
|
|
394
|
+
<div id="pruned-warning">
|
|
395
|
+
⚠ Showing {len(vis_nodes)} / {stats.get("total_nodes", 0)} nodes (--max-nodes to adjust)
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<div id="info-panel">
|
|
400
|
+
<span id="info-close" onclick="closeInfo()">✕</span>
|
|
401
|
+
<div id="info-body"></div>
|
|
402
|
+
</div>
|
|
403
|
+
<script>
|
|
404
|
+
var ALL_NODES = {nodes_json};
|
|
405
|
+
var ALL_EDGES = {edges_json};
|
|
406
|
+
var nodesDS = new vis.DataSet(ALL_NODES);
|
|
407
|
+
var edgesDS = new vis.DataSet(ALL_EDGES);
|
|
408
|
+
var options = {{
|
|
409
|
+
nodes: {{ borderWidth: 1, size: 14, font: {{ size: 12, color: "#c9d1d9" }} }},
|
|
410
|
+
edges: {{
|
|
411
|
+
smooth: {{ type: "dynamic" }},
|
|
412
|
+
font: {{ size: 10, color: "#8b949e", align: "middle" }},
|
|
413
|
+
selectionWidth: 3,
|
|
414
|
+
}},
|
|
415
|
+
physics: {{
|
|
416
|
+
enabled: true,
|
|
417
|
+
stabilization: {{ iterations: 200, fit: true }},
|
|
418
|
+
barnesHut: {{
|
|
419
|
+
gravitationalConstant: -4000, springConstant: 0.04,
|
|
420
|
+
springLength: 130, damping: 0.09,
|
|
421
|
+
}},
|
|
422
|
+
}},
|
|
423
|
+
interaction: {{
|
|
424
|
+
tooltipDelay: 150, hideEdgesOnDrag: true, multiselect: true, hover: true,
|
|
425
|
+
}},
|
|
426
|
+
layout: {{ improvedLayout: false }},
|
|
427
|
+
}};
|
|
428
|
+
var container = document.getElementById("graph");
|
|
429
|
+
var network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, options);
|
|
430
|
+
var physicsEnabled = true;
|
|
431
|
+
var inFocusMode = false;
|
|
432
|
+
var searchVal = "";
|
|
433
|
+
var nodeById = {{}};
|
|
434
|
+
ALL_NODES.forEach(function(n) {{ nodeById[n.id] = n; }});
|
|
435
|
+
network.on("click", function(params) {{
|
|
436
|
+
if (params.nodes.length === 1) {{
|
|
437
|
+
var node = nodeById[params.nodes[0]];
|
|
438
|
+
if (node) showInfo(node);
|
|
439
|
+
}} else if (!params.nodes.length && !params.edges.length) {{
|
|
440
|
+
closeInfo();
|
|
441
|
+
if (inFocusMode) exitFocus();
|
|
442
|
+
}}
|
|
443
|
+
}});
|
|
444
|
+
function showInfo(node) {{
|
|
445
|
+
document.getElementById("info-body").innerHTML = node.title || node.label;
|
|
446
|
+
document.getElementById("info-panel").style.display = "block";
|
|
447
|
+
}}
|
|
448
|
+
function closeInfo() {{ document.getElementById("info-panel").style.display = "none"; }}
|
|
449
|
+
function showCallers(nodeId) {{
|
|
450
|
+
var target = nodeById[nodeId];
|
|
451
|
+
if (!target) return;
|
|
452
|
+
var CALLER_KINDS = new Set(["calls", "references"]);
|
|
453
|
+
var inEdges = ALL_EDGES.filter(function(e) {{
|
|
454
|
+
return e.to === nodeId && CALLER_KINDS.has(e._kind);
|
|
455
|
+
}});
|
|
456
|
+
var callerIds = new Set(inEdges.map(function(e) {{ return e.from; }}));
|
|
457
|
+
callerIds.add(nodeId);
|
|
458
|
+
var focusNodes = ALL_NODES.filter(function(n) {{ return callerIds.has(n.id); }});
|
|
459
|
+
nodesDS.clear(); nodesDS.add(focusNodes);
|
|
460
|
+
edgesDS.clear(); edgesDS.add(inEdges);
|
|
461
|
+
document.getElementById("s-nodes").textContent = focusNodes.length;
|
|
462
|
+
document.getElementById("s-edges").textContent = inEdges.length;
|
|
463
|
+
network.fit();
|
|
464
|
+
document.getElementById("normal-filters").style.display = "none";
|
|
465
|
+
document.getElementById("callers-panel").style.display = "flex";
|
|
466
|
+
document.getElementById("callers-title").textContent = "Callers of " + target.label;
|
|
467
|
+
var list = document.getElementById("callers-list");
|
|
468
|
+
list.innerHTML = "";
|
|
469
|
+
var callerCount = 0;
|
|
470
|
+
inEdges.forEach(function(e) {{
|
|
471
|
+
var caller = nodeById[e.from];
|
|
472
|
+
if (!caller) return;
|
|
473
|
+
callerCount++;
|
|
474
|
+
var row = document.createElement("div");
|
|
475
|
+
row.className = "caller-row";
|
|
476
|
+
row.title = caller.label;
|
|
477
|
+
row.innerHTML = "<span class='caller-kind'>[" + e._kind + "]</span>" + caller.label;
|
|
478
|
+
row.onclick = function() {{ network.selectNodes([e.from]); showInfo(caller); }};
|
|
479
|
+
list.appendChild(row);
|
|
480
|
+
}});
|
|
481
|
+
document.getElementById("focus-bar").style.display = "flex";
|
|
482
|
+
document.getElementById("focus-label").textContent = target.label + " (" + target.group + ")";
|
|
483
|
+
document.getElementById("focus-count").textContent =
|
|
484
|
+
callerCount + " caller" + (callerCount !== 1 ? "s" : "");
|
|
485
|
+
inFocusMode = true;
|
|
486
|
+
closeInfo();
|
|
487
|
+
}}
|
|
488
|
+
function exitFocus() {{
|
|
489
|
+
inFocusMode = false;
|
|
490
|
+
document.getElementById("focus-bar").style.display = "none";
|
|
491
|
+
document.getElementById("callers-panel").style.display = "none";
|
|
492
|
+
document.getElementById("normal-filters").style.display = "block";
|
|
493
|
+
applyFilters();
|
|
494
|
+
}}
|
|
495
|
+
function togglePhysics() {{
|
|
496
|
+
physicsEnabled = !physicsEnabled;
|
|
497
|
+
network.setOptions({{ physics: {{ enabled: physicsEnabled }} }});
|
|
498
|
+
document.getElementById("physics-btn").textContent =
|
|
499
|
+
physicsEnabled ? "Disable physics" : "Enable physics";
|
|
500
|
+
}}
|
|
501
|
+
function applyFilters() {{
|
|
502
|
+
if (inFocusMode) return;
|
|
503
|
+
var activeNodeKinds = new Set();
|
|
504
|
+
document.querySelectorAll(".nf:checked").forEach(function(cb) {{ activeNodeKinds.add(cb.value); }});
|
|
505
|
+
var activeEdgeKinds = new Set();
|
|
506
|
+
document.querySelectorAll(".ef:checked").forEach(function(cb) {{ activeEdgeKinds.add(cb.value); }});
|
|
507
|
+
var q = searchVal.toLowerCase();
|
|
508
|
+
var visNodes = ALL_NODES.filter(function(n) {{
|
|
509
|
+
if (!activeNodeKinds.has(n.group)) return false;
|
|
510
|
+
if (q && n.label.toLowerCase().indexOf(q) === -1) return false;
|
|
511
|
+
return true;
|
|
512
|
+
}});
|
|
513
|
+
var visIds = new Set(visNodes.map(function(n) {{ return n.id; }}));
|
|
514
|
+
var visEdges = ALL_EDGES.filter(function(e) {{
|
|
515
|
+
return visIds.has(e.from) && visIds.has(e.to) && activeEdgeKinds.has(e._kind);
|
|
516
|
+
}});
|
|
517
|
+
nodesDS.clear(); nodesDS.add(visNodes);
|
|
518
|
+
edgesDS.clear(); edgesDS.add(visEdges);
|
|
519
|
+
document.getElementById("s-nodes").textContent = visNodes.length;
|
|
520
|
+
document.getElementById("s-edges").textContent = visEdges.length;
|
|
521
|
+
}}
|
|
522
|
+
function selectAll(checked) {{
|
|
523
|
+
document.querySelectorAll(".nf, .ef").forEach(function(cb) {{ cb.checked = checked; }});
|
|
524
|
+
applyFilters();
|
|
525
|
+
}}
|
|
526
|
+
document.querySelectorAll(".nf, .ef").forEach(function(cb) {{
|
|
527
|
+
cb.addEventListener("change", applyFilters);
|
|
528
|
+
}});
|
|
529
|
+
var searchTimer;
|
|
530
|
+
document.getElementById("search").addEventListener("input", function(e) {{
|
|
531
|
+
searchVal = e.target.value;
|
|
532
|
+
clearTimeout(searchTimer);
|
|
533
|
+
if (inFocusMode) exitFocus();
|
|
534
|
+
searchTimer = setTimeout(applyFilters, 200);
|
|
535
|
+
}});
|
|
536
|
+
network.on("stabilizationIterationsDone", function() {{
|
|
537
|
+
if (physicsEnabled) {{
|
|
538
|
+
network.setOptions({{ physics: {{ enabled: false }} }});
|
|
539
|
+
physicsEnabled = false;
|
|
540
|
+
document.getElementById("physics-btn").textContent = "Enable physics";
|
|
541
|
+
}}
|
|
542
|
+
}});
|
|
543
|
+
</script>
|
|
544
|
+
</body>
|
|
545
|
+
</html>"""
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# ---------------------------------------------------------------------------
|
|
549
|
+
# CLI command
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@app.command()
|
|
554
|
+
def visualize(
|
|
555
|
+
root: Annotated[
|
|
556
|
+
Path,
|
|
557
|
+
typer.Argument(
|
|
558
|
+
help="Project root to analyse",
|
|
559
|
+
exists=True,
|
|
560
|
+
file_okay=False,
|
|
561
|
+
resolve_path=True,
|
|
562
|
+
),
|
|
563
|
+
],
|
|
564
|
+
lang: Annotated[
|
|
565
|
+
str,
|
|
566
|
+
typer.Option(
|
|
567
|
+
help="Adapter(s): auto | python | typescript | python,typescript",
|
|
568
|
+
show_default=True,
|
|
569
|
+
),
|
|
570
|
+
] = "auto",
|
|
571
|
+
output: Annotated[
|
|
572
|
+
Path | None,
|
|
573
|
+
typer.Option("--output", "-o", help="Output HTML file (default: graph-<name>.html)"),
|
|
574
|
+
] = None,
|
|
575
|
+
no_open: Annotated[
|
|
576
|
+
bool,
|
|
577
|
+
typer.Option("--no-open", help="Do not open the browser automatically"),
|
|
578
|
+
] = False,
|
|
579
|
+
show_external: Annotated[
|
|
580
|
+
bool,
|
|
581
|
+
typer.Option("--show-external", help="Include stdlib/third_party external symbol nodes"),
|
|
582
|
+
] = False,
|
|
583
|
+
show_structure: Annotated[
|
|
584
|
+
bool,
|
|
585
|
+
typer.Option("--show-structure", help="Include CONTAINS/DECLARES structural edges"),
|
|
586
|
+
] = False,
|
|
587
|
+
max_nodes: Annotated[
|
|
588
|
+
int,
|
|
589
|
+
typer.Option("--max-nodes", help="Prune low-degree nodes above this count", min=1),
|
|
590
|
+
] = 1500,
|
|
591
|
+
) -> None:
|
|
592
|
+
"""Build an interactive HTML code-graph visualisation."""
|
|
593
|
+
langs = resolve_langs(lang, root)
|
|
594
|
+
typer.echo(f"Analysing {root} [lang={', '.join(langs)}]")
|
|
595
|
+
graph, elapsed = run_analysis(root, langs)
|
|
596
|
+
typer.echo(
|
|
597
|
+
f" {len(graph.nodes)} nodes, {len(graph.relations)} relations"
|
|
598
|
+
f" ({elapsed:.2f}s)"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
vis_nodes, vis_edges = build_vis_data(
|
|
602
|
+
graph,
|
|
603
|
+
show_external=show_external,
|
|
604
|
+
show_structure=show_structure,
|
|
605
|
+
max_nodes=max_nodes,
|
|
606
|
+
)
|
|
607
|
+
typer.echo(f" rendering {len(vis_nodes)} nodes, {len(vis_edges)} edges")
|
|
608
|
+
|
|
609
|
+
project_nodes = [n for n in graph.nodes.values() if n.kind == NodeKind.PROJECT]
|
|
610
|
+
project_name = (
|
|
611
|
+
" + ".join(sorted({n.name for n in project_nodes}))
|
|
612
|
+
if project_nodes
|
|
613
|
+
else root.name
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
stats: dict[str, Any] = {
|
|
617
|
+
"total_nodes": len(graph.nodes),
|
|
618
|
+
"total_relations": len(graph.relations),
|
|
619
|
+
"lang": ", ".join(langs),
|
|
620
|
+
"elapsed": elapsed,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
html = render_html(project_name, vis_nodes, vis_edges, stats)
|
|
624
|
+
|
|
625
|
+
out_name = project_name.replace(" + ", "-").replace(" ", "_")
|
|
626
|
+
out = output or Path(f"graph-{out_name}.html")
|
|
627
|
+
out.write_text(html, encoding="utf-8")
|
|
628
|
+
typer.echo(f" written → {out.resolve()}")
|
|
629
|
+
|
|
630
|
+
if not no_open:
|
|
631
|
+
webbrowser.open(out.resolve().as_uri())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: graphlens-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Command-line interface for graphlens code analysis
|
|
5
|
+
Requires-Dist: graphlens
|
|
6
|
+
Requires-Dist: typer[all]>=0.15
|
|
7
|
+
Requires-Dist: graphlens-python ; extra == 'all'
|
|
8
|
+
Requires-Dist: graphlens-typescript ; extra == 'all'
|
|
9
|
+
Requires-Dist: neo4j ; extra == 'all'
|
|
10
|
+
Requires-Dist: neo4j ; extra == 'neo4j'
|
|
11
|
+
Requires-Dist: graphlens-python ; extra == 'python'
|
|
12
|
+
Requires-Dist: graphlens-typescript ; extra == 'typescript'
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Provides-Extra: all
|
|
15
|
+
Provides-Extra: neo4j
|
|
16
|
+
Provides-Extra: python
|
|
17
|
+
Provides-Extra: typescript
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
graphlens_cli/__init__.py,sha256=8XGX0GfOEL9bOQehZ54QgutaXitdW4eIzRD6mIWBcK4,268
|
|
2
|
+
graphlens_cli/_analyze.py,sha256=C4Pu7riL3NrQeizvWFzcyLf4bCCzW3iEI8qwB-iMDKE,2252
|
|
3
|
+
graphlens_cli/_app.py,sha256=3e51UTSsFTHj0Fufo0vXisul5ufSCf8UIsJV9t-tHuk,3157
|
|
4
|
+
graphlens_cli/_neo4j.py,sha256=GsnbaLBc3JGXsuOchDXluZ6z_i-cxtoYWILLo5unkMk,6647
|
|
5
|
+
graphlens_cli/_visualize.py,sha256=QRbYON237-5iCwkk8N3y9Vw9CDLF0tn0xU3AOyqOHdQ,23103
|
|
6
|
+
graphlens_cli-0.4.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
|
|
7
|
+
graphlens_cli-0.4.0.dist-info/entry_points.txt,sha256=eUk4hEmbK0NsyCx8aiDhBU5jTffnr73iivmdbSuT_Lc,49
|
|
8
|
+
graphlens_cli-0.4.0.dist-info/METADATA,sha256=aOIjwyYBPRxIMqrIubGgGAY6K8CMWh_9Ozy-MOZ0T98,582
|
|
9
|
+
graphlens_cli-0.4.0.dist-info/RECORD,,
|