graphlens-cli 0.4.0__tar.gz → 0.5.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.
@@ -1,17 +1,20 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: graphlens-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Command-line interface for graphlens code analysis
5
5
  Requires-Dist: graphlens
6
6
  Requires-Dist: typer[all]>=0.15
7
7
  Requires-Dist: graphlens-python ; extra == 'all'
8
8
  Requires-Dist: graphlens-typescript ; extra == 'all'
9
9
  Requires-Dist: neo4j ; extra == 'all'
10
+ Requires-Dist: mcp>=1.2 ; extra == 'all'
11
+ Requires-Dist: mcp>=1.2 ; extra == 'mcp'
10
12
  Requires-Dist: neo4j ; extra == 'neo4j'
11
13
  Requires-Dist: graphlens-python ; extra == 'python'
12
14
  Requires-Dist: graphlens-typescript ; extra == 'typescript'
13
15
  Requires-Python: >=3.13
14
16
  Provides-Extra: all
17
+ Provides-Extra: mcp
15
18
  Provides-Extra: neo4j
16
19
  Provides-Extra: python
17
20
  Provides-Extra: typescript
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphlens-cli"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Command-line interface for graphlens code analysis"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -12,7 +12,8 @@ dependencies = [
12
12
  python = ["graphlens-python"]
13
13
  typescript = ["graphlens-typescript"]
14
14
  neo4j = ["neo4j"]
15
- all = ["graphlens-python", "graphlens-typescript", "neo4j"]
15
+ mcp = ["mcp>=1.2"]
16
+ all = ["graphlens-python", "graphlens-typescript", "neo4j", "mcp>=1.2"]
16
17
 
17
18
  [project.scripts]
18
19
  graphlens = "graphlens_cli:app"
@@ -1,7 +1,9 @@
1
1
  """graphlens-cli — command-line interface for graphlens code analysis."""
2
2
 
3
3
  import graphlens_cli._analyze
4
+ import graphlens_cli._mcp
4
5
  import graphlens_cli._neo4j
6
+ import graphlens_cli._query
5
7
  import graphlens_cli._visualize # noqa: F401 — registers visualize command
6
8
  from graphlens_cli._app import app
7
9
 
@@ -1,4 +1,4 @@
1
- """graphlens analyze — print graph statistics."""
1
+ """graphlens analyze — print graph statistics or serialize to JSON."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -7,40 +7,14 @@ from pathlib import Path
7
7
  from typing import Annotated
8
8
 
9
9
  import typer
10
- from graphlens import NodeKind, RelationKind
10
+ from graphlens import RESOLVER_STATUS_KEY, GraphLens, NodeKind, RelationKind
11
11
 
12
12
  from graphlens_cli._app import app, resolve_langs, run_analysis
13
13
 
14
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)
15
+ def _print_stats(graph: GraphLens, elapsed: float) -> None:
16
+ """Print node/relation/external/caller statistics for *graph*."""
42
17
  nodes = graph.nodes
43
-
44
18
  typer.echo(
45
19
  f"{len(nodes)} nodes · "
46
20
  f"{len(graph.relations)} relations · "
@@ -78,3 +52,76 @@ def analyze(
78
52
  n = nodes.get(nid)
79
53
  name = n.qualified_name if n else nid
80
54
  typer.echo(f" {count:>4} {name}")
55
+
56
+
57
+ @app.command()
58
+ def analyze(
59
+ root: Annotated[
60
+ Path,
61
+ typer.Argument(
62
+ help="Project root to analyse",
63
+ exists=True,
64
+ file_okay=False,
65
+ resolve_path=True,
66
+ ),
67
+ ],
68
+ lang: Annotated[
69
+ str,
70
+ typer.Option(
71
+ help=(
72
+ "Adapter(s) to use: auto | python | typescript"
73
+ " | python,typescript"
74
+ ),
75
+ show_default=True,
76
+ ),
77
+ ] = "auto",
78
+ output_format: Annotated[
79
+ str,
80
+ typer.Option(
81
+ "--format",
82
+ "-f",
83
+ help="Output format: text (stats) or json (serialized graph)",
84
+ ),
85
+ ] = "text",
86
+ output: Annotated[
87
+ Path | None,
88
+ typer.Option(
89
+ "--output",
90
+ "-o",
91
+ help="Write the serialized graph (JSON) to this path",
92
+ dir_okay=False,
93
+ writable=True,
94
+ ),
95
+ ] = None,
96
+ strict: Annotated[
97
+ bool,
98
+ typer.Option(
99
+ "--strict",
100
+ help="Exit non-zero if the resolver status is not 'ok'",
101
+ ),
102
+ ] = False,
103
+ ) -> None:
104
+ """Analyse a project: print stats, or serialize the graph to JSON."""
105
+ quiet = output_format == "json" and output is None
106
+ langs = resolve_langs(lang, root)
107
+ if not quiet:
108
+ typer.echo(f"Analysing {root} [lang={', '.join(langs)}]\n")
109
+
110
+ graph, elapsed = run_analysis(root, langs, verbose=not quiet)
111
+
112
+ if output is not None:
113
+ output.write_text(graph.to_json(indent=2), encoding="utf-8")
114
+ typer.echo(
115
+ f"Wrote graph JSON to {output} "
116
+ f"({len(graph.nodes)} nodes, {len(graph.relations)} relations)"
117
+ )
118
+ elif output_format == "json":
119
+ typer.echo(graph.to_json(indent=2))
120
+ else:
121
+ _print_stats(graph, elapsed)
122
+
123
+ status = str(graph.metadata.get(RESOLVER_STATUS_KEY, "ok"))
124
+ if not quiet and status != "ok":
125
+ typer.echo(f"\nresolver status: {status}", err=True)
126
+ if strict and status != "ok":
127
+ raise typer.Exit(code=1)
@@ -6,7 +6,12 @@ import time
6
6
  from typing import TYPE_CHECKING
7
7
 
8
8
  import typer
9
- from graphlens import GraphLens, adapter_registry
9
+ from graphlens import (
10
+ RESOLVER_STATUS_KEY,
11
+ GraphLens,
12
+ ResolverStatus,
13
+ adapter_registry,
14
+ )
10
15
 
11
16
  if TYPE_CHECKING:
12
17
  from pathlib import Path
@@ -76,6 +81,14 @@ def load_adapter(lang: str) -> LanguageAdapter:
76
81
  from graphlens_typescript import TypescriptAdapter
77
82
 
78
83
  return TypescriptAdapter()
84
+ if lang == "go":
85
+ from graphlens_go import GoAdapter
86
+
87
+ return GoAdapter()
88
+ if lang == "rust":
89
+ from graphlens_rust import RustAdapter
90
+
91
+ return RustAdapter()
79
92
 
80
93
  msg = f"Unknown or unavailable adapter: {lang!r}"
81
94
  raise typer.BadParameter(msg)
@@ -98,16 +111,29 @@ def run_analysis(
98
111
  ) -> tuple[GraphLens, float]:
99
112
  """Analyse *root* with each adapter; return merged graph and elapsed."""
100
113
  combined = GraphLens()
114
+ statuses: list[ResolverStatus] = []
101
115
  t0 = time.monotonic()
102
116
  for lang in langs:
103
117
  if verbose:
104
118
  typer.echo(f" [{lang}] analysing {root} …")
105
119
  adapter = load_adapter(lang)
106
120
  g = adapter.analyze(root)
121
+ raw_status = g.metadata.get(RESOLVER_STATUS_KEY)
122
+ status = (
123
+ ResolverStatus.OK
124
+ if raw_status is None
125
+ else ResolverStatus.from_value(
126
+ raw_status, default=ResolverStatus.DEGRADED
127
+ )
128
+ )
129
+ statuses.append(status)
107
130
  if verbose:
108
131
  typer.echo(
109
132
  f" [{lang}] {len(g.nodes)} nodes,"
110
133
  f" {len(g.relations)} relations"
111
134
  )
112
135
  merge_graph(combined, g)
136
+ combined.metadata[RESOLVER_STATUS_KEY] = ResolverStatus.combine(
137
+ statuses
138
+ ).value
113
139
  return combined, time.monotonic() - t0
@@ -0,0 +1,234 @@
1
+ """
2
+ graphlens mcp — serve the graph query API to agents over MCP (TCK-7).
3
+
4
+ The query logic lives in plain functions that operate on a ``GraphLens``
5
+ so it is fully testable without the optional ``mcp`` dependency. The
6
+ ``mcp`` package is imported lazily inside :func:`_build_server`, and the
7
+ ``mcp`` subcommand prints a friendly hint if it is not installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Annotated
14
+
15
+ import typer
16
+ from graphlens import RESOLVER_STATUS_KEY, GraphLens, NodeKind, RelationKind
17
+
18
+ from graphlens_cli._app import app
19
+
20
+ if TYPE_CHECKING:
21
+ from graphlens import Node
22
+
23
+
24
+ def load_graph(path: Path) -> GraphLens:
25
+ """Load a graph from a JSON file produced by ``analyze --output``."""
26
+ return GraphLens.from_json(path.read_text(encoding="utf-8"))
27
+
28
+
29
+ def _node_dict(node: Node) -> dict[str, object]:
30
+ return {
31
+ "id": node.id,
32
+ "kind": node.kind.value,
33
+ "qualified_name": node.qualified_name,
34
+ "name": node.name,
35
+ "file_path": node.file_path,
36
+ }
37
+
38
+
39
+ def _resolve_ids(graph: GraphLens, node: str) -> list[str]:
40
+ """Resolve *node* (an id or a qualified/short name) to node ids."""
41
+ if node in graph.nodes:
42
+ return [node]
43
+ return [n.id for n in graph.nodes_by_name(node)]
44
+
45
+
46
+ def graph_stats(graph: GraphLens) -> dict[str, object]:
47
+ """Return node/relation counts and the resolver status."""
48
+ nodes_by_kind: dict[str, int] = {}
49
+ for node in graph.nodes.values():
50
+ nodes_by_kind[node.kind.value] = (
51
+ nodes_by_kind.get(node.kind.value, 0) + 1
52
+ )
53
+ rels_by_kind: dict[str, int] = {}
54
+ for rel in graph.relations:
55
+ rels_by_kind[rel.kind.value] = rels_by_kind.get(rel.kind.value, 0) + 1
56
+ return {
57
+ "nodes": len(graph.nodes),
58
+ "relations": len(graph.relations),
59
+ "nodes_by_kind": nodes_by_kind,
60
+ "relations_by_kind": rels_by_kind,
61
+ "resolver_status": graph.metadata.get(RESOLVER_STATUS_KEY),
62
+ }
63
+
64
+
65
+ def find_nodes(graph: GraphLens, name: str) -> list[dict[str, object]]:
66
+ """Return nodes whose short or qualified name matches *name*."""
67
+ return [_node_dict(n) for n in graph.nodes_by_name(name)]
68
+
69
+
70
+ def _gather(
71
+ graph: GraphLens, node: str, op: str, depth: int
72
+ ) -> list[dict[str, object]]:
73
+ seen: dict[str, Node] = {}
74
+ for nid in _resolve_ids(graph, node):
75
+ if op == "callers":
76
+ results = graph.callers(nid)
77
+ elif op == "callees":
78
+ results = graph.callees(nid)
79
+ elif op == "references":
80
+ results = graph.references_to(nid)
81
+ else: # neighbors
82
+ results = graph.neighbors(nid, depth=depth)
83
+ for n in results:
84
+ seen[n.id] = n
85
+ return [_node_dict(n) for n in seen.values()]
86
+
87
+
88
+ def callers(graph: GraphLens, node: str) -> list[dict[str, object]]:
89
+ """Return functions/methods that call *node*."""
90
+ return _gather(graph, node, "callers", 1)
91
+
92
+
93
+ def callees(graph: GraphLens, node: str) -> list[dict[str, object]]:
94
+ """Return functions/methods that *node* calls."""
95
+ return _gather(graph, node, "callees", 1)
96
+
97
+
98
+ def references(graph: GraphLens, node: str) -> list[dict[str, object]]:
99
+ """Return nodes that reference *node*."""
100
+ return _gather(graph, node, "references", 1)
101
+
102
+
103
+ def neighbors(
104
+ graph: GraphLens, node: str, depth: int = 1
105
+ ) -> list[dict[str, object]]:
106
+ """Return nodes within *depth* hops of *node*."""
107
+ return _gather(graph, node, "neighbors", depth)
108
+
109
+
110
+ def communicates_with(graph: GraphLens) -> list[dict[str, object]]:
111
+ """Return cross-language ``COMMUNICATES_WITH`` edges (consumer→server)."""
112
+ edges: list[dict[str, object]] = []
113
+ for rel in graph.relations:
114
+ if rel.kind is not RelationKind.COMMUNICATES_WITH:
115
+ continue
116
+ source = graph.nodes.get(rel.source_id)
117
+ target = graph.nodes.get(rel.target_id)
118
+ if source is None or target is None:
119
+ continue # pragma: no cover - dangling edge
120
+ edges.append(
121
+ {
122
+ "consumer": source.qualified_name,
123
+ "provider": target.qualified_name,
124
+ "mechanism": rel.metadata.get("mechanism"),
125
+ "key": rel.metadata.get("boundary_key"),
126
+ "confidence": rel.metadata.get("confidence"),
127
+ }
128
+ )
129
+ return edges
130
+
131
+
132
+ def boundaries(graph: GraphLens) -> list[dict[str, object]]:
133
+ """Return all cross-language boundary contracts in the graph."""
134
+ return [
135
+ {
136
+ "mechanism": n.metadata.get("mechanism"),
137
+ "key": n.metadata.get("key"),
138
+ "exposed_by": [
139
+ graph.nodes[r.source_id].qualified_name
140
+ for r in graph.incoming(n.id, RelationKind.EXPOSES)
141
+ if r.source_id in graph.nodes
142
+ ],
143
+ "consumed_by": [
144
+ graph.nodes[r.source_id].qualified_name
145
+ for r in graph.incoming(n.id, RelationKind.CONSUMES)
146
+ if r.source_id in graph.nodes
147
+ ],
148
+ }
149
+ for n in graph.nodes_by_kind(NodeKind.BOUNDARY)
150
+ ]
151
+
152
+
153
+ def _build_server(graph: GraphLens): # noqa: ANN202 # pragma: no cover
154
+ """Build a FastMCP server exposing the query tools (needs ``mcp``)."""
155
+ from mcp.server.fastmcp import ( # ty: ignore[unresolved-import]
156
+ FastMCP,
157
+ )
158
+
159
+ server = FastMCP("graphlens")
160
+
161
+ # Typed wrappers (not lambdas) so FastMCP derives correct input schemas
162
+ # — e.g. ``depth`` is advertised as an int, ``node``/``name`` as strings.
163
+ @server.tool(name="stats")
164
+ def stats_tool() -> dict[str, object]:
165
+ """Return node/relation counts and the resolver status."""
166
+ return graph_stats(graph)
167
+
168
+ @server.tool(name="find")
169
+ def find_tool(name: str) -> list[dict[str, object]]:
170
+ """Find nodes whose short or qualified name matches ``name``."""
171
+ return find_nodes(graph, name)
172
+
173
+ @server.tool(name="callers")
174
+ def callers_tool(node: str) -> list[dict[str, object]]:
175
+ """Return functions/methods that call ``node`` (id or name)."""
176
+ return callers(graph, node)
177
+
178
+ @server.tool(name="callees")
179
+ def callees_tool(node: str) -> list[dict[str, object]]:
180
+ """Return functions/methods that ``node`` (id or name) calls."""
181
+ return callees(graph, node)
182
+
183
+ @server.tool(name="references")
184
+ def references_tool(node: str) -> list[dict[str, object]]:
185
+ """Return nodes that reference ``node`` (id or name)."""
186
+ return references(graph, node)
187
+
188
+ @server.tool(name="neighbors")
189
+ def neighbors_tool(node: str, depth: int = 1) -> list[dict[str, object]]:
190
+ """Return nodes within ``depth`` hops of ``node`` (id or name)."""
191
+ return neighbors(graph, node, depth)
192
+
193
+ @server.tool(name="communicates_with")
194
+ def communicates_with_tool() -> list[dict[str, object]]:
195
+ """Return cross-language ``COMMUNICATES_WITH`` edges."""
196
+ return communicates_with(graph)
197
+
198
+ @server.tool(name="boundaries")
199
+ def boundaries_tool() -> list[dict[str, object]]:
200
+ """Return all cross-language boundary contracts in the graph."""
201
+ return boundaries(graph)
202
+
203
+ return server
204
+
205
+
206
+ def serve(graph_path: Path) -> None: # pragma: no cover - stdio server loop
207
+ """Load the graph and run the MCP server over stdio."""
208
+ _build_server(load_graph(graph_path)).run()
209
+
210
+
211
+ @app.command("mcp")
212
+ def mcp_command(
213
+ graph_path: Annotated[
214
+ Path,
215
+ typer.Option(
216
+ "--graph",
217
+ "-g",
218
+ help="Path to a graph JSON file (from `analyze --output`)",
219
+ exists=True,
220
+ dir_okay=False,
221
+ ),
222
+ ],
223
+ ) -> None:
224
+ """Serve the graph query API to agents over MCP (stdio)."""
225
+ try:
226
+ import mcp # noqa: F401 # ty: ignore[unresolved-import]
227
+ except ImportError as exc:
228
+ typer.echo(
229
+ "MCP support requires the 'mcp' package. Install it with:\n"
230
+ " pip install 'graphlens-cli[mcp]'",
231
+ err=True,
232
+ )
233
+ raise typer.Exit(code=1) from exc
234
+ serve(graph_path) # pragma: no cover - requires mcp
@@ -0,0 +1,83 @@
1
+ """graphlens query — query a previously serialized graph JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Annotated
7
+
8
+ import typer
9
+ from graphlens import GraphLens
10
+
11
+ from graphlens_cli._app import app
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ from graphlens import Node
17
+
18
+ _OPERATIONS = ("callers", "callees", "references", "neighbors")
19
+
20
+
21
+ def _resolve_ids(graph: GraphLens, node: str) -> list[str]:
22
+ """Resolve *node* (an id or a qualified/short name) to node ids."""
23
+ if node in graph.nodes:
24
+ return [node]
25
+ return [n.id for n in graph.nodes_by_name(node)]
26
+
27
+
28
+ @app.command()
29
+ def query(
30
+ node: Annotated[
31
+ str,
32
+ typer.Argument(help="Node id, qualified name, or short name"),
33
+ ],
34
+ graph_path: Annotated[
35
+ Path,
36
+ typer.Option(
37
+ "--graph",
38
+ "-g",
39
+ help="Path to a graph JSON file (from `analyze --output`)",
40
+ exists=True,
41
+ dir_okay=False,
42
+ ),
43
+ ],
44
+ operation: Annotated[
45
+ str,
46
+ typer.Option(
47
+ "--op",
48
+ help="callers | callees | references | neighbors",
49
+ ),
50
+ ] = "callers",
51
+ depth: Annotated[
52
+ int,
53
+ typer.Option(help="Hop depth for the 'neighbors' operation"),
54
+ ] = 1,
55
+ ) -> None:
56
+ """Query a saved graph for relationships of a node."""
57
+ if operation not in _OPERATIONS:
58
+ msg = f"operation must be one of {', '.join(_OPERATIONS)}"
59
+ raise typer.BadParameter(msg)
60
+
61
+ graph = GraphLens.from_json(graph_path.read_text(encoding="utf-8"))
62
+ ids = _resolve_ids(graph, node)
63
+ if not ids:
64
+ typer.echo(f"No node matching {node!r}", err=True)
65
+ raise typer.Exit(code=1)
66
+
67
+ ops: dict[str, Callable[[str], list[Node]]] = {
68
+ "callers": graph.callers,
69
+ "callees": graph.callees,
70
+ "references": graph.references_to,
71
+ }
72
+ for nid in ids:
73
+ src = graph.nodes[nid]
74
+ results = (
75
+ graph.neighbors(nid, depth=depth)
76
+ if operation == "neighbors"
77
+ else ops[operation](nid)
78
+ )
79
+ typer.echo(f"{operation} of {src.qualified_name} ({src.kind.value}):")
80
+ for r in results:
81
+ typer.echo(f" {r.kind.value:<16} {r.qualified_name}")
82
+ if not results:
83
+ typer.echo(" (none)")