codegraphy 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.
repolens/mcp/server.py ADDED
@@ -0,0 +1,306 @@
1
+ from mcp.server.fastmcp import FastMCP
2
+ from ..db.store import Store
3
+ from ..config import DATABASE_URL, REPOLENS_ROOT
4
+ import subprocess
5
+
6
+ mcp = FastMCP("codegraphy")
7
+ store = Store(DATABASE_URL)
8
+
9
+ @mcp.tool()
10
+ def search_symbol(name: str, kind: str = None, limit: int = 10, fallback_grep: bool = True) -> list[dict]:
11
+ """
12
+ Find symbols by name (exact, prefix, or substring match).
13
+ """
14
+ results = []
15
+
16
+ with store.get_connection() as conn:
17
+ cursor = conn.cursor()
18
+ p = "%s" if store.is_postgres else "?"
19
+
20
+ # 1. Exact match
21
+ query_exact = f"SELECT qualified_name, kind, file_path, line_start, summary FROM cg_symbols WHERE name = {p}"
22
+ params_exact = [name]
23
+
24
+ if kind:
25
+ query_exact += f" AND kind = {p}"
26
+ params_exact.append(kind)
27
+
28
+ cursor.execute(query_exact + f" LIMIT {limit}", params_exact)
29
+ rows = cursor.fetchall()
30
+
31
+ # 2. Substring match if no exact match
32
+ if not rows:
33
+ like_op = "ILIKE" if store.is_postgres else "LIKE"
34
+ query_like = f"SELECT qualified_name, kind, file_path, line_start, summary FROM cg_symbols WHERE name {like_op} {p}"
35
+ params_like = [f"%{name}%"]
36
+ if kind:
37
+ query_like += f" AND kind = {p}"
38
+ params_like.append(kind)
39
+ cursor.execute(query_like + f" LIMIT {limit}", params_like)
40
+ rows = cursor.fetchall()
41
+
42
+ for row in rows:
43
+ results.append({
44
+ "qualified_name": row[0],
45
+ "kind": row[1],
46
+ "file_path": row[2],
47
+ "line_start": row[3],
48
+ "summary": row[4],
49
+ "source": "graph"
50
+ })
51
+
52
+ # 3. Fallback to grep
53
+ if not results and fallback_grep:
54
+ try:
55
+ # We use subprocess to run grep
56
+ grep_cmd = ['grep', '-rn', '--include=*.py', '--include=*.js', '--include=*.ts', '--include=*.html', name, REPOLENS_ROOT]
57
+ res = subprocess.run(grep_cmd, capture_output=True, text=True)
58
+ if res.stdout:
59
+ for line in res.stdout.splitlines()[:limit]:
60
+ parts = line.split(':', 2)
61
+ if len(parts) >= 3:
62
+ results.append({
63
+ "file_path": parts[0],
64
+ "line_start": parts[1],
65
+ "match_text": parts[2].strip(),
66
+ "source": "grep"
67
+ })
68
+ except Exception:
69
+ pass
70
+
71
+ return results
72
+
73
+ @mcp.tool()
74
+ def get_file_summary(file_path: str) -> dict:
75
+ """
76
+ One-shot summary of a file: classes, functions, imports.
77
+ """
78
+ with store.get_connection() as conn:
79
+ cursor = conn.cursor()
80
+ p = "%s" if store.is_postgres else "?"
81
+
82
+ cursor.execute(f"SELECT module_path, summary FROM cg_files WHERE file_path = {p}", (file_path,))
83
+ row = cursor.fetchone()
84
+
85
+ if not row:
86
+ # Fallback
87
+ try:
88
+ with open(file_path, 'r') as f:
89
+ lines = [next(f).strip() for _ in range(5)]
90
+ return {"error": "File not indexed", "head": lines, "source": "fallback"}
91
+ except Exception as e:
92
+ return {"error": f"Could not read file: {e}"}
93
+
94
+ module_path, summary = row
95
+
96
+ cursor.execute(f"SELECT name, kind FROM cg_symbols WHERE file_path = {p} AND kind IN ('class', 'function', 'import')", (file_path,))
97
+ symbols = cursor.fetchall()
98
+
99
+ classes = [s[0] for s in symbols if s[1] == 'class']
100
+ functions = [s[0] for s in symbols if s[1] == 'function']
101
+ imports = [s[0] for s in symbols if s[1] == 'import']
102
+
103
+ return {
104
+ "module_path": module_path,
105
+ "summary": summary,
106
+ "classes": classes,
107
+ "functions": functions,
108
+ "imports": imports,
109
+ "source": "graph"
110
+ }
111
+
112
+ @mcp.tool()
113
+ def find_usages(qualified_name: str, limit: int = 20, fallback_grep: bool = True) -> list[dict]:
114
+ """
115
+ Find every symbol that imports, calls, or references this symbol.
116
+ """
117
+ results = []
118
+ with store.get_connection() as conn:
119
+ cursor = conn.cursor()
120
+ p = "%s" if store.is_postgres else "?"
121
+
122
+ # We need the ID of the target
123
+ cursor.execute(f"SELECT id FROM cg_symbols WHERE qualified_name = {p}", (qualified_name,))
124
+ row = cursor.fetchone()
125
+
126
+ if row:
127
+ to_id = row[0]
128
+ query = f"""
129
+ SELECT s.qualified_name, e.relation, s.file_path, s.line_start
130
+ FROM cg_edges e
131
+ JOIN cg_symbols s ON e.from_id = s.id
132
+ WHERE e.to_id = {p}
133
+ LIMIT {limit}
134
+ """
135
+ cursor.execute(query, (to_id,))
136
+ for r in cursor.fetchall():
137
+ results.append({
138
+ "from_qualified": r[0],
139
+ "relation": r[1],
140
+ "file_path": r[2],
141
+ "line_start": r[3],
142
+ "source": "graph"
143
+ })
144
+
145
+ if not results and fallback_grep:
146
+ short_name = qualified_name.split('.')[-1]
147
+ try:
148
+ grep_cmd = ['grep', '-rn', short_name, REPOLENS_ROOT]
149
+ res = subprocess.run(grep_cmd, capture_output=True, text=True)
150
+ if res.stdout:
151
+ for line in res.stdout.splitlines()[:limit]:
152
+ parts = line.split(':', 2)
153
+ if len(parts) >= 3:
154
+ results.append({
155
+ "file_path": parts[0],
156
+ "line_start": parts[1],
157
+ "match_text": parts[2].strip(),
158
+ "source": "grep"
159
+ })
160
+ except Exception:
161
+ pass
162
+
163
+ return results
164
+
165
+ @mcp.tool()
166
+ def get_context(file_path: str, line: int, radius: int = 30) -> str:
167
+ """
168
+ Read N lines around a specific line number.
169
+ """
170
+ try:
171
+ with open(file_path, 'r') as f:
172
+ lines = f.readlines()
173
+
174
+ start = max(0, line - radius - 1)
175
+ end = min(len(lines), line + radius)
176
+
177
+ output = []
178
+ for i in range(start, end):
179
+ output.append(f"{i+1}: {lines[i].rstrip()}")
180
+
181
+ return "\n".join(output)
182
+ except Exception as e:
183
+ return f"Error reading file: {e}"
184
+
185
+ @mcp.tool()
186
+ def path_between(from_qualified: str, to_qualified: str, max_depth: int = 6) -> list[dict]:
187
+ """
188
+ BFS shortest path through the edge graph between two symbols.
189
+ """
190
+ with store.get_connection() as conn:
191
+ cursor = conn.cursor()
192
+ p = "%s" if store.is_postgres else "?"
193
+
194
+ # Get IDs
195
+ cursor.execute(f"SELECT id FROM cg_symbols WHERE qualified_name = {p}", (from_qualified,))
196
+ row1 = cursor.fetchone()
197
+ cursor.execute(f"SELECT id FROM cg_symbols WHERE qualified_name = {p}", (to_qualified,))
198
+ row2 = cursor.fetchone()
199
+
200
+ if not row1 or not row2:
201
+ return []
202
+
203
+ start_id = row1[0]
204
+ end_id = row2[0]
205
+
206
+ # BFS queue: (current_id, path_so_far)
207
+ from collections import deque
208
+ queue = deque([(start_id, [])])
209
+ visited = {start_id}
210
+
211
+ while queue:
212
+ curr_id, path = queue.popleft()
213
+
214
+ if len(path) >= max_depth:
215
+ continue
216
+
217
+ # Get neighbors
218
+ cursor.execute(f"""
219
+ SELECT e.to_id, s.qualified_name, e.relation
220
+ FROM cg_edges e
221
+ JOIN cg_symbols s ON e.to_id = s.id
222
+ WHERE e.from_id = {p}
223
+ """, (curr_id,))
224
+ neighbors = cursor.fetchall()
225
+
226
+ for next_id, qualname, rel in neighbors:
227
+ new_path = path + [{"symbol": qualname, "relation": rel}]
228
+ if next_id == end_id:
229
+ return new_path
230
+ if next_id not in visited:
231
+ visited.add(next_id)
232
+ queue.append((next_id, new_path))
233
+
234
+ return []
235
+
236
+ @mcp.tool()
237
+ def search_semantic(query: str, limit: int = 10) -> list[dict]:
238
+ """
239
+ pgvector semantic search over symbol summaries.
240
+ No-ops on SQLite.
241
+ """
242
+ if not store.is_postgres:
243
+ return []
244
+
245
+ # TODO: implement actual vector search with embeddings
246
+ return []
247
+
248
+ @mcp.tool()
249
+ def graph_stats() -> dict:
250
+ """Quick health check."""
251
+ with store.get_connection() as conn:
252
+ cursor = conn.cursor()
253
+ cursor.execute("SELECT COUNT(*) FROM cg_files")
254
+ files = cursor.fetchone()[0]
255
+ cursor.execute("SELECT COUNT(*) FROM cg_symbols")
256
+ symbols = cursor.fetchone()[0]
257
+ cursor.execute("SELECT COUNT(*) FROM cg_edges")
258
+ edges = cursor.fetchone()[0]
259
+ return {
260
+ "files": files,
261
+ "symbols": symbols,
262
+ "edges": edges,
263
+ "backend": "postgres" if store.is_postgres else "sqlite"
264
+ }
265
+
266
+ @mcp.tool()
267
+ def grep_search(pattern: str, include: list[str] = None, exclude: list[str] = None, limit: int = 30) -> list[dict]:
268
+ """
269
+ Direct grep tool — bypass the graph entirely.
270
+ """
271
+ cmd = ['grep', '-rn']
272
+ if include:
273
+ for inc in include:
274
+ cmd.append(f'--include={inc}')
275
+ if exclude:
276
+ for exc in exclude:
277
+ cmd.append(f'--exclude-dir={exc}')
278
+
279
+ cmd.extend([pattern, REPOLENS_ROOT])
280
+
281
+ results = []
282
+ try:
283
+ res = subprocess.run(cmd, capture_output=True, text=True)
284
+ if res.stdout:
285
+ for line in res.stdout.splitlines()[:limit]:
286
+ parts = line.split(':', 2)
287
+ if len(parts) >= 3:
288
+ results.append({
289
+ "file_path": parts[0],
290
+ "line_start": parts[1],
291
+ "match_text": parts[2].strip(),
292
+ "source": "grep"
293
+ })
294
+ except Exception:
295
+ pass
296
+ return results
297
+
298
+ @mcp.tool()
299
+ def what_touches_model(model_name: str) -> dict:
300
+ """
301
+ Django plugin only. Returns views, admin registrations, signals.
302
+ """
303
+ return {"views": [], "admin": [], "signals": [], "references": []}
304
+
305
+ def start_server():
306
+ mcp.run()
@@ -0,0 +1,3 @@
1
+ from .base import BasePlugin
2
+
3
+ __all__ = ["BasePlugin"]
@@ -0,0 +1,10 @@
1
+ from ..indexer.base import Symbol, Edge
2
+
3
+ class BasePlugin:
4
+ def on_symbol(self, symbol: Symbol) -> Symbol:
5
+ """Mutate or re-tag a symbol. Return unchanged if not applicable."""
6
+ return symbol
7
+
8
+ def extra_edges(self, symbols: list[Symbol]) -> list[Edge]:
9
+ """Derive extra edges from the full symbol list after file is indexed."""
10
+ return []
@@ -0,0 +1,24 @@
1
+ from .base import BasePlugin, Symbol, Edge
2
+
3
+ class DjangoPlugin(BasePlugin):
4
+ def on_symbol(self, symbol: Symbol) -> Symbol:
5
+ # Detect models
6
+ if symbol.kind == "class":
7
+ # Very simplistic heuristic: if the file contains 'models.py' or we see it inherits
8
+ if "models.py" in symbol.file_path:
9
+ symbol.kind = "model"
10
+ elif "views.py" in symbol.file_path:
11
+ symbol.kind = "view"
12
+
13
+ if symbol.kind == "function" and "views.py" in symbol.file_path:
14
+ symbol.kind = "view"
15
+
16
+ return symbol
17
+
18
+ def extra_edges(self, symbols: list[Symbol]) -> list[Edge]:
19
+ edges = []
20
+ # Find admin registrations and signals based on names or naive heuristics
21
+ # In a real AST plugin we'd inspect decorators, but as a post-parse plugin
22
+ # we only have symbols with names and maybe raw_signatures.
23
+ # This is a stub for M5 as requested.
24
+ return edges