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.
@@ -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
@@ -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
@@ -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)