blasttrace 1.0.2__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.

Potentially problematic release.


This version of blasttrace might be problematic. Click here for more details.

@@ -0,0 +1,490 @@
1
+ """
2
+ BlastTrace Cross-Stack Contract Linker
3
+ Deterministically connects Database Tables/Columns <-> ORM Models <-> Backend Functions <->
4
+ API Routes/Payloads <-> Frontend React/TS Consumers <-> Covering Test Suites.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from typing import Dict, Any, List, Optional, Tuple
10
+
11
+ from blasttrace.parsers.sql_schema_parser import (
12
+ parse_sql_content,
13
+ parse_prisma_content,
14
+ parse_orm_python_content,
15
+ _pluralize,
16
+ _singularize,
17
+ )
18
+ from blasttrace.parsers.python_ast_parser import parse_python_content
19
+ from blasttrace.parsers.api_route_parser import (
20
+ parse_python_api_routes,
21
+ parse_ts_js_api_routes,
22
+ parse_openapi_spec,
23
+ _normalize_route_path,
24
+ )
25
+ from blasttrace.parsers.ts_react_parser import parse_ts_react_content
26
+ from blasttrace.parsers.test_mapper import (
27
+ is_test_file,
28
+ parse_python_test_content,
29
+ parse_ts_test_content,
30
+ )
31
+
32
+
33
+ _IGNORED_DIRS = {
34
+ ".git", "__pycache__", "node_modules", ".venv", "venv", "env",
35
+ "build", "dist", ".pytest_cache", ".mypy_cache", ".next", ".turbo",
36
+ "website", "website_v3", "egg-info",
37
+ }
38
+
39
+
40
+ def build_fullstack_topology(repo_path: str) -> Dict[str, Any]:
41
+ """
42
+ Scans a repository and builds the complete cross-stack contract topology
43
+ spanning Database, Backend, API, Frontend, and Tests.
44
+ """
45
+ if not os.path.exists(repo_path):
46
+ return {"error": f"Repository path not found: {repo_path}"}
47
+
48
+ root = os.path.abspath(repo_path)
49
+ if os.path.isfile(root):
50
+ root = os.path.dirname(root)
51
+
52
+ nodes_by_id: Dict[str, Dict[str, Any]] = {}
53
+ edges: List[Dict[str, str]] = []
54
+ edge_set = set()
55
+
56
+ # Collected layer registries
57
+ db_tables: Dict[str, Dict[str, Any]] = {}
58
+ orm_models: Dict[str, Dict[str, Any]] = {}
59
+ py_modules: Dict[str, Dict[str, Any]] = {}
60
+ api_routes: List[Dict[str, Any]] = []
61
+ fe_modules: Dict[str, Dict[str, Any]] = {}
62
+ test_suites: Dict[str, Dict[str, Any]] = {}
63
+ module_import_edges: List[Dict[str, str]] = []
64
+
65
+ def _add_node(n: Dict[str, Any]) -> None:
66
+ nid = n["id"]
67
+ if nid not in nodes_by_id:
68
+ nodes_by_id[nid] = n
69
+ else:
70
+ nodes_by_id[nid].update(n)
71
+
72
+ def _add_edge(src: str, dst: str, relation: str = "impacts") -> None:
73
+ if not src or not dst or src == dst:
74
+ return
75
+ key = (src, dst)
76
+ if key not in edge_set:
77
+ edge_set.add(key)
78
+ edges.append({"source": src, "target": dst, "relation": relation})
79
+
80
+ # Walk repository files
81
+ all_files: List[Tuple[str, str]] = []
82
+ for dirpath, dirnames, filenames in os.walk(root):
83
+ dirnames[:] = [
84
+ d for d in dirnames
85
+ if d not in _IGNORED_DIRS and not d.endswith(".egg-info")
86
+ ]
87
+ for fn in sorted(filenames):
88
+ abs_p = os.path.join(dirpath, fn)
89
+ rel_p = os.path.relpath(abs_p, root).replace("\\", "/")
90
+ all_files.append((abs_p, rel_p))
91
+
92
+ for abs_p, rel_p in all_files:
93
+ lower = rel_p.lower()
94
+ try:
95
+ with open(abs_p, "r", encoding="utf-8", errors="ignore") as f:
96
+ content = f.read()
97
+ except Exception:
98
+ continue
99
+
100
+ # 1. SQL / Prisma schemas
101
+ if lower.endswith(".sql"):
102
+ sql_res = parse_sql_content(content, rel_p)
103
+ for tname, tinfo in sql_res["tables"].items():
104
+ if tname in db_tables:
105
+ db_tables[tname]["columns"].update(tinfo.get("columns", {}))
106
+ else:
107
+ db_tables[tname] = tinfo
108
+ for n in sql_res["nodes"]:
109
+ _add_node(n)
110
+ for e in sql_res["edges"]:
111
+ _add_edge(e["source"], e["target"], e.get("relation", "schema"))
112
+
113
+ elif lower.endswith(".prisma"):
114
+ pr_res = parse_prisma_content(content, rel_p)
115
+ for tname, tinfo in pr_res["tables"].items():
116
+ if tname in db_tables:
117
+ db_tables[tname]["columns"].update(tinfo.get("columns", {}))
118
+ else:
119
+ db_tables[tname] = tinfo
120
+ for n in pr_res["nodes"]:
121
+ _add_node(n)
122
+ for e in pr_res["edges"]:
123
+ _add_edge(e["source"], e["target"], e.get("relation", "schema"))
124
+
125
+ # 2. Test files (Python or TS/JS)
126
+ elif is_test_file(rel_p):
127
+ if lower.endswith(".py"):
128
+ t_res = parse_python_test_content(content, rel_p)
129
+ else:
130
+ t_res = parse_ts_test_content(content, rel_p)
131
+ test_suites[rel_p] = t_res
132
+ for n in t_res["nodes"]:
133
+ _add_node(n)
134
+ for e in t_res["edges"]:
135
+ _add_edge(e["source"], e["target"], e.get("relation", "test"))
136
+
137
+ # 3. Python Backend / ORM / API files
138
+ elif lower.endswith(".py"):
139
+ py_res = parse_python_content(content, rel_p)
140
+ py_modules[rel_p] = py_res
141
+ for n in py_res["nodes"]:
142
+ _add_node(n)
143
+ for e in py_res["edges"]:
144
+ _add_edge(e["source"], e["target"], e.get("relation", "python"))
145
+
146
+ orm_res = parse_orm_python_content(content, rel_p)
147
+ for cls_name, minfo in orm_res["orm_models"].items():
148
+ orm_models[cls_name] = minfo
149
+ tbl = minfo["table"]
150
+ db_tables.setdefault(tbl, {"name": tbl, "file": rel_p, "columns": {}})
151
+ for fname, finfo in minfo["fields"].items():
152
+ db_tables[tbl]["columns"].setdefault(fname.lower(), {
153
+ "name": fname.lower(),
154
+ "table": tbl,
155
+ "type": finfo.get("args", "ORM"),
156
+ "nullable": "nullable=False" not in finfo.get("args", ""),
157
+ "has_default": "default=" in finfo.get("args", ""),
158
+ "file": rel_p,
159
+ })
160
+ for n in orm_res["nodes"]:
161
+ _add_node(n)
162
+ for e in orm_res["edges"]:
163
+ _add_edge(e["source"], e["target"], e.get("relation", "orm"))
164
+
165
+ api_res = parse_python_api_routes(content, rel_p)
166
+ api_routes.extend(api_res["routes"])
167
+ for n in api_res["nodes"]:
168
+ _add_node(n)
169
+ for e in api_res["edges"]:
170
+ _add_edge(e["source"], e["target"], e.get("relation", "api"))
171
+
172
+ # 4. TypeScript / React / JS files
173
+ elif lower.endswith((".ts", ".tsx", ".js", ".jsx")):
174
+ fe_res = parse_ts_react_content(content, rel_p)
175
+ fe_modules[rel_p] = fe_res
176
+ for n in fe_res["nodes"]:
177
+ _add_node(n)
178
+ for e in fe_res["edges"]:
179
+ _add_edge(e["source"], e["target"], e.get("relation", "frontend"))
180
+
181
+ ts_api = parse_ts_js_api_routes(content, rel_p)
182
+ api_routes.extend(ts_api["routes"])
183
+ for n in ts_api["nodes"]:
184
+ _add_node(n)
185
+ for e in ts_api["edges"]:
186
+ _add_edge(e["source"], e["target"], e.get("relation", "api"))
187
+
188
+ # 5. OpenAPI specs
189
+ elif os.path.basename(lower) in ("openapi.json", "openapi.yaml", "openapi.yml", "swagger.json", "swagger.yaml"):
190
+ oa_res = parse_openapi_spec(content, rel_p)
191
+ api_routes.extend(oa_res["routes"])
192
+ for n in oa_res["nodes"]:
193
+ _add_node(n)
194
+ for e in oa_res["edges"]:
195
+ _add_edge(e["source"], e["target"], e.get("relation", "api"))
196
+
197
+ # =========================================================================
198
+ # CROSS-STACK LINKING PASS 1: DB Table/Column <-> ORM Model <-> Python Class/Function
199
+ # =========================================================================
200
+ # Map class names & table names
201
+ class_to_py_id: Dict[str, str] = {}
202
+ func_to_py_ids: Dict[str, List[str]] = {}
203
+ module_stem_to_rel: Dict[str, str] = {}
204
+
205
+ for rel_p, py_info in py_modules.items():
206
+ stem = os.path.splitext(rel_p)[0].replace("/", ".")
207
+ short_stem = os.path.splitext(os.path.basename(rel_p))[0]
208
+ module_stem_to_rel[stem] = rel_p
209
+ module_stem_to_rel[short_stem] = rel_p
210
+
211
+ for cname, cinfo in py_info["classes"].items():
212
+ cls_id = f"py:{rel_p}:{cname}"
213
+ class_to_py_id[cname] = cls_id
214
+ tbl_guess = (cinfo.get("tablename") or _pluralize(cname)).lower()
215
+ if tbl_guess in db_tables:
216
+ _add_edge(f"db:{tbl_guess}", cls_id, "maps_table_to_class")
217
+ if cname in orm_models:
218
+ _add_edge(f"orm:{cname}", cls_id, "orm_to_py_class")
219
+ for fname in cinfo["fields"]:
220
+ col_id = f"db:{tbl_guess}.{fname.lower()}"
221
+ py_field_id = f"py:{rel_p}:{cname}.{fname}"
222
+ if fname.lower() in db_tables[tbl_guess]["columns"]:
223
+ _add_edge(col_id, py_field_id, "maps_column_to_field")
224
+ if cname in orm_models and fname in orm_models[cname]["fields"]:
225
+ _add_edge(f"orm:{cname}.{fname}", py_field_id, "orm_field_to_py_field")
226
+
227
+ for fname in py_info["functions"]:
228
+ func_to_py_ids.setdefault(fname, []).append(f"py:{rel_p}:{fname}")
229
+
230
+ # Link Python imports & cross-module function calls + SQL/ORM references inside functions
231
+ for rel_p, py_info in py_modules.items():
232
+ mod_id = f"py:{rel_p}"
233
+ for imp in py_info["imports"]:
234
+ target_mod = imp.get("module", "")
235
+ target_sym = imp.get("symbol", "")
236
+ resolved_rel = (
237
+ module_stem_to_rel.get(target_mod)
238
+ or module_stem_to_rel.get(target_mod.split(".")[-1])
239
+ or module_stem_to_rel.get(target_sym)
240
+ )
241
+ if resolved_rel and resolved_rel != rel_p:
242
+ # Module import edge for circular dependency detection & module blast radius
243
+ _add_edge(f"py:{resolved_rel}", mod_id, "imported_by")
244
+ module_import_edges.append({"source": mod_id, "target": f"py:{resolved_rel}"})
245
+
246
+ for fn_name, fn_meta in py_info["functions"].items():
247
+ fn_id = f"py:{rel_p}:{fn_name}"
248
+
249
+ # Cross-module function calls
250
+ for called in fn_meta.get("calls", []):
251
+ short_called = called.split(".")[-1]
252
+ for target_fn_id in func_to_py_ids.get(short_called, []):
253
+ if target_fn_id != fn_id:
254
+ _add_edge(target_fn_id, fn_id, "called_by")
255
+ if called in class_to_py_id:
256
+ _add_edge(class_to_py_id[called], fn_id, "instantiates_class")
257
+
258
+ # Attribute accesses (e.g. user.balance, User.balance) -> link from ORM/DB column to function
259
+ for attr in fn_meta.get("attributes", []):
260
+ attr_short = attr.split(".")[-1].lower()
261
+ for tbl_name, tbl_info in db_tables.items():
262
+ if attr_short in tbl_info["columns"]:
263
+ col_id = f"db:{tbl_name}.{attr_short}"
264
+ _add_edge(col_id, fn_id, "column_read_by_function")
265
+ for cls_name, minfo in orm_models.items():
266
+ for orm_f in minfo["fields"]:
267
+ if orm_f.lower() == attr_short:
268
+ _add_edge(f"orm:{cls_name}.{orm_f}", fn_id, "orm_field_read_by_function")
269
+ if cls_name in class_to_py_id:
270
+ py_cls_file = py_modules_file_for_class(py_modules, cls_name)
271
+ if py_cls_file:
272
+ _add_edge(f"py:{py_cls_file}:{cls_name}.{orm_f}", fn_id, "field_read_by_function")
273
+
274
+ # Raw SQL string literals inside function bodies (e.g. "SELECT id, balance FROM users")
275
+ for s_lit in fn_meta.get("string_literals", []):
276
+ s_low = s_lit.lower()
277
+ for tbl_name, tbl_info in db_tables.items():
278
+ if re.search(rf"\b{re.escape(tbl_name)}\b", s_low):
279
+ _add_edge(f"db:{tbl_name}", fn_id, "queries_table")
280
+ for col_name in tbl_info["columns"]:
281
+ if re.search(rf"\b{re.escape(col_name)}\b", s_low):
282
+ _add_edge(f"db:{tbl_name}.{col_name}", fn_id, "queries_column")
283
+
284
+ # =========================================================================
285
+ # CROSS-STACK LINKING PASS 2: Backend/ORM <-> API Route & Payload Fields
286
+ # =========================================================================
287
+ routes_by_norm_path: Dict[str, List[Dict[str, Any]]] = {}
288
+ for route in api_routes:
289
+ norm_p = route["normalized_path"]
290
+ routes_by_norm_path.setdefault(norm_p, []).append(route)
291
+ route_id = route["id"]
292
+ handler_id = route.get("handler_id")
293
+
294
+ # Connect DB/ORM fields referenced or returned by handler directly to API payload fields
295
+ for rf in route.get("response_fields", []):
296
+ rf_low = rf.lower()
297
+ field_node = f"api_field:{norm_p}.{rf}"
298
+ if handler_id:
299
+ _add_edge(handler_id, field_node, "populates_api_field")
300
+ for tbl_name, tbl_info in db_tables.items():
301
+ if rf_low in tbl_info["columns"]:
302
+ _add_edge(f"db:{tbl_name}.{rf_low}", route_id, "exposes_db_column")
303
+ _add_edge(f"db:{tbl_name}.{rf_low}", field_node, "maps_to_api_field")
304
+ for cls_name, minfo in orm_models.items():
305
+ for orm_f in minfo["fields"]:
306
+ if orm_f.lower() == rf_low:
307
+ _add_edge(f"orm:{cls_name}.{orm_f}", route_id, "exposes_orm_field")
308
+ _add_edge(f"orm:{cls_name}.{orm_f}", field_node, "maps_orm_to_api_field")
309
+
310
+ # =========================================================================
311
+ # CROSS-STACK LINKING PASS 3: API Route/Payload <-> Frontend TS/React Consumers
312
+ # =========================================================================
313
+ fe_symbol_to_id: Dict[str, str] = {}
314
+ for rel_p, fe_info in fe_modules.items():
315
+ for sym_name in fe_info["symbols"]:
316
+ fe_symbol_to_id[sym_name] = f"fe:{rel_p}:{sym_name}"
317
+
318
+ for rel_p, fe_info in fe_modules.items():
319
+ fe_mod_id = f"fe:{rel_p}"
320
+ fe_syms = [f"fe:{rel_p}:{s}" for s in fe_info["symbols"]]
321
+ target_fe_nodes = fe_syms if fe_syms else [fe_mod_id]
322
+
323
+ # Link frontend cross-file imports
324
+ for imp in fe_info["imports"]:
325
+ sym = imp.get("symbol", "")
326
+ if sym in fe_symbol_to_id and fe_symbol_to_id[sym] not in target_fe_nodes:
327
+ for t_node in target_fe_nodes:
328
+ _add_edge(fe_symbol_to_id[sym], t_node, "imported_by_component")
329
+
330
+ # Link API calls (fetch, axios, useSWR, useQuery) to API routes & payload fields
331
+ for call in fe_info["api_calls"]:
332
+ c_path = call["normalized_path"]
333
+ c_method = call["method"]
334
+ matched_routes = routes_by_norm_path.get(c_path, [])
335
+ if not matched_routes:
336
+ # Try prefix / parameter matching (e.g. /api/v1/users/123 vs /api/v1/users/:param)
337
+ for cand_path, r_list in routes_by_norm_path.items():
338
+ if _paths_match(cand_path, c_path):
339
+ matched_routes.extend(r_list)
340
+
341
+ for route in matched_routes:
342
+ route_id = route["id"]
343
+ _add_edge(route_id, fe_mod_id, "consumed_by_frontend_module")
344
+ for t_node in target_fe_nodes:
345
+ _add_edge(route_id, t_node, "consumed_by_component")
346
+
347
+ # Connect accessed properties (e.g. data.balance) to api_field nodes
348
+ for prop in call.get("accessed_fields", []):
349
+ field_node = f"api_field:{route['normalized_path']}.{prop}"
350
+ if field_node in nodes_by_id:
351
+ _add_edge(field_node, fe_mod_id, "field_read_by_frontend")
352
+ for t_node in target_fe_nodes:
353
+ _add_edge(field_node, t_node, "field_read_by_component")
354
+ prop_node_id = f"{t_node}.{prop}"
355
+ _add_node({
356
+ "id": prop_node_id,
357
+ "type": "frontend_property_access",
358
+ "name": f"{os.path.basename(rel_p)} ({prop})",
359
+ "property": prop,
360
+ "file": rel_p,
361
+ "layer": "frontend",
362
+ })
363
+ _add_edge(field_node, prop_node_id, "renders_field")
364
+ _add_edge(t_node, prop_node_id, "accesses_property")
365
+
366
+ # =========================================================================
367
+ # CROSS-STACK LINKING PASS 4: Backend/API/Frontend <-> Covering Test Suites
368
+ # =========================================================================
369
+ for test_rel, t_info in test_suites.items():
370
+ suite_id = f"test:{test_rel}"
371
+ for sym in t_info.get("imported_symbols", []):
372
+ for fn_id in func_to_py_ids.get(sym, []):
373
+ _add_edge(fn_id, suite_id, "covered_by_test_suite")
374
+ if sym in class_to_py_id:
375
+ _add_edge(class_to_py_id[sym], suite_id, "covered_by_test_suite")
376
+ if sym in fe_symbol_to_id:
377
+ _add_edge(fe_symbol_to_id[sym], suite_id, "covered_by_test_suite")
378
+
379
+ for tc in t_info.get("test_cases", []):
380
+ tc_id = tc["id"]
381
+ for called in tc.get("called_symbols", []):
382
+ for fn_id in func_to_py_ids.get(called, []):
383
+ _add_edge(fn_id, tc_id, "verified_by_test")
384
+ if called in class_to_py_id:
385
+ _add_edge(class_to_py_id[called], tc_id, "verified_by_test")
386
+ if called in fe_symbol_to_id:
387
+ _add_edge(fe_symbol_to_id[called], tc_id, "verified_by_test")
388
+
389
+ for ep in tc.get("tested_endpoints", []):
390
+ parts = ep.split(" ", 1)
391
+ if len(parts) == 2:
392
+ m, raw_p = parts[0], parts[1]
393
+ norm_p = _normalize_route_path(raw_p)
394
+ for r in routes_by_norm_path.get(norm_p, []):
395
+ _add_edge(r["id"], tc_id, "endpoint_tested_by")
396
+ _add_edge(r["id"], suite_id, "endpoint_covered_by_suite")
397
+
398
+ return {
399
+ "repo_root": root,
400
+ "nodes": list(nodes_by_id.values()),
401
+ "nodes_by_id": nodes_by_id,
402
+ "edges": edges,
403
+ "db_tables": db_tables,
404
+ "orm_models": orm_models,
405
+ "py_modules": py_modules,
406
+ "api_routes": api_routes,
407
+ "fe_modules": fe_modules,
408
+ "test_suites": test_suites,
409
+ "module_import_edges": module_import_edges,
410
+ }
411
+
412
+
413
+ def py_modules_file_for_class(py_modules: Dict[str, Dict[str, Any]], cls_name: str) -> Optional[str]:
414
+ for rel_p, info in py_modules.items():
415
+ if cls_name in info.get("classes", {}):
416
+ return rel_p
417
+ return None
418
+
419
+
420
+ def _paths_match(route_norm: str, call_norm: str) -> bool:
421
+ if route_norm == call_norm:
422
+ return True
423
+ r_parts = [p for p in route_norm.split("/") if p]
424
+ c_parts = [p for p in call_norm.split("/") if p]
425
+ if len(r_parts) != len(c_parts):
426
+ return False
427
+ for rp, cp in zip(r_parts, c_parts):
428
+ if rp == ":param" or cp == ":param" or rp.startswith(":") or cp.startswith(":"):
429
+ continue
430
+ if rp != cp:
431
+ return False
432
+ return True
433
+
434
+
435
+ def resolve_entity_id(topology: Dict[str, Any], query: str) -> List[str]:
436
+ """
437
+ Resolves a user or AI agent symbol/table/route/component query string
438
+ into matching canonical node IDs inside the Full-Stack Contract Topology Engine.
439
+ """
440
+ if not query:
441
+ return []
442
+ q = query.strip()
443
+ nodes_by_id: Dict[str, Dict[str, Any]] = topology.get("nodes_by_id", {})
444
+ if q in nodes_by_id:
445
+ return [q]
446
+
447
+ q_low = q.lower()
448
+ exact_matches: List[str] = []
449
+ suffix_matches: List[str] = []
450
+ substring_matches: List[str] = []
451
+
452
+ # Direct prefix candidates
453
+ for prefix in ("db:", "orm:", "py:", "api:", "api_field:", "fe:", "test:"):
454
+ cand = f"{prefix}{q}"
455
+ if cand in nodes_by_id:
456
+ exact_matches.append(cand)
457
+ cand_low = f"{prefix}{q_low}"
458
+ if cand_low in nodes_by_id and cand_low not in exact_matches:
459
+ exact_matches.append(cand_low)
460
+
461
+ # Check API route path normalization if query looks like a URL path
462
+ if q.startswith("/") or q_low.startswith(("get ", "post ", "put ", "delete ", "patch ")):
463
+ if " " in q:
464
+ m_part, p_part = q.split(" ", 1)
465
+ norm_cand = f"api:{m_part.upper()} {_normalize_route_path(p_part)}"
466
+ if norm_cand in nodes_by_id:
467
+ exact_matches.append(norm_cand)
468
+ else:
469
+ norm_p = _normalize_route_path(q)
470
+ for m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
471
+ cand = f"api:{m} {norm_p}"
472
+ if cand in nodes_by_id:
473
+ exact_matches.append(cand)
474
+
475
+ if exact_matches:
476
+ return exact_matches
477
+
478
+ for nid, nmeta in nodes_by_id.items():
479
+ nid_low = nid.lower()
480
+ name_low = str(nmeta.get("name", "")).lower()
481
+ file_low = str(nmeta.get("file", "")).lower()
482
+ if name_low == q_low or file_low == q_low or os.path.basename(file_low) == q_low:
483
+ suffix_matches.append(nid)
484
+ elif nid_low.endswith(f":{q_low}") or nid_low.endswith(f".{q_low}") or nid_low.endswith(f"/{q_low}"):
485
+ suffix_matches.append(nid)
486
+ elif q_low in nid_low or q_low in name_low:
487
+ substring_matches.append(nid)
488
+
489
+ return suffix_matches if suffix_matches else substring_matches
490
+