descry-codegraph 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.
- descry/__init__.py +4 -0
- descry/_env.py +30 -0
- descry/_graph.py +36 -0
- descry/ast_grep.py +382 -0
- descry/cli.py +457 -0
- descry/cross_lang.py +404 -0
- descry/embeddings.py +593 -0
- descry/generate.py +3579 -0
- descry/git_history.py +1631 -0
- descry/handlers.py +1907 -0
- descry/mcp_server.py +355 -0
- descry/query.py +2345 -0
- descry/scip/__init__.py +0 -0
- descry/scip/cache.py +1047 -0
- descry/scip/parser.py +531 -0
- descry/scip/pb2.py +92 -0
- descry/scip/support.py +174 -0
- descry/tree_sitter_parser.py +356 -0
- descry/web/__init__.py +0 -0
- descry/web/server.py +1457 -0
- descry/web/web/index.html +2444 -0
- descry/web/web/style.css +1791 -0
- descry_codegraph-0.1.0.dist-info/METADATA +345 -0
- descry_codegraph-0.1.0.dist-info/RECORD +27 -0
- descry_codegraph-0.1.0.dist-info/WHEEL +4 -0
- descry_codegraph-0.1.0.dist-info/entry_points.txt +5 -0
- descry_codegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
descry/cross_lang.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cross-Language Tracing Module
|
|
4
|
+
|
|
5
|
+
Maps frontend API calls to backend handlers via OpenAPI specification.
|
|
6
|
+
Enables queries like "what backend handler does this frontend function call?"
|
|
7
|
+
|
|
8
|
+
Architecture:
|
|
9
|
+
- Frontend (TypeScript) uses apiClient.get/post/put/delete(endpoint)
|
|
10
|
+
- Backend (Rust) has handlers with operationId matching function names
|
|
11
|
+
- OpenAPI spec provides the bridge: path -> operationId -> handler function
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
config = DescryConfig(openapi_path="public/api/openapi.json", ...)
|
|
15
|
+
tracer = CrossLangTracer(config)
|
|
16
|
+
handler = tracer.endpoint_to_handler("GET", "/api/v1/actions")
|
|
17
|
+
# Returns: "list_actions"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CrossLangTracer:
|
|
30
|
+
"""Traces API calls across frontend/backend language boundaries."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
openapi_path: str,
|
|
35
|
+
graph_path: Optional[str] = None,
|
|
36
|
+
backend_handler_patterns: list[str] | None = None,
|
|
37
|
+
frontend_api_patterns: list[str] | None = None,
|
|
38
|
+
api_prefixes: list[str] | None = None,
|
|
39
|
+
):
|
|
40
|
+
"""Initialize the tracer with OpenAPI spec.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
openapi_path: Path to OpenAPI JSON spec
|
|
44
|
+
graph_path: Optional path to codebase graph for enriched lookups
|
|
45
|
+
backend_handler_patterns: Path substrings identifying backend handler files
|
|
46
|
+
(e.g., ["backend/src/routes"]). If empty, handler filtering is skipped.
|
|
47
|
+
frontend_api_patterns: Path substrings identifying frontend API files
|
|
48
|
+
(e.g., ["webapp/src/lib/api"]). If empty, frontend filtering is skipped.
|
|
49
|
+
api_prefixes: API version prefixes to strip when matching endpoints
|
|
50
|
+
(defaults to ["/api/v1", "/api/v2", "/api"]).
|
|
51
|
+
"""
|
|
52
|
+
self.openapi_path = Path(openapi_path)
|
|
53
|
+
self.graph_path = Path(graph_path) if graph_path else None
|
|
54
|
+
self.backend_handler_patterns = backend_handler_patterns or []
|
|
55
|
+
self.frontend_api_patterns = frontend_api_patterns or []
|
|
56
|
+
self._API_PREFIXES = (
|
|
57
|
+
tuple(api_prefixes) if api_prefixes else ("/api/v1", "/api/v2", "/api")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Parsed mappings
|
|
61
|
+
self.path_to_operation: dict[
|
|
62
|
+
tuple[str, str], dict
|
|
63
|
+
] = {} # (method, path) -> operation info
|
|
64
|
+
self.operation_to_handler: dict[str, str] = {} # operationId -> handler node_id
|
|
65
|
+
self.endpoint_patterns: list[
|
|
66
|
+
tuple[str, str, re.Pattern, dict]
|
|
67
|
+
] = [] # For path param matching
|
|
68
|
+
|
|
69
|
+
self._parse_openapi()
|
|
70
|
+
if self.graph_path and self.graph_path.exists():
|
|
71
|
+
self._link_to_graph()
|
|
72
|
+
|
|
73
|
+
def _parse_openapi(self):
|
|
74
|
+
"""Parse OpenAPI spec to extract endpoint -> operationId mappings."""
|
|
75
|
+
if not self.openapi_path.exists():
|
|
76
|
+
logger.warning(f"OpenAPI spec not found: {self.openapi_path}")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with open(self.openapi_path) as f:
|
|
81
|
+
spec = json.load(f)
|
|
82
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
83
|
+
logger.error(f"Failed to parse OpenAPI spec: {e}")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
for path, methods in spec.get("paths", {}).items():
|
|
87
|
+
for method, details in methods.items():
|
|
88
|
+
if method not in ("get", "post", "put", "delete", "patch"):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
operation_id = details.get("operationId", "")
|
|
92
|
+
if not operation_id:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
method_upper = method.upper()
|
|
96
|
+
operation_info = {
|
|
97
|
+
"operationId": operation_id,
|
|
98
|
+
"path": path,
|
|
99
|
+
"method": method_upper,
|
|
100
|
+
"summary": details.get("summary", ""),
|
|
101
|
+
"tags": details.get("tags", []),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
self.path_to_operation[(method_upper, path)] = operation_info
|
|
105
|
+
|
|
106
|
+
# Create regex pattern for path with parameters.
|
|
107
|
+
# Escape the path first so literal `.`, `+`, `(` in routes are
|
|
108
|
+
# matched literally, then replace OpenAPI `{param}` markers
|
|
109
|
+
# (which escape to `\{param\}`) with the `[^/]+` capture.
|
|
110
|
+
escaped = re.escape(path)
|
|
111
|
+
pattern_str = re.sub(r"\\\{[^}]+\\\}", r"[^/]+", escaped)
|
|
112
|
+
pattern = re.compile(f"^{pattern_str}$")
|
|
113
|
+
self.endpoint_patterns.append(
|
|
114
|
+
(method_upper, path, pattern, operation_info)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
logger.info(
|
|
118
|
+
f"Loaded {len(self.path_to_operation)} API endpoints from OpenAPI spec"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _link_to_graph(self):
|
|
122
|
+
"""Link operationIds to graph node IDs for backend handlers."""
|
|
123
|
+
if not self.graph_path or not self.graph_path.exists():
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
from descry._graph import load_graph_with_schema
|
|
128
|
+
|
|
129
|
+
graph = load_graph_with_schema(self.graph_path)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Failed to load graph: {e}")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Build index of function/method names to node IDs
|
|
135
|
+
# Looking for handlers matching backend_handler_patterns
|
|
136
|
+
handler_index = {}
|
|
137
|
+
for node in graph.get("nodes", []):
|
|
138
|
+
if node["type"] not in ("Function", "Method"):
|
|
139
|
+
continue
|
|
140
|
+
node_id = node["id"]
|
|
141
|
+
# If patterns are configured, filter to matching files only
|
|
142
|
+
if self.backend_handler_patterns:
|
|
143
|
+
if not any(pat in node_id for pat in self.backend_handler_patterns):
|
|
144
|
+
continue
|
|
145
|
+
name = node.get("metadata", {}).get("name", "")
|
|
146
|
+
if name:
|
|
147
|
+
handler_index[name] = node_id
|
|
148
|
+
|
|
149
|
+
# Map operationIds to handlers
|
|
150
|
+
for (method, path), info in self.path_to_operation.items():
|
|
151
|
+
op_id = info["operationId"]
|
|
152
|
+
if op_id in handler_index:
|
|
153
|
+
self.operation_to_handler[op_id] = handler_index[op_id]
|
|
154
|
+
|
|
155
|
+
logger.info(
|
|
156
|
+
f"Linked {len(self.operation_to_handler)} operationIds to graph nodes"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _strip_api_prefix(self, path: str) -> Optional[str]:
|
|
160
|
+
"""Strip known API version prefix from a path for matching against OpenAPI spec paths.
|
|
161
|
+
|
|
162
|
+
Returns the stripped path, or None if no prefix matched.
|
|
163
|
+
"""
|
|
164
|
+
for prefix in self._API_PREFIXES:
|
|
165
|
+
if path.startswith(prefix):
|
|
166
|
+
stripped = path[len(prefix) :]
|
|
167
|
+
return stripped if stripped else "/"
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def endpoint_to_handler(self, method: str, path: str) -> Optional[str]:
|
|
171
|
+
"""Find the backend handler for an API endpoint.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
175
|
+
path: API path (e.g., "/api/v1/actions" or "/api/v1/actions/123")
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
operationId (handler function name) or None if not found
|
|
179
|
+
"""
|
|
180
|
+
method_upper = method.upper()
|
|
181
|
+
|
|
182
|
+
# Try exact match first
|
|
183
|
+
key = (method_upper, path)
|
|
184
|
+
if key in self.path_to_operation:
|
|
185
|
+
return self.path_to_operation[key]["operationId"]
|
|
186
|
+
|
|
187
|
+
# Try pattern matching for paths with IDs
|
|
188
|
+
for pattern_method, template_path, pattern, info in self.endpoint_patterns:
|
|
189
|
+
if pattern_method == method_upper and pattern.match(path):
|
|
190
|
+
return info["operationId"]
|
|
191
|
+
|
|
192
|
+
# Fallback: strip API version prefix and retry.
|
|
193
|
+
# Handles runtime paths (e.g., /api/v1/health) vs spec paths (e.g., /health).
|
|
194
|
+
stripped = self._strip_api_prefix(path)
|
|
195
|
+
if stripped is not None:
|
|
196
|
+
return self.endpoint_to_handler(method, stripped)
|
|
197
|
+
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def endpoint_to_node_id(self, method: str, path: str) -> Optional[str]:
|
|
201
|
+
"""Find the graph node ID for a backend handler.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
method: HTTP method
|
|
205
|
+
path: API path
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Full node ID (e.g., "FILE:backend/src/routes/actions/handlers.rs::list_actions")
|
|
209
|
+
"""
|
|
210
|
+
op_id = self.endpoint_to_handler(method, path)
|
|
211
|
+
if op_id:
|
|
212
|
+
return self.operation_to_handler.get(op_id)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def get_handler_info(self, method: str, path: str) -> Optional[dict]:
|
|
216
|
+
"""Get full information about an endpoint's handler.
|
|
217
|
+
|
|
218
|
+
Returns dict with operationId, path, method, summary, tags, node_id.
|
|
219
|
+
"""
|
|
220
|
+
method_upper = method.upper()
|
|
221
|
+
|
|
222
|
+
# Try exact match first
|
|
223
|
+
key = (method_upper, path)
|
|
224
|
+
if key in self.path_to_operation:
|
|
225
|
+
info = self.path_to_operation[key].copy()
|
|
226
|
+
info["node_id"] = self.operation_to_handler.get(info["operationId"])
|
|
227
|
+
return info
|
|
228
|
+
|
|
229
|
+
# Try pattern matching
|
|
230
|
+
for pattern_method, template_path, pattern, info in self.endpoint_patterns:
|
|
231
|
+
if pattern_method == method_upper and pattern.match(path):
|
|
232
|
+
result = info.copy()
|
|
233
|
+
result["node_id"] = self.operation_to_handler.get(info["operationId"])
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
# Fallback: strip API version prefix and retry
|
|
237
|
+
stripped = self._strip_api_prefix(path)
|
|
238
|
+
if stripped is not None:
|
|
239
|
+
return self.get_handler_info(method, stripped)
|
|
240
|
+
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def list_endpoints(self, tag: Optional[str] = None) -> list[dict]:
|
|
244
|
+
"""List all endpoints, optionally filtered by tag.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
tag: Optional tag to filter by (e.g., "actions", "deployments")
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of endpoint info dicts
|
|
251
|
+
"""
|
|
252
|
+
results = []
|
|
253
|
+
for info in self.path_to_operation.values():
|
|
254
|
+
if tag and tag.lower() not in [t.lower() for t in info.get("tags", [])]:
|
|
255
|
+
continue
|
|
256
|
+
result = info.copy()
|
|
257
|
+
result["node_id"] = self.operation_to_handler.get(info["operationId"])
|
|
258
|
+
results.append(result)
|
|
259
|
+
return sorted(results, key=lambda x: (x["path"], x["method"]))
|
|
260
|
+
|
|
261
|
+
def find_ts_api_calls(self, graph_data: dict) -> list[dict]:
|
|
262
|
+
"""Find frontend functions that call API endpoints.
|
|
263
|
+
|
|
264
|
+
Looks for patterns like:
|
|
265
|
+
- apiClient.get('/actions')
|
|
266
|
+
- createCrudApi('/tags')
|
|
267
|
+
- API_BASE_URL + '/api/v1/...'
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
graph_data: Parsed codebase graph
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List of dicts with ts_node_id, endpoint, method, backend_handler
|
|
274
|
+
"""
|
|
275
|
+
# This would require parsing TypeScript source to find API calls
|
|
276
|
+
# For now, we use heuristics based on file names and function names
|
|
277
|
+
results = []
|
|
278
|
+
|
|
279
|
+
for node in graph_data.get("nodes", []):
|
|
280
|
+
if node["type"] not in ("Function", "Method"):
|
|
281
|
+
continue
|
|
282
|
+
node_id = node["id"]
|
|
283
|
+
# If patterns are configured, filter to matching files only
|
|
284
|
+
if self.frontend_api_patterns:
|
|
285
|
+
if not any(pat in node_id for pat in self.frontend_api_patterns):
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Infer endpoint from file name
|
|
289
|
+
# e.g., webapp/src/lib/api/actions.ts -> /actions
|
|
290
|
+
parts = node_id.split("/")
|
|
291
|
+
if "api" in parts:
|
|
292
|
+
api_idx = parts.index("api")
|
|
293
|
+
if api_idx + 1 < len(parts):
|
|
294
|
+
file_name = parts[api_idx + 1].split("::")[0]
|
|
295
|
+
endpoint_name = file_name.replace(".ts", "")
|
|
296
|
+
|
|
297
|
+
# Map function names to HTTP methods
|
|
298
|
+
func_name = node.get("metadata", {}).get("name", "")
|
|
299
|
+
method = self._infer_http_method(func_name)
|
|
300
|
+
|
|
301
|
+
# Try to find matching backend handler under any configured
|
|
302
|
+
# API prefix (E.3: previous hardcoded /api/v1/ removed).
|
|
303
|
+
handler = None
|
|
304
|
+
api_path = None
|
|
305
|
+
for prefix in self._API_PREFIXES:
|
|
306
|
+
candidate = f"{prefix}/{endpoint_name}".replace("//", "/")
|
|
307
|
+
h = self.endpoint_to_handler(method, candidate)
|
|
308
|
+
if h:
|
|
309
|
+
handler = h
|
|
310
|
+
api_path = candidate
|
|
311
|
+
break
|
|
312
|
+
if handler is None:
|
|
313
|
+
api_path = f"{self._API_PREFIXES[0] if self._API_PREFIXES else '/api'}/{endpoint_name}"
|
|
314
|
+
|
|
315
|
+
if handler:
|
|
316
|
+
results.append(
|
|
317
|
+
{
|
|
318
|
+
"ts_node_id": node_id,
|
|
319
|
+
"ts_function": func_name,
|
|
320
|
+
"inferred_endpoint": api_path,
|
|
321
|
+
"inferred_method": method,
|
|
322
|
+
"rust_handler": handler,
|
|
323
|
+
"rust_node_id": self.operation_to_handler.get(handler),
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return results
|
|
328
|
+
|
|
329
|
+
def _infer_http_method(self, func_name: str) -> str:
|
|
330
|
+
"""Infer HTTP method from function name."""
|
|
331
|
+
name_lower = func_name.lower()
|
|
332
|
+
if name_lower.startswith(("get", "list", "fetch", "load")):
|
|
333
|
+
return "GET"
|
|
334
|
+
if name_lower.startswith(("create", "add", "post")):
|
|
335
|
+
return "POST"
|
|
336
|
+
if name_lower.startswith(("update", "put", "edit", "modify")):
|
|
337
|
+
return "PUT"
|
|
338
|
+
if name_lower.startswith(("delete", "remove")):
|
|
339
|
+
return "DELETE"
|
|
340
|
+
return "GET" # Default
|
|
341
|
+
|
|
342
|
+
def get_stats(self) -> dict:
|
|
343
|
+
"""Get statistics about the cross-language mapping."""
|
|
344
|
+
return {
|
|
345
|
+
"total_endpoints": len(self.path_to_operation),
|
|
346
|
+
"linked_to_graph": len(self.operation_to_handler),
|
|
347
|
+
"openapi_path": str(self.openapi_path),
|
|
348
|
+
"graph_path": str(self.graph_path) if self.graph_path else None,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _create_cross_lang_edges(
|
|
353
|
+
graph_data: dict,
|
|
354
|
+
openapi_path: str,
|
|
355
|
+
) -> list[dict]:
|
|
356
|
+
"""Create CALLS_API edges linking frontend to backend handlers.
|
|
357
|
+
|
|
358
|
+
This can be used during graph generation to add cross-language edges.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
graph_data: Current graph data (nodes and edges)
|
|
362
|
+
openapi_path: Path to OpenAPI spec
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
List of new edges to add
|
|
366
|
+
"""
|
|
367
|
+
tracer = CrossLangTracer(openapi_path)
|
|
368
|
+
api_calls = tracer.find_ts_api_calls(graph_data)
|
|
369
|
+
|
|
370
|
+
edges = []
|
|
371
|
+
for call in api_calls:
|
|
372
|
+
if call.get("rust_node_id"):
|
|
373
|
+
edges.append(
|
|
374
|
+
{
|
|
375
|
+
"source": call["ts_node_id"],
|
|
376
|
+
"target": call["rust_node_id"],
|
|
377
|
+
"relation": "CALLS_API",
|
|
378
|
+
"metadata": {
|
|
379
|
+
"endpoint": call["inferred_endpoint"],
|
|
380
|
+
"method": call["inferred_method"],
|
|
381
|
+
"handler": call["rust_handler"],
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return edges
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
# Generic stats-only smoke test. Prints OpenAPI + graph linkage stats.
|
|
391
|
+
import sys
|
|
392
|
+
|
|
393
|
+
if len(sys.argv) < 2:
|
|
394
|
+
print("Usage: python -m descry.cross_lang <openapi_path> [graph_path]")
|
|
395
|
+
sys.exit(1)
|
|
396
|
+
|
|
397
|
+
openapi_path = sys.argv[1]
|
|
398
|
+
graph_path = sys.argv[2] if len(sys.argv) > 2 else None
|
|
399
|
+
|
|
400
|
+
tracer = CrossLangTracer(openapi_path, graph_path)
|
|
401
|
+
stats = tracer.get_stats()
|
|
402
|
+
print("CrossLangTracer stats:")
|
|
403
|
+
for key, value in stats.items():
|
|
404
|
+
print(f" {key}: {value}")
|