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.
- codegraphy-0.1.0.dist-info/METADATA +310 -0
- codegraphy-0.1.0.dist-info/RECORD +21 -0
- codegraphy-0.1.0.dist-info/WHEEL +5 -0
- codegraphy-0.1.0.dist-info/entry_points.txt +2 -0
- codegraphy-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraphy-0.1.0.dist-info/top_level.txt +1 -0
- repolens/__init__.py +5 -0
- repolens/cli.py +141 -0
- repolens/config.py +13 -0
- repolens/db/__init__.py +3 -0
- repolens/db/schema.py +84 -0
- repolens/db/store.py +162 -0
- repolens/indexer/__init__.py +5 -0
- repolens/indexer/base.py +27 -0
- repolens/indexer/python.py +177 -0
- repolens/indexer/walker.py +77 -0
- repolens/mcp/__init__.py +3 -0
- repolens/mcp/server.py +306 -0
- repolens/plugins/__init__.py +3 -0
- repolens/plugins/base.py +10 -0
- repolens/plugins/django.py +24 -0
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()
|
repolens/plugins/base.py
ADDED
|
@@ -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
|