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.
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/PKG-INFO +4 -1
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/pyproject.toml +3 -2
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/src/graphlens_cli/__init__.py +2 -0
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/src/graphlens_cli/_analyze.py +77 -30
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/src/graphlens_cli/_app.py +27 -1
- graphlens_cli-0.5.0/src/graphlens_cli/_mcp.py +234 -0
- graphlens_cli-0.5.0/src/graphlens_cli/_query.py +83 -0
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/src/graphlens_cli/_neo4j.py +0 -0
- {graphlens_cli-0.4.0 → graphlens_cli-0.5.0}/src/graphlens_cli/_visualize.py +0 -0
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: graphlens-cli
|
|
3
|
-
Version: 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.
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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)")
|
|
File without changes
|
|
File without changes
|