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/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}")