graphlens-cli 0.4.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.
@@ -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,45 @@
1
+ [project]
2
+ name = "graphlens-cli"
3
+ version = "0.4.0"
4
+ description = "Command-line interface for graphlens code analysis"
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "graphlens",
8
+ "typer[all]>=0.15",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ python = ["graphlens-python"]
13
+ typescript = ["graphlens-typescript"]
14
+ neo4j = ["neo4j"]
15
+ all = ["graphlens-python", "graphlens-typescript", "neo4j"]
16
+
17
+ [project.scripts]
18
+ graphlens = "graphlens_cli:app"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.9.18,<0.12.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.sources]
25
+ graphlens = { workspace = true }
26
+ graphlens-python = { workspace = true }
27
+ graphlens-typescript = { workspace = true }
28
+
29
+ [tool.bandit]
30
+ skips = ["B101", "B110", "B107"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+
35
+ [tool.coverage.run]
36
+ source = ["graphlens_cli"]
37
+
38
+ [tool.coverage.report]
39
+ fail_under = 85
40
+ show_missing = true
41
+ exclude_lines = [
42
+ "pragma: no cover",
43
+ "if TYPE_CHECKING:",
44
+ "\\.\\.\\.",
45
+ ]
@@ -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}")
@@ -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
@@ -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())