suitable-loop 0.1.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.
- suitable_loop/__init__.py +3 -0
- suitable_loop/__main__.py +5 -0
- suitable_loop/analyzers/__init__.py +1 -0
- suitable_loop/analyzers/code_analyzer.py +652 -0
- suitable_loop/analyzers/git_analyzer.py +510 -0
- suitable_loop/analyzers/log_analyzer.py +663 -0
- suitable_loop/config.py +60 -0
- suitable_loop/db.py +497 -0
- suitable_loop/graph/__init__.py +1 -0
- suitable_loop/graph/engine.py +341 -0
- suitable_loop/models.py +131 -0
- suitable_loop/server.py +46 -0
- suitable_loop/tools/__init__.py +1 -0
- suitable_loop/tools/code_tools.py +104 -0
- suitable_loop/tools/git_tools.py +52 -0
- suitable_loop/tools/log_tools.py +53 -0
- suitable_loop/tools/util_tools.py +49 -0
- suitable_loop-0.1.0.dist-info/METADATA +12 -0
- suitable_loop-0.1.0.dist-info/RECORD +21 -0
- suitable_loop-0.1.0.dist-info/WHEEL +4 -0
- suitable_loop-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""NetworkX-based graph engine for CodeZero.
|
|
2
|
+
|
|
3
|
+
Builds an in-memory directed graph from the database and exposes
|
|
4
|
+
query methods for callers/callees, dependency trees, blast-radius
|
|
5
|
+
analysis, and centrality reports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import networkx as nx # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
from suitable_loop.db import Database
|
|
16
|
+
from suitable_loop.models import (
|
|
17
|
+
ClassEntity,
|
|
18
|
+
FileEntity,
|
|
19
|
+
FunctionEntity,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GraphEngine:
|
|
26
|
+
"""Provides graph-based queries over the indexed codebase."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, db: Database) -> None:
|
|
29
|
+
self.db = db
|
|
30
|
+
self.graph: nx.DiGraph = nx.DiGraph()
|
|
31
|
+
self._built = False
|
|
32
|
+
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
# Graph construction
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def build_graph(self, project_root: str) -> None:
|
|
38
|
+
"""Load all entities from the database and build the NetworkX graph.
|
|
39
|
+
|
|
40
|
+
Node types: ``"file"``, ``"function"``, ``"class"``.
|
|
41
|
+
Edge types: ``"contains"`` (file->function/class), ``"calls"``
|
|
42
|
+
(function->function), ``"imports"`` (file->file).
|
|
43
|
+
"""
|
|
44
|
+
self.graph.clear()
|
|
45
|
+
|
|
46
|
+
files = self.db.get_all_files(project_root)
|
|
47
|
+
logger.info("Building graph for %d files", len(files))
|
|
48
|
+
|
|
49
|
+
for f in files:
|
|
50
|
+
file_node = f"file:{f.path}"
|
|
51
|
+
self.graph.add_node(
|
|
52
|
+
file_node,
|
|
53
|
+
type="file",
|
|
54
|
+
path=f.path,
|
|
55
|
+
entity_id=f.id,
|
|
56
|
+
line_count=f.line_count,
|
|
57
|
+
size_bytes=f.size_bytes,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Functions
|
|
61
|
+
functions = self.db.get_functions_by_file(f.id) # type: ignore[arg-type]
|
|
62
|
+
for func in functions:
|
|
63
|
+
func_node = f"func:{func.qualified_name}"
|
|
64
|
+
self.graph.add_node(
|
|
65
|
+
func_node,
|
|
66
|
+
type="function",
|
|
67
|
+
name=func.name,
|
|
68
|
+
qualified_name=func.qualified_name,
|
|
69
|
+
entity_id=func.id,
|
|
70
|
+
complexity=func.complexity,
|
|
71
|
+
is_method=func.is_method,
|
|
72
|
+
is_async=func.is_async,
|
|
73
|
+
line_start=func.line_start,
|
|
74
|
+
line_end=func.line_end,
|
|
75
|
+
signature=func.signature,
|
|
76
|
+
docstring=func.docstring,
|
|
77
|
+
class_name=func.class_name,
|
|
78
|
+
)
|
|
79
|
+
self.graph.add_edge(file_node, func_node, type="contains")
|
|
80
|
+
|
|
81
|
+
# Classes
|
|
82
|
+
classes = self.db.get_classes_by_file(f.id) # type: ignore[arg-type]
|
|
83
|
+
for cls in classes:
|
|
84
|
+
cls_node = f"class:{cls.qualified_name}"
|
|
85
|
+
self.graph.add_node(
|
|
86
|
+
cls_node,
|
|
87
|
+
type="class",
|
|
88
|
+
name=cls.name,
|
|
89
|
+
qualified_name=cls.qualified_name,
|
|
90
|
+
entity_id=cls.id,
|
|
91
|
+
bases=cls.bases,
|
|
92
|
+
line_start=cls.line_start,
|
|
93
|
+
line_end=cls.line_end,
|
|
94
|
+
docstring=cls.docstring,
|
|
95
|
+
)
|
|
96
|
+
self.graph.add_edge(file_node, cls_node, type="contains")
|
|
97
|
+
|
|
98
|
+
# Call edges
|
|
99
|
+
for func in functions:
|
|
100
|
+
caller_node = f"func:{func.qualified_name}"
|
|
101
|
+
callees = self.db.get_callees(func.id) # type: ignore[arg-type]
|
|
102
|
+
for callee in callees:
|
|
103
|
+
callee_node = f"func:{callee.qualified_name}"
|
|
104
|
+
self.graph.add_edge(caller_node, callee_node, type="calls")
|
|
105
|
+
|
|
106
|
+
# File-level import dependencies
|
|
107
|
+
deps = self.db.get_file_dependencies(f.id) # type: ignore[arg-type]
|
|
108
|
+
for dep in deps:
|
|
109
|
+
target_node = f"file:{dep.path}"
|
|
110
|
+
self.graph.add_edge(file_node, target_node, type="imports")
|
|
111
|
+
|
|
112
|
+
self._built = True
|
|
113
|
+
logger.info(
|
|
114
|
+
"Graph built: %d nodes, %d edges",
|
|
115
|
+
self.graph.number_of_nodes(),
|
|
116
|
+
self.graph.number_of_edges(),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Query methods
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def get_callers(self, function_name: str) -> list[dict]:
|
|
124
|
+
"""Find all direct callers of *function_name*.
|
|
125
|
+
|
|
126
|
+
*function_name* may be a bare name (``my_func``) or a qualified
|
|
127
|
+
name (``pkg.mod.my_func``).
|
|
128
|
+
"""
|
|
129
|
+
node = self._find_function_node(function_name)
|
|
130
|
+
if node is None:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
callers: list[dict] = []
|
|
134
|
+
for pred in self.graph.predecessors(node):
|
|
135
|
+
edge_data = self.graph.edges[pred, node]
|
|
136
|
+
if edge_data.get("type") != "calls":
|
|
137
|
+
continue
|
|
138
|
+
callers.append(self._node_to_dict(pred))
|
|
139
|
+
return callers
|
|
140
|
+
|
|
141
|
+
def get_callees(self, function_name: str) -> list[dict]:
|
|
142
|
+
"""Find all functions directly called by *function_name*."""
|
|
143
|
+
node = self._find_function_node(function_name)
|
|
144
|
+
if node is None:
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
callees: list[dict] = []
|
|
148
|
+
for succ in self.graph.successors(node):
|
|
149
|
+
edge_data = self.graph.edges[node, succ]
|
|
150
|
+
if edge_data.get("type") != "calls":
|
|
151
|
+
continue
|
|
152
|
+
callees.append(self._node_to_dict(succ))
|
|
153
|
+
return callees
|
|
154
|
+
|
|
155
|
+
def dependency_tree(self, file_path: str, depth: int = 3) -> dict:
|
|
156
|
+
"""Return a recursive import-dependency tree rooted at *file_path*.
|
|
157
|
+
|
|
158
|
+
Each level is a dict with ``"file"`` and ``"dependencies"`` keys.
|
|
159
|
+
The tree is bounded by *depth* to avoid runaway recursion.
|
|
160
|
+
"""
|
|
161
|
+
node = self._find_file_node(file_path)
|
|
162
|
+
if node is None:
|
|
163
|
+
return {"file": file_path, "dependencies": [], "error": "file not found"}
|
|
164
|
+
|
|
165
|
+
return self._build_dep_tree(node, depth, visited=set())
|
|
166
|
+
|
|
167
|
+
def blast_radius(self, file_path: str) -> dict:
|
|
168
|
+
"""Compute the transitive blast radius of changing *file_path*.
|
|
169
|
+
|
|
170
|
+
Returns counts and lists of all files and functions that
|
|
171
|
+
(transitively) depend on this file.
|
|
172
|
+
"""
|
|
173
|
+
node = self._find_file_node(file_path)
|
|
174
|
+
if node is None:
|
|
175
|
+
return {
|
|
176
|
+
"file": file_path,
|
|
177
|
+
"error": "file not found",
|
|
178
|
+
"affected_files": [],
|
|
179
|
+
"affected_functions": [],
|
|
180
|
+
"total_affected_files": 0,
|
|
181
|
+
"total_affected_functions": 0,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Reverse graph: we want nodes that *import* this file, transitively.
|
|
185
|
+
reverse = self.graph.reverse(copy=False)
|
|
186
|
+
try:
|
|
187
|
+
dependents = nx.descendants(reverse, node)
|
|
188
|
+
except nx.NetworkXError:
|
|
189
|
+
dependents = set()
|
|
190
|
+
|
|
191
|
+
affected_files: list[dict] = []
|
|
192
|
+
affected_functions: list[dict] = []
|
|
193
|
+
|
|
194
|
+
for dep_node in dependents:
|
|
195
|
+
data = self.graph.nodes[dep_node]
|
|
196
|
+
if data.get("type") == "file":
|
|
197
|
+
affected_files.append(self._node_to_dict(dep_node))
|
|
198
|
+
elif data.get("type") == "function":
|
|
199
|
+
affected_functions.append(self._node_to_dict(dep_node))
|
|
200
|
+
|
|
201
|
+
# Also include functions contained in the file itself.
|
|
202
|
+
for succ in self.graph.successors(node):
|
|
203
|
+
edge_data = self.graph.edges[node, succ]
|
|
204
|
+
if edge_data.get("type") == "contains":
|
|
205
|
+
data = self.graph.nodes[succ]
|
|
206
|
+
if data.get("type") == "function":
|
|
207
|
+
affected_functions.append(self._node_to_dict(succ))
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"file": file_path,
|
|
211
|
+
"affected_files": affected_files,
|
|
212
|
+
"affected_functions": affected_functions,
|
|
213
|
+
"total_affected_files": len(affected_files),
|
|
214
|
+
"total_affected_functions": len(affected_functions),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def get_entity_info(self, name: str) -> dict | None:
|
|
218
|
+
"""Look up any entity (file, function, class) by name.
|
|
219
|
+
|
|
220
|
+
Tries qualified name first, then falls back to a substring search
|
|
221
|
+
across node IDs.
|
|
222
|
+
"""
|
|
223
|
+
# Direct lookup by known prefixes.
|
|
224
|
+
for prefix in ("func:", "class:", "file:"):
|
|
225
|
+
candidate = f"{prefix}{name}"
|
|
226
|
+
if candidate in self.graph:
|
|
227
|
+
return self._node_to_dict(candidate)
|
|
228
|
+
|
|
229
|
+
# Substring search.
|
|
230
|
+
for node_id, data in self.graph.nodes(data=True):
|
|
231
|
+
qname = data.get("qualified_name") or data.get("path") or ""
|
|
232
|
+
node_name = data.get("name", "")
|
|
233
|
+
if name == qname or name == node_name:
|
|
234
|
+
return self._node_to_dict(node_id)
|
|
235
|
+
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
def get_most_connected(self, top_n: int = 10) -> list[dict]:
|
|
239
|
+
"""Return the *top_n* nodes with the highest total degree."""
|
|
240
|
+
if not self.graph:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
degree_list = sorted(
|
|
244
|
+
self.graph.degree(), key=lambda pair: pair[1], reverse=True
|
|
245
|
+
)
|
|
246
|
+
results: list[dict] = []
|
|
247
|
+
for node_id, degree in degree_list[:top_n]:
|
|
248
|
+
info = self._node_to_dict(node_id)
|
|
249
|
+
info["degree"] = degree
|
|
250
|
+
results.append(info)
|
|
251
|
+
return results
|
|
252
|
+
|
|
253
|
+
def get_complexity_report(self, top_n: int = 20) -> list[dict]:
|
|
254
|
+
"""Return the *top_n* most complex functions in the graph."""
|
|
255
|
+
func_nodes: list[tuple[str, dict[str, Any]]] = []
|
|
256
|
+
for node_id, data in self.graph.nodes(data=True):
|
|
257
|
+
if data.get("type") == "function" and data.get("complexity", 0) > 0:
|
|
258
|
+
func_nodes.append((node_id, data))
|
|
259
|
+
|
|
260
|
+
func_nodes.sort(key=lambda pair: pair[1].get("complexity", 0), reverse=True)
|
|
261
|
+
|
|
262
|
+
results: list[dict] = []
|
|
263
|
+
for node_id, data in func_nodes[:top_n]:
|
|
264
|
+
info = self._node_to_dict(node_id)
|
|
265
|
+
info["callers"] = len([
|
|
266
|
+
p for p in self.graph.predecessors(node_id)
|
|
267
|
+
if self.graph.edges[p, node_id].get("type") == "calls"
|
|
268
|
+
])
|
|
269
|
+
info["callees"] = len([
|
|
270
|
+
s for s in self.graph.successors(node_id)
|
|
271
|
+
if self.graph.edges[node_id, s].get("type") == "calls"
|
|
272
|
+
])
|
|
273
|
+
results.append(info)
|
|
274
|
+
return results
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
# Internal helpers
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def _find_function_node(self, function_name: str) -> str | None:
|
|
281
|
+
"""Find a function node by qualified or bare name."""
|
|
282
|
+
candidate = f"func:{function_name}"
|
|
283
|
+
if candidate in self.graph:
|
|
284
|
+
return candidate
|
|
285
|
+
|
|
286
|
+
# Fall back to searching by bare name or suffix.
|
|
287
|
+
for node_id, data in self.graph.nodes(data=True):
|
|
288
|
+
if data.get("type") != "function":
|
|
289
|
+
continue
|
|
290
|
+
if data.get("name") == function_name:
|
|
291
|
+
return node_id
|
|
292
|
+
qname = data.get("qualified_name", "")
|
|
293
|
+
if qname.endswith(f".{function_name}"):
|
|
294
|
+
return node_id
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def _find_file_node(self, file_path: str) -> str | None:
|
|
298
|
+
"""Find a file node by path (exact or suffix match)."""
|
|
299
|
+
candidate = f"file:{file_path}"
|
|
300
|
+
if candidate in self.graph:
|
|
301
|
+
return candidate
|
|
302
|
+
|
|
303
|
+
# Try suffix matching for relative paths.
|
|
304
|
+
for node_id, data in self.graph.nodes(data=True):
|
|
305
|
+
if data.get("type") != "file":
|
|
306
|
+
continue
|
|
307
|
+
path = data.get("path", "")
|
|
308
|
+
if path.endswith(file_path) or file_path.endswith(path):
|
|
309
|
+
return node_id
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def _node_to_dict(self, node_id: str) -> dict:
|
|
313
|
+
"""Convert a graph node to a plain dict for API consumption."""
|
|
314
|
+
data = dict(self.graph.nodes[node_id])
|
|
315
|
+
data["node_id"] = node_id
|
|
316
|
+
return data
|
|
317
|
+
|
|
318
|
+
def _build_dep_tree(
|
|
319
|
+
self, node: str, depth: int, visited: set[str]
|
|
320
|
+
) -> dict:
|
|
321
|
+
"""Recursively build a dependency tree dict."""
|
|
322
|
+
data = self.graph.nodes[node]
|
|
323
|
+
result: dict[str, Any] = {
|
|
324
|
+
"file": data.get("path", node),
|
|
325
|
+
"node_id": node,
|
|
326
|
+
"dependencies": [],
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if depth <= 0 or node in visited:
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
visited.add(node)
|
|
333
|
+
|
|
334
|
+
for succ in self.graph.successors(node):
|
|
335
|
+
edge_data = self.graph.edges[node, succ]
|
|
336
|
+
if edge_data.get("type") != "imports":
|
|
337
|
+
continue
|
|
338
|
+
child = self._build_dep_tree(succ, depth - 1, visited)
|
|
339
|
+
result["dependencies"].append(child)
|
|
340
|
+
|
|
341
|
+
return result
|
suitable_loop/models.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Data models for CodeZero entities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class FileEntity:
|
|
10
|
+
id: int | None = None
|
|
11
|
+
path: str = ""
|
|
12
|
+
project_root: str = ""
|
|
13
|
+
size_bytes: int = 0
|
|
14
|
+
last_modified: float = 0.0
|
|
15
|
+
last_indexed: float = 0.0
|
|
16
|
+
line_count: int = 0
|
|
17
|
+
hash: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FunctionEntity:
|
|
22
|
+
id: int | None = None
|
|
23
|
+
file_id: int | None = None
|
|
24
|
+
name: str = ""
|
|
25
|
+
qualified_name: str = ""
|
|
26
|
+
class_name: str | None = None
|
|
27
|
+
line_start: int = 0
|
|
28
|
+
line_end: int = 0
|
|
29
|
+
signature: str = ""
|
|
30
|
+
docstring: str | None = None
|
|
31
|
+
complexity: int = 0
|
|
32
|
+
is_method: bool = False
|
|
33
|
+
is_async: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ClassEntity:
|
|
38
|
+
id: int | None = None
|
|
39
|
+
file_id: int | None = None
|
|
40
|
+
name: str = ""
|
|
41
|
+
qualified_name: str = ""
|
|
42
|
+
line_start: int = 0
|
|
43
|
+
line_end: int = 0
|
|
44
|
+
bases: list[str] = field(default_factory=list)
|
|
45
|
+
docstring: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ImportEntity:
|
|
50
|
+
id: int | None = None
|
|
51
|
+
file_id: int | None = None
|
|
52
|
+
module: str = ""
|
|
53
|
+
alias: str | None = None
|
|
54
|
+
is_internal: bool = False
|
|
55
|
+
resolved_file_id: int | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CallEdge:
|
|
60
|
+
id: int | None = None
|
|
61
|
+
caller_id: int | None = None
|
|
62
|
+
callee_id: int | None = None
|
|
63
|
+
file_id: int | None = None
|
|
64
|
+
line_number: int = 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class FileDependency:
|
|
69
|
+
id: int | None = None
|
|
70
|
+
source_file_id: int | None = None
|
|
71
|
+
target_file_id: int | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CommitInfo:
|
|
76
|
+
id: int | None = None
|
|
77
|
+
repo_path: str = ""
|
|
78
|
+
sha: str = ""
|
|
79
|
+
author: str = ""
|
|
80
|
+
timestamp: float = 0.0
|
|
81
|
+
message: str = ""
|
|
82
|
+
files_changed: int = 0
|
|
83
|
+
insertions: int = 0
|
|
84
|
+
deletions: int = 0
|
|
85
|
+
risk_score: float = 0.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class CommitFile:
|
|
90
|
+
id: int | None = None
|
|
91
|
+
commit_id: int | None = None
|
|
92
|
+
file_path: str = ""
|
|
93
|
+
change_type: str = ""
|
|
94
|
+
insertions: int = 0
|
|
95
|
+
deletions: int = 0
|
|
96
|
+
complexity_before: int | None = None
|
|
97
|
+
complexity_after: int | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class LogEntry:
|
|
102
|
+
id: int | None = None
|
|
103
|
+
source_file: str = ""
|
|
104
|
+
timestamp: float | None = None
|
|
105
|
+
level: str = ""
|
|
106
|
+
logger_name: str = ""
|
|
107
|
+
message: str = ""
|
|
108
|
+
raw_line: str = ""
|
|
109
|
+
error_group_id: int | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class ErrorGroup:
|
|
114
|
+
id: int | None = None
|
|
115
|
+
signature: str = ""
|
|
116
|
+
exception_type: str = ""
|
|
117
|
+
exception_message: str = ""
|
|
118
|
+
traceback: str = ""
|
|
119
|
+
first_seen: float = 0.0
|
|
120
|
+
last_seen: float = 0.0
|
|
121
|
+
occurrence_count: int = 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ErrorCodeLink:
|
|
126
|
+
id: int | None = None
|
|
127
|
+
error_group_id: int | None = None
|
|
128
|
+
function_id: int | None = None
|
|
129
|
+
file_id: int | None = None
|
|
130
|
+
line_number: int = 0
|
|
131
|
+
frame_position: int = 0
|
suitable_loop/server.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Suitable Loop MCP Server — entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from .config import load_config
|
|
10
|
+
from .db import Database
|
|
11
|
+
from .tools.code_tools import register_code_tools
|
|
12
|
+
from .tools.git_tools import register_git_tools
|
|
13
|
+
from .tools.log_tools import register_log_tools
|
|
14
|
+
from .tools.util_tools import register_util_tools
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_server() -> FastMCP:
|
|
20
|
+
config = load_config()
|
|
21
|
+
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=getattr(logging, config.log_level, logging.INFO),
|
|
24
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
db = Database(config)
|
|
28
|
+
db.connect()
|
|
29
|
+
|
|
30
|
+
mcp = FastMCP("Suitable Loop", json_response=True)
|
|
31
|
+
|
|
32
|
+
register_code_tools(mcp, db, config)
|
|
33
|
+
register_git_tools(mcp, db, config)
|
|
34
|
+
register_log_tools(mcp, db, config)
|
|
35
|
+
register_util_tools(mcp, db, config)
|
|
36
|
+
|
|
37
|
+
logger.info("Suitable Loop MCP server initialized (db: %s)", config.db_path)
|
|
38
|
+
return mcp
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
server = create_server()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
"""CLI entry point for uvx / python -m suitable_loop."""
|
|
46
|
+
server.run(transport="stdio")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Suitable Loop MCP tool handlers."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""MCP tool handlers for code analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..analyzers.code_analyzer import CodeAnalyzer
|
|
10
|
+
from ..config import SuitableLoopConfig
|
|
11
|
+
from ..db import Database
|
|
12
|
+
from ..graph.engine import GraphEngine
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_code_tools(mcp: FastMCP, db: Database, config: SuitableLoopConfig):
|
|
18
|
+
analyzer = CodeAnalyzer(db, config)
|
|
19
|
+
graph = GraphEngine(db)
|
|
20
|
+
|
|
21
|
+
@mcp.tool()
|
|
22
|
+
def index_codebase(path: str, force: bool = False) -> dict:
|
|
23
|
+
"""Index a Python codebase. Parses all .py files, extracts functions, classes,
|
|
24
|
+
imports, and call relationships. Builds a semantic graph for querying.
|
|
25
|
+
Use force=True to re-index even unchanged files."""
|
|
26
|
+
result = analyzer.index_codebase(path, force=force)
|
|
27
|
+
graph.build_graph(path)
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
def query_entity(name: str) -> dict:
|
|
32
|
+
"""Look up a function, class, or file by name. Returns details and all
|
|
33
|
+
relationships (callers, callees, file location, complexity)."""
|
|
34
|
+
info = graph.get_entity_info(name)
|
|
35
|
+
if not info:
|
|
36
|
+
return {"error": f"Entity '{name}' not found. Try search_code for broader search."}
|
|
37
|
+
return info
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def find_callers(function_name: str) -> dict:
|
|
41
|
+
"""Find all functions that call the given function."""
|
|
42
|
+
callers = graph.get_callers(function_name)
|
|
43
|
+
return {
|
|
44
|
+
"function": function_name,
|
|
45
|
+
"caller_count": len(callers),
|
|
46
|
+
"callers": callers,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def find_callees(function_name: str) -> dict:
|
|
51
|
+
"""Find all functions called by the given function."""
|
|
52
|
+
callees = graph.get_callees(function_name)
|
|
53
|
+
return {
|
|
54
|
+
"function": function_name,
|
|
55
|
+
"callee_count": len(callees),
|
|
56
|
+
"callees": callees,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def dependency_tree(file_path: str, depth: int = 3) -> dict:
|
|
61
|
+
"""Get the import dependency tree for a file, up to N levels deep."""
|
|
62
|
+
return graph.dependency_tree(file_path, depth=depth)
|
|
63
|
+
|
|
64
|
+
@mcp.tool()
|
|
65
|
+
def search_code(query: str, max_results: int = 20) -> dict:
|
|
66
|
+
"""Full-text search across indexed functions and classes."""
|
|
67
|
+
functions = db.search_functions(query, limit=max_results)
|
|
68
|
+
results = []
|
|
69
|
+
for f in functions:
|
|
70
|
+
file_entity = db.get_file_by_id(f.file_id) if f.file_id else None
|
|
71
|
+
results.append({
|
|
72
|
+
"name": f.qualified_name,
|
|
73
|
+
"type": "method" if f.is_method else "function",
|
|
74
|
+
"file": file_entity.path if file_entity else None,
|
|
75
|
+
"line_start": f.line_start,
|
|
76
|
+
"line_end": f.line_end,
|
|
77
|
+
"complexity": f.complexity,
|
|
78
|
+
"signature": f.signature,
|
|
79
|
+
"docstring": f.docstring,
|
|
80
|
+
})
|
|
81
|
+
return {"query": query, "result_count": len(results), "results": results}
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def complexity_report(top_n: int = 20) -> dict:
|
|
85
|
+
"""Get the most complex functions in the indexed codebase, ranked by
|
|
86
|
+
cyclomatic complexity."""
|
|
87
|
+
report = graph.get_complexity_report(top_n=top_n)
|
|
88
|
+
return {"top_n": top_n, "functions": report}
|
|
89
|
+
|
|
90
|
+
@mcp.tool()
|
|
91
|
+
def codebase_summary() -> dict:
|
|
92
|
+
"""High-level summary of the indexed codebase: file count, function count,
|
|
93
|
+
class count, average complexity, most-connected modules."""
|
|
94
|
+
stats = db.get_stats()
|
|
95
|
+
most_connected = graph.get_most_connected(top_n=10)
|
|
96
|
+
return {
|
|
97
|
+
"files": stats.get("files", 0),
|
|
98
|
+
"functions": stats.get("functions", 0),
|
|
99
|
+
"classes": stats.get("classes", 0),
|
|
100
|
+
"imports": stats.get("imports", 0),
|
|
101
|
+
"call_edges": stats.get("call_edges", 0),
|
|
102
|
+
"file_dependencies": stats.get("file_dependencies", 0),
|
|
103
|
+
"most_connected_modules": most_connected,
|
|
104
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""MCP tool handlers for git analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..analyzers.git_analyzer import GitAnalyzer
|
|
10
|
+
from ..config import SuitableLoopConfig
|
|
11
|
+
from ..db import Database
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_git_tools(mcp: FastMCP, db: Database, config: SuitableLoopConfig):
|
|
17
|
+
analyzer = GitAnalyzer(db, config)
|
|
18
|
+
|
|
19
|
+
@mcp.tool()
|
|
20
|
+
def analyze_recent_changes(repo_path: str, n_commits: int = 50) -> dict:
|
|
21
|
+
"""Analyze recent git commits and score them by risk. Risk factors include
|
|
22
|
+
complexity delta, blast radius, churn rate, lines changed, and file count.
|
|
23
|
+
Returns commits sorted by risk score (highest first)."""
|
|
24
|
+
commits = analyzer.analyze_recent_changes(repo_path, n_commits=n_commits)
|
|
25
|
+
return {
|
|
26
|
+
"repo_path": repo_path,
|
|
27
|
+
"commits_analyzed": len(commits),
|
|
28
|
+
"commits": commits,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def analyze_commit(repo_path: str, sha: str) -> dict:
|
|
33
|
+
"""Deep-dive a single git commit. Shows changed files, complexity delta per file,
|
|
34
|
+
blast radius, and detailed risk breakdown."""
|
|
35
|
+
return analyzer.analyze_commit(repo_path, sha)
|
|
36
|
+
|
|
37
|
+
@mcp.tool()
|
|
38
|
+
def hotspot_report(repo_path: str, n_commits: int = 100) -> dict:
|
|
39
|
+
"""Find code hotspots — files that change frequently AND are highly depended upon.
|
|
40
|
+
These are the highest-risk areas of the codebase."""
|
|
41
|
+
hotspots = analyzer.hotspot_report(repo_path, n_commits=n_commits)
|
|
42
|
+
return {
|
|
43
|
+
"repo_path": repo_path,
|
|
44
|
+
"commits_analyzed": n_commits,
|
|
45
|
+
"hotspots": hotspots,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def blast_radius(file_path: str) -> dict:
|
|
50
|
+
"""Calculate the blast radius of a file — how many other files and functions
|
|
51
|
+
are transitively affected if this file breaks."""
|
|
52
|
+
return analyzer.blast_radius(file_path)
|